Article épinglé

Domaine ipa-troulet.fr, ce n'est plus moi...

Mise en garde - Domaine ipa-troulet.fr Ce domaine fut le mien de 2012 à 2025, il était hébergé au Canada chez HostPapa. Mais faute de motiva...

vendredi 23 octobre 2026

Qgis, drone, orthophoto, RealityCapture et scripts python avec IA

Expérimentation du comptage de bouses dans un champ autour de Gramat.

Protocole en cours de rédaction

  • Prise de vue DJI Mini 4 Pro sur RC2
  • Orthomosaïque  générée par RéalityCapture
  • Script IA Claude pour Qgis 

 

Pour les collègues relecteurs (biologie, aménagement) : ce document décrit une démarche testée les 21-23/10/2026 sur une prairie pâturée de 0,71 ha, du plan de vol jusqu'au comptage automatique des bouses. Il est soumis à votre avis avant que le protocole ne soit figé pour les BTSA GPN. Les points sur lesquels votre avis est attendu sont réunis à la section 6.

Convention : les cadres « Figure n – image à coller ici » indiquent l'emplacement de chaque copie d'écran.

1. Contexte et chaîne de travail

La mission drone se prépare entièrement dans QGIS : le script produit un fichier KMZ que la RC 2 exécute tel quel. Testée le 21/10/2026 (essai 2 points) puis le 22/10/2026 (mission complète, 286 photos), la chaîne fonctionne de bout en bout.

Élément Valeur
Objet du test Décompte des bouses sur placettes, comparaison sol / drone
Site Prairie pâturée, causse de Gramat (Lot), près de la route de Roussières
Zone de vol zone_vol, 0,71 ha (≈ 167 × 56 m), EPSG:2154
Drone DJI Mini 4 Pro + radiocommande RC 2 (application DJI Fly)
Logiciels QGIS 3.34 (console Python), RealityScan (orthophoto)
Dossier de travail C:\Users\pierr\Downloads\Test Drone\
Projet QGIS Projet test drone.qgz

La chaîne, dans l'ordre :

  1. QGIS : dessiner la zone de vol, placer le point de décollage, tirer les placettes.
  2. QGIS : mission_drone_dji.py calcule les waypoints, corrige les hauteurs par le relief (RGE ALTI de l'IGN) et écrit le KMZ à partir d'une mission « leurre » créée dans DJI Fly.
  3. RC 2 : le KMZ remplace le fichier du leurre (même nom, même dossier) ; DJI Fly affiche la mission.
  4. Vol : décollage au point prévu, prise de vue automatique toutes les 2 s, lancement de la mission.
  5. QGIS : trier_photos_vol.py classe les photos (orthophoto / placettes / écartées) d'après leur hauteur réelle au-dessus du sol.
  6. RealityScan : orthophoto calculée avec les seules photos de la grille.
  7. À venir : comptage automatique des bouses sur toute l'orthophoto, puis contrôle de fiabilité (compter_bouses_ortho.py).

Pourquoi passer par un leurre : DJI Fly n'a pas de bouton d'import pour le Mini 4 Pro. Le script recopie les réglages propres au drone contenus dans le leurre (modèle, mode de hauteur, action de fin) et ne remplace que les waypoints.

2. Phase 1 – Préparation et vol d'essai (21/10/2026)

L'essai à 2 points a validé l'import du KMZ, la prise de vue à intervalle, la nacelle à −90° et la correction des hauteurs. Il a aussi révélé la règle la plus importante : décoller exactement au point prévu.

Étape 1 – Dessiner la zone de vol et la passer en EPSG:2154

  • zone_vol est dessinée sur l'orthophoto IGN, en retrait des houppiers.
  • Le projet était en EPSG:3857 (celui des flux IGN). En 3857, à 45° N, les « mètres » sont étirés de × 1,41 : un tampon de 10 m n'en ferait que 7.
  • Conversion par clic droit > Exporter > Sauvegarder les entités sous… > SCR EPSG:2154, dans couches\zone_vol.gpkg. Ne pas utiliser « Définir la projection », qui change l'étiquette sans recalculer les coordonnées.
  • Contrôle : emprise X ≈ 598 000, Y ≈ 6 414 100 (coordonnées Lambert 93 plausibles).
zone_vol dessinée, encore en EPSG:3857
zone_vol dessinée, encore en EPSG:3857
Propriétés de la copie : EPSG:2154, emprise en Lambert 93
Propriétés de la copie : EPSG:2154, emprise en Lambert 93

Étape 2 – Créer la couche du point de décollage

  • Couche > Créer une couche > Nouvelle couche GeoPackage, dans le même fichier zone_vol.gpkg, table decollage, type Point, EPSG:2154, aucun champ.
  • Piège évité : la première saisie mettait zone_vol dans « Nom de la table », ce qui aurait écrasé la zone. Toujours choisir « Ajouter une nouvelle couche », jamais « Écraser ».
Saisie corrigée : table decollage, sans champ
Saisie corrigée : table decollage, sans champ

Étape 3 – Créer la mission leurre sur la RC 2

  1. Dans DJI Fly, créer une mission de 2 waypoints quelconques et l'enregistrer.
  2. Brancher la RC 2 au PC en USB.
  3. Copier le .kmz le plus récent de Android/data/dji.go.v5/files/waypoint/<ID>/ sur le PC, sans le modifier, et en garder une copie.

Le leurre contient droneEnumValue 68 (Mini 4 Pro), des hauteurs relatives au décollage et un retour automatique en fin de mission.

Étape 4 – Générer la mission d'essai

mission_drone_dji.py, mode Essai 2 points : 6 waypoints, 238 m, 1 min environ. La prairie est 6,5 m plus bas que le point de décollage prévu : A est à 23,5 m et B à 25,2 m sur la RC, soit 30 m au-dessus de l'herbe.

Message de fin du script, mission d'essai
Message de fin du script, mission d'essai
Trajet d'essai : 0 et 5 superposés au-dessus du décollage, A et B au centre
Trajet d'essai : 0 et 5 superposés au-dessus du décollage, A et B au centre

Étape 5 – Charger la mission sur la RC 2

  1. Sauvegarder le KMZ d'origine du leurre.
  2. Copier le KMZ généré à sa place, même nom, même dossier.
  3. Fermer complètement DJI Fly, le rouvrir, ouvrir la mission : 6 points affichés, import validé.

Étape 6 – Vol d'essai et contrôle

Déroulé : décollage, stationnaire à 3 m, intervalle photo 2 s lancé à la main, puis lancement de la mission. 90 photos, analysées avec lire_hauteurs_photos_dji.py et RealityScan.

Contrôle Résultat
Intervalle 2 s maintenu pendant la mission oui, aucune coupure
Montée verticale à 40 m oui
Nacelle à −90° au waypoint 0 oui
Hauteur en A (prévu 23,5 m) 23,7 m
Longueur A → B (prévu 20 m) 19,8 m
Retour et atterrissage automatiques oui
Décollage au point prévu non : ≈ 47 m plus au sud-ouest

Le sol au décollage réel (390,0 m) est 2,3 m plus bas qu'au point prévu (392,3 m). Toutes les hauteurs étant comptées depuis le vrai décollage, le drone a volé à 27,9 m au-dessus de l'herbe au lieu de 30 m.

Règle retenue : décoller exactement au point decollage (à 2-3 m près), ou déplacer le point dans QGIS et régénérer la mission.

Vol d'essai dans RealityScan : trajectoire des caméras (descente, A → B, remontée)
Vol d'essai dans RealityScan : trajectoire des caméras (descente, A → B, remontée)

3. Phase 2 – Placettes et mission complète (22/10/2026)

Huit placettes de 10 × 10 m ont été tirées (graine 2026), puis la mission complète générée : 54 waypoints, environ 1 450 m et 10 min, une seule batterie.

Étape 7 – Déplacer le point de décollage

Le point decollage est placé au lieu de décollage réel du 21/10, dont on sait qu'il convient : X = 598120,02 ; Y = 6414116,21 (coin sud-est de la zone, sol à 389,7 m). Le déplacement se fait par la console Python (script en annexe), pour avoir des coordonnées exactes. Contrôle : la couche contient un seul point.

Point de décollage déplacé au coin sud-est de zone_vol
Point de décollage déplacé au coin sud-est de zone_vol

Étape 8 – Tirer les placettes

tirage_placettes_generique.py avec les paramètres suivants :

Paramètre Valeur
Zone de tirage zone_vol (0,71 ha), sans tampon
Placettes demandées / placées 10 / 8 (P01 à P08, du nord au sud)
Forme, taille carré de 10 × 10 m (100 m²)
Espacement entre bords 10 m, soit 24,1 m exigés entre centres
Distance minimale obtenue entre centres 24,3 m
Taux d'échantillonnage 11,34 %
Graine 2026
Fichier retenu placettes_20261022_0955.gpkg

Un premier tirage (9 h 51) a été annulé : la valeur proposée de 4 m avait été gardée pour le côté du carré. Il a été relancé avec la même graine, et le fichier renommé placettes_20261022_0951_ANNULE.gpkg. Seules 8 placettes ont pu être placées à cause de l'espacement : le résultat est conservé, sans nouveau tirage.

Tirage annulé : carrés de 4 m
Tirage annulé : carrés de 4 m
Tirage retenu : 8 carrés de 10 m et message de fin
Tirage retenu : 8 carrés de 10 m et message de fin
Dossier Test Drone : tirage retenu et tirage annulé conservés
Dossier Test Drone : tirage retenu et tirage annulé conservés

Étape 9 – Générer la mission complète

mission_drone_dji.py, mode Mission complète, avec zone_vol, decollage et centres. Résultat dans mission_20261022_1039 :

  • 54 waypoints, environ 1 450 m, environ 10 min, environ 289 photos ;
  • 6 lignes espacées de 10,2 m, les lignes extérieures sur les bords de la zone ;
  • relief de −14,8 m (ouest) à +3,0 m (est) par rapport au décollage, d'où des hauteurs RC de 6,1 à 40 m pour un vol à 30 m (grille) et 15 m (placettes) au-dessus de l'herbe.
Mission complète dans QGIS : trajet, waypoints étiquetés, message de fin
Mission complète dans QGIS : trajet, waypoints étiquetés, message de fin

Lecture des hauteurs : hauteur RC = hauteur au-dessus de l'herbe + (sol au waypoint − sol au décollage).

Phase WP Rôle Hauteur RC (m) Au-dessus de l'herbe (m) Sol / décollage (m)
Montée 0 Montée verticale au-dessus du décollage 40,0 40,0 0,0

1 Approche, 2 m avant la ligne 1 40,0 40,6 −0,6
Grille 2 → 7 L1 est → ouest 29,1 → 18,4 30 −0,9 → −11,6

8 → 14 L2 ouest → est 16,8 → 29,8 30 −13,2 → −0,2

15 → 21 L3 est → ouest 30,5 → 15,2 30 +0,5 → −14,8

22 → 28 L4 ouest → est 15,3 → 31,5 30 −14,7 → +1,5

29 → 35 L5 est → ouest 32,5 → 15,8 30 +2,5 → −14,2

36 → 42 L6 ouest → est 16,6 → 33,0 30 −13,4 → +3,0
Placettes 43 Approche de P01 32,6 30,0 +2,6

44 P01 17,6 15 +2,6

45 P04 14,9 15 −0,1

46 P07 12,9 15 −2,1

47 P06 11,2 15 −3,8

48 P03 10,0 15 −5,0

49 P08 7,4 15 −7,6

50 P05 6,1 15 −8,9

51 P02 13,0 15 −2,0
Retour 52 Remontée sur place 40,0 41,7 −1,7

53 Au-dessus du décollage, puis retour automatique 40,0 40,0 0,0

Le drone s'arrête en bout de ligne et passe sans s'arrêter sur les points intermédiaires. Il enchaîne les placettes à 15 m sans remonter, les trajets restant à plus de 5 m du bord de la zone.

Points de vigilance en vol :

  1. Les 6,1 m affichés sur P05 correspondent à 15 m au-dessus de l'herbe : le sol y est 8,9 m plus bas que le décollage.
  2. Les extrémités ouest (WP 7, 8, 21, 22, 35, 36) sont les points les plus loin (≈ 170 m) et les plus bas : garder le drone à vue.
  3. Les extrémités est débordent de 10 m vers la haie de la route : vérifier que les arbres ne dépassent pas ≈ 20 m.
  4. Entre deux points d'une ligne (30 m), le vol est en ligne droite : une bosse entre les deux n'est pas suivie.

Étape 10 – Charger la mission sur la RC 2

Même procédure qu'à l'étape 5 : KMZ copié à la place du leurre, DJI Fly redémarré, 54 points affichés.

4. Phase 3 – Vol complet, tri des photos, orthophoto (22/10/2026)

Le vol complet a produit 286 photos (environ 289 prévues). L'orthophoto est en cours de calcul dans RealityScan à partir de 193 photos de grille.

Étape 11 – Vol complet

  • Vol vers 14 h 08 – 14 h 18, décollage au point decollage.
  • 286 photos dans DJI_001\22 oct\.
  • Les photos font environ 10 à 11 Mo : l'appareil était en 48 MP (12 MP prévus). L'image est 2 fois plus fine (environ 2,7 mm/pixel à 15 m, contre 5,4 mm en 12 MP), un avantage pour distinguer les classes de bouses, mais l'orthophoto est plus longue à calculer.
Dossier des photos du vol complet : 286 éléments
Dossier des photos du vol complet : 286 éléments

Étape 12 – Trier les photos

Avec 15 m de pente, la hauteur affichée sur la RC ne suffit plus à trier : une photo de la grille prise à l'ouest (≈ 15 m sur la RC) a la même hauteur qu'une photo de placette prise à l'est (≈ 17 m). trier_photos_vol.py calcule donc pour chaque photo sa hauteur réelle au-dessus du sol et tient compte de sa position :

Classe Conditions
ortho nacelle verticale, ≈ 30 m au-dessus du sol (± 4 m), à moins de 15 m de zone_vol
placette nacelle verticale, ≈ 15 m au-dessus du sol (± 4 m), à moins de 8 m d'un centre
écartée décollage, montées et descentes, transits, atterrissage

Les photos retenues sont copiées dans ortho\ et placettes\ (les originaux ne bougent pas). Les photos de placettes sont renommées, par exemple P05_MEILLEURE_DJI_….JPG.

Étape 13 – Orthophoto dans RealityScan

  • Import du seul dossier ortho\ : 193 images.
  • Zone de reconstruction (« Verify Reconstruction Region ») : le cadre englobe les 6 lignes de vol, de la lisière ouest à la route de Roussières.
  • Calcul : alignement, reconstruction, simplification et texture terminés ; projection orthographique en cours (≈ 23 min au total).
RealityScan : zone de reconstruction et positions des 193 photos de grille
RealityScan : zone de reconstruction et positions des 193 photos de grille
RealityScan : calcul de l'orthophoto en cours
RealityScan : calcul de l'orthophoto en cours

Étape 14 – Convertir l'orthophoto pour QGIS (23/10/2026)

L'export de RealityScan (OrthoRealityScan.tiff, 2 Go) s'ouvrait dans QGIS mais était très lent, et les pyramides étaient refusées. gdalinfo en a donné trois causes :

Défaut de l'export Conséquence
Coordonnées en WGS 84 (degrés), pas en Lambert 93 reprojection à la volée de 537 millions de pixels
Image stockée tournée de 180° (origine au coin sud-est) retournement à chaque lecture ; pyramides refusées
Bandes de 35 311 × 256 pixels, non tuilées ni compressées lecture de lignes entières pour le moindre zoom

Correction dans OSGeo4W Shell (installé avec QGIS), QGIS fermé :

cd /d "C:\Users\pierr\Downloads\Test Drone\couches"
gdalinfo OrthoRealityScan.tiff
gdalwarp -t_srs EPSG:2154 -tr 0.01 0.01 -tap -r average -multi -wo NUM_THREADS=ALL_CPUS -co TILED=YES -co COMPRESS=DEFLATE -co BIGTIFF=IF_SAFER OrthoRealityScan.tiff ortho_tmp.tif
gdal_translate -b 1 -b 2 -b 3 -mask 4 -co TILED=YES -co COMPRESS=JPEG -co PHOTOMETRIC=YCBCR -co JPEG_QUALITY=85 --config GDAL_TIFF_INTERNAL_MASK YES ortho_tmp.tif ortho_20261022.tif
gdaladdo -r average ortho_20261022.tif 2 4 8 16 32 64
gdalinfo ortho_20261022.tif
del ortho_tmp.tif
Commande Rôle
gdalinfo diagnostic : système de coordonnées, taille de pixel, bandes, structure
gdalwarp reprojette en Lambert 93, remet le nord en haut, rééchantillonne à 1 cm (moyenne), tuile le fichier
gdal_translate compression JPEG qualité 85 ; la bande alpha devient un masque (bords transparents)
gdaladdo aperçus réduits (1/2 à 1/64) pour un affichage immédiat

Résultat, couches\ortho_20261022.tif : EPSG:2154, pixel de 1 cm, 22 273 × 9 894 pixels (222,7 × 98,9 m), tuiles de 256 × 256, 6 niveaux d'aperçus, 78 Mo au lieu de 2 Go (27 fois plus léger). Pourquoi 1 cm : une bouse d'environ 25 cm couvre encore environ 25 pixels de large, et le comptage fin se fait sur les photos de placettes (≈ 2,7 mm/pixel). Les exports d'origine vont dans couches\archives_ortho\.

Pour la prochaine fois : convertir systématiquement l'export RealityScan avec ces commandes, ou régler l'export en EPSG:2154 avec une taille de pixel de 0,01 m si l'option est trouvée dans RealityScan.

À faire

  • [ ] Lire le bilan de trier_photos_vol.py : hauteur de la grille ≈ 30 m partout, chaque placette avec ses photos, cap ≈ 0°, écart au centre < 2 m.
  • [ ] Exporter l'orthophoto (GeoTIFF, EPSG:2154) et l'ouvrir dans QGIS : fait le 23/10, voir l'étape 14.
  • [ ] Écrire compter_bouses_ortho.py et le régler sur 4 placettes, puis le valider sur les 4 autres en le comparant au comptage au sol.

5. Phase 4 – Comptage automatique des bouses sur l'orthophoto (23/10/2026)

Un premier comptage sur toute la zone, sans placettes, donne 3 453 taches, soit environ 4 900 bouses/ha, couvrant 1,1 % de la surface. Ce résultat n'est pas encore validé : un contrôle visuel sur 5 carrés tirés au hasard doit mesurer sa fiabilité (étape 19).

Étape 15 – Principe de la méthode

Le script compter_bouses_ortho.py reprend l'idée d'une palette de couleurs et de pixels jointifs :

  1. Palette par l'exemple : des points posés sur l'orthophoto, sur des bouses (fraîche, croûtée, ancienne) et sur ce qui n'en est pas (herbe, herbe sèche, sol nu, autre). Le script lit leur couleur moyenne sur 5 × 5 pixels, en couleurs Lab (proches de la perception humaine).
  2. Classement : chaque pixel prend la classe de l'exemple le plus proche en couleur. Trop loin de tous les exemples, il n'est pas classé bouse.
  3. Ouverture puis fermeture : suppression des grains isolés, puis comblement des fentes et des trous d'une bouse.
  4. Nettoyage : suppression des taches plus petites que la surface minimale.
  5. Pixels jointifs : chaque groupe de pixels « bouse » qui se touchent, y compris en diagonale, devient une bouse.
  6. Filtres : surface minimale, compacité (une trace allongée est rejetée), part minimale de vrais pixels bouse ; une tache très grande est marquée « à vérifier ».
  7. Classe de chaque bouse : celle de la majorité de ses pixels.

Sorties : couche de points bouses_auto, contours bouses_contours, raster classes.tif (classe de chaque pixel), fichier parametres.txt (traçabilité).

Étape 16 – Constituer la palette d'exemples

Le vol complet a eu lieu par ciel couvert : il n'y a pas d'ombre portée, et une même bouse a la même couleur partout. C'est favorable au comptage, mais la palette n'est valable que pour ce vol.

Classe 1re version Version utilisée (14 h 06)
bouse_fraiche 9 12
bouse_croutee 5 6
bouse_ancienne 8 10
herbe 4 16
herbe_seche 4 18
sol_nu 8 8
ombre 1 1
autre 18 20
Total 57 91

La première version manquait d'exemples d'herbe : l'herbe couvre plus de 90 % de la prairie, et ses nuances doivent toutes être représentées pour ne pas être confondues avec des bouses.


Couche echantillons_bouses sur l'orthophoto : vue d'ensemble de la répartition des 91 exemples, colorés par classe

Zoom au 1:50 : un exemple de chaque classe (bouse fraîche, croûtée, ancienne, herbe, herbe sèche, litière « autre »)

Étape 17 – Premier comptage (23/10, 14 h 06)

Réglages : surface minimale 150 cm², compacité minimale 0,25, écart de couleur maximal 25, fermeture 2 px, sans ouverture ni filtre de part minimale. Durée : 285 s.

Résultat Valeur
Taches retenues 3 453, soit environ 4 900/ha
Dont bouse fraîche / croûtée / ancienne 382 / 129 / 2 183
Indéterminées (surtout des trous comblés) 759
À vérifier (> 2 500 cm²) 1
Écartées 29 trop petites, 6 578 trop allongées
Surface médiane d'une tache 225 cm² (80 % des taches entre 160 et 450 cm²)
Surface couverte environ 79 m², soit 1,1 % de la zone

Ce qui est plausible : 1,1 % de surface couverte et environ 290 journées de pâturage d'une vache (12 bouses par jour), sachant qu'une bouse ancienne reste visible plusieurs mois. Les filtres ont éliminé la pointe ouest, entièrement classée bouse au niveau des pixels.

Ce qui interroge : 759 taches indéterminées, une majorité de bouses anciennes (la classe que la couleur sépare le moins bien de la litière sèche) et une répartition très régulière, alors que les bouses se concentrent habituellement près des zones de repos, des haies et des passages.


Couche bouses_auto sur l'orthophoto : vue d'ensemble de la zone de vol, points colorés par classe

Zoom au 1:50 sur une zone typique : orthophoto avec bouses_contours, pour juger à l'œil des détections justes et fausses

Extraits du raster classes.tif (6 × 6 m) avant filtrage. Vert : rien ; brun foncé : fraîche ; brun : croûtée ; beige : ancienne ; magenta : trous comblés par la fermeture.

classes.tif, centre de la prairie : grains beiges épars réunis par la fermeture
classes.tif, centre de la prairie : grains beiges épars réunis par la fermeture
classes.tif, pointe ouest : végétation brun-beige presque entièrement classée bouse, puis écartée par les filtres
classes.tif, pointe ouest : végétation brun-beige presque entièrement classée bouse, puis écartée par les filtres

Étape 18 – Réglages corrigés (version 2 du script)

Ces réglages limitent l'agrégation de grains de litière en fausses bouses. La version 2 n'a pas encore tourné ; elle sera comparée à la première sur les mêmes carrés de contrôle.

Réglage Version 1 Version 2 Effet attendu
Ouverture (nouvelle) — 1 px supprime les grains isolés avant le comblement
Fermeture 2 px 1 px soude moins les grains voisins
Part minimale de vrais pixels (nouveau filtre) — 0,6 rejette les taches faites surtout de trous comblés
Écart de couleur maximal 25 15 ne garde que les pixels très proches d'un exemple de bouse
Surface minimale 150 cm² 250 cm² ≈ une bouse de 18 cm de diamètre
Compacité minimale 0,25 0,35 rejette les formes déchiquetées

Une simulation sur classes.tif, sans le nouvel écart de couleur, donne environ 700 taches (environ 1 000/ha) au lieu d'environ 6 700 avant filtres de forme.


Même zoom au 1:50 que l'étape 17, avec les contours de la version 1 et de la version 2 superposés (deux couleurs)

Étape 19 – Contrôle de fiabilité (à faire)

  1. Tirer 5 carrés de 10 × 10 m dans zone_vol avec tirage_placettes_generique.py, graine 2027.
  2. Pour chaque carré, au 1:50 sur l'orthophoto, compter : les vrais positifs (VP, un point sur une vraie bouse), les faux positifs (FP, un point sans bouse dessous) et les manquées (FN, une bouse sans point).
  3. Calculer la précision = VP / (VP + FP) et le rappel = VP / (VP + FN), pour la version 1 puis pour la version 2.
Carré VP FP FN Précision Rappel
C01




C02




C03




C04




C05




Total




Règle de décision proposée : une version est retenue si la précision et le rappel dépassent tous deux 80 %. La densité corrigée s'obtient alors par densité brute × précision / rappel.

FIGURE 23 – IMAGE À COLLER ICI
Les 5 carrés de contrôle sur l'orthophoto (vue d'ensemble, étiquettes C01 à C05)
FIGURE 24 – IMAGE À COLLER ICI
Un carré de contrôle au 1:50 avec VP, FP et FN repérés (exemple de comptage)

6. Points soumis à l'avis des collègues

  1. Validation sans comptage au sol : le contrôle de l'étape 19 compare le script à un comptage à l'œil sur l'orthophoto, pas à un comptage sur le terrain. Est-ce suffisant pour un usage pédagogique, ou faut-il des placettes comptées au sol ?
  2. Seuil de 80 % de précision et de rappel : ce niveau vous paraît-il adapté ?
  3. Classes d'âge des bouses : à 1 cm/pixel, la bouse ancienne se distingue mal de la litière. Faut-il garder trois classes, ou regrouper en « fraîche et croûtée » et « ancienne (faible confiance) » ?
  4. Interprétation biologique : une densité de l'ordre de 1 000 à 5 000 bouses/ha est-elle cohérente avec le chargement et la durée de pâturage connus de cette parcelle ?
  5. Conditions de vol : imposer un ciel couvert, ou le soleil haut, pour que la palette reste valable ?
  6. Recul aux lisières : la zone de vol a été tracée en retrait des houppiers, sans tampon supplémentaire ; les bouses de lisière sont donc exclues. Est-ce acceptable pour la question posée ?

7. Les scripts et leurs paramètres expliqués

Chaque valeur proposée découle de trois contraintes : la taille de l'image au sol, la précision des capteurs (GPS, baromètre, MNT) et la sécurité autour des haies. Les tableaux ci-dessous donnent, pour chaque fenêtre de saisie, la valeur utilisée, la raison et l'effet d'une modification.

Repère : ce que couvre une photo du Mini 4 Pro

Objectif équivalent 24 mm, image 4:3, nacelle à −90° : l'emprise au sol vaut environ 1,44 × la hauteur dans le grand côté et 1,08 × la hauteur dans le petit côté.

Hauteur au-dessus du sol Emprise au sol Taille d'un pixel en 12 MP Taille d'un pixel en 48 MP
15 m ≈ 21,6 × 16,2 m ≈ 5,4 mm ≈ 2,7 mm
30 m ≈ 43 × 32 m ≈ 1,1 cm ≈ 5,4 mm

Pendant les lignes, le nez suit la trajectoire : le petit côté (32 m à 30 m) est dans le sens de la marche, le grand côté (43 m) en travers.

mission_drone_dji.py – génération de la mission

Fenêtre Valeur Pourquoi cette valeur Si on la change
Type de mission Essai 2 points, puis Mission complète L'essai teste l'import et les réglages en 1 min, sans risque, avant de lancer 10 min de vol —
Zone / Décollage / Placettes zone_vol / decollage / centres Les noms par défaut des couches du projet. Avec « (aucune) » pour les placettes, on obtient l'orthophoto seule Une autre couche de placettes = une autre série de photos verticales
Altitude du sol Service IGN RGE ALTI à 1 m, précis à ≈ 0,5 m, sans téléchargement. Une dalle MNT locale sert de secours sans internet Sans correction du relief, le drone aurait volé à 45 m au-dessus du sol à l'ouest au lieu de 30 m
KMZ leurre fichier copié de la RC 2 Il contient les réglages propres au Mini 4 Pro, que DJI Fly exige ; le KMZ produit garde son nom Un leurre d'un autre drone donne une mission refusée
Hauteur de sécurité 40 m Les arbres des haies font jusqu'à ≈ 20-25 m : 40 m laisse 15 m de marge pour la montée, les transits et le retour Moins = risque de toucher la haie ; plus = montées plus longues, limite légale à 120 m
Hauteur de l'orthophoto 30 m au-dessus du sol Emprise de 43 × 32 m : la zone de 56 m de large se couvre en 6 lignes, avec des pixels de 0,5 à 1 cm Plus bas = image plus fine mais plus de lignes et de photos ; plus haut = moins de détail sur les bouses
Vitesse sur les lignes 3 m/s Avec une photo toutes les 2 s, une photo tous les 6 m, soit (32 − 6) / 32 ≈ 80 % de recouvrement avant. Le flou de bougé reste ≈ 1 pixel (6 mm en 1/500 s) Plus vite = moins de recouvrement, risque de trous dans l'orthophoto
Écart entre lignes 12,5 m (43 − 12,5) / 43 ≈ 70 % de recouvrement latéral, le minimum courant en photogrammétrie. Le script le réduit pour que les lignes extérieures tombent sur les bords (10,2 m ici) Plus grand = moins de lignes mais une orthophoto fragile ; plus petit = plus de temps de vol
Débord hors zone 10 m Les bords d'une orthophoto sont mal reconstruits faute de recouvrement. Le débord ne s'applique qu'en bout de ligne, pas sur les côtés (les haies) Plus = survol des haies ; 0 = bords flous
Points intermédiaires tous les 30 m Le drone suit le relief point par point. Sur une pente régulière (10 % ici), 30 m suffisent ; cela donne 54 waypoints au total Plus serré = relief mieux suivi mais plus de waypoints ; 0 = extrémités seules, à éviter sur pente
Hauteur des placettes 15 m au-dessus du sol Emprise de 21,6 × 16,2 m : le carré de 10 m tient avec ≈ 3 m de marge de chaque côté, plus que la dérive GPS du drone (1 à 3 m) 10 m : marge de 0,2 m, le carré sort de l'image ; 20 m : moins de détail
Stationnaire par placette 6 s 3 photos à 2 s ; on garde la plus nette et la plus centrée Moins = risque d'une seule photo floue ; plus = vol plus long

Réglages fixes dans le code : 2 m de décalage pour les montées et descentes (DJI Fly accepte mal deux waypoints superposés ; 2 m donne une descente presque verticale) et 5 m de marge au bord de la zone pour enchaîner les placettes sans remonter (on reste au-dessus de l'herbe, loin des branches).

tirage_placettes_generique.py – tirage des placettes

Fenêtre Valeur Pourquoi cette valeur Si on la change
Zone d'étude zone_vol (prévu : zone_tiree) Le carré doit être entier dans la zone. Un tampon négatif éloigne des lisières (ombre, effet lisière) ; ici, la zone était déjà tracée en retrait Sans retrait, une placette peut toucher la haie
Nombre de placettes 10 (8 placées) Une pré-étude demande 10 à 12 placettes pour estimer la variabilité, puis n = s² / (CV² × ȳ²) Trop peu = variance mal estimée
Forme Carré Facile à repérer au pas et à la boussole sur le terrain, facile à découper sur la photo Un cercle se trace au pas autour d'un point, mais se repère moins bien sur l'image
Côté du carré 10 m (la valeur proposée est 4) Les bouses sont rares et dispersées : avec 100 m², on évite trop de placettes à 0 bouse. Le carré de 4 m servait aux ligneux 4 m = beaucoup de zéros, comptage peu informatif (erreur du tirage annulé)
Espacement entre bords 10 m Évite deux placettes sur la même photo et rend les comptages indépendants. Le script exige 24,1 m entre centres (2 demi-diagonales de 7,07 m + 10 m), pour que l'écart tienne même en diagonale Plus grand = moins de placettes possibles (c'est ce qui a limité à 8)
Graine 2026 Même graine et mêmes paramètres = même tirage : n'importe qui peut le refaire et le vérifier Changer la graine pour « un plus joli tirage » biaise l'échantillon : interdit

trier_photos_vol.py – tri des photos

Fenêtre Valeur Pourquoi cette valeur Si on la change
Dossier des photos DJI_001\22 oct Un dossier par vol : le tri et les copies restent rangés avec leurs originaux —
Hauteur de l'orthophoto 30 m La hauteur visée par la mission Doit être celle de la mission, sinon aucune photo n'est classée ortho
Hauteur des placettes 15 m Idem Idem
Altitude du sol Service IGN Même source que pour la mission : les deux calculs sont cohérents Une autre source peut décaler les hauteurs de quelques dizaines de cm
Copie Oui RealityScan reçoit le seul dossier ortho\, et les placettes sont nommées par numéro. Les originaux ne bougent pas Non = tri seulement dans le CSV et la couche QGIS

Réglages fixes dans le code :

  • Nacelle ≤ −85° : la nacelle annonce −90° à 1-2° près, et les photos de transit sont à 0° ou à −13/−40° (retour automatique).
  • Tolérance de ± 4 m sur la hauteur : elle couvre le baromètre (± 0,5 m), le MNT (± 0,5 m) et le GPS sur la pente, tout en excluant les montées et descentes entre 15 et 30 m.
  • 15 m autour de la zone pour l'orthophoto : les 10 m de débord plus la marge du GPS.
  • 8 m autour d'un centre pour les placettes : on capte toutes les photos du stationnaire malgré la dérive, puis on retient comme « meilleure » la plus proche du centre.

compter_bouses_ortho.py – comptage automatique (version 2)

Fenêtre Valeur Pourquoi cette valeur Si on la change
Orthophoto / Zone ortho_20261022 / zone_vol orthophoto convertie (Lambert 93, 1 cm, nord en haut) ; le script refuse une image tournée —
Surface minimale 250 cm² une bouse fraîche fait 300 à 700 cm² ; en dessous, ce sont des brins, des cailloux ou de la litière plus bas = plus de faux positifs ; plus haut = petites bouses anciennes perdues
Surface « à vérifier » 2 500 cm² au-delà de 50 × 50 cm, ce sont probablement plusieurs bouses collées n'enlève rien, signale seulement
Compacité minimale 0,35 4π × surface / périmètre² vaut environ 0,7 pour un disque dessiné en pixels, et moins de 0,1 pour une trace de roue plus haut = bouses éclatées rejetées
Écart de couleur maximal 15 (ΔE Lab) 15 écarts « juste perceptibles » environ : seuls les pixels très proches d'un exemple sont gardés plus haut = plus de litière prise pour des bouses
Ouverture 1 px efface les grains de moins de 3 pixels (3 cm) de large avant le comblement 0 = grains conservés puis soudés en fausses bouses
Fermeture 1 px comble les fentes de 2 cm des croûtes 2 px ou plus = grains voisins soudés
Part minimale de vrais pixels 0,6 au moins 60 % de la tache doit être de la couleur d'une bouse, pas du comblement plus bas = taches indéterminées conservées

Réglages fixes dans le code : fenêtre de 5 × 5 pixels autour de chaque exemple (5 cm, moyenne de la couleur sans mélanger avec l'herbe voisine), traitement par tuiles de 2 048 pixels (mémoire), pixels jointifs y compris en diagonale (connexité 8).

lire_hauteurs_photos_dji.py – lecture rapide des métadonnées

Il a servi au vol d'essai. Les réglages se font en tête de script : DOSSIER (le dossier des photos), NACELLE_MIN = -85, H_ORTHO = (23, 26) (hauteurs RC attendues entre A et B), H_PLACETTE = None. Il ne compare que la hauteur RC : il convient pour un terrain plat. Sur pente, utiliser trier_photos_vol.py.

Petits scripts de console

Ils sont donnés en annexe et n'ont pas de paramètre à saisir :

  • déplacer le point de décollage aux coordonnées voulues ;
  • calculer la hauteur réelle au-dessus de l'herbe après un décollage décalé ;
  • exporter la table des waypoints en CSV.

8. Écarts au protocole et choix retenus

Point Prévu Réalisé Raison
Recul aux lisières tampon de −10 m (règle de Gramat), puis −2 m 0 m (tirage dans zone_vol) zone_vol déjà tracée en retrait des houppiers ; pas de 3ᵉ tirage
Nombre de placettes 10 (pré-étude 10-12) 8 Espacement de 24,1 m entre centres
Tirage un seul 1 annulé (côté de 4 m), 1 retenu, même graine Erreur de saisie, pas une recherche de répartition
Point de décollage sur la route, au nord-est coin sud-est, lieu du décollage réel du 21/10 Hauteurs calculées depuis le vrai décollage
Hauteur des photos de placettes 10 m 15 m Le carré de 10 m reste dans l'image malgré la dérive GPS
Déclenchement des photos une action photo par waypoint intervalle de 2 s lancé à la main 54 waypoints au lieu de ≈ 300 ; fonctionnement vérifié à l'essai
Résolution 12 MP 48 MP Réglage de l'appareil ; image plus fine, calcul plus long
Hauteurs constantes sur la RC corrigées par le relief (RGE ALTI) DJI Fly compte depuis le décollage ; 18 m de dénivelé sur la zone
Survol des haies — WP0 et transits à 40 m ; lignes extérieures sur les bords ; débord en bout de ligne seulement Sécurité
Retour automatique — ≥ 40 m dans DJI Fly Haies

9. Annexes – code des scripts

Voici les versions utilisées les 21 et 22/10/2026. Les fichiers de référence se trouvent aussi dans les documents du projet (claude/…). Chaque script s'exécute dans QGIS : Console Python > Afficher l'éditeur > Ouvrir > Exécuter.

A1. mission_drone_dji.py

Afficher le code (538 lignes)
# -*- coding: utf-8 -*-
"""
Mission drone DJI (Mini 4 Pro + RC 2 / DJI Fly) generee depuis QGIS
====================================================================
A executer dans QGIS : Extensions > Console Python > Afficher l'editeur >
ouvrir ce fichier > Executer.

Prerequis
  - Projet QGIS ENREGISTRE, en EPSG:2154.
  - Couche de polygones 'zone_vol' (zone a cartographier).
  - Couche de points 'decollage' (UN point, sur la route).
  - Facultatif : couche de points 'centres' (placettes tirees) -> photo
    verticale en vol stationnaire au-dessus de chaque placette.
  - Le .kmz d'une mission 'leurre' creee dans DJI Fly et copiee depuis la
    RC 2 (Android/data/dji.go.v5/files/waypoint/<ID>/<ID>.kmz). Le script en
    garde les reglages propres au drone et ne remplace que les waypoints.

Deroulement de la mission produite
  WP0 a la verticale du decollage (montee droite au-dessus de la haie)
  -> grille d'orthophoto (lignes paralleles au grand axe de la zone,
     debordant de la zone, hauteur constante AU-DESSUS DU SOL)
  -> placettes : descente, vol stationnaire nez au nord
  -> remontee, retour au-dessus du decollage, retour automatique (RTH).
  Nacelle a -90 deg pendant toute la mission. Les photos sont prises par
  l'intervalle automatique (ex. 2 s) lance a la main AVANT la mission.

Les hauteurs sont corrigees par le relief : altitude du sol lue sur le
service d'altimetrie IGN (RGE ALTI, internet) ou sur une couche raster MNT
chargee dans le projet. Dans le KMZ, les hauteurs sont relatives au point
de decollage (c'est ce qu'attend DJI Fly).

Sorties (dossier du projet, sous-dossier mission_AAAAMMJJ_HHMM) :
  - <meme nom que le leurre>.kmz  : a copier sur la RC 2 a la place du leurre
  - mission.gpkg : couches 'waypoints' et 'trajet' (controle visuel)
"""

import os
import re
import json
import math
import zipfile
import time
from datetime import datetime

from qgis.core import (QgsProject, QgsVectorLayer, QgsRasterLayer, QgsField, QgsFeature,
                       QgsGeometry, QgsPointXY, QgsWkbTypes, QgsUnitTypes,
                       QgsCoordinateReferenceSystem, QgsCoordinateTransform,
                       QgsVectorFileWriter, QgsBlockingNetworkRequest,
                       QgsPalLayerSettings, QgsVectorLayerSimpleLabeling)
from qgis.PyQt.QtCore import QVariant, QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtWidgets import QInputDialog, QMessageBox, QFileDialog

try:
    PARENT = iface.mainWindow()          # noqa: F821  (iface existe dans la console QGIS)
except NameError:
    PARENT = None

AUCUNE = '(aucune)'
IGN = 'Service altimétrie IGN (internet)'
URL_IGN = ('https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json'
           '?lon=%s&lat=%s&resource=ign_rge_alti_wld&delimiter=|&zonly=true')
DECALAGE_MONTEE = 2.0    # m : decalage horizontal des points de montee / descente
MARGE_HAIE = 5.0         # m : vol bas entre placettes seulement a > 5 m du bord de la zone


class Annule(Exception):
    pass


# ---------------------------------------------------------------- saisies
def demander_reel(titre, texte, defaut, mini, maxi, decimales=1):
    v, ok = QInputDialog.getDouble(PARENT, titre, texte, defaut, mini, maxi, decimales)
    if not ok:
        raise Annule()
    return v


def demander_choix(titre, texte, choix, defaut=0):
    v, ok = QInputDialog.getItem(PARENT, titre, texte, choix, defaut, False)
    if not ok:
        raise Annule()
    return v


def message(titre, texte, erreur=False):
    if PARENT is not None:
        (QMessageBox.critical if erreur else QMessageBox.information)(PARENT, titre, texte)
    print(('ERREUR - ' if erreur else '') + titre + ' : ' + texte)


def choisir_couche(titre, texte, couches, nom_defaut, facultative=False):
    noms = [l.name() for l in couches]
    if facultative:
        noms = [AUCUNE] + noms
    defaut = noms.index(nom_defaut) if nom_defaut in noms else 0
    nom = demander_choix(titre, texte, noms, defaut)
    if nom == AUCUNE:
        return None
    return couches[[l.name() for l in couches].index(nom)]


# ---------------------------------------------------------------- altitudes du sol
def altitudes_ign(pts_wgs):
    z = []
    for i in range(0, len(pts_wgs), 50):
        lot = pts_wgs[i:i + 50]
        url = URL_IGN % ('|'.join('%.7f' % p.x() for p in lot), '|'.join('%.7f' % p.y() for p in lot))
        req = QgsBlockingNetworkRequest()
        if req.get(QNetworkRequest(QUrl(url))) != QgsBlockingNetworkRequest.NoError:
            raise RuntimeError('service IGN injoignable : ' + req.errorMessage())
        rep = json.loads(bytes(req.reply().content()).decode('utf-8'))
        for v in rep['elevations']:
            z.append(float(v['z'] if isinstance(v, dict) else v))
    return z


def altitudes_raster(couche, pts, crs_pts, contexte):
    tr = QgsCoordinateTransform(crs_pts, couche.crs(), contexte)
    z = []
    for p in pts:
        v, ok = couche.dataProvider().sample(tr.transform(p), 1)
        z.append(float(v) if ok else -99999.0)
    return z


# ---------------------------------------------------------------- geometrie
def tourner(p, c, ang):
    """Rotation de p autour de c d'un angle ang (radians, sens trigonometrique)."""
    dx, dy = p.x() - c.x(), p.y() - c.y()
    ca, sa = math.cos(ang), math.sin(ang)
    return QgsPointXY(c.x() + dx * ca - dy * sa, c.y() + dx * sa + dy * ca)


def tourner_geom(g, c, ang):
    parties = g.asMultiPolygon() if g.isMultipart() else [g.asPolygon()]
    return QgsGeometry.fromMultiPolygonXY([[[tourner(p, c, ang) for p in anneau] for anneau in poly]
                                           for poly in parties])


def axe_principal(g):
    """Angle (radians) du plus grand cote du rectangle englobant oriente minimal."""
    boite = g.orientedMinimumBoundingBox()[0]
    pts = boite.asPolygon()[0]
    best, ang = -1, 0.0
    for a, b in zip(pts[:-1], pts[1:]):
        d = math.hypot(b.x() - a.x(), b.y() - a.y())
        if d > best:
            best, ang = d, math.atan2(b.y() - a.y(), b.x() - a.x())
    return ang


def grille(zone, debord, ecart, pas, depart):
    """Lignes de vol en serpentin. Renvoie une liste de lignes, chaque ligne = liste de points."""
    zb = zone.buffer(debord, 16)
    c = zb.centroid().asPoint()
    ang = axe_principal(zone)
    loc = tourner_geom(zb, c, -ang)                        # grand axe horizontal
    bz = tourner_geom(zone, c, -ang).boundingBox()
    bb = loc.boundingBox()
    # lignes extremes SUR les bords de la zone (pas au-dessus des haies laterales),
    # ecart reel <= ecart demande ; le debord s'applique en bout de ligne
    n = max(2, int(math.ceil(bz.height() / ecart)) + 1)
    pas_y = bz.height() / (n - 1)
    lignes = []
    for k in range(n):
        y = bz.yMinimum() + 0.01 + k * (pas_y - 0.02 / (n - 1))
        seg = QgsGeometry.fromPolylineXY([QgsPointXY(bb.xMinimum() - 1, y), QgsPointXY(bb.xMaximum() + 1, y)])
        inter = seg.intersection(loc)
        if inter.isEmpty():
            continue
        x1, x2 = inter.boundingBox().xMinimum(), inter.boundingBox().xMaximum()
        nb = max(1, int(math.ceil((x2 - x1) / pas))) if pas > 0 else 1
        lignes.append([QgsPointXY(x1 + (x2 - x1) * i / nb, y) for i in range(nb + 1)])
    # serpentin
    for k in range(1, len(lignes), 2):
        lignes[k].reverse()
    lignes = [[tourner(p, c, ang) for p in l] for l in lignes]
    # 4 parcours possibles du meme serpentin : on commence au coin le plus proche du decollage
    variantes = [lignes,
                 [list(reversed(l)) for l in lignes],
                 [list(reversed(l)) for l in reversed(lignes)],
                 [list(l) for l in reversed(lignes)]]
    lignes = min(variantes, key=lambda v: v[0][0].distance(depart))
    return lignes, math.degrees(ang), pas_y


def vers(a, b, d):
    """Point a d metres de a, en direction de b."""
    L = a.distance(b)
    if L < 1e-6:
        return QgsPointXY(a.x(), a.y() - d)
    return QgsPointXY(a.x() + (b.x() - a.x()) * d / L, a.y() + (b.y() - a.y()) * d / L)


# ---------------------------------------------------------------- WPML
def xml_waypoint(i, wp, lon, lat):
    if wp['cap'] is None:
        cap = ('<wpml:waypointHeadingMode>followWayline</wpml:waypointHeadingMode>'
               '<wpml:waypointHeadingAngle>0</wpml:waypointHeadingAngle>')
    else:
        cap = ('<wpml:waypointHeadingMode>smoothTransition</wpml:waypointHeadingMode>'
               '<wpml:waypointHeadingAngle>%d</wpml:waypointHeadingAngle>' % wp['cap'])
    virage = ('toPointAndPassWithContinuityCurvature' if wp['passage']
              else 'toPointAndStopWithDiscontinuityCurvature')
    actions = ''
    if i == 0:
        actions += ('''
        <wpml:actionGroup>
          <wpml:actionGroupId>{g}</wpml:actionGroupId>
          <wpml:actionGroupStartIndex>0</wpml:actionGroupStartIndex>
          <wpml:actionGroupEndIndex>0</wpml:actionGroupEndIndex>
          <wpml:actionGroupMode>parallel</wpml:actionGroupMode>
          <wpml:actionTrigger><wpml:actionTriggerType>reachPoint</wpml:actionTriggerType></wpml:actionTrigger>
          <wpml:action>
            <wpml:actionId>0</wpml:actionId>
            <wpml:actionActuatorFunc>gimbalRotate</wpml:actionActuatorFunc>
            <wpml:actionActuatorFuncParam>
              <wpml:gimbalHeadingYawBase>aircraft</wpml:gimbalHeadingYawBase>
              <wpml:gimbalRotateMode>absoluteAngle</wpml:gimbalRotateMode>
              <wpml:gimbalPitchRotateEnable>1</wpml:gimbalPitchRotateEnable>
              <wpml:gimbalPitchRotateAngle>-90</wpml:gimbalPitchRotateAngle>
              <wpml:gimbalRollRotateEnable>0</wpml:gimbalRollRotateEnable>
              <wpml:gimbalRollRotateAngle>0</wpml:gimbalRollRotateAngle>
              <wpml:gimbalYawRotateEnable>0</wpml:gimbalYawRotateEnable>
              <wpml:gimbalYawRotateAngle>0</wpml:gimbalYawRotateAngle>
              <wpml:gimbalRotateTimeEnable>0</wpml:gimbalRotateTimeEnable>
              <wpml:gimbalRotateTime>0</wpml:gimbalRotateTime>
              <wpml:payloadPositionIndex>0</wpml:payloadPositionIndex>
            </wpml:actionActuatorFuncParam>
          </wpml:action>
        </wpml:actionGroup>''').format(g=wp['groupe'])
    if wp['stationnaire'] > 0:
        actions += ('''
        <wpml:actionGroup>
          <wpml:actionGroupId>{g}</wpml:actionGroupId>
          <wpml:actionGroupStartIndex>{i}</wpml:actionGroupStartIndex>
          <wpml:actionGroupEndIndex>{i}</wpml:actionGroupEndIndex>
          <wpml:actionGroupMode>sequence</wpml:actionGroupMode>
          <wpml:actionTrigger><wpml:actionTriggerType>reachPoint</wpml:actionTriggerType></wpml:actionTrigger>
          <wpml:action>
            <wpml:actionId>0</wpml:actionId>
            <wpml:actionActuatorFunc>hover</wpml:actionActuatorFunc>
            <wpml:actionActuatorFuncParam>
              <wpml:hoverTime>{t}</wpml:hoverTime>
            </wpml:actionActuatorFuncParam>
          </wpml:action>
        </wpml:actionGroup>''').format(g=wp['groupe'] + 1, i=i, t=int(round(wp['stationnaire'])))
    return '''
      <Placemark>
        <Point><coordinates>{lon:.10f},{lat:.10f}</coordinates></Point>
        <wpml:index>{i}</wpml:index>
        <wpml:executeHeight>{h:.1f}</wpml:executeHeight>
        <wpml:waypointSpeed>{v:.1f}</wpml:waypointSpeed>
        <wpml:waypointHeadingParam>
          {cap}
          <wpml:waypointPoiPoint>0.000000,0.000000,0.000000</wpml:waypointPoiPoint>
          <wpml:waypointHeadingAngleEnable>1</wpml:waypointHeadingAngleEnable>
          <wpml:waypointHeadingPathMode>followBadArc</wpml:waypointHeadingPathMode>
          <wpml:waypointHeadingPoiIndex>0</wpml:waypointHeadingPoiIndex>
        </wpml:waypointHeadingParam>
        <wpml:waypointTurnParam>
          <wpml:waypointTurnMode>{virage}</wpml:waypointTurnMode>
          <wpml:waypointTurnDampingDist>0</wpml:waypointTurnDampingDist>
        </wpml:waypointTurnParam>
        <wpml:useStraightLine>1</wpml:useStraightLine>{actions}
        <wpml:waypointGimbalHeadingParam>
          <wpml:waypointGimbalPitchAngle>-90</wpml:waypointGimbalPitchAngle>
          <wpml:waypointGimbalYawAngle>0</wpml:waypointGimbalYawAngle>
        </wpml:waypointGimbalHeadingParam>
      </Placemark>'''.format(lon=lon, lat=lat, i=i, h=wp['h_rel'], v=wp['vitesse'], cap=cap,
                             virage=virage, actions=actions)


# ---------------------------------------------------------------- programme
def generer():
    projet = QgsProject.instance()
    dossier = projet.homePath()
    if not dossier:
        message('Projet non enregistré', "Enregistrez d'abord le projet, puis relancez.", True)
        return
    ctx = projet.transformContext()
    vect = [l for l in projet.mapLayers().values() if isinstance(l, QgsVectorLayer)]
    polys = [l for l in vect if l.geometryType() == QgsWkbTypes.PolygonGeometry]
    points = [l for l in vect if l.geometryType() == QgsWkbTypes.PointGeometry]
    rasters = [l for l in projet.mapLayers().values()
               if isinstance(l, QgsRasterLayer) and l.providerType() == 'gdal' and l.bandCount() == 1]

    # 1. choix
    mode = demander_choix('Mission', 'Type de mission :',
                          ['Mission complète (orthophoto + placettes)',
                           'Essai 2 points à 30 m (sans descente)'], 0)
    essai = mode.startswith('Essai')
    if not polys or not points:
        message('Couches manquantes', "Il faut une couche de polygones 'zone_vol' et une couche "
                "de points 'decollage'.", True)
        return
    zone_l = choisir_couche('Zone', 'Couche de la zone à cartographier :', polys, 'zone_vol')
    dec_l = choisir_couche('Décollage', 'Couche du point de décollage :', points, 'decollage')
    for l in (zone_l, dec_l):
        if l.crs().isGeographic() or l.crs().mapUnits() != QgsUnitTypes.DistanceMeters:
            message('Projection', "La couche '%s' doit être en EPSG:2154 (mètres)." % l.name(), True)
            return
    crs = zone_l.crs()
    zone = QgsGeometry.unaryUnion([f.geometry() for f in zone_l.getFeatures()])
    decs = [f.geometry() for f in dec_l.getFeatures()]
    if len(decs) != 1:
        message('Décollage', "La couche '%s' doit contenir UN seul point (elle en a %d)."
                % (dec_l.name(), len(decs)), True)
        return
    g_dec = QgsGeometry(decs[0])
    if dec_l.crs() != crs:
        g_dec.transform(QgsCoordinateTransform(dec_l.crs(), crs, ctx))
    p_dec = g_dec.asPoint()

    pl_l = None
    if not essai:
        autres = [l for l in points if l is not dec_l]
        if autres:
            pl_l = choisir_couche('Placettes', 'Couche des centres de placettes\n'
                                  '(« (aucune) » = orthophoto seule) :', autres, 'centres', True)

    source_z = demander_choix('Altitude du sol', "Source de l'altitude du sol :",
                              [IGN] + [r.name() for r in rasters], 0)

    leurre, _ = QFileDialog.getOpenFileName(PARENT, 'KMZ de la mission leurre (copié depuis la RC 2)',
                                            dossier, 'Mission DJI (*.kmz)')
    if not leurre:
        raise Annule()
    with zipfile.ZipFile(leurre) as z:
        tpl = z.read('wpmz/template.kml').decode('utf-8')
        wpl = z.read('wpmz/waylines.wpml').decode('utf-8')
    if 'droneEnumValue' not in wpl or '<Folder>' not in wpl:
        message('Leurre', "Ce fichier ne ressemble pas à une mission DJI Fly.", True)
        return

    # 2. parametres
    h_wp0 = demander_reel('Hauteur de sécurité', 'Hauteur du WP0 au-dessus du décollage et des '
                          'transits (m) :\n(au-dessus des arbres de la haie)', 40, 15, 120)
    h_ortho = demander_reel('Orthophoto', 'Hauteur de vol au-dessus du SOL (m) :', 30, 10, 120)
    v_ortho = demander_reel('Orthophoto', 'Vitesse sur les lignes (m/s) :\n'
                            '(3 m/s + photo toutes les 2 s = une photo tous les 6 m)', 3.0, 1, 10)
    if not essai:
        ecart = demander_reel('Orthophoto', 'Écart entre lignes (m) :\n(12,5 m à 30 m ≈ 70 % de '
                              'recouvrement latéral)', 12.5, 3, 100)
        debord = demander_reel('Orthophoto', 'Débord de la grille hors de la zone (m) :', 10, 0, 50)
        pas = demander_reel('Relief', 'Distance entre points intermédiaires sur les lignes (m) :\n'
                            '(suivi du relief ; 0 = extrémités seulement)', 30, 0, 500)
    if pl_l is not None:
        h_pl = demander_reel('Placettes', 'Hauteur des photos de placettes au-dessus du SOL (m) :',
                             15, 5, 60)
        t_pl = demander_reel('Placettes', 'Durée du vol stationnaire par placette (s) :\n'
                             '(6 s ≈ 3 photos à 2 s)', 6, 1, 60, 0)

    # 3. trajet (x, y en EPSG du projet ; h = hauteur voulue au-dessus du sol, ou 'rel' fixe)
    wps = []

    def ajouter(p, h_sol=None, h_rel=None, type_='transit', vitesse=None, passage=False,
                cap=None, stationnaire=0, etiquette=''):
        wps.append({'p': QgsPointXY(p), 'h_sol': h_sol, 'h_rel_fixe': h_rel, 'type': type_,
                    'vitesse': vitesse or v_ortho, 'passage': passage, 'cap': cap,
                    'stationnaire': stationnaire, 'etiquette': etiquette})

    ajouter(p_dec, h_rel=h_wp0, type_='WP0', etiquette='WP0')
    orient = 0.0
    if essai:
        c = zone.centroid().asPoint()
        a = QgsPointXY(c.x() - 10, c.y())
        b = QgsPointXY(c.x() + 10, c.y())
        ajouter(vers(a, p_dec, DECALAGE_MONTEE), h_rel=h_wp0, type_='transit')
        ajouter(a, h_sol=h_ortho, type_='essai', etiquette='A')
        ajouter(b, h_sol=h_ortho, type_='essai', etiquette='B')
    else:
        lignes, orient, ecart_reel = grille(zone, debord, ecart, pas, p_dec)
        ajouter(vers(lignes[0][0], p_dec, DECALAGE_MONTEE), h_rel=h_wp0, type_='transit')
        for k, l in enumerate(lignes, 1):
            for j, p in enumerate(l):
                ext = j in (0, len(l) - 1)
                ajouter(p, h_sol=h_ortho, type_='ortho', passage=not ext,
                        etiquette=('L%d%s' % (k, 'a' if j == 0 else 'b')) if ext else '')
        if pl_l is not None:
            g_pl = []
            tr = QgsCoordinateTransform(pl_l.crs(), crs, ctx) if pl_l.crs() != crs else None
            champ = 'id_placette' if 'id_placette' in pl_l.fields().names() else None
            for f in pl_l.getFeatures():
                g = QgsGeometry(f.geometry())
                if tr:
                    g.transform(tr)
                g_pl.append((str(f[champ]) if champ else 'P%d' % f.id(), g.asPoint()))
            interieur = zone.buffer(-MARGE_HAIE, 16)
            precedent, bas = wps[-1]['p'], False
            while g_pl:                                    # plus proche voisin
                k = min(range(len(g_pl)), key=lambda i: g_pl[i][1].distance(precedent))
                nom, p = g_pl.pop(k)
                seg = QgsGeometry.fromPolylineXY([precedent, p])
                if not (bas and interieur.contains(seg)):
                    ajouter(vers(p, precedent, DECALAGE_MONTEE), h_sol=h_ortho, type_='transit')
                ajouter(p, h_sol=h_pl, type_='placette', cap=0, stationnaire=t_pl, etiquette=nom)
                precedent, bas = p, True
    # remontee puis retour au-dessus du decollage
    ajouter(vers(wps[-1]['p'], p_dec, DECALAGE_MONTEE), h_rel=h_wp0, type_='transit')
    ajouter(p_dec, h_rel=h_wp0, type_='retour', etiquette='fin')

    # 4. altitudes du sol
    wgs = QgsCoordinateReferenceSystem('EPSG:4326')
    vers_wgs = QgsCoordinateTransform(crs, wgs, ctx)
    pts = [p_dec] + [w['p'] for w in wps]
    try:
        if source_z == IGN:
            z = altitudes_ign([vers_wgs.transform(p) for p in pts])
        else:
            z = altitudes_raster(rasters[[r.name() for r in rasters].index(source_z)], pts, crs, ctx)
    except Exception as e:
        message('Altitude du sol', "Lecture impossible (%s).\nCharger une dalle MNT (.tif) dans le "
                "projet et la choisir comme source." % e, True)
        return
    if min(z) < -1000:
        message('Altitude du sol', "Pas d'altitude pour certains points (hors de la dalle MNT ?).", True)
        return
    z0 = z[0]
    for w, zz in zip(wps, z[1:]):
        w['z_sol'] = zz
        if w['h_rel_fixe'] is not None:
            # transit : jamais plus bas que h_ortho au-dessus du sol local
            w['h_rel'] = max(w['h_rel_fixe'], zz - z0 + h_ortho)
        else:
            w['h_rel'] = w['h_sol'] + zz - z0
        w['h_sur_sol'] = w['h_rel'] - (zz - z0)

    # 5. bilan
    dist, duree = 0.0, 0.0
    for a, b in zip(wps[:-1], wps[1:]):
        d = math.sqrt(a['p'].sqrDist(b['p']) + (a['h_rel'] - b['h_rel']) ** 2)
        dist += d
        duree += d / b['vitesse'] + b['stationnaire'] + (0 if b['passage'] else 2)

    # 6. ecriture du KMZ
    g = 1
    for w in wps:
        w['groupe'] = g
        g += 2
    lonlat = [vers_wgs.transform(w['p']) for w in wps]
    corps = ''.join(xml_waypoint(i, w, ll.x(), ll.y()) for i, (w, ll) in enumerate(zip(wps, lonlat)))
    entete = re.search(r'^(.*?<Folder>)', wpl, re.S).group(1)
    entete = re.sub(r'<wpml:globalTransitionalSpeed>[^<]*<',
                    '<wpml:globalTransitionalSpeed>%.1f<' % v_ortho, entete)
    dossier_folder = re.search(r'<Folder>(.*?)<Placemark>', wpl, re.S).group(1)
    dossier_folder = re.sub(r'<wpml:distance>[^<]*<', '<wpml:distance>%d<' % dist, dossier_folder)
    dossier_folder = re.sub(r'<wpml:duration>[^<]*<', '<wpml:duration>%d<' % duree, dossier_folder)
    dossier_folder = re.sub(r'<wpml:autoFlightSpeed>[^<]*<',
                            '<wpml:autoFlightSpeed>%.1f<' % v_ortho, dossier_folder)
    if 'relativeToStartPoint' not in dossier_folder:
        message('Leurre', "Le leurre n'est pas en hauteurs relatives au décollage : recréez-le "
                "dans DJI Fly sans modifier le réglage d'altitude.", True)
        return
    waylines = entete[:-len('<Folder>')] + '<Folder>' + dossier_folder + corps + \
        '\n    </Folder>\n  </Document>\n</kml>\n'
    maintenant = str(int(time.time() * 1000))
    template = re.sub(r'<wpml:updateTime>[^<]*<', '<wpml:updateTime>%s<' % maintenant, tpl)

    sortie = os.path.join(dossier, 'mission_%s' % datetime.now().strftime('%Y%m%d_%H%M'))
    os.makedirs(sortie, exist_ok=True)
    kmz = os.path.join(sortie, os.path.basename(leurre))
    with zipfile.ZipFile(kmz, 'w', zipfile.ZIP_DEFLATED) as zf:
        zf.writestr('wpmz/template.kml', template)
        zf.writestr('wpmz/waylines.wpml', waylines)

    # 7. couches de controle
    champs = [QgsField('index', QVariant.Int), QgsField('type', QVariant.String),
              QgsField('etiquette', QVariant.String), QgsField('h_rel_decollage', QVariant.Double),
              QgsField('h_sur_sol', QVariant.Double), QgsField('z_sol', QVariant.Double),
              QgsField('vitesse', QVariant.Double), QgsField('stationnaire_s', QVariant.Double)]
    cw = QgsVectorLayer('Point?crs=%s' % crs.authid(), 'waypoints', 'memory')
    ct = QgsVectorLayer('LineString?crs=%s' % crs.authid(), 'trajet', 'memory')
    cw.dataProvider().addAttributes(champs)
    cw.updateFields()
    feats = []
    for i, w in enumerate(wps):
        f = QgsFeature(cw.fields())
        f.setAttributes([i, w['type'], w['etiquette'], round(w['h_rel'], 1), round(w['h_sur_sol'], 1),
                         round(w['z_sol'], 2), w['vitesse'], w['stationnaire']])
        f.setGeometry(QgsGeometry.fromPointXY(w['p']))
        feats.append(f)
    cw.dataProvider().addFeatures(feats)
    ft = QgsFeature()
    ft.setGeometry(QgsGeometry.fromPolylineXY([w['p'] for w in wps]))
    ct.dataProvider().addFeatures([ft])
    gpkg = os.path.join(sortie, 'mission.gpkg')
    premier = True
    for lyr in (ct, cw):
        opt = QgsVectorFileWriter.SaveVectorOptions()
        opt.driverName = 'GPKG'
        opt.layerName = lyr.name()
        opt.actionOnExistingFile = (QgsVectorFileWriter.CreateOrOverwriteFile if premier
                                    else QgsVectorFileWriter.CreateOrOverwriteLayer)
        QgsVectorFileWriter.writeAsVectorFormatV3(lyr, gpkg, ctx, opt)
        premier = False
    for nom in ('trajet', 'waypoints'):
        couche = QgsVectorLayer('%s|layername=%s' % (gpkg, nom), 'mission_' + nom, 'ogr')
        if nom == 'waypoints':
            r = QgsPalLayerSettings()
            r.fieldName = "\"index\" || ' (' || \"h_rel_decollage\" || ' m)'"
            r.isExpression = True
            r.enabled = True
            couche.setLabeling(QgsVectorLayerSimpleLabeling(r))
            couche.setLabelsEnabled(True)
        projet.addMapLayer(couche)

    # 8. message final
    hs = [w['h_rel'] for w in wps]
    alertes = []
    if len(wps) > 99:
        alertes.append('%d waypoints : au-delà de 99, vérifier que DJI Fly accepte la mission '
                       '(sinon augmenter la distance entre points intermédiaires).' % len(wps))
    if min(z[1:]) - z0 < -10 or max(z[1:]) - z0 > 10:
        alertes.append('Relief : le sol varie de %.0f à %+.0f m par rapport au décollage.'
                       % (min(z[1:]) - z0, max(z[1:]) - z0))
    if duree > 15 * 60:
        alertes.append('Durée estimée > 15 min : prévoir deux batteries ou réduire la zone.')
    lignes_msg = [
        'Mission %s : %d waypoints.' % ('d\'essai' if essai else 'complète', len(wps)),
        'Longueur ≈ %.0f m ; durée ≈ %.0f min ; ≈ %d photos à 2 s.' % (dist, duree / 60, duree / 2),
        'Hauteurs relatives au décollage : %.1f à %.1f m.' % (min(hs), max(hs)),
        'Altitude du sol au décollage : %.1f m (%s).' % (z0, source_z)]
    if not essai:
        lignes_msg.append('%d lignes parallèles au grand axe de la zone, écart réel %.1f m.'
                          % (len(lignes), ecart_reel))
    lignes_msg += alertes
    lignes_msg += ['', 'KMZ : %s' % kmz,
                   'Copier ce fichier sur la RC 2 à la place du leurre, MÊME NOM, même dossier.',
                   "Vérifier le trajet et les hauteurs (couche mission_waypoints) avant le vol."]
    message('Mission générée', '\n'.join(lignes_msg))


try:
    generer()
except Annule:
    print('Génération annulée.')

A2. tirage_placettes_generique.py

Afficher le code (288 lignes)
# -*- coding: utf-8 -*-
"""
Tirage aleatoire de placettes dans une zone d'etude - script generique
=======================================================================
A executer dans QGIS : Extensions > Console Python > Afficher l'editeur >
ouvrir ce fichier > Executer.

Prerequis
  - Projet QGIS ENREGISTRE (les resultats sont ecrits dans son dossier).
  - Une couche de polygones dessinee par l'utilisateur, appelee par defaut
    'zone_tiree', dans un SCR en metres (ex. EPSG:2154 Lambert 93).
    Un polygone = une strate. Si la couche a un champ 'strate', son contenu
    sert de nom de strate ; sinon les strates sont numerotees S1, S2...

Le script demande, par des fenetres de saisie :
  - la couche zone ;
  - le nombre de placettes ;
  - la forme (carre ou cercle) et la dimension (cote ou rayon, en m) ;
  - la distance minimale entre les BORDS de deux placettes (m) ;
  - la repartition entre strates (si plusieurs polygones) ;
  - la graine aleatoire (meme graine = meme tirage).

Chaque placette est tiree ENTIEREMENT a l'interieur de la zone (pas seulement
son centre). Resultat : placettes_AAAAMMJJ_HHMM.gpkg avec deux couches,
'centres' (points) et 'placettes' (carres ou cercles), numerotees P01, P02...
du nord au sud.
"""

import os
import math
import random
from datetime import datetime

from qgis.core import (QgsProject, QgsVectorLayer, QgsField, QgsFeature, QgsGeometry,
                       QgsPointXY, QgsWkbTypes, QgsUnitTypes, QgsVectorFileWriter,
                       QgsSpatialIndex, QgsPalLayerSettings, QgsVectorLayerSimpleLabeling)
from qgis.PyQt.QtCore import QVariant
from qgis.PyQt.QtWidgets import QInputDialog, QMessageBox

try:
    PARENT = iface.mainWindow()          # noqa: F821  (iface existe dans la console QGIS)
except NameError:
    PARENT = None

NOM_ZONE_DEFAUT = 'zone_tiree'
ESSAIS_PAR_PLACETTE = 2000


class Annule(Exception):
    pass


# ---------------------------------------------------------------- saisies
def demander_entier(titre, texte, defaut, mini, maxi):
    v, ok = QInputDialog.getInt(PARENT, titre, texte, defaut, mini, maxi, 1)
    if not ok:
        raise Annule()
    return v


def demander_reel(titre, texte, defaut, mini, maxi, decimales=1):
    v, ok = QInputDialog.getDouble(PARENT, titre, texte, defaut, mini, maxi, decimales)
    if not ok:
        raise Annule()
    return v


def demander_choix(titre, texte, choix, defaut=0):
    v, ok = QInputDialog.getItem(PARENT, titre, texte, choix, defaut, False)
    if not ok:
        raise Annule()
    return v


def message(titre, texte, erreur=False):
    if PARENT is not None:
        (QMessageBox.critical if erreur else QMessageBox.information)(PARENT, titre, texte)
    print(('ERREUR - ' if erreur else '') + titre + ' : ' + texte)


# ---------------------------------------------------------------- geometrie
def forme_placette(x, y, forme, dim):
    """Carre de cote dim, ou cercle de rayon dim, centre en (x, y)."""
    if forme == 'Carré':
        h = dim / 2.0
        return QgsGeometry.fromPolygonXY([[QgsPointXY(x - h, y - h), QgsPointXY(x + h, y - h),
                                           QgsPointXY(x + h, y + h), QgsPointXY(x - h, y + h),
                                           QgsPointXY(x - h, y - h)]])
    return QgsGeometry.fromPointXY(QgsPointXY(x, y)).buffer(dim, 36)


def repartir(effectif, surfaces, mode):
    """Nombre de placettes par strate (methode des plus forts restes)."""
    k = len(surfaces)
    if k == 1:
        return [effectif]
    if mode.startswith('Égale'):
        base = [effectif // k] * k
        for i in range(effectif - sum(base)):
            base[i] += 1
        return base
    total = sum(surfaces)
    brut = [effectif * s / total for s in surfaces]
    base = [int(math.floor(b)) for b in brut]
    restes = sorted(range(k), key=lambda i: brut[i] - base[i], reverse=True)
    for i in restes[:effectif - sum(base)]:
        base[i] += 1
    return base


# ---------------------------------------------------------------- programme
def tirer():
    projet = QgsProject.instance()
    dossier = projet.homePath()
    if not dossier:
        message('Projet non enregistré', "Enregistre d'abord le projet (Projet > Enregistrer), puis relance.", True)
        return

    # 1. couche zone
    polys = [l for l in projet.mapLayers().values()
             if isinstance(l, QgsVectorLayer) and l.geometryType() == QgsWkbTypes.PolygonGeometry]
    if not polys:
        message('Aucune zone', "Dessine d'abord une couche de polygones 'zone_tiree'.", True)
        return
    noms = [l.name() for l in polys]
    defaut = noms.index(NOM_ZONE_DEFAUT) if NOM_ZONE_DEFAUT in noms else 0
    zone = polys[noms.index(demander_choix('Zone d\'étude', 'Couche de polygones où tirer les placettes :',
                                           noms, defaut))]
    if zone.crs().isGeographic() or zone.crs().mapUnits() != QgsUnitTypes.DistanceMeters:
        message('Projection', "La couche '%s' doit être dans un SCR en mètres (ex. EPSG:2154), "
                "pas en %s. La reprojeter d'abord." % (zone.name(), zone.crs().authid()), True)
        return

    strates = []
    champ_strate = 'strate' if 'strate' in zone.fields().names() else None
    for i, f in enumerate(zone.getFeatures(), 1):
        g = f.geometry()
        if g is None or g.isEmpty():
            continue
        nom = str(f[champ_strate]) if champ_strate and f[champ_strate] not in (None, '') else 'S%d' % i
        strates.append({'nom': nom, 'geom': g, 'surface': g.area()})
    if not strates:
        message('Zone vide', "La couche '%s' ne contient aucun polygone." % zone.name(), True)
        return
    surface_totale = sum(s['surface'] for s in strates)

    # 2. parametres
    n = demander_entier('Nombre de placettes', 'Combien de placettes tirer au hasard ?\n'
                        '(zone : %.2f ha, %d polygone(s))' % (surface_totale / 1e4, len(strates)), 12, 1, 500)
    forme = demander_choix('Forme', 'Forme des placettes :', ['Carré', 'Cercle'], 0)
    if forme == 'Carré':
        dim = demander_reel('Dimension', 'Côté du carré (m) :', 4.0, 0.5, 500.0)
        demi_diag = dim * math.sqrt(2) / 2
        surface_placette = dim * dim
    else:
        dim = demander_reel('Dimension', 'Rayon du cercle (m) :', 2.26, 0.2, 250.0, 2)
        demi_diag = dim
        surface_placette = math.pi * dim * dim
    ecart = demander_reel('Espacement', 'Distance minimale entre les BORDS de deux placettes (m) :\n'
                          '(conseil : au moins la taille d\'une placette)',
                          round(max(dim, 5.0), 1), 0.0, 1000.0)
    mode = 'Unique'
    if len(strates) > 1:
        mode = demander_choix('Répartition', '%d polygones (strates). Répartir les placettes :' % len(strates),
                              ['Proportionnelle à la surface', 'Égale entre strates'], 0)
    graine = demander_entier('Graine aléatoire', 'Graine (même graine = même tirage) :',
                             datetime.now().year, 0, 999999)

    # distance minimale entre centres garantissant l'ecart entre bords (cas le plus defavorable)
    dist_centres = 2 * demi_diag + ecart
    taux = 100.0 * n * surface_placette / surface_totale
    if taux > 30:
        message('Attention', 'Les placettes couvriraient %.0f %% de la zone : tirage probablement '
                'impossible. Réduis le nombre, la taille ou l\'espacement.' % taux)

    # 3. tirage
    rng = random.Random(graine)
    effectifs = repartir(n, [s['surface'] for s in strates], mode)
    tires = []                           # (x, y, nom_strate)
    index = QgsSpatialIndex()
    bilan = []
    for s, k in zip(strates, effectifs):
        g = s['geom']
        # zone ou un CENTRE peut tomber pour que la placette reste entiere dans le polygone
        coeur = g.buffer(-demi_diag, 16) if forme == 'Carré' else g.buffer(-dim, 16)
        if coeur.isEmpty():
            bilan.append('%s : polygone trop étroit pour une placette de cette taille' % s['nom'])
            continue
        moteur = QgsGeometry.createGeometryEngine(coeur.constGet())
        moteur.prepareGeometry()
        bb = coeur.boundingBox()
        places = 0
        essais = 0
        while places < k and essais < k * ESSAIS_PAR_PLACETTE:
            essais += 1
            x = rng.uniform(bb.xMinimum(), bb.xMaximum())
            y = rng.uniform(bb.yMinimum(), bb.yMaximum())
            p = QgsGeometry.fromPointXY(QgsPointXY(x, y))
            if not moteur.contains(p.constGet()):
                continue
            voisins = index.nearestNeighbor(QgsPointXY(x, y), 1)
            if voisins:
                vx, vy, _ = tires[voisins[0]]
                if math.hypot(x - vx, y - vy) < dist_centres:
                    continue
            fid = len(tires)
            tires.append((x, y, s['nom']))
            f = QgsFeature(fid)
            f.setGeometry(p)
            index.addFeature(f)
            places += 1
        bilan.append('%s : %d / %d placettes' % (s['nom'], places, k))

    if not tires:
        message('Tirage impossible', '\n'.join(bilan), True)
        return

    # 4. numerotation du nord au sud et creation des couches
    ordre = sorted(range(len(tires)), key=lambda i: -tires[i][1])
    crs = zone.crs().authid()
    champs = [QgsField('id_placette', QVariant.String), QgsField('strate', QVariant.String),
              QgsField('X', QVariant.Double), QgsField('Y', QVariant.Double),
              QgsField('forme', QVariant.String), QgsField('dim_m', QVariant.Double),
              QgsField('surface_m2', QVariant.Double), QgsField('graine', QVariant.Int)]
    centres = QgsVectorLayer('Point?crs=%s' % crs, 'centres', 'memory')
    placettes = QgsVectorLayer('Polygon?crs=%s' % crs, 'placettes', 'memory')
    for lyr in (centres, placettes):
        lyr.dataProvider().addAttributes(champs)
        lyr.updateFields()
    fc, fp = [], []
    for rang, i in enumerate(ordre, 1):
        x, y, nom = tires[i]
        attrs = ['P%02d' % rang if len(tires) < 100 else 'P%03d' % rang, nom, round(x, 1), round(y, 1),
                 forme, dim, round(surface_placette, 2), graine]
        a = QgsFeature(centres.fields()); a.setAttributes(attrs)
        a.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y))); fc.append(a)
        b = QgsFeature(placettes.fields()); b.setAttributes(attrs)
        b.setGeometry(forme_placette(x, y, forme, dim)); fp.append(b)
    centres.dataProvider().addFeatures(fc)
    placettes.dataProvider().addFeatures(fp)

    # 5. enregistrement dans un GeoPackage date, dans le dossier du projet
    chemin = os.path.join(dossier, 'placettes_%s.gpkg' % datetime.now().strftime('%Y%m%d_%H%M'))
    premier = True
    for lyr in (centres, placettes):
        opt = QgsVectorFileWriter.SaveVectorOptions()
        opt.driverName = 'GPKG'
        opt.layerName = lyr.name()
        opt.actionOnExistingFile = (QgsVectorFileWriter.CreateOrOverwriteFile if premier
                                    else QgsVectorFileWriter.CreateOrOverwriteLayer)
        QgsVectorFileWriter.writeAsVectorFormatV3(lyr, chemin, projet.transformContext(), opt)
        premier = False
    for nom in ('placettes', 'centres'):
        couche = QgsVectorLayer('%s|layername=%s' % (chemin, nom), nom, 'ogr')
        if nom == 'centres':
            reglage = QgsPalLayerSettings()
            reglage.fieldName = 'id_placette'
            reglage.enabled = True
            couche.setLabeling(QgsVectorLayerSimpleLabeling(reglage))
            couche.setLabelsEnabled(True)
        projet.addMapLayer(couche)

    # 6. controle : distance entre centres voisins
    mini = None
    for i, (x, y, _) in enumerate(tires):
        for j, (x2, y2, _) in enumerate(tires):
            if i < j:
                d = math.hypot(x - x2, y - y2)
                mini = d if mini is None else min(mini, d)
    lignes = ['%d placettes tirées sur %d demandées (graine %d).' % (len(tires), n, graine),
              'Placette : %s de %s m (%.1f m²).' % (forme.lower(), ('%g' % dim) if forme == 'Carré'
                                                     else 'rayon %g' % dim, surface_placette),
              'Zone : %.2f ha ; taux d\'échantillonnage : %.2f %%.' % (surface_totale / 1e4,
                                                                     100.0 * len(tires) * surface_placette / surface_totale)]
    if mini is not None:
        lignes.append('Distance minimale entre centres : %.1f m (exigée : %.1f m).' % (mini, dist_centres))
    lignes += bilan
    lignes.append('Fichier : %s' % chemin)
    if len(tires) < n:
        lignes.append("Toutes les placettes n'ont pas pu être placées : réduire le nombre, "
                      "la taille ou l'espacement, et le noter dans le protocole.")
    message('Tirage terminé', '\n'.join(lignes))


try:
    tirer()
except Annule:
    print('Tirage annulé.')

A3. trier_photos_vol.py

Afficher le code (300 lignes)
# -*- coding: utf-8 -*-
r"""
Tri des photos d'une mission drone DJI (orthophoto / placettes / ecartees)
==========================================================================
A executer dans QGIS : Console Python > Afficher l'editeur > ouvrir > Executer.

Prerequis (projet QGIS ouvert, EPSG:2154) :
  - couche de points 'decollage' (point de decollage REEL de la mission) ;
  - couche de polygones 'zone_vol' ;
  - couche de points 'centres' (placettes, champ 'id_placette') - facultative ;
  - un dossier de photos DJI (JPG) ;
  - internet (service d'altimetrie IGN) OU une couche raster MNT dans le projet.

Pour chaque photo, le script lit dans les metadonnees DJI : position GPS,
hauteur relative au decollage (barometre), inclinaison de nacelle, cap. Il lit
l'altitude du sol sous la photo (RGE ALTI) et calcule la HAUTEUR REELLE
AU-DESSUS DU SOL :
        h_sol = h_rel + (sol au decollage - sol sous la photo)
Classement :
  ortho     : nacelle verticale, h_sol ~ hauteur ortho, a moins de 15 m de la zone
  placette  : nacelle verticale, h_sol ~ hauteur placette, a moins de 8 m d'un centre
  ecartee   : decollage, transits, montees / descentes, atterrissage...
Pour chaque placette, la photo la plus proche du centre est marquee 'meilleure'.

Sorties (dans le dossier des photos) :
  - tri_photos.csv + couche de points 'tri_photos' (couleur par usage) ;
  - facultatif : COPIE des photos dans les sous-dossiers ortho\ et placettes\
    (les originaux ne sont pas modifies), pour RealityScan / WebODM.
"""

import os
import re
import csv
import json
import math
import shutil

from qgis.core import (QgsProject, QgsVectorLayer, QgsRasterLayer, QgsGeometry, QgsPointXY,
                       QgsWkbTypes, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
                       QgsBlockingNetworkRequest, QgsCategorizedSymbolRenderer, QgsRendererCategory,
                       QgsMarkerSymbol)
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtWidgets import QInputDialog, QMessageBox, QFileDialog

try:
    PARENT = iface.mainWindow()          # noqa: F821
except NameError:
    PARENT = None

IGN = 'Service altimétrie IGN (internet)'
URL_IGN = ('https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json'
           '?lon=%s&lat=%s&resource=ign_rge_alti_wld&delimiter=|&zonly=true')
NACELLE_MIN = -85.0       # nacelle verticale si pitch <= -85 deg
TOLERANCE_H = 4.0         # m autour de la hauteur visee
DIST_ZONE = 15.0          # m : photo ortho a moins de 15 m de la zone de vol
DIST_CENTRE = 8.0         # m : photo de placette a moins de 8 m du centre
CHAMPS_XMP = ['RelativeAltitude', 'GimbalPitchDegree', 'FlightYawDegree', 'GpsLatitude', 'GpsLongitude']


class Annule(Exception):
    pass


def demander_reel(titre, texte, defaut, mini, maxi):
    v, ok = QInputDialog.getDouble(PARENT, titre, texte, defaut, mini, maxi, 1)
    if not ok:
        raise Annule()
    return v


def demander_choix(titre, texte, choix, defaut=0):
    v, ok = QInputDialog.getItem(PARENT, titre, texte, choix, defaut, False)
    if not ok:
        raise Annule()
    return v


def message(titre, texte, erreur=False):
    if PARENT is not None:
        (QMessageBox.critical if erreur else QMessageBox.information)(PARENT, titre, texte)
    print(('ERREUR - ' if erreur else '') + titre + ' : ' + texte)


def couche(nom, type_geom, obligatoire=True):
    for l in QgsProject.instance().mapLayersByName(nom):
        if isinstance(l, QgsVectorLayer) and l.geometryType() == type_geom:
            return l
    if obligatoire:
        raise RuntimeError("couche '%s' introuvable dans le projet" % nom)
    return None


def lire_xmp(chemin):
    with open(chemin, 'rb') as f:
        data = f.read(200000)
    d, f_ = data.find(b'<x:xmpmeta'), data.find(b'</x:xmpmeta>')
    if d < 0 or f_ < 0:
        return {}
    xmp = data[d:f_].decode('utf-8', 'ignore')
    res = {}
    for c in CHAMPS_XMP:
        m = re.search(r'drone-dji:%s\s*=\s*"([^"]*)"' % c, xmp) or \
            re.search(r'<drone-dji:%s>([^<]*)<' % c, xmp)
        if m:
            try:
                res[c] = float(m.group(1).replace('+', ''))
            except ValueError:
                pass
    return res


def altitudes_ign(pts_wgs):
    z = []
    for i in range(0, len(pts_wgs), 50):
        lot = pts_wgs[i:i + 50]
        url = URL_IGN % ('|'.join('%.7f' % p.x() for p in lot), '|'.join('%.7f' % p.y() for p in lot))
        req = QgsBlockingNetworkRequest()
        if req.get(QNetworkRequest(QUrl(url))) != QgsBlockingNetworkRequest.NoError:
            raise RuntimeError('service IGN injoignable : ' + req.errorMessage())
        rep = json.loads(bytes(req.reply().content()).decode('utf-8'))
        z += [float(v['z'] if isinstance(v, dict) else v) for v in rep['elevations']]
    return z


def altitudes_raster(r, pts_wgs, ctx):
    tr = QgsCoordinateTransform(QgsCoordinateReferenceSystem('EPSG:4326'), r.crs(), ctx)
    z = []
    for p in pts_wgs:
        v, ok = r.dataProvider().sample(tr.transform(p), 1)
        z.append(float(v) if ok else -99999.0)
    return z


def trier():
    projet = QgsProject.instance()
    ctx = projet.transformContext()
    dec_l = couche('decollage', QgsWkbTypes.PointGeometry)
    zone_l = couche('zone_vol', QgsWkbTypes.PolygonGeometry)
    cen_l = couche('centres', QgsWkbTypes.PointGeometry, obligatoire=False)
    crs = zone_l.crs()
    wgs = QgsCoordinateReferenceSystem('EPSG:4326')
    vers_l93 = QgsCoordinateTransform(wgs, crs, ctx)
    vers_wgs = QgsCoordinateTransform(crs, wgs, ctx)
    zone = QgsGeometry.unaryUnion([f.geometry() for f in zone_l.getFeatures()])
    g_dec = QgsGeometry(next(dec_l.getFeatures()).geometry())
    if dec_l.crs() != crs:
        g_dec.transform(QgsCoordinateTransform(dec_l.crs(), crs, ctx))
    centres = []
    if cen_l is not None:
        tr_c = QgsCoordinateTransform(cen_l.crs(), crs, ctx) if cen_l.crs() != crs else None
        champ = 'id_placette' if 'id_placette' in cen_l.fields().names() else None
        for f in cen_l.getFeatures():
            g = QgsGeometry(f.geometry())
            if tr_c:
                g.transform(tr_c)
            centres.append((str(f[champ]) if champ else 'P%d' % f.id(), g.asPoint()))

    dossier = QFileDialog.getExistingDirectory(PARENT, 'Dossier des photos de la mission', projet.homePath())
    if not dossier:
        raise Annule()
    h_ortho = demander_reel('Hauteurs visées', 'Hauteur ortho au-dessus du SOL (m) :', 30, 5, 120)
    h_pl = demander_reel('Hauteurs visées', 'Hauteur des photos de placettes au-dessus du SOL (m) :', 15, 3, 60)
    rasters = [l for l in projet.mapLayers().values()
               if isinstance(l, QgsRasterLayer) and l.providerType() == 'gdal' and l.bandCount() == 1]
    source = demander_choix('Altitude du sol', "Source de l'altitude du sol :",
                            [IGN] + [r.name() for r in rasters], 0)
    copier = demander_choix('Copie', 'Copier les photos dans ortho\\ et placettes\\ ?\n'
                            '(les originaux ne sont pas modifiés)', ['Oui', 'Non'], 0) == 'Oui'

    # 1. lecture des photos
    photos = []
    for nom in sorted(os.listdir(dossier)):
        if not nom.lower().endswith(('.jpg', '.jpeg')):
            continue
        x = lire_xmp(os.path.join(dossier, nom))
        if 'GpsLatitude' not in x or 'GpsLongitude' not in x or 'RelativeAltitude' not in x:
            photos.append({'photo': nom, 'ok': False})
            continue
        photos.append({'photo': nom, 'ok': True, 'h_rel': x['RelativeAltitude'],
                       'nacelle': x.get('GimbalPitchDegree'), 'cap': x.get('FlightYawDegree'),
                       'wgs': QgsPointXY(x['GpsLongitude'], x['GpsLatitude'])})
    valides = [p for p in photos if p['ok']]
    if not valides:
        message('Aucune photo', 'Aucune photo DJI avec position GPS dans ce dossier.', True)
        return
    print('%d photos, %d avec position GPS : lecture du sol...' % (len(photos), len(valides)))

    # 2. altitude du sol
    pts = [vers_wgs.transform(g_dec.asPoint())] + [p['wgs'] for p in valides]
    try:
        z = altitudes_ign(pts) if source == IGN else \
            altitudes_raster(rasters[[r.name() for r in rasters].index(source)], pts, ctx)
    except Exception as e:
        message('Altitude du sol', 'Lecture impossible (%s).' % e, True)
        return
    z0 = z[0]

    # 3. classement
    for p, zz in zip(valides, z[1:]):
        p['l93'] = vers_l93.transform(p['wgs'])
        p['z_sol'] = zz
        p['h_sol'] = p['h_rel'] + z0 - zz
        g = QgsGeometry.fromPointXY(p['l93'])
        p['dist_zone'] = 0.0 if zone.contains(g) else zone.distance(g)
        p['placette'], p['dist_centre'] = '', None
        if centres:
            nom, c = min(centres, key=lambda t: t[1].distance(p['l93']))
            p['placette_proche'], p['dist_centre'] = nom, c.distance(p['l93'])
        verticale = p['nacelle'] is not None and p['nacelle'] <= NACELLE_MIN
        if verticale and abs(p['h_sol'] - h_pl) <= TOLERANCE_H and \
                p['dist_centre'] is not None and p['dist_centre'] <= DIST_CENTRE:
            p['usage'], p['placette'] = 'placette', p['placette_proche']
        elif verticale and abs(p['h_sol'] - h_ortho) <= TOLERANCE_H and p['dist_zone'] <= DIST_ZONE:
            p['usage'] = 'ortho'
        else:
            p['usage'] = 'ecartee'
    for p in photos:
        if not p['ok']:
            p['usage'] = 'ecartee'

    # meilleure photo par placette = la plus proche du centre
    for nom, _ in centres:
        sel = [p for p in valides if p['placette'] == nom]
        if sel:
            min(sel, key=lambda p: p['dist_centre'])['meilleure'] = True

    # 4. CSV
    champs = ['photo', 'usage', 'placette', 'meilleure', 'h_rel', 'z_sol', 'h_sol', 'nacelle', 'cap',
              'dist_centre', 'dist_zone', 'x_l93', 'y_l93']
    sortie = os.path.join(dossier, 'tri_photos.csv')
    with open(sortie, 'w', newline='', encoding='utf-8') as f:
        w = csv.writer(f, delimiter=';')
        w.writerow(champs)
        for p in photos:
            if not p['ok']:
                w.writerow([p['photo'], 'ecartee'] + [''] * (len(champs) - 2))
                continue
            r1 = lambda v: '' if v is None else round(v, 1)
            w.writerow([p['photo'], p['usage'], p['placette'], 'oui' if p.get('meilleure') else '',
                        r1(p['h_rel']), round(p['z_sol'], 2), r1(p['h_sol']), r1(p['nacelle']), r1(p['cap']),
                        r1(p['dist_centre']), r1(p['dist_zone']),
                        round(p['l93'].x(), 2), round(p['l93'].y(), 2)])

    # 5. couche de points
    uri = ('file:///%s?delimiter=;&xField=x_l93&yField=y_l93&crs=%s'
           % (sortie.replace('\\', '/'), crs.authid()))
    lyr = QgsVectorLayer(uri, 'tri_photos', 'delimitedtext')
    if lyr.isValid():
        cats = []
        for val, coul in (('ortho', '#2b83ba'), ('placette', '#d7191c'), ('ecartee', '#bdbdbd')):
            s = QgsMarkerSymbol.createSimple({'name': 'circle', 'color': coul, 'size': '2'})
            cats.append(QgsRendererCategory(val, s, val))
        lyr.setRenderer(QgsCategorizedSymbolRenderer('usage', cats))
        projet.addMapLayer(lyr)

    # 6. copie
    if copier:
        for u in ('ortho', 'placettes'):
            os.makedirs(os.path.join(dossier, u), exist_ok=True)
        for p in valides:
            if p['usage'] == 'ortho':
                shutil.copy2(os.path.join(dossier, p['photo']), os.path.join(dossier, 'ortho', p['photo']))
            elif p['usage'] == 'placette':
                cible = '%s_%s%s' % (p['placette'], 'MEILLEURE_' if p.get('meilleure') else '', p['photo'])
                shutil.copy2(os.path.join(dossier, p['photo']), os.path.join(dossier, 'placettes', cible))

    # 7. bilan
    n = {u: sum(1 for p in photos if p['usage'] == u) for u in ('ortho', 'placette', 'ecartee')}
    lignes = ['%d photos : %d ortho, %d placettes, %d écartées.' % (len(photos), n['ortho'], n['placette'],
                                                                  n['ecartee']),
              'Sol au décollage : %.1f m.' % z0]
    o = [p['h_sol'] for p in valides if p['usage'] == 'ortho']
    if o:
        lignes.append('Ortho : hauteur au-dessus du sol %.1f à %.1f m (moyenne %.1f).'
                      % (min(o), max(o), sum(o) / len(o)))
    if centres:
        lignes.append('')
        lignes.append('Placette : nb photos | meilleure : écart au centre, h_sol, cap')
        for nom, _ in sorted(centres):
            sel = [p for p in valides if p['placette'] == nom]
            best = [p for p in sel if p.get('meilleure')]
            if best:
                b = best[0]
                lignes.append('%s : %d | %.1f m, %.1f m, cap %.0f°' % (nom, len(sel), b['dist_centre'],
                                                                     b['h_sol'], b['cap'] or 0))
            else:
                lignes.append('%s : AUCUNE photo' % nom)
    lignes += ['', 'CSV : %s' % sortie]
    if copier:
        lignes.append('Copies : sous-dossiers ortho\\ et placettes\\')
    message('Tri des photos', '\n'.join(lignes))


try:
    trier()
except Annule:
    print('Tri annulé.')
except RuntimeError as e:
    message('Tri des photos', str(e), True)

A4. lire_hauteurs_photos_dji.py

Utilisable dans Thonny ou dans la console QGIS. À réserver au terrain plat ; sur pente, utiliser A3.

Afficher le code (128 lignes)
# -*- coding: utf-8 -*-
"""
Lecture des metadonnees de vol des photos DJI (Mini 4 Pro)
==========================================================
Utilisable dans Thonny OU dans la console Python de QGIS (Ouvrir > Executer).

Chaque photo DJI contient, dans ses metadonnees XMP, des donnees que
l'Explorateur Windows n'affiche pas :
  - RelativeAltitude  : hauteur par rapport au point de decollage (barometre)
  - AbsoluteAltitude  : altitude GPS
  - GimbalPitchDegree : inclinaison de la nacelle (-90 = verticale)
  - FlightYawDegree   : cap du drone (0 = nord)
  - GpsLatitude / GpsLongitude

Le script lit toutes les photos d'un dossier et ecrit photos_vol.csv dans ce
dossier, avec une colonne 'usage' :
  ortho     : nacelle verticale, hauteur proche de la hauteur ortho
  placette  : nacelle verticale, hauteur proche de la hauteur placettes
  ecartee   : decollage, transit, nacelle non verticale...
Dans QGIS, la couche de points 'photos_vol' est ajoutee automatiquement.

ATTENTION : les hauteurs sont relatives au DECOLLAGE, comme dans la mission.
Pour une mission sur relief, comparer a la colonne h_rel_decollage de la
couche mission_waypoints, pas a 30 m.
"""

import os
import re
import csv

# ------------------------------------------------------------- a regler
DOSSIER = r'C:\Users\pierr\Downloads\Test Drone\DJI_001'
NACELLE_MIN = -85.0      # nacelle consideree verticale si pitch <= -85 deg
# hauteurs relatives au decollage attendues (m) et tolerance
H_ORTHO = (23.0, 26.0)   # essai : A = 23,5 m, B = 25,2 m
H_PLACETTE = None        # ex. (8.0, 11.0) pour la mission complete
# ----------------------------------------------------------------------

CHAMPS = ['RelativeAltitude', 'AbsoluteAltitude', 'GimbalPitchDegree',
          'GimbalYawDegree', 'FlightYawDegree', 'GpsLatitude', 'GpsLongitude']


def lire_xmp(chemin):
    with open(chemin, 'rb') as f:
        data = f.read(200000)                      # le XMP est en tete de fichier
    debut = data.find(b'<x:xmpmeta')
    fin = data.find(b'</x:xmpmeta>')
    if debut < 0 or fin < 0:
        return {}
    xmp = data[debut:fin].decode('utf-8', 'ignore')
    res = {}
    for c in CHAMPS:
        m = re.search(r'drone-dji:%s\s*=\s*"([^"]*)"' % c, xmp) or \
            re.search(r'<drone-dji:%s>([^<]*)<' % c, xmp)
        if m:
            try:
                res[c] = float(m.group(1).replace('+', ''))
            except ValueError:
                pass
    return res


def dans(v, borne):
    return borne is not None and v is not None and borne[0] <= v <= borne[1]


def lire_dossier(dossier):
    lignes = []
    for nom in sorted(os.listdir(dossier)):
        if not nom.lower().endswith(('.jpg', '.jpeg')):
            continue
        x = lire_xmp(os.path.join(dossier, nom))
        h, p = x.get('RelativeAltitude'), x.get('GimbalPitchDegree')
        m = re.search(r'DJI_(\d{8})(\d{6})', nom)
        heure = '%s:%s:%s' % (m.group(2)[:2], m.group(2)[2:4], m.group(2)[4:]) if m else ''
        if p is None or p > NACELLE_MIN:
            usage = 'ecartee'
        elif dans(h, H_PLACETTE):
            usage = 'placette'
        elif dans(h, H_ORTHO):
            usage = 'ortho'
        else:
            usage = 'ecartee'
        lignes.append({'photo': nom, 'heure': heure, 'h_rel': h, 'alt_gps': x.get('AbsoluteAltitude'),
                       'nacelle': p, 'cap': x.get('FlightYawDegree'),
                       'lat': x.get('GpsLatitude'), 'lon': x.get('GpsLongitude'), 'usage': usage})
    return lignes


def main():
    lignes = lire_dossier(DOSSIER)
    if not lignes:
        print('Aucune photo JPG dans', DOSSIER)
        return
    sortie = os.path.join(DOSSIER, 'photos_vol.csv')
    with open(sortie, 'w', newline='', encoding='utf-8') as f:
        w = csv.DictWriter(f, fieldnames=list(lignes[0].keys()), delimiter=';')
        w.writeheader()
        w.writerows(lignes)

    print('%-36s %8s %8s %8s %6s  %s' % ('photo', 'heure', 'h_rel', 'nacelle', 'cap', 'usage'))
    for l in lignes:
        print('%-36s %8s %8s %8s %6s  %s' % (l['photo'], l['heure'],
              '' if l['h_rel'] is None else '%.1f' % l['h_rel'],
              '' if l['nacelle'] is None else '%.0f' % l['nacelle'],
              '' if l['cap'] is None else '%.0f' % l['cap'], l['usage']))
    hs = [l['h_rel'] for l in lignes if l['h_rel'] is not None]
    for u in ('ortho', 'placette', 'ecartee'):
        sel = [l['h_rel'] for l in lignes if l['usage'] == u and l['h_rel'] is not None]
        if sel:
            print('%-9s : %3d photos, h_rel %.1f a %.1f m (moyenne %.1f)'
                  % (u, len(sel), min(sel), max(sel), sum(sel) / len(sel)))
    if hs:
        print('Toutes : %d photos, h_rel %.1f a %.1f m' % (len(lignes), min(hs), max(hs)))
    print('CSV :', sortie)

    try:                                            # dans QGIS : couche de points
        from qgis.core import QgsVectorLayer, QgsProject
        uri = ('file:///%s?delimiter=;&xField=lon&yField=lat&crs=EPSG:4326'
               % sortie.replace('\\', '/'))
        couche = QgsVectorLayer(uri, 'photos_vol', 'delimitedtext')
        if couche.isValid():
            QgsProject.instance().addMapLayer(couche)
    except ImportError:
        pass


main()

A5. Petits scripts de console

Ils se collent directement dans la console Python de QGIS.

Déplacer le point de décollage (la couche garde un seul point) :

from qgis.core import QgsProject, QgsGeometry, QgsPointXY, QgsFeature
c = QgsProject.instance().mapLayersByName('decollage')[0]
c.startEditing()
for f in c.getFeatures():
    c.deleteFeature(f.id())
p = QgsFeature(c.fields())
p.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(598120.0177, 6414116.2058)))
c.addFeature(p)
c.commitChanges()
print(c.featureCount(), 'point :', next(c.getFeatures()).geometry().asPoint())

Hauteur réelle au-dessus de l'herbe après un décollage décalé (vol d'essai du 21/10) :

import json
from qgis.core import QgsBlockingNetworkRequest
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtCore import QUrl
url = ('https://data.geopf.fr/altimetrie/1.0/calcul/alti/rest/elevation.json'
       '?lon=1.7112378|1.7115045&lat=44.8187268|44.8191028'
       '&resource=ign_rge_alti_wld&delimiter=|&zonly=true')
r = QgsBlockingNetworkRequest(); r.get(QNetworkRequest(QUrl(url)))
z = json.loads(bytes(r.reply().content()))['elevations']
print('sol décollage réel : %.1f m | sol point prévu : %.1f m' % (z[0], z[1]))
print('hauteur réelle au-dessus de l\'herbe en A ≈ %.1f m' % (30.2 + z[0] - z[1]))

Exporter la table des waypoints en CSV (en filtrant sur le dossier de la mission, car le projet contient deux couches mission_waypoints) :

import csv, os
c = [l for l in QgsProject.instance().mapLayersByName('mission_waypoints')
     if 'mission_20261022_1039' in l.source()][0]
champs = ['index', 'type', 'etiquette', 'h_rel_decollage', 'h_sur_sol', 'z_sol', 'vitesse', 'stationnaire_s']
lignes = sorted(([f[k] for k in champs] for f in c.getFeatures()), key=lambda l: l[0])
print(len(lignes), 'waypoints')
sortie = os.path.join(os.path.dirname(c.source().split('|')[0]), 'waypoints.csv')
with open(sortie, 'w', newline='', encoding='utf-8') as f:
    w = csv.writer(f, delimiter=';'); w.writerow(champs); w.writerows(lignes)
print('CSV :', sortie)

Aucun commentaire:

Enregistrer un commentaire

Tout commentaire nous engage ;)