Files
lidar_rendu/AGENTS.md
Antoine Jacquin 31645a42e8 Faire du relief orienté la seule couche produite et affichée
PANEL_VIZ ne contient plus que relief_oriente et pilote désormais tout :
le pipeline sans --only ne produit que cette couche, la génération lancée
depuis la carte aussi, et la carte ne liste ni ne sert en tuiles (panneau,
XYZ, TileJSON, WMTS, JOSM) les autres visualisations présentes sur disque.
Les autres visualisations restent calculables explicitement avec --only.

Corrige au passage _panel_viz_steps, qui ne retenait que les couches dont
le nom de fichier diffère du nom d'étape : la génération depuis la carte
ne produisait que l'openness positive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:10:08 +02:00

16 KiB
Raw Blame History

Workflow

  • install: docker build -t lidar-lidar . (deps baked into image)
  • 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 du Pi de prod (192.168.3.10, checkout /srv/lidar_rendu, override maps + Traefik) : ssh — ssh 192.168.3.10 "cd /srv/lidar_rendu && 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).

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 mapui.py (constantes _MAP_HTML/_MAP_CSS/_MAP_JS, é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 les boutons.
  • index.py = catalogue + registres partagés (plus d'interface) : VIZ_LABELS/VIZ_LEGENDS, défauts d'affichage (DEFAULT_LAYERS/DEFAULT_OPACITY/DEFAULT_BLEND), 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 ; ortho/topo sont encodées en AVIF q75 (_SUBTILE_DETAIL_VIZ) contre q55 pour les rampes de couleur. 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.
  • 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.
  • 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',)) : 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).
  • 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 0 = off ; case « Raccord des bords » de la carte, EDGE_BUFFER_METERS = 100 m dans mapserve.py) 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.

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.