Retour/Échap depuis la fiche détaillée ne retouche plus la hauteur du volet téléphone (openTab remplacé par un rétablissement direct de l'onglet) ; le fond OSM suit le thème sauf choix explicite (migration du localStorage et des défauts serveur en v3, plus de dark:true hérité de l'ex-webapp) et part du bon thème dès sa création (plus de flash sombre) ; l'onglet Export mémorisé ne recadre plus la vue au premier rendu (lien partagé préservé) et referme son cadre pendant que la fiche détaillée est ouverte ; la qualité affichée suit le nom réel de la dalle rendue ; un second clic referme la fiche au lieu de déplacer son contour derrière elle ; couleurs en dur restantes tokenisées (--tile-sel, --gps, .gen-log) et lisibles en thème clair ; localStorage corrompu (lidar-panel, lidar-print) n'y casse plus l'interface. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
97 lines
30 KiB
Markdown
97 lines
30 KiB
Markdown
## 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`).
|
||
- 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 / 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 ouvre une bulle (date, densité) → « Détails » bascule une fiche complète dans le panneau (« ← Retour » rouvre l'onglet précédent) ; Échap ferme la bulle, puis la fiche, 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–4 (onglets, sans effet si l'onglet est masqué), P (mode d'affichage suivant), Échap. 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 / les deux (`VIEW_MODES`, précision en `multiply` à `DEFAULT_PRECISION_OPACITY` 0,6), touche P, lien `&M=…&P=mode:opacité`, défauts figés `{main, mode, precision_opacity, base}`.
|
||
- **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.
|