Translate the whole project to English and fix outdated comments and help

Comments, docstrings, logs, CLI help, map UI, legends, PDF sheet, scripts,
compose files and AGENTS.md are now English. Data keys stay unchanged
(relief_oriente, densite_sol, visualisations/, API JSON keys, link params).
Wrong comments and help defaults found along the way are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2026-09-27 23:16:45 +02:00
parent cc1c22d2b8
commit fb892ea9f2
52 changed files with 4356 additions and 4323 deletions

101
AGENTS.md
View File

@ -1,88 +1,89 @@
## 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.
- install: `docker build -t lidar-lidar .` (deps baked into the image)
- one-command local start (GPU optional): `./start.sh` (map on 8973), `./start.sh process [options]`, `./start.sh logs|stop` — creates `input/`/`output/` as the current uid, adds `docker-compose.cpu.yml` (`gpus: !reset`) when Docker cannot reach any 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)
- build the lightweight map (Raspberry Pi, two-machine deployment — see `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.maps.yml up -d --build` (image `Dockerfile.maps`, no PDAL/GPU)
- build the tile generator (processing machine): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker` = mapserve on the full image: API + tiles + inventory for the remote maps)
- test all: `./run.sh --test` (rebuilds the image automatically before the tests; with a direct `docker run`, rebuild by hand first)
- 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)
- **RULE 1 — always run through docker compose** (never a direct `docker run`): local map/API → `docker compose up -d --build serve` (port 8973, mapserve on the full image); one-off processing → `docker compose run --rm --build process [options]`; logs → `docker compose logs -f serve`; stop → `docker compose down`.
- **RULE 2 — ALWAYS `--build`: the code is baked into the image (never mounted).** Without `--build`, `up`/`run` reuse the existing image and the OLD code runs. `--build` is almost instant thanks to the cache (`.dockerignore` keeps input/ and output/ out of the context). After an edit, `docker compose up -d --build serve` recreates the container on fresh code.
- quick test without rebuild (code mounted over the 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; prefix with `timeout 600`, and NO `| tail` pipe, which hides the progress)
- 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`.
- `./run.sh` passes its own defaults to the CLI: `-r 0.5` and `-w 1` unless given (the CLI alone defaults to `-r 0.2`, `-w auto`); image quality, format and ground classification fall through to the CLI defaults (60, avif, ign).
- updating a remote lightweight map (git checkout + local maps override, see `docker-compose.maps.override.yml.example`): `ssh <host> "cd <checkout> && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"` (procedure in `docs/DEPLOY_WEBAPP.md`).
- backfilling the quality sidecars (tiles processed before the sidecar existed): the fixed command of `process` (compose) is replaced entirely as soon as any argument follows the service name, so `--quality-backfill` alone does not work — use `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.
- **A single web server: `mapserve.py`** (image `lidar-maps`, port 8975 lightweight / 8973 worker). The old webapp (`webapp.py`, `export.py`, index.html/`_APP_JS`) has been removed: tile generation (the scope of the historical webapp — `/api/preview`, `/api/generate`, `/api/status`, `/api/stop`, `/api/queue/clear`, `/api/cell`) lives in `mapserve.py`, the interface in `web/map.{html,css,js}` (read by `mapui.py`, `_MAP_*` constants kept, written as `app.css`/`app.js` by `write_map_assets()` and baked into the images). On the lightweight image without `LIDAR_GENERATION_URL`, `/api/status` answers `available: false` and the interface hides the Generation tab.
- **Single-panel interface** (`web/map.{html,css,js}`): one tabbed panel View / Tile / PDF export / Generation (hidden when the generator is unavailable or not allowed) / Share, replacing the old stack of blocks. Below 720 px wide, the panel becomes a bottom sheet with three heights (`closed`/`half`/`full`; handle `#sheetGrip` dragged or simply tapped, or the active tab tapped again). A click on a tile **selects** it (outline) and fills the Tile tab (extent, IGN, strip alignment) without switching the displayed tab; clicking the same tile again deselects it, clicking another moves the selection. Escape deselects the tile, then collapses the panel (icon strip on desktop, closed sheet on phone — the strip stays usable, a click on a tab expands the panel again). Keyboard shortcuts: 1–5 (tabs, no effect when the tab is hidden), P (next display mode), Escape. Relief intensity (`&I=`, contrast 0.5–2×, default 1×): a personal comfort setting stored in `lidarMapView_v2.intensity` and shared in the link, never frozen as a server default (`★ Set as default` ignores it). Two themes, light/dark (`lidar-theme`, `auto` by default, follows the system), and every shared setting set on `:root` as CSS variables (no hard-coded colour in the components). `localStorage` keys (through `lsGet`/`lsSet`, silent in private browsing): `lidarMapView_v2` (view/layers), `lidar-print` (export settings), `lidar-panel` (tab, sheet height, collapse to the icon strip), `lidar-theme`.
- **`index.py` = catalogue + shared registries** (no interface any more): `VIZ_LABELS`/`VIZ_LEGENDS`, display defaults (`DEFAULT_VIZ`/`PRECISION_VIZ`/`VIEW_MODES`), `PANEL_VIZ`/`KEYWORD_TO_STEP`, `scan_tiles`/`cells_with_all_viz`, thumbnails + sub-tiles + the `index_tiles.json` inventory (`build_index`). The inventory is served by mapserve's `/api/tiles` to the lightweight machines (`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 an explicit `-r 0.5` (note: `./run.sh` still passes `-r 0.5` by default). Completeness detection (`complete_cells`) requires the viz at 0.2 m only.
- **North-to-south generation**: tiles are processed by decreasing row (row = northing in km), then increasing column — `find_laz_files` (pipeline.py) for batch passes and `_resolve_request` (mapserve.py) for runs started from the map. Workers take the files in submission order: the map fills from top to bottom during a run (an explicit `--file` on the CLI keeps the user's order). Generation parallelism: `LIDAR_WORKERS` (10 in `docker-compose.yml`, `auto` in `docker-compose.worker.yml` and when unset).
- **Full sub-tiling**: `_CARTO_SUBTILED_VIZ` (empty in `index.py`) splits ALL layers into 500 m quadrants at 0.2 m; all in AVIF 4:2:0 q75 (`_SUBTILE_AVIF_QUALITY`), encoded ONCE from the original raster: `tif_to_crop` calls `index.write_subtiles` (through `rendering._write_subtiles_from`) right after the tile (so they are newer than it, and `build_index` does not re-encode them; the old chain tile q60 → sub-tile q55 stacked two losses: 18.1 dB/SSIM 0.84 against 19.5 dB/0.93). 4:2:0 tops out around 19.5 dB on the relief (per-pixel hue averaged over 2 × 2): only 4:4:4 would go further (q75: 28 dB, ~1.6× heavier). Exception: flat level maps (`_SUBTILE_LOSSLESS_VIZ`, the precision layer) in lossless WebP (`.webp`, accepted by `tiles.py`). **Pillow ignores `lossless=True` for AVIF** (q75, lossy); in RGB no AVIF setting is exact. A layer that fails to split falls back to the whole tile (`_fallback_full_dalle`) without penalising the others.
- **XYZ tiles reusable outside the project**: `/tiles/{layer}/{z}/{x}/{y}.png` follows the OpenStreetMap scheme (256 px, EPSG:3857, RGBA PNG transparent outside coverage, CORS `*`) — JOSM, iD, QGIS, uMap and MapLibre consume it as is, with discovery through TileJSON / WMTS / `josm.imagery.xml`. The 512 px variant (`@2x.avif`) is reserved for the internal interface (half as many requests over HTTP/1.1). Any change to the URL template breaks client configurations: changing it requires an explicit decision.
- **Naming**: all code identifiers, comments, logs, help and user-facing strings are English; data keys inherited from the French era (`relief_oriente`, `densite_sol`, `visualisations/`, `--ign-classes sol`, stats key `telechargees`…) are stable contracts and must not be renamed.
- **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 the 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.
- **Joint scan-line adjustment (3rd pass of strip alignment, `STRIP_ALIGN_VERSION` 3)**: each scan line (~150 lines/s, split by `_scan_line_ids`: flyback of the `scan_angle` sawtooth) can be offset AND tilted (roll) by 1 to 15 cm — isolated sunken lines, a whole pass tilted and visible as a band (measured on LHD_FXX_0999_6882). `_joint_line_corrections` (`dtm.py`) re-fits each line (a + b·u) against the consensus of the OTHER strips in its 1 m cell (local plane through the slope), least squares trimmed at 5 MAD via `bincount`, damped step 0.5, stop below 1 mm, re-centring (no global drift), estimated on 1 point in 3; CuPy when the GPU is active, numpy fallback. On top of that, a **calibration profile per strip and per degree of scan angle** (`STRIP_ANGLE_BIN`, shared by all lines of the strip, mean removed, slope kept because the per-line tilt is undetermined when the swath only partly crosses the tile; sparse bins = neighbouring value) removes the non-linear across-swath errors (±1.5 cm measured at the edge), a source of steps parallel to the flight line at the swath edge. Lines without overlap: `_scan_line_corrections_beam` (against the surface of their own strip, line-to-line component only, σ 3 lines). Validated on never-seen blocks (10 m checkerboard). Jitter by time windows (2nd pass) is no longer computed when `scan_angle` exists (redundant). CPU cost ~26 s per tile for the whole alignment (73 s before). Sidecar: `lines`, `line_window`, `line_cell`, `line_model`.
- **Filling gaps between points, bounded by the envelope (`GAP_FILL_VERSION` 3, `_fill_small_gaps` in `dtm.py`)**: at 0.2 m ~80 % of the pixels contain no point. The old `fillnodata` at 1 m filled every pixel within 1 m of a point — each isolated point became a flat 2 m disc (hue `atan2(0,0)` = saturated pink in the oriented relief) and each hole received an extrapolated 1 m band (coloured fringe). Now: morphological closing of the measured pixels (nothing is extended outwards) whose radius follows the local point spacing (`GAP_RADIUS_K` 1.5 × spacing measured over 5 m, steps `GAP_RADII_M` 1/1.5/2/3 m, 1 m minimum to plug car-sized holes), then islands < `GAP_MIN_ISLAND_M2` (1 m²) removed. Morphology with numpy slices (`_morph_step`, ~10× scipy); ~5–9 s per 5000² tile. Version stored in the GeoTIFF tag `LIDAR_GAP_FILL` (2 = envelope, 3 = + density sidecar file): absent/different ⇒ DTM regenerated (`_gap_fill_matches` in `pipeline.py`). `rasterio.fill.fillnodata` writes into its input: always pass it a copy.
- **Fixed-scale openness**: `generate_openness` normalises with frozen references `OPENNESS_POS_REF` / `OPENNESS_NEG_REF` (degrees, medians measured on 15 tiles spread over the territory) instead of a per-tile z-score — same openness = same colour, seamless mosaic.
- **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).
- **Imposed generation settings**: the map no longer offers a classification or stitching setting — `_build_command` (`mapserve.py`) always passes `--ground-classification ign --ign-classes sol --edge-buffer 100`; any `ground_class`/`ign_classes`/`reclassify`/`edge_buffer` fields sent are ignored. CLI defaults aligned (`ign`, `100`).
- **IGN classification by direct extraction**: `_extract_ign_ground` (`dtm.py`) filters the classes with laspy (returns ≥ 1) and writes the ground LAS without PDAL (4.9 s instead of 13.5 s per tile); the read done by the automatic detection is reused (`_LAST_READ`). PDAL remains the fallback.
- **Pyramid updated during rendering**: the pipeline rebuilds the inventory after each tile **by default** (`incremental_index` unless `--no-index`, whatever the launcher), with a deferred pass when the 3 s debounce skips a tile. On the map side, `_bg_poller` watches the inventory every `BG_WATCH_S` = 10 s (local file or upstream payload) and starts a scan as soon as it changes; tiles of modified source tiles go to the **front** of the queue (`_bg_enqueue(front=True)`), ahead of the backlog.
- **Full pyramid generated ahead of time**: by default `tiles.zoom_cached` stores ALL levels up to native (`TILE_CACHE_MAX_Z` = `TILE_MAX_NATIVE_Z` 19, `TILE_EVEN_LEVELS` off) and background maintenance — enabled with `LIDAR_TILE_BACKGROUND=1`, set in the Pi override — generates them ahead of time up to `18@2x` (default of `LIDAR_TILE_BACKGROUND_MAX_Z`); on the Pi it DOWNLOADS them from the worker. Map capped at zoom 19 (`maxZoom`, 1 screen px = 1 LiDAR px, never an upscaled tile). Reason: on-the-fly rendering on the Pi (~170 ms/tile, 3 at a time) gave 1.5–6 s per screen at zooms 17–19. Bounded maintenance queue (`queue_max`): each source tile keeps in `_bg["incomplete"]` the index of its first tile rejected for lack of room and resumes from there on the next scans (before: the first scan filled the queue and the rest of the pyramid was never generated). ~110,000 @2x tiles for 3,240 source tiles (~5 GB). Reduced storage still possible (`LIDAR_TILE_EVEN_LEVELS=1` → `EvenLevelTileLayer`, even levels only, native 19 requested as is).
- **Bounded map memory (Pi, `mem_limit: 1g`)**: `_open_source` (`tiles.py`) caches a **detached copy** of each source (an open AVIF image keeps its decoder, ~18 MB more per 2500² quadrant) and counts 4 bytes/pixel (PIL stores RGB on 32 bits); `Dockerfile.maps` sets `MALLOC_MMAP_THRESHOLD_=1048576` so that glibc returns large buffers to the system. Without these two points, browsing at high zoom (levels rendered on the fly) climbed to ~700 MB RSS and the container was killed in a loop by the OOM killer (502 on the Traefik side).
- **Standalone map (Pi without worker)**: upstream (`LIDAR_SOURCE_URL`/`LIDAR_MAPS_URL`) down ⇒ the map is still served from disk. `_remote_payload` (`tiles.py`) also remembers the failure (TTL 60 s, timeout 10 s: otherwise every request paid the network timeout again and `/api/map/meta` stopped answering), then the local index (`_build_index`) takes over; circuit breakers on sources (`_SOURCE_OFFLINE`, 60 s) and tiles (`_UPSTREAM`, re-armed on expiry); a fetched source is dated to its upstream version (`os.utime`) so the local index keeps the same dates; no upstream request for a tile without a source tile when the inventory comes from upstream. In cache-only mode, a stale tile keeps being served (`no-store`, `X-Tile-Pending`) until the new one arrives.
- **Pyramid downloaded in the background**: with `LIDAR_MAPS_URL`, maintenance (`_bg_process_one`) DOWNLOADS the tiles of the stored levels from upstream (stat `telechargees`), without waiting for them to be displayed, and with no pause between two downloads (the worker copes); local rendering as a fallback when upstream does not answer, which alone is followed by `LIDAR_TILE_BACKGROUND_PAUSE` (Pi resources).
- **First appearance of source tiles (`_apply_seen`, `index_xyz/.sources_seen.json`)**: effective date of a source = max(version, first entry in the inventory). A source tile written before but inventoried after an XYZ tile (upstream index TTL, inventory debounce) therefore invalidates that tile — otherwise a permanent hole at that level, on disk, in memory and in the browser (unchanged `?v=` stamp). Persistent registry; absent with an existing cache (upgrade) ⇒ everything is invalidated once.
- **Browser cost of the map (measured in Chrome, Intel Iris Xe)**: the darkening filter of the OSM base map (`.base-dark`) is set on the layer **container**, never on each tile — identical rendering (per-pixel operations), GPU process 92 % → 56 % while panning and 99 % → 53 % on wheel zoom. LiDAR layers use `keepBuffer: 1` (512 px tiles = 1 MB decoded each). Measured with no notable effect: the cards' `backdrop-filter`, `isolation`/`mix-blend-mode` set to "normal", `will-change`. JS heap 2–5 MB, idle CPU ~1 %. AVIF decoding ~12 ms/tile against ~5 ms for WebP (accepted trade-off: AVIF storage).
- **Fast AVIF encoding**: `AVIF_SPEED = 9` (`rendering.py`, `tiles.py`, `_SUBTILE_AVIF_SPEED` in `index.py`) — a 5000 × 5000 px tile encoded in 0.6 s instead of 4 s (+3 % size, −0.3 dB); encoding was the longest step of rendering a layer.
- **Workers bounded by VRAM**: each pool process takes a fixed slot when it is created (`_init_worker_slot` in `pipeline.py`, multiprocessing queue) computed by `gpu_worker_slots` (`gpu.py`): at most (free VRAM − `LIDAR_GPU_RESERVE_MIB` 512) / `LIDAR_GPU_WORKER_MIB` (2048, estimated peak: CUDA context + joint CuPy alignment in float64 + EDT of `_fill_nans`) workers per GPU, at least one, interleaved; the excess runs on the CPU (`force_cpu`). The old round-robin by file number put 6 workers on each 8 GB RTX 5060 with `LIDAR_WORKERS=auto` (12): OOM. Unreadable VRAM (nvidia-smi failing): unbounded round-robin.
- **Preparation on the GPU**: `_fill_nans` (`cupyx` distance transform) and the `SharedDEM` gradients run on the GPU when it is active (CPU fallback). `NUMBA_CACHE_DIR=/tmp/numba-cache` (Dockerfile) avoids recompiling the numba kernels in every worker.
- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth colour ramps, ~÷3 vs q98).
- **Vertical alignment of flight strips**: each tile mixes several passes (1–2 `PointSourceId` per pass), sometimes vertically biased by a few cm (±2.5 cm measured on 1000_6882). `create_dtm_fast` measures the robust offset of each strip (ground points, 1 m cells, median surface iterated 3×) and subtracts offsets ≥ 0.5 cm (`STRIP_ALIGN_THRESHOLD` in `dtm.py`) before rasterization. Offsets computed **per tile** (they drift along a flight line: never a global table), memoised per ground LAS, recorded in `DTM/*_dtm*_stripalign.json` (cache sidecar: absent, or different version/threshold/parameters ⇒ DTM regenerated). Can be disabled: `--no-strip-align`.
- **Intra-strip jitter (2nd pass of the alignment)**: successive scan lines of the SAME pass can be randomly offset vertically (sensor vibration / high-frequency trajectory noise) — a constant offset per strip is not enough. `_strip_jitter_offsets` splits each strip into GPS time windows (`STRIP_JITTER_BIN` = 0.1 s, time origin specific to each strip), measures the robust offset of each window against the median surface of the OTHER strips (shared 1 m cells, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cells), smooths the series (rolling median `STRIP_JITTER_SMOOTH` = 5 windows), clamps it to ± `STRIP_JITTER_MAX` (10 cm), then interpolates it at each point's GPS time (`_apply_strip_jitter`); where two strips overlap, each gets a series (each absorbs its share). Requires the `gps_time` dimension (silently skipped otherwise). Sidecar version 2 (series in `jitter`), covered by `--no-strip-align`.
- **Layers produced and served = `PANEL_VIZ` (`index.py`, currently `('relief_oriente', 'densite_sol')`)**: pipeline without `--only`/`--skip` (`panel_steps()`), generation started from the map (`_panel_viz_steps` in `mapserve.py`) and served layers (`tiles.available_layers` filters: panel, `/tiles/…`, TileJSON, WMTS, JOSM). The other visualizations can still be computed with `--only`. Map tests lift the restriction via `_setup(..., panel=None)`.
- **Precision (`densite_sol`) and map display**: `create_dtm_fast` writes the density of the kept ground points (stitching band included) to `DTM/*_dtm*_density.tif` (1 m cells, 3 × 3 mean, pts/m²); `generate_densite_sol` quantises it into 16 levels (`density_levels`: fixed log scale, level k from 0.25 × 2^(k/2) pts/m²) and keeps it at 1 m (1000² tile, ~200 KB in lossless WebP, `rendering.LOSSLESS_GRAY_KEYWORDS`); nearest-neighbour tiles (`tiles.NEAREST_LAYERS`) to keep 16 crisp greys at zooms 18–19. Interface (`web/map.js`, View tab): no more layer stack — one main layer (`DEFAULT_VIZ`) and three modes relief / precision / compare (`VIEW_MODES`, sliding bar, link `&P=compare&C=left:right:%`); `both` (old product) read as `relief`. "How to read" text: the `reading` field of `VIZ_LEGENDS` (map + PDF).
- **Oriented relief (`relief_oriente`, the only relief layer of the map)**: a single RGB image (3-band uint8 GeoTIFF, rendered as is like ortho/topo via `RGB_KEYWORDS` in `rendering.py`) — CIELAB lightness = **local** positive openness (DTM − Gaussian `RELIEF_DETREND_M` = 10 m, radii `RELIEF_RADII_M` = 5/10/20 m, 16 directions) 65 % + shading 35 %; hue = aspect, fixed chroma (`RELIEF_CHROMA`). Fixed log scale `RELIEF_OPEN_RANGE` (no per-tile statistic) and a total support of ~60 m (20 m maximum ray radius + 4σ = 40 m of the 10 m Gaussian detrend), still < the 100 m stitching band: seamless tiles. Fast: detrend + radii on a decimated grid ~0.8 m (`RELIEF_GRID_M`), a dedicated kernel that only accumulates the mean of the angles (`_mean_horizon_*`: CuPy `RawKernel` on GPU, parallel numba on CPU, numpy fallback), fused colourisation (numba) or vectorised without trigonometry (CuPy) through an L* × hue table (`_relief_lut`). ~5 s of computation excluding preparation on a 12-core CPU. Any constant change changes the rendering: regenerate the tiles (`--only relief_oriente --force`).
- **Downsampled openness**: `generate_openness` computes the ray tracing (the most expensive step: 532 s/tile at 0.2 m on CPU) on a block-decimated grid (`OPENNESS_DOWNSAMPLE = 2`: block max for positive, min for negative — preserves the reliefs that bound the horizon) then resamples bilinearly. Cost ÷ factor³: 532 s → 40 s (×13). Archaeological signal preserved (corr. 0.93 after smoothing); the sub-metre noise texture disappears. `--openness-downsample 1` = full resolution. SVF is NOT affected (anisotropic openness no longer exists).
- **Edge stitching between tiles**: large-kernel renderings (openness/SVF: 100 m radii; LRM: 15 m) truncate their window at the tile edge — bands of artefacts at every tile change. `--edge-buffer N` (default 100; always applied by generation from the map, `EDGE_BUFFER_METERS` = 100 m in `mapserve.py`, no checkbox any more; 0 = off on the command line) rasterizes the DTM over the **nominal 1 km tile aligned on the grid** plus an N m band filled with the ground points of the 8 neighbouring LAZ files (`_neighbor_ground_points` in `dtm.py`: streaming PDAL, crop + IGN class filter; missing neighbour = automatic download from the IGN catalogue before the run, **kept apart in `input/edge_neighbors/`** so as not to swell the corpus of global passes (deduplicated over the whole batch, `_fetch_edge_neighbors` in `pipeline.py`); not found or failure = empty band). Visualizations compute on the extended extent, then `rendering.py` (`_core_tile_window`, via `tif_to_crop`/`tif_to_png`) crops the outputs to the exact 1 km tile read from the LHD name — the AVIF images stay aligned 1 km squares in the mosaic. Buffer recorded in the GeoTIFF tag `LIDAR_EDGE_BUFFER` of the DTM: changing `--edge-buffer` invalidates the DTM cache automatically (tag absent = 0). Neighbouring bands are not strip-aligned (context only, cropped out of the final image). Cost: ~7 s of reading per neighbour + ~44 % more pixels at 100 m/0.2 m. Name outside the LHD pattern: option ignored (header bounds, no cropping).
- **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).
- **`build_index()` writes the inventory + the source levels**: `output/index_tiles.json` (source tiles, layers, versioned URLs — served by `/api/tiles`), thumbnails `index_thumbs/` (≈3.9 m/px + `_mid` 1.56 m/px) and quadrants `index_subtiles/` — levels of the XYZ pyramid (`tiles.py`). Each tile of the current run carries its WGS84 corners for the progress frames. No HTML any more: the interface lives in `mapui.py`.
- **PDF export (`export_pdf.py`)**: Pillow + pyproj + reportlab, no numpy — runs in the lightweight image alone. `/api/export/frame` (frame) and `/api/export/pdf` (sheet) in `mapserve.py`, never delegated to `LIDAR_GENERATION_URL`, single export lock (`_export_lock`, 429 when busy). Composed directly in L93 (`tiles.sources_in_bbox`/`load_source`, crop + Lanczos resampling), without going through the XYZ tiles. Sheet: L93 grid (step depends on the scale), WGS84 corners, north arrow accounting for meridian convergence, graphic and numeric scale, 8-point orientation rose, legend text taken from `VIZ_LEGENDS`, quality inset (white hatching = "not recorded", a "Missing data (no relief)" line for tiles outside coverage), 300 dpi in A4 / 250 dpi in A3 (bounds the Pi's memory). `NODATA_RGB` copies `visualizations.RELIEF_NODATA_RGB` (numpy is absent from the lightweight image); outside the tiles' extent = hatched white.
- **Quality sidecar (`quality.py`)**: `output/quality/{basename}.json` (`QUALITY_VERSION`), ground density per 50 m cell and acquisition dates, computed after the DTM (`ensure_quality`, including on an already cached DTM — it then measures the input LAZ directly); batch backfill `--quality-backfill` (see Workflow); copied into the `quality` table of `index_tiles.json` (`build_index`) and onto the lightweight machines (`tiles._persist_remote_quality`, from the upstream inventory).
## 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)
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (catalogue: thumbnails + inventory)
- `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.
- `mapserve.py` (FastAPI, image `lidar-maps`) serves the XYZ pyramid (`tiles.py`), the interface (`mapui.py`), the `/api/tiles` inventory + static source tiles for the lightweight machines, and the generation API: pipeline as a subprocess on the full image, delegation via `LIDAR_GENERATION_URL` on the lightweight image (Raspberry Pi). Single job + persistent queue (`_job_lock` is an **RLock**: `/api/status` calls `_queue_summary()` under the lock). `LIDAR_API_TOKEN` (worker) / `LIDAR_REMOTE_TOKEN` (lightweight) protect the calls; `LIDAR_REGEN_CIDR` restricts generation to the local network.
- `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.
- **Repeated try/except in visualizations** (same pattern in every `generate_*`): 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.
- **`pkill -9 -f "pdal pipeline <output>/temp/"`** in the cli.py signal handler: belt-and-suspenders alongside `os.killpg`. Scoped to this run's temp directory to avoid killing unrelated PDAL processes (e.g. a concurrent generation job).
### 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.
- Multi-resolution: 0.5 m has no filename suffix whatever its position; every other resolution (including the 0.2 m default) uses a `_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