Files
lidar_rendu/AGENTS.md
2026-09-27 22:31:54 +02:00

30 KiB
Raw Blame History

Workflow

  • install: docker build -t lidar-lidar . (deps baked into image)
  • lancement local en une commande (GPU facultatif) : ./start.sh (carte 8973), ./start.sh process [options], ./start.sh logs|stop — crée input//output/ à l'uid courant, ajoute docker-compose.cpu.yml (gpus: !reset) si Docker n'accède à aucun GPU.
  • build: docker build -t lidar-lidar .
  • build carte légère (Raspberry Pi, déploiement 2 machines — cf. docs/DEPLOY_WEBAPP.md) : docker compose -f docker-compose.maps.yml up -d --build (image Dockerfile.maps, sans PDAL/GPU)
  • build générateur de tuiles (machine de traitement) : docker compose -f docker-compose.worker.yml up -d --build (service worker = mapserve sur l'image complète, API + tuiles + inventaire pour les cartes distantes)
  • test all: ./run.sh --test (rebuild automatique de l'image avant les tests ; en docker run direct, rebuild manuellement d'abord)
  • test file: docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>
  • test case: docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>
  • lint: not configured
  • format: not configured
  • after every edit: ./run.sh --test
  • RÈGLE 1 — toujours lancer via docker compose (jamais docker run direct) : carte/API locale → docker compose up -d --build serve (port 8973, mapserve sur image complète) ; traitement ponctuel → docker compose run --rm --build process [options] ; logs → docker compose logs -f serve ; arrêt → docker compose down.
  • RÈGLE 2 — TOUJOURS --build : le code est baké dans l'image (jamais monté). Sans --build, up/run réutilisent l'image existante et l'ANCIEN code tourne. --build est quasi instantané grâce au cache (le .dockerignore exclut input/ et output/ du contexte). Après édition : docker compose up -d --build serve recrée le conteneur sur du neuf.
  • test rapide sans rebuild (code monté par-dessus l'image) : docker run --rm -e PYTHONPATH=/app -v $(pwd)/lidar_pipeline:/app/lidar_pipeline lidar-lidar python3 -m pytest --pyargs lidar_pipeline.tests -q (~3 min ; ajouter timeout 600 devant, et PAS de pipe | tail qui masque la progression)
  • debug: ./run.sh --debug (file:line logging); container shell: docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output --entrypoint bash lidar-lidar
  • mise à jour d'une carte légère distante (checkout git + override maps local) : ssh <hôte> "cd <checkout> && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build" (procédure dans docs/DEPLOY_WEBAPP.md).
  • rattrapage des sidecars qualité (dalles traitées avant l'ajout du sidecar) : la commande fixe de process (compose) est remplacée en entier dès qu'un argument suit le nom du service, donc --quality-backfill seul ne fonctionne pas — utiliser docker compose run --rm --build process python3 -m lidar_pipeline /data/input -o /data/output --quality-backfill.

Conventions

  • Un seul serveur web : mapserve.py (image lidar-maps, port 8975 léger / 8973 worker). L'ancienne webapp (webapp.py, export.py, index.html/_APP_JS) a été supprimée : la génération de tuiles (portée de la webapp historique — /api/preview, /api/generate, /api/status, /api/stop, /api/queue/clear, /api/cell) vit dans mapserve.py, l'interface dans web/map.{html,css,js} (relus par mapui.py, constantes _MAP_* conservées, écrites par write_map_assets() et bâchées dans les images). Sur l'image légère sans LIDAR_GENERATION_URL, /api/status répond available: false et l'interface masque l'onglet Génération.
  • Interface en panneau unique (web/map.{html,css,js}) : un seul panneau à onglets Affichage / Dalle / Export PDF / Génération (masqué si le générateur est indisponible ou non autorisé) / Partager, remplaçant l'ancienne pile de blocs empilés. Sous 720 px de large, le panneau devient un volet en bas d'écran à trois hauteurs (closed/half/full, poignée #sheetGrip glissée ou simplement touchée, ou onglet actif retouché). Un clic sur une dalle la sélectionne (contour) et remplit l'onglet Dalle (emprise, IGN, recalage des passes) sans changer l'onglet affiché ; re-cliquer la même dalle désélectionne, cliquer une autre déplace la sélection. Échap désélectionne la dalle puis replie le panneau (bande d'icônes sur ordinateur, volet fermé sur téléphone — la bande reste utilisable, un clic sur un onglet redéplie le panneau). Raccourcis clavier : 1–5 (onglets, sans effet si l'onglet est masqué), P (mode d'affichage suivant), Échap. Intensité du relief (&I=, contraste 0,5–2×, défaut 1×) : réglage de confort personnel mémorisé dans lidarMapView_v2.intensity et partagé dans le lien, jamais figé comme défaut serveur (★ Définir par défaut l'ignore). Deux thèmes clair/sombre (lidar-theme, auto par défaut, suit le système) et tous les réglages communs posés sur :root en variables CSS (aucune couleur en dur dans les composants). Clés localStorage (via lsGet/lsSet, silencieux en navigation privée) : lidarMapView_v2 (vue/couches), lidar-print (réglages d'export), lidar-panel (onglet, hauteur du volet, repli en bande d'icônes), lidar-theme.
  • index.py = catalogue + registres partagés (plus d'interface) : VIZ_LABELS/VIZ_LEGENDS, défauts d'affichage (DEFAULT_VIZ/PRECISION_VIZ/VIEW_MODES), PANEL_VIZ/KEYWORD_TO_STEP, scan_tiles/cells_with_all_viz, vignettes + sous-tuiles + inventaire index_tiles.json (build_index). L'inventaire est servi par /api/tiles de mapserve aux machines légères (LIDAR_SOURCE_URL).
  • Generation is 0.2 m only (policy): /api/generate (GENERATE_RESOLUTIONS in mapserve.py), the compose process command and the CLI -r default all produce 0.2 m exclusively; 0.5 m stays available via explicit -r 0.5. Completeness detection (complete_cells) requires the viz at 0.2 m only.
  • Génération du nord au sud : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes — find_laz_files (pipeline.py) pour les passes batch et _resolve_request (mapserve.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (--file explicite au CLI = ordre utilisateur préservé). Parallélisme de génération : LIDAR_WORKERS (10 dans les compose ; auto sinon).
  • Sub-tuilage intégral : _CARTO_SUBTILED_VIZ (vide dans index.py) découpe TOUTES les couches en quadrants 500 m à 0,2 m ; toutes en AVIF 4:2:0 q75 (_SUBTILE_AVIF_QUALITY), encodées UNE fois depuis le raster d'origine : tif_to_crop appelle index.write_subtiles juste après la dalle (plus récentes qu'elle, build_index ne les réencode pas ; l'ancienne chaîne dalle q60 → sous-tuile q55 cumulait deux pertes : 18,1 dB/SSIM 0,84 contre 19,5 dB/0,93). Le 4:2:0 plafonne vers 19,5 dB sur le relief (teinte pixel par pixel moyennée par 2 × 2) : seul le 4:4:4 irait plus loin (q75 : 28 dB, ~1,6× plus lourd). Exception : les aplats de niveaux (_SUBTILE_LOSSLESS_VIZ, la précision) en WebP sans perte (.webp, accepté par tiles.py). Pillow ignore lossless=True en AVIF (q75 avec perte) ; en RGB aucun réglage AVIF n'est exact. Une couche qui échoue à la découpe retombe en dalle entière (_fallback_full_dalle) sans pénaliser les autres.
  • Tuiles XYZ réutilisables hors du projet : /tiles/{layer}/{z}/{x}/{y}.png suit le schéma OpenStreetMap (256 px, EPSG:3857, PNG RGBA transparent hors emprise, CORS *) — JOSM, iD, QGIS, uMap et MapLibre le consomment tel quel, avec découverte via TileJSON / WMTS / josm.imagery.xml. Le 512 px (@2x.webp) est réservé à l'interface interne (moitié moins de requêtes en HTTP/1.1). Toute évolution du gabarit d'URL casse des configurations clientes : la changer demande une décision explicite.
  • Bilingual naming: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
  • Adding a visualization requires 4 edits: (1) generate_X() in visualizations.py, (2) entry in VIZ_STEPS in pipeline.py, (3) entry in COLORMAPS in rendering.py (or RGB_LEGENDS for an RGB output), (4) entry in VIZ_LEGENDS in index.py (title/legend/description + sampled cmap gradient — single text source merged into COLORMAPS at import, also served in /api/map/meta and le TileJSON). Missing any one breaks the pipeline.
  • generate_* signature is strict: (dem_file, basename, vis_dir, resolution, shared=None) returning Path on success, None on failure. IGN overlays (ortho, topo) omit shared.
  • Return None on failure, never raise: dtm.py, visualizations.py, and ign.py all return None to let the pipeline continue. Raising aborts the entire file.
  • Logger is always logging.getLogger("lidar"), never __name__. All modules route through this single logger so worker processes can configure it.
  • Ajustement conjoint des lignes de balayage (3ᵉ passe du calage, STRIP_ALIGN_VERSION 3) : chaque ligne de balayage (~150 lignes/s, découpée par _scan_line_ids : retour de la dent de scie de scan_angle) peut être décalée ET inclinée (roulis) de 1 à 15 cm — lignes en creux isolées, passe entière basculée visible en bande (mesuré sur LHD_FXX_0999_6882). _joint_line_corrections (dtm.py) recale chaque ligne (a + b·u) contre le consensus des AUTRES faisceaux de sa maille 1 m (plan local par la pente), moindres carrés tronqués à 5 MAD par bincount, pas amorti 0,5, arrêt sous 1 mm, recentrage (pas de dérive d'ensemble), estimation sur 1 point sur 3 ; CuPy si GPU actif, repli numpy. S'y ajoute un profil d'étalonnage par faisceau et par degré d'angle (STRIP_ANGLE_BIN, commun à toutes les lignes du faisceau, moyenne retirée, pente conservée car l'inclinaison par ligne est indéterminable quand la fauchée ne traverse la dalle qu'en partie ; classes pauvres = valeur voisine) : il retire les écarts non linéaires en travers de la fauchée (±1,5 cm mesurés au bord), sources de marches parallèles au vol au bord de fauchée. Lignes sans recouvrement : _scan_line_corrections_beam (contre la surface de leur propre faisceau, composante ligne à ligne seule, σ 3 lignes). Validé sur blocs jamais vus (damier 10 m). La gigue par fenêtres de temps (2ᵉ passe) n'est plus calculée quand scan_angle existe (redondante). Coût CPU ~26 s par dalle pour tout le calage (73 s avant). Sidecar : lines, line_window, line_cell, line_model.
  • Comblement des vides entre points borné à l'enveloppe (GAP_FILL_VERSION 2, _fill_small_gaps dans dtm.py) : à 0,2 m ~80 % des pixels n'ont aucun point. L'ancien fillnodata à 1 m comblait tout pixel à moins de 1 m d'un point — chaque point isolé devenait une pastille plate de 2 m (teinte atan2(0,0) = rose saturé dans le relief orienté) et chaque trou recevait une bande extrapolée de 1 m (liseré coloré). Désormais : fermeture morphologique des pixels mesurés (rien n'est étendu vers l'extérieur) dont le rayon suit l'espacement local des points (GAP_RADIUS_K 1,5 × espacement mesuré sur 5 m, paliers GAP_RADII_M 1/1,5/2/3 m, 1 m mini pour boucher les trous de voitures), puis îlots < GAP_MIN_ISLAND_M2 (1 m²) retirés. Morphologie par tranches numpy (_morph_step, ~10× scipy) ; ~5–9 s par dalle 5000². Version dans le tag GeoTIFF LIDAR_GAP_FILL (3 : + fichier annexe de densité) : absent/différent ⇒ DTM régénéré (_gap_fill_matches dans pipeline.py). rasterio.fill.fillnodata écrit dans son entrée : toujours lui passer une copie.
  • Openness à échelle fixe : generate_openness normalise par des références figées OPENNESS_POS_REF / OPENNESS_NEG_REF (degrés, médianes mesurées sur 15 dalles réparties sur le territoire) et plus par z-score de dalle — même ouverture = même couleur, mosaïque jointive.
  • Filename special-cases in _expected_output_path(): pos_open → positive_openness, neg_open → negative_openness, hillshade → hillshade_multi.
  • Génération imposée : la carte ne propose plus de réglage de classification ni de raccord — _build_command (mapserve.py) passe toujours --ground-classification ign --ign-classes sol --edge-buffer 100 ; les champs ground_class/ign_classes/reclassify/edge_buffer éventuellement envoyés sont ignorés. Défauts CLI alignés (ign, 100).
  • Classification IGN par extraction directe : _extract_ign_ground (dtm.py) filtre les classes avec laspy (retours ≥ 1) et écrit le LAS sol sans passer par PDAL (4,9 s au lieu de 13,5 s par dalle) ; la lecture de la détection automatique est réutilisée (_LAST_READ). PDAL reste le repli.
  • Pyramide mise à jour pendant le rendu : le pipeline régénère l'inventaire après chaque dalle par défaut (incremental_index sauf --no-index, quel que soit le lanceur), avec passe différée quand l'anti-rebond de 3 s ignore une dalle. Côté carte, _bg_poller surveille l'inventaire toutes les BG_WATCH_S = 10 s (fichier local ou charge utile amont) et lance un scan dès qu'il change ; les tuiles des dalles modifiées passent en tête de file (_bg_enqueue(front=True)), devant l'arriéré.
  • Pyramide complète générée d'avance : par défaut tiles.zoom_cached stocke TOUS les niveaux jusqu'au natif (TILE_CACHE_MAX_Z = TILE_MAX_NATIVE_Z 19, TILE_EVEN_LEVELS off) et la maintenance de fond les génère d'avance jusqu'à 18@2x (défaut de LIDAR_TILE_BACKGROUND_MAX_Z) ; sur le Pi elle les TÉLÉCHARGE du worker. Carte plafonnée au zoom 19 (maxZoom, 1 px écran = 1 px LiDAR, jamais de tuile agrandie). Raison : le rendu à la volée du Pi (~170 ms/tuile, 3 à la fois) donnait 1,5–6 s par écran aux zooms 17–19. File de maintenance bornée (queue_max) : chaque dalle garde dans _bg["incomplete"] l'indice de sa première tuile refusée faute de place et reprend de là aux scans suivants (avant : le premier scan saturait la file et le reste de la pyramide n'était jamais généré). ~110 000 tuiles @2x pour 3 240 dalles (~5 Go). Stockage réduit toujours possible (LIDAR_TILE_EVEN_LEVELS=1 → EvenLevelTileLayer, niveaux pairs, natif 19 demandé tel quel).
  • Mémoire bornée de la carte (Pi, mem_limit: 1g) : _open_source (tiles.py) met en cache une copie détachée de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ; Dockerfile.maps fixe MALLOC_MMAP_THRESHOLD_=1048576 pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik).
  • Carte autonome (Pi sans worker) : amont (LIDAR_SOURCE_URL/LIDAR_MAPS_URL) éteint ⇒ la carte reste servie depuis le disque. _remote_payload mémorise aussi l'échec (TTL 60 s, délai 10 s : sinon chaque requête repayait le délai réseau et /api/map/meta ne répondait plus), puis l'index local (_build_index) prend le relais ; coupe-circuits sur les sources (_SOURCE_OFFLINE, 60 s) et les tuiles (_UPSTREAM, réarmé à expiration) ; source rapatriée datée à sa version amont (os.utime) pour que l'index local garde les mêmes dates ; pas de requête amont pour une tuile sans dalle quand l'inventaire vient de l'amont. En cache seule, une tuile périmée reste servie (no-store, X-Tile-Pending) en attendant la nouvelle.
  • Pyramide téléchargée en tâche de fond : avec LIDAR_MAPS_URL, la maintenance (_bg_process_one) TÉLÉCHARGE les tuiles des niveaux stockés depuis l'amont (stat telechargees), sans attendre l'affichage, et sans pause entre deux téléchargements (le worker encaisse) ; rendu local en repli si l'amont ne répond pas, lui seul suivi de LIDAR_TILE_BACKGROUND_PAUSE (ressources du Pi).
  • Première apparition des dalles (_apply_seen, index_xyz/.sources_seen.json) : date effective d'une source = max(version, première entrée dans l'inventaire). Une dalle écrite avant mais inventoriée après une tuile (TTL de l'index amont, anti-rebond de l'inventaire) périme donc la tuile — sinon trou permanent à ce niveau, sur disque, en mémoire et dans le navigateur (stamp ?v= inchangé). Registre persistant ; absent avec un cache existant (mise à jour) ⇒ tout est périmé une fois.
  • Coût navigateur de la carte (mesuré sous Chrome, Intel Iris Xe) : le filtre d'assombrissement du fond OSM (.base-dark) est posé sur le conteneur de la couche, jamais sur chaque tuile — rendu identique (opérations par pixel), processus GPU 92 % → 56 % en déplacement et 99 % → 53 % au zoom molette. Couches LiDAR en keepBuffer: 1 (tuiles 512 px = 1 Mo décodé chacune). Mesuré sans effet notable : backdrop-filter des cartes, isolation/mix-blend-mode en « normal », will-change. Tas JS 2-5 Mo, CPU au repos ~1 %. Décodage AVIF ~12 ms/tuile contre ~5 ms en WebP (compromis assumé : stockage AVIF).
  • Encodage AVIF rapide : AVIF_SPEED = 9 (rendering.py, tiles.py, _SUBTILE_AVIF_SPEED dans index.py) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche.
  • Workers bornés par la VRAM : chaque processus du pool prend une place fixe à sa création (_init_worker_slot dans pipeline.py, file multiprocessing) calculée par gpu_worker_slots (gpu.py) : au plus (VRAM libre − LIDAR_GPU_RESERVE_MIB 512) / LIDAR_GPU_WORKER_MIB (2048, pic estimé : contexte CUDA + calage conjoint CuPy en float64 + EDT de _fill_nans) workers par GPU, au moins un, entrelacés ; l'excédent tourne en CPU (force_cpu). L'ancien round-robin par numéro de fichier mettait 6 workers par RTX 5060 de 8 Go avec LIDAR_WORKERS=auto (12) : OOM. VRAM illisible (nvidia-smi en échec) : round-robin sans borne.
  • Préparation sur GPU : _fill_nans (transformée de distance cupyx) et les gradients de SharedDEM passent sur GPU quand il est actif (repli CPU). NUMBA_CACHE_DIR=/tmp/numba-cache (Dockerfile) évite la recompilation des noyaux numba à chaque worker.
  • Default output is AVIF, not WebP. Use --format webp for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98).
  • Calage vertical des faisceaux de vol : chaque tuile mélange plusieurs passes (1-2 PointSourceId par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). create_dtm_fast mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (STRIP_ALIGN_THRESHOLD dans dtm.py) avant rastérisation. Offsets calculés par tuile (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans DTM/*_dtm*_stripalign.json (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : --no-strip-align.
  • Gigue intra-faisceau (2ᵉ passe du calage) : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. _strip_jitter_offsets découpe chaque faisceau en fenêtres de temps GPS (STRIP_JITTER_BIN = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ STRIP_JITTER_MIN_CELLS = 40 cellules), lisse la série (médiane glissante STRIP_JITTER_SMOOTH = 5 fenêtres), la borne à ± STRIP_JITTER_MAX (10 cm) puis l'interpole au temps GPS de chaque point (_apply_strip_jitter) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension gps_time (silencieusement ignorée sinon). Sidecar version 2 (séries dans jitter), couverte par --no-strip-align.
  • Couches produites et servies = PANEL_VIZ (index.py, aujourd'hui ('relief_oriente', 'densite_sol')) : pipeline sans --only/--skip (panel_steps()), génération lancée depuis la carte (_panel_viz_steps dans mapserve.py) et couches servies (tiles.available_layers filtre : panneau, /tiles/…, TileJSON, WMTS, JOSM). Les autres visualisations restent calculables avec --only. Les tests de la carte lèvent la restriction via _setup(..., panel=None).
  • Précision (densite_sol) et affichage de la carte : create_dtm_fast écrit la densité des points sol retenus (bande de raccord comprise) dans DTM/*_dtm*_density.tif (mailles 1 m, moyenne 3 × 3, pts/m²) ; generate_densite_sol la quantifie en 16 niveaux (density_levels : échelle log fixe, niveau k dès 0,25 × 2^(k/2) pts/m²) et la garde à 1 m (dalle 1000², ~200 Ko en WebP sans perte, rendering.LOSSLESS_GRAY_KEYWORDS) ; tuiles au plus proche voisin (tiles.NEAREST_LAYERS) pour garder 16 gris nets aux zooms 18–19. Interface (web/map.js, onglet Affichage) : plus de pile de couches — une couche principale (DEFAULT_VIZ) et trois modes relief / précision / comparer (VIEW_MODES, barre glissante, lien &P=compare&C=gauche:droite:%) ; both (ancien produit) lu comme relief. Texte « Comment lire » : champ reading de VIZ_LEGENDS (carte + PDF).
  • Relief orienté (relief_oriente, seule couche de la carte) : image RGB unique (GeoTIFF uint8 3 bandes, rendue telle quelle comme ortho/topo via RGB_KEYWORDS dans rendering.py) — clarté CIELAB = openness positive locale (MNT − gaussienne RELIEF_DETREND_M = 10 m, rayons RELIEF_RADII_M = 5/10/20 m, 16 directions) 65 % + ombrage 35 % ; teinte = aspect, chroma fixe (RELIEF_CHROMA). Échelle log fixe RELIEF_OPEN_RANGE (pas de statistique par dalle) et support total 40 m < bande de raccord 100 m : dalles jointives. Rapide : détendance + rayons sur grille décimée ~0,8 m (RELIEF_GRID_M), noyau dédié qui n'accumule que la moyenne des angles (_mean_horizon_* : CuPy RawKernel sur GPU, numba parallèle sur CPU, numpy en repli), colorisation fusionnée (numba) ou vectorisée sans trigonométrie (CuPy) via une table L* × teinte (_relief_lut). ~5 s de calcul hors préparation sur CPU 12 cœurs. Tout changement de constante change le rendu : régénérer les dalles (--only relief_oriente --force).
  • Openness sous-échantillonnée : generate_openness calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (OPENNESS_DOWNSAMPLE = 2 : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. --openness-downsample 1 = pleine résolution. SVF et openness anisotrope ne sont PAS concernées.
  • Raccord des bords entre tuiles : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile. --edge-buffer N (défaut 100 ; toujours appliqué par la génération depuis la carte, EDGE_BUFFER_METERS = 100 m dans mapserve.py, plus de case à cocher ; 0 = off en ligne de commande) fait rastériser le DTM sur la dalle nominale 1 km alignée sur la grille plus une bande de N m remplie avec les points sol des 8 LAZ voisines (_neighbor_ground_points dans dtm.py : PDAL en flux, découpe + filtre de classes IGN ; voisine absente = téléchargement automatique depuis le catalogue IGN avant le run, isolée dans input/edge_neighbors/ pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot, _fetch_edge_neighbors dans pipeline.py) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puis rendering.py (_core_tile_window, via tif_to_crop/tif_to_png) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFF LIDAR_EDGE_BUFFER du DTM : changer --edge-buffer invalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage).
  • Tests use lazy imports inside each test function, never at module top, to avoid importing CuPy/GDAL at import time.
  • _-prefixed names are critical private: _create_ground_pipeline, _fallback_to_smrf, _fill_nans, _init_gpu, _process_file_standalone — do not call from outside their module.
  • build_index() écrit l'inventaire + les paliers sources : output/index_tiles.json (dalles, couches, URLs versionnées — servi par /api/tiles), vignettes index_thumbs/ (≈3,9 m/px + _mid 1,56 m/px) et quadrants index_subtiles/ — paliers de la pyramide XYZ (tiles.py). Chaque tuile du run en cours porte ses coins WGS84 pour les cadres de progression. Plus d'HTML : l'interface vit dans mapui.py.
  • Export PDF (export_pdf.py) : Pillow + pyproj + reportlab, sans numpy — tourne dans l'image légère seule. /api/export/frame (cadre) et /api/export/pdf (planche) dans mapserve.py, jamais délégués à LIDAR_GENERATION_URL, verrou d'export unique (_export_lock, 429 si occupé). Composition directe en L93 (tiles.sources_in_bbox/load_source, recadrage + rééchantillonnage Lanczos), sans passer par les tuiles XYZ. Planche : quadrillage L93 (pas selon l'échelle), coins WGS84, flèche du nord tenant compte de la convergence du méridien, échelle graphique et numérique, rose des orientations 8 points, texte de légende tiré de VIZ_LEGENDS, encart qualité (hachures blanches = « non renseigné », ligne « Donnée manquante (sans relief) » pour les dalles hors couverture), 300 dpi en A4 / 250 dpi en A3 (borne la mémoire du Pi). NODATA_RGB recopie visualizations.RELIEF_NODATA_RGB (numpy absent de l'image légère) ; hors emprise des dalles = blanc hachuré.
  • Sidecar qualité (quality.py) : output/quality/{basename}.json (QUALITY_VERSION), densité sol par maille 50 m et dates d'acquisition, calculé après le DTM (ensure_quality, y compris sur un DTM déjà en cache — mesure alors directement le LAZ d'entrée) ; rattrapage batch --quality-backfill (cf. Workflow) ; recopié dans la table quality de index_tiles.json (build_index) et sur les machines légères (tiles._persist_remote_quality, depuis l'inventaire amont).

Architecture Notes (from code audit 2025-09)

Module structure & data flow

  • cli.py → pipeline.py (LidarArchaeoPipeline) → per-file: dtm.py (classify + rasterize) → visualizations.py (17 products) → rendering.py (GeoTIFF→AVIF) → index.py (catalogue : vignettes + inventaire)
  • gpu.py provides CuPy/NumPy proxy (xp), lazy init, OOM fallback. safe_gpu_call wraps all non-IGN viz calls.
  • mapserve.py (FastAPI, image lidar-maps) sert la pyramide XYZ (tiles.py), l'interface (mapui.py), l'inventaire /api/tiles + statiques dalles pour les machines légères, et l'API de génération : pipeline en sous-processus sur l'image complète, délégation via LIDAR_GENERATION_URL sur l'image légère (Raspberry Pi). Job unique + file persistante (_job_lock est un RLock : /api/status appelle _queue_summary() sous verrou). LIDAR_API_TOKEN (worker) / LIDAR_REMOTE_TOKEN (léger) protègent les appels ; LIDAR_REGEN_CIDR réserve la génération au réseau local.
  • progress.py writes JSONL events (O_APPEND, atomic) read by mapserve for live progress (frames on the map).

Key design decisions (intentional, do not "fix")

  • _res_suffix hardcodes 0.5 as "no suffix": coupled to index.py parsing (_strip_res_suffix defaults to 0.5 when no suffix). Changing requires sidecar metadata.
  • GPU scoring (major*1000 + minor*100 + mem_mi): compute capability priority is intentional — a newer GPU with less VRAM is preferred.
  • mapserve.py reads env at import time: deployment-focused single-purpose server, env is set once in docker-compose.
  • Repeated try/except in visualizations (14× same pattern): intentional convention for uniform return None behavior.
  • _d8_accumulate_numba defines @njit inside the function: cache=True makes subsequent calls fast; the Python function object creation is negligible.
  • pkill -9 -f "pdal pipeline" in cli.py signal handler: belt-and-suspenders alongside os.killpg. Scoped to "pdal pipeline" to avoid killing unrelated PDAL processes.

Performance characteristics

  • _priority_flood uses numba JIT binary heap (single int64 array, flat view for elevation). Python heapq fallback if numba unavailable.
  • _d8_accumulate_numba uses numba with argsort top-down sweep. Python fallback exists.
  • Ray-tracing (SVF, openness): processes one direction at a time to limit VRAM. Auto-falls back to CPU on OOM via _ray_trace_horizons.
  • Multi-resolution: primary res (default 0.5) has no filename suffix; additional resolutions use _r0p2 style suffix. Ground classification done once, shared across resolutions.
  • ProcessPoolExecutor has a 2-hour wall-clock safety timeout (prevents indefinite hang from stuck workers).

Numba usage pattern

  • Defined at function scope with @njit(cache=True) — first call compiles (~2-3s), subsequent calls hit disk cache.
  • Must use flat 1D array views (arr.ravel()) for integer indexing — 2D arrays with a single int index return a row slice in nopython mode.
  • Pattern: try numba → return None on ImportError → caller falls back to pure Python.

Commit & Pull Request Guidelines

Commits use imperative tense, short single-line subjects (~60–80 chars), no prefixes or scopes. Compound commits are common — multiple related changes joined by commas or "and". Examples: Fix multi-GPU with lazy CuPy init + rendering improvements, Add multi-resolution support and remove PDF generation, Fix corrupted COPC detection, add CSF→SMRF fallback, improve MSRM colormap, add SVF and anisotropic openness.

No PR template, no CI pipeline, no issue tracker. This is a standalone Docker project with no formal PR process.