L'ancienne carte n'est pas une carte à tuiles : une div Leaflet par dalle,
rotée en CSS pour coller la grille Lambert 93 sur le Web Mercator, trois
paliers d'images choisis à la main, un plafond d'images pleine résolution et
une mosaïque d'overview pour boucher les trous au dézoom. Des centaines de
nœuds DOM, des AVIF de 2500² à 5000² décodés dans le navigateur, un LOD
maison — et rien de réutilisable hors de cette page.
Nouvelle image légère (Pillow + pyproj, ni GDAL ni PDAL, port 8975) servant
une pyramide XYZ EPSG:3857 au schéma OpenStreetMap, rendue à la demande
depuis les dalles et mise en cache dans output/index_xyz/ :
- tiles.py : grille XYZ, reprojection par transformation projective dalle par
dalle (calage mesuré < 1 px), choix du palier source parmi ceux que le
pipeline produit déjà (vignette, intermédiaire, quadrant, dalle), cache
périmé dès qu'une dalle contributrice est plus récente, marqueur .empty
pour les zones sans donnée, cache d'images sources à budget mémoire ;
- mapserve.py : /tiles/{couche}/{z}/{x}/{y}.png (256 px canonique) et
@2x.webp (512 px, interface), TileJSON, WMTS, josm.imagery.xml, CORS —
les rendus deviennent un fond d'imagerie pour JOSM, iD, QGIS, uMap ;
- mapui.py : une L.tileLayer par couche dans un pane isolé — LOD, cache et
animation natifs de Leaflet ; pile réordonnable (glisser avec barre
d'insertion, ou boutons ▲▼ au doigt), modes de fusion CSS par couche,
configuration figeable comme défaut de tous les navigateurs.
Deux amonts pour les déploiements en deux machines : LIDAR_SOURCE_URL
(webapp du pipeline — inventaire complet, dalles rapatriées à la demande) et
LIDAR_MAPS_URL (autre instance carte). Charge bornée et réglable, taillée
par défaut pour une petite machine : 2 rendus et 2 téléchargements
simultanés, 192 Mo de cache de sources.
Mesuré sur 849 dalles réelles : première tuile d'une zone 0,9–3,2 s
(téléchargement compris), ~1 ms ensuite ; un chemin OSM se superpose
exactement à la trace du rendu de pente, et les coutures entre tuiles
restent sous le bruit naturel du terrain.
L'interface historique (port 8973) n'est pas touchée : génération et export
y restent, les deux cartes coexistent.
14 KiB
14 KiB
Workflow
- install:
docker build -t lidar-lidar .(deps baked into image) - build:
docker build -t lidar-lidar . - build webapp légère (Raspberry Pi, déploiement 2 machines — cf.
docs/DEPLOY_WEBAPP.md):docker compose -f docker-compose.webapp.yml up -d --build(imageDockerfile.webapp, sans PDAL/GPU) - build générateur de tuiles (machine de traitement):
docker compose -f docker-compose.worker.yml up -d --build(serviceworker, API pour les webapp distantes) - simulation locale du mode deux machines :
docker compose -f docker-compose.local-2m.yml up -d --build— worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cacheoutput-webapp/peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS). - carte à tuiles XYZ (style OSM/Google Maps, image légère
lidar-maps, port 8975) :docker compose -f docker-compose.maps.yml up -d --buildou./run.sh --serve-maps— cf.docs/MAPS.md. Coexiste avec la webapp historique (8973) ; lecture seule (génération/export restent surwebapp.py). - stack webapp (machine légère):
./serve-webapp.sh [start|stop|restart|status|sync|logs], config danswebapp.env(modèlewebapp.env.example, ignoré par git) ; mise à jour =git pullpuisrestart(rebuild inclus). Le script pilotedocker composeet charge automatiquementdocker-compose.webapp.override.ymls'il existe — utilisable sur le Pi de prod (192.168.3.10) : labels Traefiklidar.example.fr+ volume réel/srv/lidar/outputde l'override préservés. Mise à jour distante du Pi depuis ce poste :ssh(checkout/srv/lidar_rendu, procédure dansdocs/DEPLOY_WEBAPP.md§2). - test all:
./run.sh --test(rebuild automatique de l'image avant les tests ; endocker rundirect, 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 rundirect) : carte/API →docker compose up -d --build serve(port 8973) ; 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/runréutilisent l'image existante et l'ANCIEN code tourne.--buildest quasi instantané grâce au cache (le .dockerignore exclut input/ et output/ du contexte). Après édition :docker compose up -d --build serverecré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 - 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
Conventions
- Generation is 0.2 m only (policy):
/api/generate(GENERATE_RESOLUTIONSinwebapp.py), the composeprocesscommand and the CLI-rdefault 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(webapp.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 (--fileexplicite au CLI = ordre utilisateur préservé). Parallélisme de génération :LIDAR_WORKERS(10 dans les compose ; fallback 10 danswebapp.py). - Sub-tuilage intégral :
_CARTO_SUBTILED_VIZ(vide dansindex.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}.pngsuit 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()invisualizations.py, (2) entry inVIZ_STEPSinpipeline.py, (3) entry inCOLORMAPSinrendering.py, (4) entry inVIZ_LEGENDSinindex.py(title/legend/description + sampled cmap gradient — single text source merged intoCOLORMAPSat import, also used by the export mosaic legend inexport.py). Missing any one breaks the pipeline. generate_*signature is strict:(dem_file, basename, vis_dir, resolution, shared=None)returningPathon success,Noneon failure. IGN overlays (ortho,topo) omitshared.- Return
Noneon failure, never raise:dtm.py,visualizations.py, andign.pyall returnNoneto 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. - 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 webpfor 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
PointSourceIdpar passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882).create_dtm_fastmesure 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_THRESHOLDdansdtm.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 dansDTM/*_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_offsetsdé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 glissanteSTRIP_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 dimensiongps_time(silencieusement ignorée sinon). Sidecar version 2 (séries dansjitter), couverte par--no-strip-align. - Openness sous-échantillonnée :
generate_opennesscalcule 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és. - 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 webapp,EDGE_BUFFER_METERS= 100 m danswebapp.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_pointsdansdtm.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 dansinput/edge_neighbors/pour ne pas gonfler le corpus des passes globales (dédupliqué sur tout le lot,_fetch_edge_neighborsdanspipeline.py) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puisrendering.py(_core_tile_window, viatif_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 GeoTIFFLIDAR_EDGE_BUFFERdu DTM : changer--edge-bufferinvalide 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()writes 3 files:output/index.html(data shell,const TILESembedded),output/assets/app.cssandoutput/assets/app.js(source:_APP_CSS/_APP_JSconstants inindex.py).webapp.pyserves/assetswith no-cache headers. Each tile carriesmeta— ground method read fromDTM/*_dtm{_rXpY}_method.txt(falls back to the primary-resolution sidecar) + per-viz dates/sizes.
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(Leaflet map)gpu.pyprovides CuPy/NumPy proxy (xp), lazy init, OOM fallback.safe_gpu_callwraps all non-IGN viz calls.webapp.py(FastAPI) serves the map +/api/generatelaunches the pipeline as a subprocess. Two-machine mode:LIDAR_GENERATION_URLdelegates to remote worker./api/exportassembles adjacent tiles into an image/PDF viaexport.py(local cache, no delegation)./api/presets(GET/POST/DELETE) shares layer presets between browsers (output/.presets.json, localStorage echo in the interface as fallback).progress.pywrites JSONL events (O_APPEND, atomic) read by webapp for live progress.export.pystitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
Key design decisions (intentional, do not "fix")
_res_suffixhardcodes 0.5 as "no suffix": coupled toindex.pyparsing (_strip_res_suffixdefaults 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. webapp.pyreads 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 Nonebehavior. _d8_accumulate_numbadefines@njitinside the function:cache=Truemakes subsequent calls fast; the Python function object creation is negligible.pkill -9 -f "pdal pipeline"in cli.py signal handler: belt-and-suspenders alongsideos.killpg. Scoped to "pdal pipeline" to avoid killing unrelated PDAL processes.
Performance characteristics
_priority_flooduses numba JIT binary heap (single int64 array, flat view for elevation). Python heapq fallback if numba unavailable._d8_accumulate_numbauses numba withargsorttop-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
_r0p2style suffix. Ground classification done once, shared across resolutions. ProcessPoolExecutorhas 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.