From d3704d6714973684bd8f1d417f98632e43215388 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 27 Sep 2026 22:31:54 +0200 Subject: [PATCH] Rewrite README and docs in English for GitHub, add MIT license, remove internal files Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + AGENTS.md | 2 +- Dockerfile | 3 - LICENSE | 21 + README.md | 512 +++++++++++------------- docs/DEPLOY_WEBAPP.md | 197 +++++----- docs/GROUND_CLASSIFICATION.md | 217 ++++++----- docs/MAPS.md | 707 +++++++++++++++++----------------- process_lidar.py | 13 - scripts/transcode_q60.py | 54 --- setup.py | 3 +- 11 files changed, 810 insertions(+), 923 deletions(-) create mode 100644 LICENSE delete mode 100755 process_lidar.py delete mode 100644 scripts/transcode_q60.py diff --git a/.gitignore b/.gitignore index 20b29e3..1523918 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ output-test/ # Maquettes et suivi des sessions de brainstorming / SDD .superpowers/ + +# Plans et specs de sessions de travail (internes) +docs/superpowers/ +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index 8f5fdcc..815a738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ - **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`). +- mise à jour d'une carte légère distante (checkout git + override maps local) : `ssh "cd && 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 diff --git a/Dockerfile b/Dockerfile index d541bc3..ae39387 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,9 +48,6 @@ RUN pip3 install --no-cache-dir . # paquet installé (dist-packages) qu'utilise le conteneur au runtime. RUN cd /tmp && python3 -c "import pathlib, lidar_pipeline.mapui as m; m.write_map_assets(pathlib.Path(m.__file__).resolve().parent / m.ASSETS_DIRNAME)" -# Copy backward-compatible entry point -COPY process_lidar.py /usr/local/bin/ -RUN chmod +x /usr/local/bin/process_lidar.py # Create user with uid/gid 1000:1000 and run as that user RUN groupadd -g 1000 lidar && \ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a172d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Antoine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3ec3225..fb9d5a9 100644 --- a/README.md +++ b/README.md @@ -1,318 +1,256 @@ -# Pipeline LiDAR Archéologique +
-Workflow automatisé pour générer des visualisations exploitables à partir de données LiDAR HD (IGN) pour la détection de structures archéologiques. Tourne en Docker avec accélération GPU optionnelle (NVIDIA/CuPy). +# lidar_rendu -## Visualisations (18 par fichier) +**See through the forest.** Turn France's open LiDAR HD point clouds into seamless, +archaeology-grade relief maps — served as a fast slippy map, reusable XYZ tiles +and print-ready PDF field sheets. -### Relief orienté (couche par défaut) -Une seule image fusionne le micro-relief et l'orientation des pentes : la **clarté** porte le relief local (openness positive sur MNT détendancé, rayons 5–20 m, plus un léger ombrage), la **teinte** porte l'orientation (aspect, cercle CIELAB à clarté constante : aucune couleur ne crée de faux relief). Échelle fixe : les dalles voisines se raccordent sans couture. +[![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f.svg)](LICENSE) +![Docker](https://img.shields.io/badge/run%20with-Docker%20Compose-2496ED?logo=docker&logoColor=white) +![Python](https://img.shields.io/badge/python-3.10+-3776AB?logo=python&logoColor=white) +![GPU](https://img.shields.io/badge/GPU-optional%20(CUDA%20%2F%20CuPy)-76B900?logo=nvidia&logoColor=white) +![Data](https://img.shields.io/badge/data-IGN%20LiDAR%20HD-0b6fb7) +![Tiles](https://img.shields.io/badge/tiles-XYZ%20%C2%B7%20TileJSON%20%C2%B7%20WMTS-7952b3) -C'est la **seule couche produite et affichée par défaut** (`PANEL_VIZ` dans `index.py`) : un traitement sans `--only`, la génération lancée depuis la carte et la carte elle-même (panneau, tuiles XYZ, TileJSON, WMTS) se limitent au relief orienté. Les visualisations ci-dessous restent calculables explicitement (`--only slope aspect ...`). +Neuf-Brisach fortress in oriented relief -### Visualisations principales -| # | Visualisation | Utilité archéologique | -|---|--------------|----------------------| -| 1 | **Hillshade multidirectionnel** | Murs, terrasses, structures linéaires, routes | -| 2 | **Pente (Slope)** | Murs de soutènement, talus, changements brusques | -| 3 | **Aspect (Orientation)** | Direction des pentes, exposition | -| 4 | **Courbure (Curvature)** | Fossés, terrasses, talus, concavité/convexité | -| 5 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) | -| 6 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain | -| 7 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) | -| 8 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) | +Neuf-Brisach (Haut-Rhin), Vauban's star fortress (UNESCO World Heritage), rendered from one 1 km LiDAR HD tile. +Every bastion, moat and ravelin reads at a glance; buildings are black (no ground points). -### Visualisations avancées -| # | Visualisation | Description | Détection | -|---|--------------|-------------|-----------| -| 9 | **MSRM** | Multi-Scale Relief Model (sigma 5/10/25/50/100m) | Tumulus, fossés, murs à toutes les échelles | -| 10 | **TPI multi-échelle** | Topographic Position Index (5m + 100m) | Crêtes, vallées, plateformes | -| 11 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables | -| 12 | **SAILORE** | LRM adaptatif (noyau = f(pente)) | Terrain hétérogène, tout relief | -| 13 | **Rugosité** | Écart-type de l'élévation | Surfaces anthropiques vs naturelles | -| 14 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives | -| 15 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires | -| 16 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques | +
-### Cartes de référence IGN -| # | Visualisation | Source | -|---|--------------|--------| -| 17 | **Photographie aérienne IGN** | Orthophotographie WMTS | -| 18 | **Carte topographique IGN** | Plan IGN V2 WMTS | +--- -## Classification du sol +## Why -Le pipeline classifie automatiquement les points sol à partir du nuage de points bruit. Le pré-traitement suit le workflow recommandé par PDAL : +The French mapping agency (IGN) publishes **LiDAR HD**: a nationwide airborne laser +scan, ~10 points/m², free under the *Licence Ouverte 2.0*. Hidden under forest +canopy and fields are hollow ways, trenches, burial mounds, field systems and +forgotten walls — but raw point clouds are hard to read, and naïve DEM renderings +show seams at every tile edge, stripes from the flight lines and colour blotches +that look like relief but aren't. -1. **Filtre ReturnNumber** — élimine les points avec numéros de retour invalides -2. **Réinitialisation Classification** — remet toutes les classifications à 0 -3. **ELM** (Extended Local Minimum) — marque les points bas aberrants comme bruit (Classification=7) -4. **Outlier statistique** — supprime les points isolés (mean_k=8, multiplier=3.0) -5. **Classification sol** — SMRF, PMF ou CSF -6. **Extraction** — ne conserve que les points Classification=2 +`lidar_rendu` is an end-to-end pipeline that solves exactly that: -### Méthodes de classification +- **One command** from an IGN tile ID to a browsable map. +- **One carefully designed layer** — *oriented relief* — instead of fifteen + colormaps to flip through. +- **Seamless at any scale**: fixed scales, no per-tile statistics, 100 m + overlap borrowed from the eight neighbouring tiles. +- **Measurement-aware**: a companion *precision* layer shows where the terrain is + measured and where it is interpolated, so you never mistake a gap for a feature. -| Méthode | Mode | Usage | Vitesse | -|---------|------|-------|---------| -| **SMRF** | Auto (défaut) | Terrain naturel, forêt, rocaille | Rapide | -| **PMF** | Auto (si urbain) | Zones urbaines, bâtiments, routes | Rapide | -| **CSF** | Manuel uniquement | Terrain très complexe, falaises | Lent | +## Gallery -L'auto-détection analyse le ratio de retours uniques du nuage de points : ratio > 0.6 = milieu urbain → PMF, sinon → SMRF. + + + + + + + + + +
Hartmannswillerkopf battlefieldA4 PDF field sheet of Neuf-Brisach
Hartmannswillerkopf (tile 1011_6760) — the WWI battlefield of 1915 under dense +forest: roads, trench lines and shell craters appear on the slope.PDF field sheet exported from the map (A4 landscape, 1:5,000): Lambert 93 grid, +true-north arrow, scale bar, legend and data-quality inset.
-### Pré-traitement ELM (terrain calcaire) +## Highlights -Les paramètres ELM sont adaptés au terrain calcaire rocailleux avec végétation basse : -- `cell=5.0m` — résolution fine pour capturer le relief rocheux -- `threshold=2.0m` — seuil élevé pour ne pas marquer les affleurements comme bruit +| | | +|---|---| +| **Oriented relief** | A single RGB image: CIELAB *lightness* carries local openness (bumps light, hollows dark), *hue* carries slope orientation at constant lightness — so colour never fakes relief. | +| **Flight-strip calibration** | Each tile mixes several passes, sometimes offset by a few cm. The pipeline estimates per-strip offsets, per-scan-line shift and roll, and a per-beam cross-track profile, then removes them before rasterising. No more stripes. | +| **Honest gap filling** | At 0.2 m, ~80 % of pixels contain no point. Gaps are closed morphologically within the point envelope, with a radius that follows local point spacing — nothing is extrapolated outward. | +| **Seamless tiles** | DTMs are built on the nominal 1 km tile plus a 100 m buffer taken from the neighbours (downloaded automatically), then cropped back exactly. | +| **Fast** | GPU (CuPy) when available, numba otherwise. Ground extraction straight from IGN classes with laspy (~5 s per tile), relief kernel ~5 s on a 12-core CPU, AVIF encoding in 0.6 s. | +| **Slippy map** | Built-in Leaflet UI: tile selection with IGN metadata, relief / precision / side-by-side compare, adjustable intensity, light & dark themes, mobile bottom sheet, shareable links. | +| **Standard tiles** | `/tiles/{layer}/{z}/{x}/{y}.png` in the OpenStreetMap scheme, plus TileJSON, WMTS and a JOSM imagery file — drop it into QGIS, JOSM, iD, uMap or MapLibre. | +| **Generate from the map** | Draw a rectangle: the tiles are downloaded from IGN, processed, and appear on the map row by row as they finish. | +| **Print** | Vector PDF sheets (A4/A3, 1:1,000 – 1:10,000) with grid, WGS84 corners, meridian convergence, legend and point-density inset. | +| **Runs on a Raspberry Pi** | A lightweight image (no PDAL, no GPU) serves the map and delegates generation to a worker machine; it keeps working offline. | -## Architecture modulaire +## Quick start + +Requirements: Docker with Compose ≥ 2.24. An NVIDIA GPU is optional. + +```bash +git clone https://github.com//lidar_rendu.git && cd lidar_rendu + +./start.sh # build the image, start the map on http://localhost:8973/ +./start.sh process --fetch-tiles 1037,6779 # download one IGN tile (Neuf-Brisach) and process it +./start.sh logs # follow the server log +./start.sh stop # stop everything +``` + +`start.sh` creates `input/` and `output/` owned by you and checks whether Docker +can reach a GPU; if not, it adds `docker-compose.cpu.yml` and everything runs on +the CPU (slower, same output). + +Tile IDs are the IGN grid coordinates in km (Lambert 93): `1037,6779` is the tile +whose top-left corner is at X = 1,037 km, Y = 6,779 km. You can also skip the +command line entirely: open the map, go to the **Génération** tab and draw a +zone. + +> The user interface and log messages are in French; code identifiers are in English. + +## How it works + +```mermaid +flowchart LR + A[IGN LiDAR HD
COPC .laz tile] --> B[Ground points
IGN classes, laspy] + N[8 neighbour tiles
100 m buffer] --> B + B --> C[Strip & scan-line
calibration] + C --> D[DTM 0.2 m
+ bounded gap fill] + D --> E[Oriented relief
+ precision layer] + E --> F[AVIF tiles
cropped to 1 km] + F --> G[Inventory +
XYZ pyramid] + G --> H[Map · XYZ/WMTS · PDF] +``` + +1. **Download** — the COPC tile and, if missing, its eight neighbours from the IGN + Géoplateforme. Tiles are processed north to south, so the map fills top down. +2. **Ground** — IGN's ground class (2) extracted directly with laspy; PDAL (SMRF / + CSF) remains available. See [ground classification](docs/GROUND_CLASSIFICATION.md). +3. **Calibration** — robust per-strip vertical offsets, then a joint least-squares + adjustment of every scan line (offset + roll) against the other strips, plus a + per-beam angular profile. Results are written to a JSON sidecar next to the DTM. +4. **DTM** — rasterised at 0.2 m on the tile + buffer; small gaps closed within the + point envelope; ground density saved alongside. +5. **Render** — oriented relief (openness at 5 / 10 / 20 m on a detrended DTM, + 16 directions, plus 35 % hillshade) and precision (16 log-scale density levels). + Fixed scales everywhere: identical terrain gives identical colour on every tile. +6. **Publish** — AVIF quadrants encoded once from the source raster, inventory + refreshed after every tile, XYZ pyramid pre-generated in the background. + +## The map + +- **Click a tile** to select it: extent, IGN acquisition date, sensors, point count, + download link, and the calibration applied to each flight strip. +- **Three view modes** — relief, precision, or *compare* with a draggable split bar. +- **Relief intensity** slider (0.5×–2×), remembered per browser and carried in the link. +- **Share** — the URL encodes position, mode, comparison and intensity. +- **Use in JOSM / QGIS** button — copies the tile URL for the current layer. +- Keyboard: `1`–`5` tabs, `P` next view mode, `Esc` deselect / collapse. +- Works on phones: the panel becomes a three-height bottom sheet; GPS centring over HTTPS. + +Full reference: [docs/MAPS.md](docs/MAPS.md). + +## Use the tiles anywhere + +| Client | URL | +|---|---| +| QGIS, iD, uMap, Leaflet, MapLibre | `http://:8973/tiles/relief_oriente/{z}/{x}/{y}.png` | +| JOSM (TMS) | `http://:8973/tiles/relief_oriente/{zoom}/{x}/{y}.png` | +| JOSM (all layers at once) | `http://:8973/tiles/josm.imagery.xml` | +| TileJSON 3.0 | `http://:8973/tiles/relief_oriente.json` | +| WMTS 1.0 | `http://:8973/tiles/wmts.xml` | + +256 px PNG, EPSG:3857, transparent outside coverage, CORS enabled, zoom 5–19 +(19 ≈ 0.2 m/px, one screen pixel per LiDAR pixel). + +## Command line + +The processing pipeline is `python3 -m lidar_pipeline`; with Docker Compose, +`./start.sh process [options]` runs it on `input/` → `output/`. + +| Option | Default | Purpose | +|---|---|---| +| `--fetch-tiles COL,ROW …` | — | Download these LiDAR HD tiles from IGN first | +| `--file NAME …` | all of `input/` | Process only these tiles (name without `.laz`) | +| `-r RES` | `0.2` | Resolution in m/px (comma-separated for several) | +| `-g [GPU]` | CPU | GPU(s): `-g`, `-g 0`, `-g 0,2`, `-g all` | +| `-w N` | `auto` | Parallel workers (bounded by free VRAM per GPU) | +| `--only VIZ …` / `--skip VIZ …` | map layers | Choose visualisations | +| `--edge-buffer M` | `100` | Buffer borrowed from neighbour tiles (0 = off) | +| `--ground-classification` | `ign` | `ign`, `auto`, `smrf`, `csf` | +| `--no-strip-align` | on | Disable flight-strip calibration | +| `--format`, `--quality` | `avif`, `60` | Output encoding | +| `-f`, `--force` | off | Regenerate even if outputs exist | +| `--rebuild-index` | — | Rebuild the tile inventory only | +| `-v`, `--debug` | — | Verbose / debug logging | + +By default only the map layers are produced (`relief_oriente`, `densite_sol`). +Classic visualisations remain one flag away, e.g. +`./start.sh process --only hillshade slope svf pos_open`: + +| Key | Visualisation | | Key | Visualisation | +|---|---|---|---|---| +| `hillshade` | Multi-directional hillshade | | `svf` | Sky-View Factor | +| `slope` | Slope | | `roughness` | Roughness | +| `aspect` | Aspect | | `wavelet` | Mexican-hat wavelet (multi-scale) | +| `mslrm` | Multi-scale relief model | | `flow_acc` | D8 flow accumulation | +| `sailore` | Slope-adaptive local relief | | `solar` | Solar illumination | +| `pos_open` / `neg_open` | Positive / negative openness | | `anomaly` | Statistical anomalies | +| `ortho` / `topo` | IGN orthophoto / topographic map | | | | + +## Deployment + +| Setup | Command | Port | +|---|---|---| +| All-in-one (map + generation) | `docker compose up -d --build serve` (or `./start.sh`) | 8973 | +| Processing worker for remote maps | `docker compose -f docker-compose.worker.yml up -d --build` | 8973 | +| Lightweight map (Raspberry Pi, no PDAL/GPU) | `docker compose -f docker-compose.maps.yml up -d --build` | 8975 | + +The lightweight map mirrors the worker's tiles, delegates generation to it, and +keeps serving from disk when the worker is off. Tokens (`LIDAR_API_TOKEN`) and a +network allow-list (`LIDAR_REGEN_CIDR`, private ranges by default) protect the +generation API. Step-by-step guide: [docs/DEPLOY_WEBAPP.md](docs/DEPLOY_WEBAPP.md). + +Always pass `--build`: the code is baked into the image, and the build is +near-instant thanks to the layer cache. + +## Project layout ``` lidar_pipeline/ -├── __init__.py # Exports publics -├── __main__.py # Point d'entrée: python -m lidar_pipeline -├── cli.py # argparse + logging + main() -├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*) -├── dtm.py # Classification PDAL (SMRF/PMF/CSF + auto) + génération DTM -├── visualizations.py # Fonctions generate_* (19 visualisations) -├── ign.py # Téléchargement tuiles IGN + overlay -├── rendering.py # Colormaps, tif_to_png, rapport PDF -├── pipeline.py # LidarArchaeoPipeline (orchestration + registry) -└── tests/ # Tests unitaires (pytest) +├── cli.py command line +├── pipeline.py orchestration, worker pool, VRAM-aware GPU slots +├── fetch_ign.py IGN catalogue and downloads +├── dtm.py ground extraction, strip calibration, DTM, gap filling +├── visualizations.py generate_* functions (relief, openness, SVF, …) +├── rendering.py colormaps, GeoTIFF → AVIF, 1 km crop +├── index.py catalogue, thumbnails, sub-tiles, inventory, layer registry +├── quality.py per-tile quality sidecar (density, acquisition dates) +├── tiles.py XYZ pyramid: reprojection, cache, background maintenance +├── mapserve.py FastAPI server: map, tiles, TileJSON/WMTS, generation API +├── export_pdf.py PDF field sheets (Pillow + pyproj + reportlab) +├── gpu.py CuPy / NumPy abstraction with CPU fallback +├── web/ map UI (HTML, CSS, JS) +└── tests/ ~400 pytest tests ``` -Ajouter une visualisation = 1 fonction + 1 entrée dans `VIZ_STEPS` + 1 entrée dans `COLORMAPS`. - -## Exemples - -Relief orienté sur deux dalles LiDAR HD, rendues par ce pipeline (`./start.sh process --fetch-tiles 1037,6779 1011,6760 ...`). - -**Neuf-Brisach** (dalle 1037_6779) — l'enceinte bastionnée de Vauban, ses fossés et ses demi-lunes ; en noir, les bâtiments (aucun point sol). - -![Neuf-Brisach, relief orienté](docs/img/neuf-brisach.png) - -**Hartmannswillerkopf** (dalle 1011_6760) — versant forestier du champ de bataille de 1915 : chemins, tranchées et trous d'obus sous le couvert. - -![Hartmannswillerkopf, relief orienté](docs/img/hartmannswillerkopf.png) - -**Planche PDF** exportée depuis la carte (onglet PDF, A4 paysage, 1:5 000) : quadrillage Lambert 93, légende, encart qualité des données. - -![Planche PDF de Neuf-Brisach](docs/img/neuf-brisach-a4.png) - -## Démarrage rapide +## Development ```bash -git clone && cd lidar_rendu -./start.sh # construit l'image, démarre la carte sur http://localhost:8973/ -./start.sh process --fetch-tiles 1037,6779 --file LHD_FXX_1037_6779_PTS_LAMB93_IGN69.copc.laz - # télécharge une dalle IGN et la traite -./start.sh logs # journal du serveur -./start.sh stop # arrêt +./run.sh --test # rebuild the image and run the full test suite +docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.test_tiles ``` -`start.sh` crée `input/` et `output/` à votre nom et détecte le GPU : sans GPU -NVIDIA utilisable par Docker, il ajoute `docker-compose.cpu.yml` et le -traitement tourne sur le CPU (plus lent). Docker Compose ≥ 2.24 requis. +Adding a visualisation takes four edits: a `generate_X()` in `visualizations.py`, +an entry in `VIZ_STEPS` (`pipeline.py`), a colormap in `rendering.py`, and a legend +in `VIZ_LEGENDS` (`index.py`). Design decisions and conventions are documented in +[AGENTS.md](AGENTS.md) (French). -## Installation Docker +Contributions are welcome: open an issue or a pull request. -```bash -cd /votre/dossier/lidar -mkdir -p input +## Documentation -# Copiez vos fichiers .laz dans input/ -cp /chemin/vos/fichiers/*.laz input/ +- [docs/MAPS.md](docs/MAPS.md) — map UI, tile contract, cache, PDF export, environment variables +- [docs/DEPLOY_WEBAPP.md](docs/DEPLOY_WEBAPP.md) — two-machine deployment (Raspberry Pi + worker) +- [docs/GROUND_CLASSIFICATION.md](docs/GROUND_CLASSIFICATION.md) — ground filters benchmark and literature -# Build l'image Docker -docker build -t lidar-lidar . -``` +## Data and licences -## Utilisation +- **Code**: [MIT](LICENSE). +- **LiDAR HD, orthophotos, maps**: © IGN, [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/). + Attribution is required and is shown on every map, tile endpoint and PDF. +- **Base map**: © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors. + Before using these renderings as a tracing source in OpenStreetMap, check the + community's position on the source. -### Traitement complet avec GPU (recommandé) -```bash -./run.sh -g -``` - -### Traitement standard (CPU seul) -```bash -./run.sh -``` - -### Options du script run.sh -``` -./run.sh [options] - -r RESOLUTION Résolution en m/px (défaut: 0.5) - -w WORKERS Nombre de workers parallèles (défaut: 1) - -g Activer l'accélération GPU NVIDIA - -v Mode verbeux (timestamps + niveaux) - --debug Mode debug (détails internes fichier:ligne) - -f / --force Régénérer tous les fichiers même si les WebP existent - --force-classification Reclassifier le sol même si le fichier .las existe déjà - --ground-classification Méthode de classification: ign, auto, smrf, csf (défaut: ign — imposé par la carte) - --edge-buffer M Raccord des bords avec les dalles voisines (défaut: 100 m — imposé par la carte) - --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques - --test Exécuter les tests unitaires - -h Afficher l'aide -``` - -### Exemples -```bash -# Traitement standard avec GPU -./run.sh -g - -# GPU + mode verbeux -./run.sh -g -v - -# GPU + 4 workers parallèles -./run.sh -g -w 4 - -# Haute résolution (0.2m/px) -./run.sh -g -r 0.2 - -# Forcer la régénération de tous les fichiers -./run.sh -g --force - -# Reclassifier le sol seulement (sans régénérer les visualisations) -./run.sh -g --force-classification - -# Forcer la classification PMF au lieu de l'auto-détection -./run.sh -g --ground-classification pmf - -# Forcer la classification CSF (lent mais robuste sur terrain complexe) -./run.sh -g --ground-classification csf - -# Traiter un fichier spécifique (test rapide) -./run.sh -g --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc - -# Traiter deux fichiers spécifiques -./run.sh -g --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc - -# Exécuter les tests unitaires -./run.sh --test -``` - -### Utilisation directe Docker -```bash -# Traitement standard -docker run --rm -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output lidar-lidar - -# Avec GPU + classification forcée -docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ - lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output \ - --ground-classification pmf - -# Forcer la reclassification du sol -docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ - lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output \ - --force-classification - -# Mode verbeux -docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \ - lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output -v -``` - -## Structure des dossiers - -``` -. -├── input/ # Fichiers .laz (monté en read-only dans Docker) -├── output/ # Résultats générés -│ ├── DTM/ # Modèles numériques de terrain (GeoTIFF) -│ ├── temp/ # Fichiers temporaires (classification .las) -│ ├── visualisations/ # Images WebP par fichier LAZ -│ │ ├── fichier_6881/ # Un sous-dossier par fichier LAZ -│ │ │ ├── ..._hillshade_multi.webp -│ │ │ ├── ..._svf.webp -│ │ │ ├── ..._mslrm.webp -│ │ │ └── ... (19 visualisations) -│ │ └── fichier_6882/ -│ │ └── ... -│ └── rapports/ # Rapports PDF A3 par fichier -│ ├── fichier_6881_rapport.pdf -│ └── fichier_6882_rapport.pdf -├── lidar_pipeline/ # Package Python modulaire -│ ├── cli.py # Arguments CLI + logging -│ ├── gpu.py # Abstraction CuPy/numpy -│ ├── dtm.py # Classification sol + DTM -│ ├── visualizations.py # 19 fonctions generate_* -│ ├── ign.py # Tuiles IGN -│ ├── rendering.py # Colormaps, WebP, PDF -│ ├── pipeline.py # Orchestration -│ └── tests/ # Tests unitaires -├── process_lidar.py # Point d'entrée compatible -├── Dockerfile -├── run.sh -└── README.md -``` - -## Paramètres - -| Paramètre | Option | Défaut | Description | -|-----------|--------|--------|-------------| -| Résolution | `-r` | 0.5 | Résolution en mètres par pixel | -| Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle | -| GPU | `-g` | off | Activer l'accélération NVIDIA GPU | -| Classification sol | `--ground-classification` | ign | Méthode : ign, auto, smrf, csf (la carte impose ign) | -| Forcer classification | `--force-classification` | off | Reclassifier le sol même si .las existe | -| Output | `-o` | /data/output | Dossier de sortie | -| Force | `-f/--force` | off | Régénérer même si les WebP existent | -| File | `--file` | tous | Traiter un ou plusieurs fichiers LAZ | -| Verbose | `-v` | off | Mode verbeux (timestamps + niveaux) | -| Debug | `--debug` | off | Mode debug (détails internes) | - -### Résolution recommandée -- `0.2` — Très fine, bâtiments individuels (lent) -- `0.5` — Recommandée archéologie (équilibre vitesse/détail) -- `1.0` — Rapide, grandes structures uniquement - -## Interprétation archéologique - -### Pour détecter les cavités et souterrains -1. **Negative Openness** — Zones sombres = creux profonds -2. **Dépressions** — Carte spécifique des dolines et sinkholes -3. **Local Relief Model** — Zones bleues = dépressions -4. **Hillshade** — Ombres inhabituelles en forme de trous - -### Pour détecter structures et bâtiments anciens -1. **MSRM** — Détection multi-échelle de tous les reliefs -2. **Sky-View Factor** — Structures géométriques claires -3. **SAILORE** — LRM adaptatif pour terrain hétérogène -4. **Anomalies statistiques** — Anomalies topographiques significatives - -### Pour hydrologie et fossés -1. **Accumulation de flux** — Fossés d'enceinte, routes antiques -2. **Dépressions** — Zones de collecte d'eau, dolines -3. **Negative Openness** — Fossés et tranchées - -## Tests - -```bash -./run.sh --test -``` - -Les tests tournent dans le conteneur Docker et couvrent la classification du sol (SMRF/PMF/CSF), l'auto-détection, le rendu, et les visualisations. - -## Dépannage - -```bash -# Vérifier Docker -docker --version - -# Shell dans le conteneur -docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output \ - --entrypoint bash lidar-lidar - -# Reconstruire l'image -docker build --no-cache -t lidar-lidar . - -# Nettoyer -docker system prune -a -``` - -### Erreur mémoire -Augmenter la mémoire Docker à 16Go+ pour les gros fichiers LiDAR HD. - -### Données LiDAR HD (IGN) -Les fichiers COPC (.laz) de l'IGN sont supportés directement. Le pipeline détecte automatiquement la méthode de classification du sol (SMRF/PMF) en analysant le ratio de retours uniques du nuage de points. \ No newline at end of file +Built on [PDAL](https://pdal.io), [laspy](https://laspy.readthedocs.io), +[rasterio](https://rasterio.readthedocs.io), [CuPy](https://cupy.dev), +[numba](https://numba.pydata.org), [FastAPI](https://fastapi.tiangolo.com), +[Leaflet](https://leafletjs.com) and [reportlab](https://www.reportlab.com). diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index fc7066d..035f9c4 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -1,104 +1,103 @@ -# Déploiement carte légère + machine de traitement +# Two-machine deployment: lightweight map + processing worker -Architecture deux machines : la **carte** (interface, pyramide de tuiles XYZ, -API) tourne sur un Raspberry Pi ; la **génération de tuiles** (téléchargement -IGN, PDAL, GPU) tourne sur une machine puissante. Les deux exécutent le même -serveur (`lidar_pipeline.mapserve`, image `lidar-maps`), la machine puissante -en version complète (pipeline inclus). +Two-machine architecture: the **map** (interface, XYZ tile pyramid, API) runs +on a Raspberry Pi; **tile generation** (IGN downloads, PDAL, GPU) runs on a +powerful machine. Both run the same server (`lidar_pipeline.mapserve`, image +`lidar-maps`), the powerful machine in its full version (pipeline included). ``` -Navigateur ──HTTP──▶ Raspberry Pi (image légère lidar-maps, port 8975) - │ sert la carte + la pyramide (cache seule + maintenance - │ de fond, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND) - │ /api/generate, /api/preview, /api/status - │ └─ transmis à ──▶ machine de traitement - │ dalles rapatriées du worker (LIDAR_SOURCE_URL : - │ inventaire /api/tiles + statiques versionnées) - ▼ - Machine de traitement (image complète, - docker-compose.worker.yml service `worker`) : - téléchargement IGN + pipeline GPU (générateur de tuiles) - + pyramide de tuiles + inventaire des dalles +Browser ──HTTP──▶ Raspberry Pi (lightweight lidar-maps image, port 8975) + │ serves the map + the pyramid (cache-only + background + │ maintenance, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND) + │ /api/generate, /api/preview, /api/status + │ └─ forwarded to ──▶ processing worker + │ tiles fetched back from the worker (LIDAR_SOURCE_URL: + │ /api/tiles inventory + versioned static assets) + ▼ + Processing worker (full image, + docker-compose.worker.yml service `worker`): + IGN download + GPU pipeline (tile generator) + + tile pyramid + tile inventory ``` -## Machine de traitement (générateur de tuiles) +See also `docs/MAPS.md` for the map/tile-pyramid internals referenced below. -Le service `worker` joue le rôle de générateur : il accepte les demandes de -génération envoyées par les cartes distantes (téléchargement IGN + traitement -PDAL/GPU), sert sa propre pyramide de tuiles et l'inventaire des dalles -(`/api/tiles` + `visualisations/`, `index_thumbs/`, `index_subtiles/` en -statique) que les machines légères rapatrient à la demande. +## Processing worker (tile generator) + +The `worker` service acts as the generator: it accepts generation requests +sent by remote maps (IGN download + PDAL/GPU processing), serves its own tile +pyramid and tile inventory (`/api/tiles` + static `visualisations/`, +`index_thumbs/`, `index_subtiles/`) that lightweight machines fetch on demand. ```bash -docker compose -f docker-compose.worker.yml up -d --build # API sur http://:8973 +docker compose -f docker-compose.worker.yml up -d --build # API at http://:8973 docker compose -f docker-compose.worker.yml logs -f worker ``` -Traitement batch ponctuel des dalles déjà présentes dans `input/` : +One-off batch processing of tiles already present in `input/`: ```bash docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.2 | --force] ``` -Sur une machine sans GPU : retirer les lignes `gpus: all` (traitement CPU, -plus lent). Optionnel mais recommandé si le réseau n'est pas de confiance : -protéger l'API avec un jeton partagé — décommenter dans -`docker-compose.worker.yml` : +On a machine without a GPU: remove the `gpus: all` lines (CPU processing, +slower). Optional but recommended if the network isn't trusted: protect the +API with a shared token — uncomment in `docker-compose.worker.yml`: ```yaml environment: - - LIDAR_API_TOKEN=un-secret-à-partager + - LIDAR_API_TOKEN=a-secret-to-share ``` -## Raspberry Pi (carte légère) +## Raspberry Pi (lightweight map) -### 0. Prérequis sur le Pi +### 0. Prerequisites on the Pi -- **Architecture** : image construite nativement sur la machine (ARM64 ou - x86_64, vérifier avec `uname -m`). Le build se fait sur le Pi lui-même - (`Dockerfile.maps` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL). -- **Docker + plugin compose** : installation officielle +- **Architecture**: the image is built natively on the machine (ARM64 or + x86_64, check with `uname -m`). The build happens on the Pi itself + (`Dockerfile.maps`: `python:3.12-slim` base, ~200 MB, no PDAL/GDAL). +- **Docker + compose plugin**: official install [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) - (tester avec `docker compose version`). -- **Espace disque** : prévoir la taille du cache (dalles rapatriées + tuiles - rendues ; compter la taille de `output/` sur la machine de traitement). + (verify with `docker compose version`). +- **Disk space**: plan for the cache size (fetched tiles + rendered tiles; + use the size of `output/` on the processing machine as a reference). -### 1. Copier le code sur le Pi (git clone) +### 1. Copy the code onto the Pi (git clone) ```bash -git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar +git clone https://github.com//lidar_rendu.git lidar cd lidar ``` -`output/` et `input/` sont ignorés par git : le dépôt ne contient que le -code, le cache se remplit à la demande depuis la machine de traitement. +`output/` and `input/` are git-ignored: the repository only holds the code, +the cache fills on demand from the processing machine. -### 2. Lancer la carte +### 2. Start the map -`docker-compose.maps.yml` (versionné) sert de base ; la configuration locale -du Pi vit dans un **override** `docker-compose.maps.override.yml` (non -versionné) : +`docker-compose.maps.yml` (versioned) serves as the base; the Pi's local +configuration lives in an **override** file `docker-compose.maps.override.yml` +(not versioned): ```yaml name: lidar-maps services: maps: - volumes: !override # Compose >= 2.24 (remplace ./output) + volumes: !override # Compose >= 2.24 (replaces ./output) - /srv/lidar/output:/data/output mem_limit: 1g memswap_limit: 1g environment: - TZ=Europe/Paris - - LIDAR_SOURCE_URL=http://192.168.1.50:8973 # worker (dalles + inventaire) - - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # worker (génération déléguée) - # - LIDAR_REMOTE_TOKEN=un-secret-à-partager # si LIDAR_API_TOKEN côté worker + - LIDAR_SOURCE_URL=http://192.168.1.50:8973 # worker (tiles + inventory) + - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # worker (delegated generation) + # - LIDAR_REMOTE_TOKEN=a-secret-to-share # if LIDAR_API_TOKEN is set on the worker - LIDAR_TILE_WORKERS=1 - LIDAR_TILE_SOURCE_CACHE_MB=64 - - LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend RIEN - - LIDAR_TILE_BACKGROUND=1 # la pyramide est entretenue en tâche de fond + - LIDAR_TILE_CACHE_ONLY=1 # browsing renders NOTHING + - LIDAR_TILE_BACKGROUND=1 # the pyramid is maintained as a background task - LIDAR_TILE_BACKGROUND_PAUSE=1.0 networks: [webapp, proxy] - labels: # routage Traefik éventuel + labels: # optional Traefik routing - "traefik.enable=true" - "traefik.http.routers.lidar-maps.rule=Host(`lidar.example.fr`)" - "traefik.http.routers.lidar-maps.entrypoints=websecure" @@ -115,76 +114,60 @@ networks: ``` ```bash -mkdir -p /srv/lidar/output # appartenant à l'uid 1000 (conteneur) +mkdir -p /srv/lidar/output # owned by uid 1000 (container) docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build ``` -La carte est sur `http://:8975/` (et via Traefik si configuré). +The map is available at `http://:8975/` (and via Traefik if +configured). -`LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` inversent la charge sur -un petit Pi : la navigation ne rend rien (tuile absente = transparente, -`X-Tile-Pending`), une tâche de fond surveille les dalles nouvelles ou -régénérées et entretient la pyramide à basse priorité — cf. `docs/MAPS.md` -§ « Cache seule + maintenance de fond ». +`LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` flip the load pattern on +a small Pi: browsing renders nothing (a missing tile is served transparent, +with `X-Tile-Pending`), and a background task watches for new or regenerated +tiles and maintains the pyramid at low priority — see `docs/MAPS.md`, section +"Cache-only + background maintenance". -### 3. Génération de tuiles depuis la carte +### 3. Generating tiles from the map -Les boutons **+ Zone** (dessiner un rectangle → téléchargement IGN + run sur -le worker), **⤒ Compléter** (dalles déjà téléchargées incomplètes) et -**↻ Générer cette dalle** (fiche d'infos au clic) sont masqués si : +The **+ Zone** ("Add area": draw a rectangle → IGN download + run on the +worker), **⤒ Compléter** ("Complete": already-downloaded but incomplete +tiles) and **↻ Générer cette dalle** ("Generate this tile": info panel on +click) buttons are hidden if: -- le navigateur vient d'une IP hors `LIDAR_REGEN_CIDR` (défaut : boucle - locale + plages privées RFC1918 ; liste de CIDR séparés par virgules, - chaîne vide pour lever la restriction). L'IP est celle de la connexion - (conservée par le DNAT Docker pour les clients du LAN) ; derrière un - reverse proxy local (dans le réseau autorisé), `X-Forwarded-For` désigne - le client réel ; -- le worker est injoignable ET aucun pipeline local n'existe. +- the browser's IP is outside `LIDAR_REGEN_CIDR` (default: loopback + private + RFC1918 ranges; a comma-separated list of CIDRs, or an empty string to lift + the restriction). The IP is the one seen on the connection (preserved by + Docker's DNAT for LAN clients); behind a local reverse proxy (itself inside + the allowed network), `X-Forwarded-For` designates the real client; +- the worker is unreachable AND no local pipeline exists. -Une demande lancée pendant un run part en **file d'attente** côté worker -(jamais de coupure du travail en place) ; la progression s'affiche dalle par -dalle (cadres orange/bleu/rouge sur la carte) et le bouton **Arrêter** envoie -un SIGTERM au pipeline. À la fin du run, la carte se rafraîchit et la -maintenance de pyramide repart automatiquement. +A request submitted while a run is already in progress is placed in a +**queue** on the worker side (an in-progress job is never interrupted); +progress is displayed tile by tile (orange/blue/red frames on the map) and the +**Arrêter** ("Stop") button sends a SIGTERM to the pipeline. At the end of the +run, the map refreshes and pyramid maintenance resumes automatically. -### 4. Centrer la carte sur la position GPS (téléphone) +### 4. Centering the map on the GPS position (phone) -Les navigateurs n'exposent l'API Geolocation qu'en **contexte sécurisé** -(HTTPS) : servir la carte derrière Traefik (TLS) comme ci-dessus, ou en -dernier recours en HTTPS direct (l'entrée autonome `python -m -lidar_pipeline.mapserve` honore `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE`). +Browsers only expose the Geolocation API in a **secure context** (HTTPS): +serve the map behind Traefik (TLS) as above, or, as a last resort, directly +over HTTPS (the standalone entry point `python -m lidar_pipeline.mapserve` +honors `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE`). -## Mise à jour +## Updating -### Machine de traitement +### Processing machine ```bash cd && git pull && docker compose -f docker-compose.worker.yml up -d --build ``` -### Pi (depuis le poste de pilotage) - -Le poste de pilotage interdit l'appel `ssh` direct : le wrapper -`ssh` fournit l'accès autorisé avec transfert d'agent (`-A`) — -les `git pull` distants utilisent la clé locale : +### Pi ```bash -#!/bin/bash -set -euo pipefail -HOST="${1:-192.168.3.10}" # machine carte par défaut (le Pi) -shift || true -exec ssh -A -o BatchMode=yes -o ConnectTimeout=5 \ - -o StrictHostKeyChecking=accept-new "$HOST" "$@" +ssh "cd && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build" ``` -Mise à jour complète du Pi (checkout git en `/srv/lidar_rendu`, -avec l'override Traefik) : - -```bash -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" -``` - -Le code de l'interface est bâché dans l'image (`Dockerfile.maps`) : **TOUT** -changement d'interface exige le rebuild (`--build`), sans lui l'ancien code -tourne. +The interface code is baked into the image (`Dockerfile.maps`): **ANY** +interface change requires a rebuild (`--build`) — without it, the old code +keeps running. diff --git a/docs/GROUND_CLASSIFICATION.md b/docs/GROUND_CLASSIFICATION.md index 0efeac4..823b76d 100644 --- a/docs/GROUND_CLASSIFICATION.md +++ b/docs/GROUND_CLASSIFICATION.md @@ -1,140 +1,151 @@ -# Classification du sol — options et références +# Ground classification — options, benchmark and references -Note de synthèse pour choisir/améliorer l'algorithme de détection du sol. -Contexte : tiles LiDAR HD IGN (Lambert 93), zones de relief fort / rochers / -forêt dense où le sol est sous-classifié et le MNT présente de grands trous. +> **Status (up to date)**: the pipeline default is `--ground-classification ign` +> with `--ign-classes sol` (IGN vendor ground class 2), extracted directly with +> laspy (`_extract_ign_ground` in `dtm.py`), with PDAL as fallback. `auto`, +> `smrf` and `csf` remain selectable via `--ground-classification`. Map-triggered +> generation always uses `ign` (no classification/reconciliation setting is +> exposed in the map UI). -Tile de référence : `LHD_FXX_0999_6778_PTS_LAMB93_IGN69` -- 65 047 919 points, ~1 km², résolutions 0.5 m et 0.2 m. -- Répartition classes (pré-classification fournisseur) : - classe 2 (sol) **25.79 %**, classe 5 (veg haute) **63.2 %**, - classe 3 (veg basse) 7.85 %, classe 4 (veg moyenne) 2.66 %, - classe 1 0.47 %, classe 6 0.01 %. Aucun point en classe 0. -- Trous dans le MNT existant (avant correction) : **43.5 %** à 0.5 m, - **44.1 %** à 0.2 m (surface du tile, bornes du header). +Synthesis note for choosing/improving the ground-detection algorithm. +Context: IGN LiDAR HD tiles (Lambert 93), areas of strong relief / rock outcrops / +dense forest where the ground is under-classified and the DTM shows large holes. -## Benchmark mesuré (PDAL, 1 km²) +Reference tile: `LHD_FXX_0999_6778_PTS_LAMB93_IGN69` +- 65,047,919 points, ~1 km², resolutions 0.5 m and 0.2 m. +- Class breakdown (vendor pre-classification): + class 2 (ground) **25.79%**, class 5 (high vegetation) **63.2%**, + class 3 (low vegetation) 7.85%, class 4 (medium vegetation) 2.66%, + class 1 0.47%, class 6 0.01%. No points in class 0. +- Holes in the existing DTM (before correction): **43.5%** at 0.5 m, + **44.1%** at 0.2 m (tile extent, header bounds). -| Méthode | Temps | Points sol | Surface sol* | Trous* | +## Measured benchmark (PDAL, 1 km²) + +| Method | Time | Ground points | Ground surface* | Holes* | |---|---|---|---|---| -| **IGN** (pré-classif.) | **9.4 s** | 25.8 % | 84.6 % | 15.4 % | -| **SMRF** | 326.3 s | 36.1 % | 90.4 % | 9.6 % | -| **CSF** | 355.2 s | 17.6 % | 46.9 % | 53.1 % | +| **IGN** (vendor pre-classification) | **9.4 s** | 25.8% | 84.6% | 15.4% | +| **SMRF** | 326.3 s | 36.1% | 90.4% | 9.6% | +| **CSF** | 355.2 s | 17.6% | 46.9% | 53.1% | -\* « Surface sol » calculée sur l'emprise des points (bounding box du nuage), -à 0.5 m. Les % de trous du MNT final (bornes du header, plus grandes) sont -supérieurs : voir le tile de référence ci-dessus. +\* "Ground surface" computed over the point extent (point cloud bounding box), +at 0.5 m. Final DTM hole percentages (header bounds, larger) are higher: see +the reference tile above. -## A. Filtres géométriques (stack PDAL actuelle) +## A. Geometric filters (current PDAL stack) -- **IGN** (pré-classification fournisseur, classe 2) : le plus rapide (~9 s). - Fiable là où le fournisseur a confiance ; trous sous forêt dense / relief. - Aucun paramètre à régler. +- **IGN** (vendor pre-classification, class 2): the fastest (~9 s). Reliable + where the vendor has confidence; holes under dense forest / steep relief. + No parameter to tune. - **SMRF** — Pingel, Clarke & McBride 2013, *ISPRS J. Photogramm. Remote - Sens.* 77:21-30. Filtre **raster** (opère sur un DSM, pas sur les points), - donc plus rapide que les filtres point-based ; **minimise les erreurs de - type I** (omission de sol) → bien adapté quand le sol est rare (forêt). - Meilleure couverture des trois ici (90.4 %) mais ~5.4 min/tile. -- **CSF** — Zhang et al. 2016, *Remote Sensing* 8(6):501. Toile inversée - drapée sur le nuage ; simple, précis, mais **la toile ne touche plus le sol - en terrain raide/vallonné** → mauvaise classification. Le plus lent ici et - le pire sur ce tile. À réserver aux zones urbaines. + Sens.* 77:21-30. **Raster-based** filter (operates on a DSM, not on points), + hence faster than point-based filters; **minimizes type I errors** + (ground omission) → well suited when ground is scarce (forest). Best + coverage of the three here (90.4%) but ~5.4 min/tile. +- **CSF** — Zhang et al. 2016, *Remote Sensing* 8(6):501. Inverted cloth + draped over the point cloud; simple, accurate, but **the cloth no longer + touches the ground on steep/hilly terrain** → poor classification. Slowest + here and worst on this tile. Best reserved for urban areas. - **PTD/PTIN** (Progressive TIN Densification) — Axelsson 2000, ISPRS - Congress. **Gagnant de la littérature** : le plus robuste sur terrain - complexe + forêt (Moudrý et al. 2020, *Measurement* 150:107047 ; Cai et al. - 2019, *Remote Sensing* 11(9):1037) et le plus rapide (benchmark lidR : - PTD ~20 s vs CSF ~156 s vs PMF ~1800 s). **NON disponible dans la version - PDAL de cette image** (`filters.ground` / TIN absents) — à ajouter pour - l'utiliser (ou via lidR / une implémentation maison). + Congress. **Literature's winner**: most robust on complex terrain + + forest (Moudrý et al. 2020, *Measurement* 150:107047; Cai et al. 2019, + *Remote Sensing* 11(9):1037) and the fastest (lidR benchmark: PTD ~20 s + vs CSF ~156 s vs PMF ~1800 s). **NOT available in the PDAL version + bundled in this image** (`filters.ground` / TIN missing) — would need to + be added to use it (or via lidR / a custom implementation). -## B. Hybride rapide (choisi pour implémentation) +## B. Fast hybrid (chosen for implementation) -Principe **PTD / Wack & Wimmer** (Wack & Wimmer 2002, *ISPRS Archives* -XXXIV/3A:293-296 : MNT par retour le plus bas, en excluant le 1 % le plus bas -par cellule pour écarter les outliers) : +**PTD / Wack & Wimmer** principle (Wack & Wimmer 2002, *ISPRS Archives* +XXXIV/3A:293-296: DTM from lowest return, excluding the lowest 1% per cell +to discard outliers): -1. **Base = pré-classification IGN** (classe 2, ~9 s, fiable et officielle). -2. **Comblement mesuré des trous** : pour chaque cellule sans point sol, - prendre le **retour le plus bas robuste** (min du 99 % des points de la - cellule) → ajoute du sol *mesuré* là où le fournisseur a échoué - (rochers, clairières, sol forestier). -3. **Inpainting topographique** des derniers vides (interpolation - terrain-aware déjà implémentée dans `dtm.py:_interpolate_holes`). +1. **Base = IGN pre-classification** (class 2, ~9 s, reliable and official). +2. **Measured gap filling**: for each cell with no ground point, take the + **robust lowest return** (min of the 99% of points in the cell) → adds + *measured* ground where the vendor failed (rock outcrops, clearings, + forest floor). +3. **Topographic inpainting** of the remaining gaps (terrain-aware + interpolation already implemented in `dtm.py:_interpolate_holes`). -Attendu : MNT **continu** (0 % de trous), robuste en forêt/relief, -**~10-15 s/tile** au lieu de 326-355 s. Aucune dépendance GPU, aucun -entraînement. +Expected: **continuous** DTM (0% holes), robust in forest/relief, +**~10-15 s/tile** instead of 326-355 s. No GPU dependency, no training. -## C. Modèles IA / ML (supervisés — nécessitent des labels) +## C. AI / ML models (supervised — require labels) -Avertissement (Qin et al. 2023, *ISPRS J. Photogramm. Remote Sens.* -202:246-261) : **tout est supervisé** ; le principal risque est la -**généralisation** — un modèle entraîné sur une région dégrade ailleurs. -Aucun filtre DL entièrement non-supervisé publié à date. +Caveat (Qin et al. 2023, *ISPRS J. Photogramm. Remote Sens.* +202:246-261): **everything is supervised**; the main risk is +**generalization** — a model trained on one region degrades elsewhere. +No fully unsupervised DL filter published to date. -**Basés sur les points (3D) :** +**Point-based (3D):** -| Modèle | Année | Archi | Précision | Vitesse (~/km², GPU) | +| Model | Year | Architecture | Accuracy | Speed (~/km², GPU) | |---|---|---|---|---| -| KPConv / RandLA-Net (Qin, OpenGF) | 2021 | KPConv / RandLA-Net | 97.8 % OA, RMSE DTM 0.20 m, IoU sol 95 % | 0.5-2.5 min | -| PFCN (Jin, *IEEE JSTARS* 13:3958) | 2020 | point-FCN | Te 1.73 %, Kappa 93.9 % | ~1/3 du coût PointNet++ | -| Terrain-Net (Li, *Remote Sensing* 14(22):5798) | 2022 | KPConv + self-attention | OA 98 %, mIoU 0.933 | param-free au transfert | -| MSVC (Štroner, *Remote Sensing* 17(4):615) | 2025 | DNN voxel 9x9x9 | bat CSF en F-score | — | +| KPConv / RandLA-Net (Qin, OpenGF) | 2021 | KPConv / RandLA-Net | 97.8% OA, DTM RMSE 0.20 m, ground IoU 95% | 0.5-2.5 min | +| PFCN (Jin, *IEEE JSTARS* 13:3958) | 2020 | point-FCN | Te 1.73%, Kappa 93.9% | ~1/3 the cost of PointNet++ | +| Terrain-Net (Li, *Remote Sensing* 14(22):5798) | 2022 | KPConv + self-attention | OA 98%, mIoU 0.933 | parameter-free at transfer | +| MSVC (Štroner, *Remote Sensing* 17(4):615) | 2025 | 9x9x9 voxel DNN | beats CSF on F-score | — | -**Rasterisés (sortent directement le MNT — le plus proche du besoin) :** +**Rasterized (directly output the DTM — closest to our need):** -| Modèle | Année | Archi | Résultat | +| Model | Year | Architecture | Result | |---|---|---|---| -| Precursor (Rizaldy, *ISPRS Annals* IV-2:231) | 2018 | 2D FCN | Te 5.22 %, 78x plus rapide | -| DeepTerRa / ALS2DTM (Lê, *IEEE JSTARS* 15:2778) | 2022 | GAN pix2pix (U-Net) | RMSE MNT < 1 m, filtre + interp en 1 passe | -| DSM2DTM (Bittner, *ISPRS Annals* X-1/W1-2023:925) | 2023 | U-Net (EfficientNet) | masque non-sol + hauteur sol/pixel | +| Precursor (Rizaldy, *ISPRS Annals* IV-2:231) | 2018 | 2D FCN | Te 5.22%, 78x faster | +| DeepTerRa / ALS2DTM (Lê, *IEEE JSTARS* 15:2778) | 2022 | GAN pix2pix (U-Net) | DTM RMSE < 1 m, filter + interpolation in one pass | +| DSM2DTM (Bittner, *ISPRS Annals* X-1/W1-2023:925) | 2023 | U-Net (EfficientNet) | non-ground mask + per-pixel ground height | -**Jeu de données d'entraînement** : OpenGF (Qin et al., CVPRW 2021, -arXiv:2101.09641 — 47.7 km², 542 M pts) ; ALS2DTM (Lê et al. 2022, -arXiv:2206.03778 — 52 km², 1.66 Md pts, urbain/forêt/montagne). +**Training datasets**: OpenGF (Qin et al., CVPRW 2021, +arXiv:2101.09641 — 47.7 km², 542 M pts); ALS2DTM (Lê et al. 2022, +arXiv:2206.03778 — 52 km², 1.66 billion pts, urban/forest/mountain). -**Coûts / obstacles pour notre cas** : (1) labels → à générer en -pseudo-labels (sortie SMRF/PTD haute qualité sur un échantillon représentatif -de nos tiles) ou pré-entraînement OpenGF/ALS2DTM ; (2) généralisation sur le -terrain divers de LiDAR HD (plaine/forêt/montagne/urbain) ; (3) infra : -checkpoint + chemin d'inférence GPU dans l'image Docker. +**Costs / obstacles for our case**: (1) labels → to be generated as +pseudo-labels (high-quality SMRF/PTD output on a representative sample of +our tiles) or pre-training on OpenGF/ALS2DTM; (2) generalization across the +diverse terrain of LiDAR HD (plain/forest/mountain/urban); (3) +infrastructure: checkpoint + GPU inference path in the Docker image. -**Meilleur fit si on part sur l'IA** : un **U-Net rasterisé (style -DSM2DTM)** — rasteriser le nuage en grilles multi-canaux (altitude, pente, -courbure, densité, stats de retours), sortir masque sol + hauteur sol. -2D = très rapide et trivial à déployer sur GPU, fusionne filtrage + -interpolation. Le KPConv/RandLA-Net est plus précis en 3D pur mais plus lourd -à déployer. +**Best fit if going the AI route**: a **rasterized U-Net (DSM2DTM-style)** — +rasterize the point cloud into multi-channel grids (elevation, slope, +curvature, density, return statistics), output a ground mask + ground +height. 2D = very fast and trivial to deploy on GPU, merges filtering + +interpolation. KPConv/RandLA-Net is more accurate in pure 3D but heavier to +deploy. -## Synthèse / décision +## Synthesis / decision -- « Rapide » contrainte dure + faible maintenance → **hybride (B)** - (~10-40 s/tile, zéro entraînement, zéro GPU). ← **choix retenu, IMPLÉMENTÉ** - - Base = pré-classification IGN (rapide, ~10 s). `auto` la préfère dès que - ≥ 20 % des points sont classés sol (seuil abaissé de 30 % à 20 %, car le - MNT est ensuite complété — voir ci-dessous). - - Le MNT n'est complété que pour les petits trous (< 1 m, `fillnodata`) : - les grands trous (forêt dense, relief raide où le sol est sous-classé) - restent en nodata (noir dans les rendus). Volontairement pas de plancher - au retour le plus bas : sous canopée dense ce retour est la végétation, - qui imprimerait les arbres dans le MNT. -- Qualité max dans les cas durs (raide + dense), ~1-2 min/tile + GPU + - entraînement acceptés → **U-Net rasterisé (C)**. (non implémenté) -- Meilleur filtre géométrique disponible dans PDAL → **SMRF (A)** (meilleure - couverture 90.4 % mais 5.4 min/tile). Sélectionnable via `--ground-classification smrf`. -- « Gagnant » absolu de la littérature (rapide + robuste) → **PTD/PTIN - (A)** : à intégrer (pas dans la stack PDAL actuelle). +- "Fast" hard constraint + low maintenance → **fast hybrid (B)** + (~10-40 s/tile, zero training, zero GPU). ← **chosen approach, IMPLEMENTED** + - Base = IGN pre-classification (fast, ~10 s). `auto` prefers it as soon + as ≥ 20% of points are classified as ground (threshold lowered from 30% + to 20%, since the DTM is subsequently completed — see below). + - **This gap-filling note is superseded**: gap filling in the DTM is no + longer a distance-based `fillnodata` pass over small holes. It is now + a morphological closing bounded to the point envelope + (`_fill_small_gaps` in `dtm.py`): the closing radius follows the local + point spacing (measured over 5 m, staged at 1/1.5/2/3 m), nothing is + extended beyond measured pixels, and islands under 1 m² are removed. + Large holes (dense forest, steep relief where ground is + under-classified) still remain as nodata (black in the renders). + Deliberately no floor at the lowest return: under dense canopy that + return is vegetation, which would print trees into the DTM. +- Maximum quality in hard cases (steep + dense), ~1-2 min/tile + GPU + + training accepted → **rasterized U-Net (C)**. (not implemented) +- Best geometric filter available in PDAL → **SMRF (A)** (best coverage + 90.4% but 5.4 min/tile). Selectable via `--ground-classification smrf`. +- Literature's absolute "winner" (fast + robust) → **PTD/PTIN + (A)**: to be integrated (not in the current PDAL stack). -## Références +## References - Axelsson (2000), PTIN/PTD, ISPRS Congress. - Pingel, Clarke & McBride (2013), SMRF, ISPRS J. P&RS 77:21-30. - Zhang et al. (2016), CSF, Remote Sensing 8(6):501. -- Wack & Wimmer (2002), DMT par retour le plus bas, ISPRS Archives XXXIV/3A. -- Moudrý et al. (2020), comparaison CSF/PTIN/PMF/SMRF, Measurement 150:107047. +- Wack & Wimmer (2002), lowest-return DTM, ISPRS Archives XXXIV/3A. +- Moudrý et al. (2020), CSF/PTIN/PMF/SMRF comparison, Measurement 150:107047. - Cai et al. (2019), CS+PTD, Remote Sensing 11(9):1037. - Qin et al. (2021), OpenGF, CVPR Workshops (arXiv:2101.09641). - Qin et al. (2023), dataset + evaluation + survey, ISPRS J. P&RS 202:246-261. - Lê et al. (2022), DeepTerRa/ALS2DTM, IEEE JSTARS 15:2778 (arXiv:2206.03778). - Bittner et al. (2023), DSM2DTM, ISPRS Annals X-1/W1-2023:925. -- lidR book (comparaison PTD/CSF/PMF) : https://r-lidar.github.io/lidRbook/gnd.html +- lidR book (PTD/CSF/PMF comparison): https://r-lidar.github.io/lidRbook/gnd.html diff --git a/docs/MAPS.md b/docs/MAPS.md index a3085f8..0c6fd65 100644 --- a/docs/MAPS.md +++ b/docs/MAPS.md @@ -1,414 +1,413 @@ -# Carte à tuiles XYZ (image `lidar-maps`) +# XYZ tile map (`lidar-maps` image) -Carte « slippy » classique — même schéma de tuiles que Google Maps et -**OpenStreetMap** — servie par une image légère dédiée. Les rendus du pipeline -deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap, -MapLibre ou OsmAnd, en plus de l'interface de consultation fournie. +A classic "slippy" map — the same tile scheme as Google Maps and +**OpenStreetMap** — served by a dedicated lightweight image. Pipeline +renders thus become a **reusable imagery basemap** in JOSM, iD, QGIS, uMap, +MapLibre or OsmAnd, on top of the browsing interface it also provides. -C'est la SEULE interface web du projet (l'ancienne webapp historique a été -retirée) : elle embarque aussi la **génération de tuiles** — locale sur -l'image complète (worker, `./run.sh --serve`), déléguée via -`LIDAR_GENERATION_URL` sur la machine légère (cf. `docs/DEPLOY_WEBAPP.md`). +This is the ONLY web interface in the project (the old historical webapp has +been removed): it also embeds **tile generation** — local on the full image +(the full pipeline server, port 8973, `./run.sh --serve`), delegated via +`LIDAR_GENERATION_URL` on the lightweight machine (see +`docs/DEPLOY_WEBAPP.md`). ```bash docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/ docker compose -f docker-compose.maps.yml logs -f maps -./run.sh --serve-maps # équivalent en conteneur au premier plan +./run.sh --serve-maps # equivalent, foreground container ``` ## Interface -Un **panneau unique à onglets** (`web/map.{html,css,js}`) regroupe tous les -réglages, à la place de l'ancienne pile de blocs empilés : **Affichage** -(couche principale, mode relief/précision, fond de carte), **Export PDF**, -**Génération** (masqué si le générateur est indisponible ou non autorisé -pour ce navigateur) et **Partager**. Sur ordinateur, le panneau occupe une -colonne fixe, repliable en bande d'icônes (**‹**, toujours utilisable : un -clic sur un onglet redéplie le panneau). Sous 720 px de large, il devient un -**volet en bas d'écran** à trois hauteurs — fermé, mi-hauteur, plein — que -l'on change en glissant la poignée, en la touchant simplement (un cran), ou -en retouchant l'onglet déjà actif. +A **single tabbed panel** (`web/map.{html,css,js}`) groups all settings, +replacing the old stack of stacked blocks: **Affichage** (Display) (main +layer, relief/precision mode, basemap), **Export PDF**, **Génération** +(Generation) (hidden if the generator is unavailable or not authorized for +that browser) and **Partager** (Share). On desktop, the panel occupies a +fixed column, collapsible into an icon strip (**‹**, always usable: clicking +a tab redeploys the panel). Below 720 px wide, it becomes a **bottom sheet** +with three heights — closed, half, full — changed by dragging the handle, by +simply tapping it (one notch), or by tapping the already-active tab again. -Un clic sur la carte **sélectionne** la dalle LiDAR HD sous le curseur -(contour en pointillés) 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. Raccourcis clavier : -**1–5** (onglets du panneau, sans effet si l'onglet est masqué), **P** -(mode d'affichage suivant), **Échap** (désélectionne la dalle, puis -replie le panneau). Deux thèmes clair/sombre (bouton ◐/☀/☾, `auto` par -défaut = suit le système) ; réglages et état du panneau retenus dans -`localStorage` (`lidarMapView_v2`, `lidar-print`, `lidar-panel`, -`lidar-theme` — silencieusement ignorés en navigation privée). +Clicking the map **selects** the LiDAR HD tile under the cursor (dashed +outline) and fills the **Dalle** (Tile) tab (footprint, IGN info, pass +alignment) without changing the displayed tab; clicking the same tile again +deselects it, clicking another moves the selection. Keyboard shortcuts: +**1–5** (panel tabs, no effect if the tab is hidden), **P** (next display +mode), **Escape** (deselects the tile, then collapses the panel). Two +light/dark themes (◐/☀/☾ button, `auto` by default = follows the system); +settings and panel state are kept in `localStorage` (`lidarMapView_v2`, +`lidar-print`, `lidar-panel`, `lidar-theme` — silently ignored in private +browsing). -## Générer des tuiles depuis la carte +## Generating tiles from the map -- **+ Zone** (onglet Génération) — dessiner un rectangle : les dalles LHD de - 1 km qui l'intersectent sont téléchargées depuis la géoplateforme IGN puis - traitées (0,2 m, options ci-dessous) ; les zones et clics successifs - s'additionnent ; -- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui - manquent au moins une des couches demandées ; -- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, onglet - Dalle) — une dalle précise, même sans données existantes. +- **+ Zone** (Génération tab) — draw a rectangle: the 1 km LHD tiles it + intersects are downloaded from the IGN geoplatform and then processed + (0.2 m, options below); successive zones and clicks are additive; +- **⤒ Compléter** (Complete) — all tiles already present in `input/` that are + missing at least one of the requested layers; +- **↻ Générer/Régénérer cette dalle** (Generate/Regenerate this tile) (button + on the tile sheet, Dalle tab) — a specific tile, even with no existing data. -Options du run : couches visées (défaut : les couches du panneau) et -régénération forcée. La classification du sol (IGN, sol seul) et le raccord -des bords (bande de 100 m prise aux dalles voisines) sont imposés : aucun -réglage. Une demande lancée pendant un run part en **file -d'attente** (persistée, jamais de coupure du travail en place) ; la -progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu = -en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis -SIGKILL). Pendant le run, chaque dalle terminée apparaît sur la carte : l'inventaire -est mis à jour après chaque dalle et la maintenance de pyramide traite ses -tuiles en priorité (surveillance toutes les 10 s). +Run options: target layers (default: the panel layers) and forced +regeneration. Ground classification (IGN, ground only) and edge stitching (a +100 m band taken from neighboring tiles) are enforced: no setting available. +A request submitted while a run is already in progress goes into a +**persisted queue** (never interrupting work in progress); progress is shown +tile by tile (orange frames = rendering in progress, blue = waiting, red = +failed) with a log and a **Stop** button (SIGTERM then SIGKILL). During a +run, each completed tile appears on the map as it finishes: the inventory is +updated after each tile and the pyramid maintenance job prioritizes its tiles +(polled every 10 s). -Les boutons sont masqués si le navigateur vient d'une IP hors -`LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun -backend de génération n'existe (image légère sans `LIDAR_GENERATION_URL`). -Sur la machine légère, tout est transmis au worker -(`LIDAR_GENERATION_URL`), qui exécute le pipeline complet ; ses dalles sont -rapatriées à la demande par l'inventaire `/api/tiles` + les statiques -versionnées (`LIDAR_SOURCE_URL`). +The buttons are hidden if the browser's IP falls outside +`LIDAR_REGEN_CIDR` (default: localhost + private RFC1918 ranges) or if no +generation backend exists (lightweight image without `LIDAR_GENERATION_URL`). +On the lightweight machine, everything is forwarded to the worker +(`LIDAR_GENERATION_URL`), which runs the full pipeline; its tiles are then +pulled back on demand via the `/api/tiles` inventory plus versioned static +assets (`LIDAR_SOURCE_URL`). -## Contrat de tuilage +## Tiling contract -| Point | Valeur | +| Point | Value | |---|---| -| Projection | EPSG:3857, schéma XYZ OSM (origine nord-ouest, `y` vers le sud) | -| URL canonique | `/tiles/{couche}/{z}/{x}/{y}.png` — 256 px, PNG RGBA | -| Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) | -| Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client | -| Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` | -| En attente (cache seule) | tuile transparente, en-têtes `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, jamais mémorisée par le navigateur | -| CORS | `Access-Control-Allow-Origin: *` sur `/tiles/*` | -| Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » | +| Projection | EPSG:3857, OSM XYZ scheme (north-west origin, `y` toward the south) | +| Canonical URL | `/tiles/{layer}/{z}/{x}/{y}.png` — 256 px, PNG RGBA | +| High-density variant | `/tiles/{layer}/{z}/{x}/{y}@2x.webp` — 512 px (internal interface) | +| Zoom levels | 5 → 19 native (0.2 m/px ≈ z19 in France); beyond that, client-side over-zoom | +| Outside coverage | fully transparent tile (overlayable), header `X-Tile-Empty: 1` | +| Pending (cache-only mode) | transparent tile, headers `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, never cached by the browser | +| CORS | `Access-Control-Allow-Origin: *` on `/tiles/*` | +| Attribution | `LIDAR_ATTRIBUTION`, default "LiDAR HD © IGN — Licence Ouverte 2.0" | -Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml` -(WMTS 1.0.0, grille `GoogleMapsCompatible`), `/tiles/josm.imagery.xml` -(toutes les couches d'un coup dans JOSM). +Discovery: `/tiles/{layer}.json` (TileJSON 3.0.0), `/tiles/wmts.xml` +(WMTS 1.0.0, `GoogleMapsCompatible` grid), `/tiles/josm.imagery.xml` +(all layers at once in JOSM). -## Utiliser les tuiles ailleurs +## Using the tiles elsewhere -- **JOSM** — *Imagery → Imagery preferences → + TMS*, coller - `http://:8975/tiles/slope/{zoom}/{x}/{y}.png`. Pour tout ajouter d'un - coup : *Imagery preferences → Offline/Custom → Add imagery source XML* avec - `http://:8975/tiles/josm.imagery.xml`. -- **iD** — *Fond de carte → Personnalisé*, coller - `http://:8975/tiles/slope/{z}/{x}/{y}.png`. -- **QGIS** — *XYZ Tiles → Nouvelle connexion* (même URL, zoom max 19), ou - *WMS/WMTS → Nouveau* avec `http://:8975/tiles/wmts.xml`. -- **uMap / MapLibre / Leaflet** — même gabarit XYZ, ou le TileJSON. -- **OsmAnd** — source de tuiles en ligne, gabarit XYZ, zoom max 19. +- **JOSM** — *Imagery → Imagery preferences → + TMS*, paste + `http://:8975/tiles/slope/{zoom}/{x}/{y}.png`. To add everything at + once: *Imagery preferences → Offline/Custom → Add imagery source XML* with + `http://:8975/tiles/josm.imagery.xml`. +- **iD** — *Background → Custom*, paste + `http://:8975/tiles/slope/{z}/{x}/{y}.png`. +- **QGIS** — *XYZ Tiles → New Connection* (same URL, max zoom 19), or + *WMS/WMTS → New* with `http://:8975/tiles/wmts.xml`. +- **uMap / MapLibre / Leaflet** — same XYZ template, or the TileJSON. +- **OsmAnd** — online tile source, XYZ template, max zoom 19. -Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces -URL pour la couche choisie. +The **"Utiliser dans JOSM / QGIS"** ("Use in JOSM / QGIS") button on the map +shows and copies these URLs for the selected layer. -## Pyramide complète générée d'avance +## Full pyramid pre-generated -Par défaut, **tous** les niveaux jusqu'au natif (19 en numérotation OSM -standard = 0,2 m/px, servi en `18@2x` par l'interface) sont écrits sur -disque en AVIF et générés d'avance par la maintenance de fond (sur une carte -alimentée par l'amont : téléchargés depuis `LIDAR_MAPS_URL`). Aucun niveau -n'est rendu à la volée : le rendu à la demande d'un Raspberry Pi (~170 ms par -tuile, 3 à la fois) donnait 1,5 à 6 s par écran aux zooms 17–19. L'interface -est plafonnée au zoom 19 (1 px écran = 1 px LiDAR) : jamais de tuile -agrandie. Ordre de grandeur mesuré : ~110 000 tuiles @2x pour 3 240 dalles, -dont 81 000 au niveau natif, ~5 Go. +By default, **all** levels up to native (19 in standard OSM numbering = +0.2 m/px, served as `18@2x` by the interface) are written to disk in AVIF +and pre-generated by the background maintenance job (on a map fed by an +upstream source: downloaded from `LIDAR_MAPS_URL`). No level is rendered +on the fly: on-demand rendering on a Raspberry Pi (~170 ms per tile, 3 at a +time) took 1.5 to 6 s per screen at zoom levels 17–19. The interface is +capped at zoom 19 (1 screen pixel = 1 LiDAR pixel): never an upscaled tile. +Measured order of magnitude: ~110,000 @2x tiles for 3,240 tiles, of which +81,000 at native level, ~5 GB. -Stockage réduit (disque compté) : `LIDAR_TILE_CACHE_MAX_Z` plus bas et/ou -`LIDAR_TILE_EVEN_LEVELS=1` (niveaux pairs seuls ; l'interface réduit alors -les tuiles du niveau supérieur aux zooms impairs, les autres niveaux sont -rendus à la volée avec un cache mémoire `LIDAR_TILE_MEMORY_CACHE_MB`). +Reduced storage (measured on disk): a lower `LIDAR_TILE_CACHE_MAX_Z` and/or +`LIDAR_TILE_EVEN_LEVELS=1` (even levels only; the interface then downsamples +the level above's tiles at odd zooms, other levels are rendered on the fly +with an in-memory cache, `LIDAR_TILE_MEMORY_CACHE_MB`). -Une carte alimentée par un serveur de dalles amont (`LIDAR_SOURCE_URL`, cas -du Pi) rapatrie et **garde localement** les sources de chaque dalle dès -qu'elle apparaît (vignettes et quadrants 500 m ; la dalle entière, doublon -de ses quadrants, n'est pas rapatriée), sans attendre de visite. Tous les -niveaux restent disponibles quand le conteneur de rendu est éteint ; une -source manquée pendant qu'il était éteint est reprise au scan suivant. +A map fed by an upstream tile-source server (`LIDAR_SOURCE_URL`, the Pi's +case) pulls in and **keeps locally** the sources for each tile as soon as it +appears (thumbnails and 500 m quadrants; the full tile, a duplicate of its +quadrants, is not pulled in), without waiting for a visit. All levels remain +available while the rendering container is off; a source missed while it was +off is picked up on the next scan. -## Fiche de dalle et rose des vents +## Tile sheet and compass rose -Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre -jaune en pointillés) et remplit l'onglet **Dalle** — nom, emprise Lambert 93, -puis, si la dalle est rendue, la résolution, la date de génération et le -recalage vertical des passes (faisceaux, décalages, correction des lignes) — -qu'elle soit générée ou non, sans changer l'onglet affiché. Les informations -IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache -dans `output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission, -opérateur, date d'édition, procédé de classement, nombre de points et lien de -téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent -ou injoignable n'empêche jamais la sélection. Re-cliquer la même dalle (ou -Échap) désélectionne. +Clicking the map selects the LiDAR HD tile under the cursor (dashed yellow +frame) and fills the **Dalle** (Tile) tab — name, Lambert 93 footprint, then, +if the tile has been rendered, resolution, generation date and vertical pass +alignment (beams, offsets, line correction) — whether it has been generated +or not, without changing the displayed tab. IGN information arrives +separately (`GET /api/map/ign?col&row`, STAC catalog cached in +`output/ign_meta/`): LiDAR scan date and time, sensors, mission, operator, +edit date, classification process, point count and a download link for the +`.copc.laz` point cloud on the geoplatform. A slow or unreachable catalog +never blocks selection. Clicking the same tile again (or Escape) deselects +it. -Quand le relief orienté est affiché, une rose des vents donne la couleur de -chaque orientation de pente (même formule CIELAB que le rendu) ; la clarté -porte le relief local (clair = bosse, sombre = creux). +When the oriented relief layer is displayed, a compass rose shows the color +of each slope orientation (same CIELAB formula as the render); lightness +carries the local relief (light = bump, dark = hollow). -## Affichage : relief et précision +## Display: relief and precision -La carte ne sert que les couches de `PANEL_VIZ` (`index.py`) : le **relief -orienté** (couche d'affichage principal, qui fusionne openness locale et -orientation des pentes) et la **précision** (`densite_sol` : densité des points -sol retenus pour le MNT). Les autres visualisations présentes sur disque ne -sont ni listées ni servies en tuiles. +The map serves only the layers in `PANEL_VIZ` (`index.py`): the **relief +orienté** (oriented relief, the main display layer, which merges local +openness and slope orientation) and the **précision** (precision) layer +(`densite_sol`: density of the ground points retained for the DTM). Other +visualizations present on disk are neither listed nor served as tiles. -Il n'y a plus de pile de couches. Le panneau propose trois modes, un clic -chacun, ou la touche **P** pour passer au suivant : +There is no more layer stack. The panel offers three modes, one click each, +or the **P** key to cycle to the next: -- **Relief** — le relief orienté seul ; -- **Précision** — la densité seule, en 16 gris (échelle log fixe : niveau k à - partir de 0,25 × 2^(k/2) pts/m², noir ≤ 0,35 ou aucun point, blanc ≥ 45) ; - se lit comme une carte de fiabilité géométrique ; -- **Comparer** — une barre glissante (souris ou doigt) sépare deux couches - choisies dans deux menus (relief, précision ou fond OSM seul) de part et - d'autre du curseur ; la même couche des deux côtés n'y ajoute aucune - découpe. +- **Relief** — the oriented relief alone; +- **Précision** (Precision) — density alone, in 16 shades of gray (fixed log + scale: level k starting at 0.25 × 2^(k/2) pts/m², black ≤ 0.35 or no + points, white ≥ 45); reads as a geometric-reliability map; +- **Comparer** (Compare) — a slider (mouse or touch) separates two layers + chosen from two menus (relief, precision, or bare OSM background) on + either side of the cursor; the same layer on both sides adds no split. -L'onglet Affichage porte aussi une **intensité du relief** (curseur 0,5×–2×, -1× par défaut) : un contraste de confort posé sur le conteneur de la couche -affichée (`contrast()` CSS), mémorisé et partagé dans le lien (`&I=`, écrit -seulement si ≠ 1×) mais jamais figé comme défaut serveur ni appliqué au PDF -exporté (qui garde le rendu standard). Un bloc **Comment lire la carte**, -repliable, reprend pour la ou les couches affichées le texte de lecture de -`VIZ_LEGENDS` (aussi servi par `/api/map/meta` et le TileJSON). +The Affichage (Display) tab also carries a **relief intensity** slider +(0.5×–2×, 1× by default): a comfort contrast applied to the displayed +layer's container (CSS `contrast()`), remembered and shared in the link +(`&I=`, written only if ≠ 1×) but never fixed as a server default nor +applied to the exported PDF (which keeps the standard render). A +collapsible **Comment lire la carte** (How to read the map) block reuses, +for the displayed layer(s), the reading text from `VIZ_LEGENDS` (also served +by `/api/map/meta` and the TileJSON). -La légende de la précision (16 paliers, info-bulle en pts/m² sur chaque -palier) s'affiche dès que la précision est visible, seule ou d'un côté de la -barre Comparer. Les couches LiDAR vivent dans un conteneur **isolé** -(`isolation: isolate`) : la barre ne découpe que les calques LiDAR, jamais le -fond de carte. +The precision legend (16 levels, tooltip in pts/m² on each level) is shown +as soon as precision is visible, either alone or on one side of the Compare +slider. LiDAR layers live in an **isolated** container (`isolation: +isolate`): the slider only splits the LiDAR layers, never the basemap. -Le lien de partage transporte la couche principale, le mode, la comparaison et -l'intensité : +The share link carries the main layer, the mode, the comparison and the +intensity: `#z/lat/lng&M=relief_oriente&P=compare&C=relief:precision:30&I=1.4&B=1:85:1` -(`&C=gauche:droite:position%` seulement en mode Comparer). Les anciens liens -de la pile (`&L=…`) et de l'ancien mode « les deux » (`&P=both:opacité`, -opacité alors ignorée) s'ouvrent sans erreur, en mode relief. +(`&C=left:right:position%` only in Compare mode). Old links from the layer +stack (`&L=…`) and the old "both" mode (`&P=both:opacity`, opacity then +ignored) open without error, in relief mode. -### Figer la configuration +### Freezing the configuration -Le bouton **★ Définir par défaut** enregistre l'affichage courant — couche -principale, mode, fond de carte — dans `output/.map-defaults.json` (jamais -l'intensité, réglage de confort propre à chaque navigateur). Tout navigateur -sans réglage local part alors de cette configuration ; **↺ Réinitialiser** -oublie l'état local et y revient. +The **★ Définir par défaut** ("Set as default") button saves the current +display — main layer, mode, basemap — to `output/.map-defaults.json` (never +the intensity, a per-browser comfort setting). Any browser with no local +setting then starts from this configuration; **↺ Réinitialiser** (Reset) +forgets the local state and reverts to it. ```bash -curl http://localhost:8975/api/map/defaults # configuration servie -curl -X DELETE http://localhost:8975/api/map/defaults # retour au registre +curl http://localhost:8975/api/map/defaults # served configuration +curl -X DELETE http://localhost:8975/api/map/defaults # revert to the registry ``` -Sans fichier enregistré, les défauts viennent du registre du pipeline -(`DEFAULT_VIZ`, `PRECISION_VIZ`, `DEFAULT_VIEW_MODE` dans `index.py`). Les -valeurs reçues sont filtrées : couche inconnue (ou la précision elle-même) -refusée comme principale, mode validé, opacité du fond bornée à 0–1. Un -fichier de l'ancienne pile (`order`/`on`/`blend`) est ignoré, sauf le fond. +With no saved file, defaults come from the pipeline registry (`DEFAULT_VIZ`, +`PRECISION_VIZ`, `DEFAULT_VIEW_MODE` in `index.py`). Received values are +filtered: an unknown layer (or precision itself) is rejected as the main +layer, the mode is validated, basemap opacity is clamped to 0–1. A file from +the old stack (`order`/`on`/`blend`) is ignored, except for the basemap. -## Export PDF (planche d'impression terrain) +## PDF export (field print sheet) -L'onglet **Export PDF** affiche les réglages (format A4/A3, paysage/portrait, -échelle 1:1 000 à 1:10 000, titre optionnel) et, tant qu'il est ouvert, un -**cadre jaune en pointillés** qui montre la zone qui sera imprimée (il -disparaît en changeant d'onglet). Le cadre est **posé sur le -terrain** : il se place au centre de la vue à l'ouverture (ou garde sa position -précédente si elle est encore visible), la carte zoome pour le montrer en -entier au-dessus du panneau, puis on navigue librement sans qu'il bouge. On le -déplace en faisant glisser sa **poignée ✥** (souris ou doigt) ; au relâchement, -la géométrie Lambert 93 exacte est recalculée (`GET /api/export/frame`) — un -relâchement suivi d'un clic immédiat ne sélectionne pas de dalle (garde de -300 ms). « ⌖ Centrer ici » le ramène au centre de la vue, « ⤢ Voir le cadre » -zoome dessus ; changer de format, d'orientation ou d'échelle recadre la vue. -Les réglages et la position du cadre sont mémorisés dans `localStorage` du -navigateur. **Exporter le PDF** télécharge la planche (`GET -/api/export/pdf`), nommée `relief_{x_km}_{y_km}_1-{échelle}.pdf` (centre -Lambert 93 en km, à trois décimales). +The **Export PDF** tab shows the settings (A4/A3 format, landscape/portrait, +scale 1:1,000 to 1:10,000, optional title) and, while it stays open, a +**dashed yellow frame** showing the area that will be printed (it disappears +when switching tabs). The frame is **anchored to the terrain**: it is +placed at the center of the view when opened (or keeps its previous +position if it's still visible), the map zooms to show it in full above the +panel, and one can then navigate freely without it moving. It is moved by +dragging its **✥ handle** (mouse or touch); on release, the exact Lambert 93 +geometry is recomputed (`GET /api/export/frame`) — a release immediately +followed by a click does not select a tile (300 ms guard). "⌖ Centrer ici" +("Center here") brings it back to the center of the view, "⤢ Voir le cadre" +("View the frame") zooms onto it; changing format, orientation or scale +recenters the view. Settings and frame position are kept in the browser's +`localStorage`. **Exporter le PDF** ("Export PDF") downloads the sheet (`GET +/api/export/pdf`), named `relief_{x_km}_{y_km}_1-{scale}.pdf` (Lambert 93 +center in km, to three decimal places). -La planche (module `lidar_pipeline/export_pdf.py`) est composée directement -en Lambert 93 depuis les sources déjà rendues (pas de passage par les tuiles -XYZ), puis dessinée en vectoriel (texte, grille, légende) avec `reportlab` — -Pillow + pyproj + reportlab uniquement, **sans numpy** : elle tourne aussi -bien sur l'image complète que sur l'image légère du Pi seul. Contenu : +The sheet (module `lidar_pipeline/export_pdf.py`) is composed directly in +Lambert 93 from the already-rendered sources (no pass through the XYZ +tiles), then drawn vectorially (text, grid, legend) with `reportlab` — +Pillow + pyproj + reportlab only, **no numpy**: it runs equally well on the +full image and on the Pi's lightweight image alone. Contents: -- la carte du **relief orienté**, recadrée à l'échelle demandée (300 dpi en - A4, 250 dpi en A3 — borne la mémoire du Pi, ~36 Mo en A3) ; hors emprise des - dalles disponibles, la zone reste blanche et hachurée ; -- un **quadrillage Lambert 93** (pas 100 m aux échelles 1:1 000/1:2 000, 500 m - au 1:5 000, 1 000 m au 1:10 000) gradué en marge, et les **coins WGS84** de - la zone imprimée aux quatre angles ; -- une **flèche du nord géographique** tenant compte de la convergence du - méridien (la carte est orientée sur le nord du quadrillage L93, pas le nord - géographique — l'écart est indiqué en degrés) ; -- une **échelle graphique** (barre alternée) et l'**échelle numérique** ; -- une **rose des orientations à 8 points** (N/NE/E/SE/S/SO/O/NO), même - formule CIELAB que la rose de la fiche de dalle ; -- le texte de légende du relief orienté, repris de `VIZ_LEGENDS` (source - unique avec l'interface et le TileJSON) ; -- un **encart qualité** : miniature de la densité de points sol (mailles - 50 m, classes de couleur), chiffres clés (densité moyenne, maille la plus - faible, part de surface interpolée, période d'acquisition), zones **hachurées - en blanc** là où la qualité n'est pas renseignée, et une ligne « **Donnée - manquante (sans relief)** » listant les dalles de la zone qui n'ont pas - encore été générées ; -- un cartouche : titre (par défaut, liste des dalles couvertes), échelle, - format, dpi, centre L93, taille de la zone, date d'export et mention de - source IGN. +- the **oriented relief** map, cropped to the requested scale (300 dpi at + A4, 250 dpi at A3 — bounds Pi memory usage, ~36 MB at A3); outside the + available tiles' coverage, the area stays white and hatched; +- a **Lambert 93 grid** (100 m spacing at scales 1:1,000/1:2,000, 500 m at + 1:5,000, 1,000 m at 1:10,000) graduated in the margin, and the printed + area's **WGS84 corners** at all four angles; +- a **geographic north arrow** accounting for meridian convergence (the map + is oriented to the L93 grid north, not geographic north — the difference + is shown in degrees); +- a **graphic scale bar** (alternating bar) and the **numeric scale**; +- an **8-point orientation rose** (N/NE/E/SE/S/SW/W/NW), same CIELAB formula + as the tile sheet's rose; +- the oriented relief's legend text, reused from `VIZ_LEGENDS` (single + source shared with the interface and the TileJSON); +- a **quality panel**: a thumbnail of ground-point density (50 m cells, + color classes), key figures (average density, weakest cell, share of + interpolated area, acquisition period), areas **hatched in white** where + quality data is not available, and a "**Donnée manquante (sans relief)**" + ("Missing data (no relief)") line listing the tiles in the area that + haven't been generated yet; +- a title block: title (by default, the list of covered tiles), scale, + format, dpi, L93 center, area size, export date and IGN source mention. -Un seul export PDF à la fois : un second appel pendant qu'un export tourne -reçoit `429` (réessayer). L'export n'est **jamais délégué** à -`LIDAR_GENERATION_URL` : contrairement à la génération de tuiles, la carte -légère seule (Pi sans worker) sait exporter par elle-même, à partir des -sources déjà rapatriées sur disque. +Only one PDF export at a time: a second call while an export is running gets +`429` (retry). Export is **never delegated** to `LIDAR_GENERATION_URL`: +unlike tile generation, the lightweight map alone (a Pi with no worker) can +export by itself, from the sources already pulled to disk. -> **Licence** — LiDAR HD est diffusé sous **Licence Ouverte 2.0** : -> l'attribution IGN est obligatoire et doit rester visible chez le client. -> Avant d'utiliser ces rendus comme calque de **saisie** dans OpenStreetMap, -> vérifier la position de la communauté (OSM-FR) sur la source concernée. +> **License** — LiDAR HD is distributed under the **Licence Ouverte 2.0** +> ("Open License 2.0"): IGN attribution is mandatory and must remain visible +> to the end user. Before using these renders as an OpenStreetMap **survey** +> layer, check the position of the community (OSM-FR) on the source in +> question. -## Comment une tuile est fabriquée +## How a tile is built -1. L'emprise de la tuile est convertie en Lambert 93 (échantillonnage 5×5 du - contour : les bords ne sont pas droits en L93). -2. Les dalles de 1 km qui l'intersectent sont retrouvées par leur nom +1. The tile's footprint is converted to Lambert 93 (5×5 sampling of the + outline: edges aren't straight in L93). +2. The 1 km tiles it intersects are found by their name (`LHD_FXX_{col}_{row}` → X ∈ [col, col+1] km, Y ∈ [row−1, row] km). -3. Pour chacune, le **palier source** le plus grossier suffisant est choisi - parmi ceux que le pipeline produit déjà : vignette `index_thumbs` - (≈3,9 m/px), vignette intermédiaire `_mid` (1,56 m/px), quadrants - `index_subtiles` (2500 px, 4× moins à décoder que la dalle) puis la dalle. - Les trois dossiers sont scannés **indépendamment** : un cache partiel — une - machine légère ne rapatrie que quadrants et vignettes, jamais les dalles - entières — reste entièrement exploitable. -4. La fenêtre utile est découpée puis reprojetée par **transformation - projective** (`Image.transform(..., PERSPECTIVE)`), dalle par dalle : le - calage mesuré est inférieur au pixel. -5. Le résultat est encodé (PNG ou WebP) et écrit dans - `output/index_xyz/{couche}/{z}/{x}/{y}[@2x].{ext}`. +3. For each one, the coarsest **source tier** that is still sufficient is + chosen among those the pipeline already produces: `index_thumbs` + thumbnail (≈3.9 m/px), intermediate `_mid` thumbnail (1.56 m/px), + `index_subtiles` quadrants (2500 px, 4× less to decode than the full + tile), then the full tile. The three folders are scanned + **independently**: a partial cache — a lightweight machine that only + pulls in quadrants and thumbnails, never full tiles — remains fully + usable. +4. The useful window is cropped, then reprojected via a **perspective + transform** (`Image.transform(..., PERSPECTIVE)`), tile by tile: the + measured alignment is sub-pixel. +5. The result is encoded (PNG or WebP) and written to + `output/index_xyz/{layer}/{z}/{x}/{y}[@2x].{ext}`. -Aucune dépendance GDAL/PDAL : **Pillow + pyproj** uniquement. +No GDAL/PDAL dependency: **Pillow + pyproj** only. -### Cache et péremption +### Cache and expiry -- Une tuile en cache est resservie tant qu'**aucune dalle contributrice n'est - plus récente qu'elle** : régénérer une dalle n'invalide que ses tuiles. -- Une tuile sans donnée est mémorisée par un marqueur `.empty` — jamais - recalculée. -- Les images sources décodées sont gardées dans un petit cache LRU : le - décodage AVIF domine le coût, les tuiles voisines le réutilisent. -- Côté navigateur, l'interface ajoute `?v=` (mtime la plus récente) et - reçoit alors un cache immuable ; les clients OSM utilisent l'URL nue, servie - en revalidation courte. +- A cached tile is re-served as long as **no contributing tile is more + recent than it**: regenerating a tile only invalidates its own tiles. +- A tile with no data is memoized with an `.empty` marker — never + recomputed. +- Decoded source images are kept in a small LRU cache: AVIF decoding + dominates the cost, and neighboring tiles reuse it. +- Client-side, the interface appends `?v=` (most recent mtime) and + then gets an immutable cache; OSM clients use the bare URL, served with + short revalidation. -### Mesures (dalles réelles 0,2 m, bloc 3×3 km, conteneur sans GPU) +### Measurements (real 0.2 m tiles, 3×3 km block, GPU-less container) -| | premier rendu | depuis le cache | +| | first render | from cache | |---|---|---| | z10–z14 | 50–200 ms | 3–9 ms | -| z16–z18 (résolution native) | 46–130 ms | 3–9 ms | +| z16–z18 (native resolution) | 46–130 ms | 3–9 ms | -Poids d'une tuile de pente à z17, mesuré sur la même tuile : +Weight of a slope tile at z17, measured on the same tile: -| encodage | poids | fidélité | +| encoding | size | fidelity | |---|---|---| -| PNG RGBA (défaut) | 210 Ko | sans perte | -| PNG palettisé (`LIDAR_TILE_PNG_PALETTE=1`) | 55 Ko | écart moyen 5,8 niveaux | -| WebP q78 (`…/{y}.webp`) | 38 Ko | avec perte, visuellement propre | +| PNG RGBA (default) | 210 KB | lossless | +| Palettized PNG (`LIDAR_TILE_PNG_PALETTE=1`) | 55 KB | average deviation 5.8 levels | +| WebP q78 (`…/{y}.webp`) | 38 KB | lossy, visually clean | -Le PNG canonique reste **sans perte** par défaut : ces rendus servent à -l'interprétation, pas à l'illustration. Pour un usage en fond d'imagerie où le -débit compte, deux leviers : demander l'URL `.webp` (les clients qui la -supportent : QGIS, iD, MapLibre, uMap) ou activer `LIDAR_TILE_PNG_PALETTE=1`. +The canonical PNG stays **lossless** by default: these renders are meant for +interpretation, not illustration. For use as an imagery basemap where +bandwidth matters, two levers: request the `.webp` URL (supported by QGIS, +iD, MapLibre, uMap) or enable `LIDAR_TILE_PNG_PALETTE=1`. -Contrôles de calage effectués sur données réelles : un chemin OSM se superpose -exactement à la trace visible dans la couche de pente, et l'écart de couture -entre tuiles voisines reste **inférieur au bruit naturel du terrain** (écart -mesuré 35–53 niveaux contre une médiane de 49–53 entre deux colonnes voisines -prises au hasard dans la même tuile). +Alignment checks performed on real data: an OSM path overlays exactly onto +the trace visible in the slope layer, and the seam gap between neighboring +tiles stays **below the terrain's natural noise** (measured gap 35–53 +levels against a median of 49–53 between two random neighboring columns +within the same tile). -## Charge et petites machines +## Load and small machines -Le service est borné à chaque étage, rien n'est illimité : +The service is bounded at every stage, nothing is unlimited: -| Étage | Défaut | Réglage | +| Stage | Default | Setting | |---|---|---| -| Rendus simultanés | 2 | `LIDAR_TILE_WORKERS` | -| Téléchargements de dalles simultanés | 2 | `LIDAR_TILE_FETCH_WORKERS` | -| Même tuile demandée en parallèle | 1 rendu, les autres attendent son résultat | — | -| Mémoire des sources décodées | 192 Mo | `LIDAR_TILE_SOURCE_CACHE_MB` | +| Concurrent renders | 2 | `LIDAR_TILE_WORKERS` | +| Concurrent tile downloads | 2 | `LIDAR_TILE_FETCH_WORKERS` | +| Same tile requested in parallel | 1 render, others wait for its result | — | +| Decoded source memory | 192 MB | `LIDAR_TILE_SOURCE_CACHE_MB` | -Les requêtes en excès attendent le sémaphore **avant** tout décodage : elles ne -consomment ni CPU ni mémoire. Un navigateur en HTTP/1.1 n'ouvre de toute façon -que 6 connexions par origine, toutes couches confondues ; derrière un proxy HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge. +Excess requests wait on the semaphore **before** any decoding: they consume +neither CPU nor memory. A browser over HTTP/1.1 only opens 6 connections per +origin anyway, across all layers combined; behind an HTTP/2 proxy this cap +disappears and only these settings hold the load. -Le pré-chauffage (`/api/map/warm`) est séquentiel : il ne peut pas saturer la -machine, seulement prendre du temps. +Pre-warming (`/api/map/warm`) is sequential: it cannot saturate the machine, +only take time. -Sur un Raspberry Pi 2 Go, `LIDAR_TILE_WORKERS=1` et -`LIDAR_TILE_SOURCE_CACHE_MB=64` restent confortables. +On a 2 GB Raspberry Pi, `LIDAR_TILE_WORKERS=1` and +`LIDAR_TILE_SOURCE_CACHE_MB=64` remain comfortable. -## Cache seule + maintenance de fond (petit Raspberry Pi) +## Cache-only + background maintenance (small Raspberry Pi) -Sur une machine qui ne doit jamais calculer au fil de la navigation (un Pi qui -tient aussi d'autres services), deux variables inversent la charge : +On a machine that must never compute while someone is browsing (a Pi that +also hosts other services), two variables invert the load: ```yaml environment: - - LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend plus rien - - LIDAR_TILE_BACKGROUND=1 # une tâche de fond entretient la pyramide + - LIDAR_TILE_CACHE_ONLY=1 # browsing no longer renders anything + - LIDAR_TILE_BACKGROUND=1 # a background task maintains the pyramid ``` -- **`LIDAR_TILE_CACHE_ONLY=1`** — une tuile absente ou périmée est servie - transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le - navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît). - Plus aucun rendu local sur le chemin des requêtes ; avec `LIDAR_MAPS_URL`, - une tuile manquante (pas encore passée par la maintenance) y est rapatriée — un téléchargement de quelques dizaines de Ko, mis en - cache — puis servie : la navigation couvre tous les zooms sans jamais - calculer sur la petite machine. -- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle - régulier (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s) ; chaque dalle nouvelle ou - régénérée (le worker vient de produire, le cache à la demande vient de - rapatrier) met sa pyramide en file d'attente. Des rendeurs à basse priorité - (`os.nice`) la vident au rythme d'une tuile par - `LIDAR_TILE_BACKGROUND_PAUSE` seconde (1 s). Premier démarrage : TOUTES les - dalles sont inconnues, la pyramide se reconstruit entière — les tuiles déjà - fraîches ne coûtent qu'un `stat`. Les niveaux partent en file du plus petit - zoom au plus grand : la carte se remplit d'abord grossièrement. +- **`LIDAR_TILE_CACHE_ONLY=1`** — a missing or stale tile is served + transparent with `X-Tile-Pending: 1` and `Cache-Control: no-store` (the + browser re-requests it: as soon as maintenance has rendered it, it + appears). No more local rendering on the request path; with + `LIDAR_MAPS_URL`, a missing tile (not yet handled by maintenance) is + pulled in from there — a download of a few dozen KB, cached — then + served: browsing covers all zoom levels without ever computing on the + small machine. +- **`LIDAR_TILE_BACKGROUND=1`** — a poller rescans tiles at a regular + interval (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s); each new or + regenerated tile (the worker just produced it, or the on-demand cache just + pulled it in) queues its pyramid. Low-priority renderers (`os.nice`) drain + it at a rate of one tile per `LIDAR_TILE_BACKGROUND_PAUSE` second (1 s). + First start-up: ALL tiles are unknown, the whole pyramid is rebuilt — + already-fresh tiles only cost a `stat`. Levels are queued from the + smallest zoom to the largest: the map fills in coarsely first. -| Variable | Défaut | Rôle | +| Variable | Default | Role | |---|---|---| -| `LIDAR_TILE_BACKGROUND_MAX_Z` | natif (`18` en @2x, `19` en 256 px) | niveau maximal entretenu en fond, numérotation des URL | -| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) | -| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) | -| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion | -| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles | -| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | file d'attente bornée — chaque dalle mémorise la première tuile refusée faute de place et reprend de là aux scans suivants, jusqu'à ce que toute sa pyramide soit passée | +| `LIDAR_TILE_BACKGROUND_MAX_Z` | native (`18` at @2x, `19` at 256 px) | maximum level maintained in the background, URL numbering | +| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tiles maintained: 2 = 512 px (the interface's) | +| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format of maintained tiles (the interface's) | +| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) between two renders — the discretion lever | +| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | seconds between two tile scans | +| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `65536` | bounded queue — each tile remembers the first pyramid tile it was refused for lack of room and resumes from there on the next scans, until its whole pyramid has gone through | -Pilotage : `GET /api/map/background` (état, compteurs, file), `POST -/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel -exhaustif, tous zooms/formats, cf. ci-dessous). Enfin, plafonner le conteneur -lui-même (`mem_limit` + `memswap_limit` dans un override compose) garantit -qu'un rendu déréglé ne peut plus emporter la machine : le tueur OOM ne -toucherait que `lidar-maps`. +Control: `GET /api/map/background` (state, counters, queue), `POST +/api/map/background` (immediate scan), `POST /api/map/warm` (manual +exhaustive pre-computation, all zooms/formats, see below). Finally, capping +the container itself (`mem_limit` + `memswap_limit` in a compose override) +guarantees a misbehaving render can no longer bring down the machine: the +OOM killer would only ever hit `lidar-maps`. -## Pré-chauffage +## Pre-warming ```bash curl -X POST http://localhost:8975/api/map/warm \ -H 'Content-Type: application/json' \ -d '{"layers": ["slope"], "z_min": 10, "z_max": 16}' -curl http://localhost:8975/api/map/warm # avancement / compte rendu +curl http://localhost:8975/api/map/warm # progress / report ``` -Sans `bounds`, l'emprise des dalles disponibles est utilisée. Utile après un -gros run pour que la première consultation soit instantanée. +Without `bounds`, the coverage of available tiles is used. Useful after a +large run so the first visit is instant. -## Deux machines +## Two machines -Deux amonts complémentaires, selon ce que la machine locale possède. +Two complementary upstreams, depending on what the local machine has. -`LIDAR_SOURCE_URL` désigne la **webapp du pipeline** (port 8973) : l'inventaire -des dalles vient de son `/api/tiles` et chaque image source est rapatriée au -premier rendu qui en a besoin. Le conteneur carte démarre alors sans aucune -donnée locale et sert tout le catalogue distant : +`LIDAR_SOURCE_URL` designates **the full pipeline server** (port 8973): the +tile inventory comes from its `/api/tiles`, and each source image is pulled +in on the first render that needs it. The map container then starts with no +local data at all and serves the entire remote catalog: ```bash docker run -d --name lidar-maps --network host \ @@ -417,48 +416,48 @@ docker run -d --name lidar-maps --network host \ -v "$PWD/output-maps:/data/output" --user "$(id -u):$(id -g)" lidar-maps ``` -Mesuré sur 849 dalles réelles servies par un tunnel SSH : première tuile d'une -zone 0,9–3,2 s (téléchargement d'un quadrant de 1,8 Mo compris), puis **~1 ms** -depuis le cache local, qui ne garde que ce qui a été consulté. +Measured on 849 real tiles served over an SSH tunnel: first tile in an area +0.9–3.2 s (including downloading a 1.8 MB quadrant), then **~1 ms** from the +local cache, which only keeps what has been consulted. -`LIDAR_MAPS_URL` désigne un serveur de tuiles amont (machine de traitement) : -une tuile absente localement y est rapatriée (quelques dizaines de Ko) puis -mise en cache — au lieu de rapatrier les dalles entières. Un disjoncteur -suspend les tentatives 2 minutes après 3 échecs consécutifs : carte -consultable même worker éteint. +`LIDAR_MAPS_URL` designates an upstream tile server (the processing +machine): a tile missing locally is pulled in from there (a few dozen KB) +then cached — instead of pulling in whole tiles. A circuit breaker suspends +attempts for 2 minutes after 3 consecutive failures: the map stays usable +even with the worker off. -## Variables d'environnement +## Environment variables -| Variable | Défaut | Rôle | +| Variable | Default | Role | |---|---|---| -| `LIDAR_OUTPUT_DIR` | `/data/output` | dalles lues, cache `index_xyz/` écrit | -| `LIDAR_PORT` | `8975` | port d'écoute | -| `LIDAR_SOURCE_URL` | — | webapp du pipeline : inventaire + dalles rapatriées à la demande | -| `LIDAR_SOURCE_TOKEN` | — | jeton si la webapp amont exige `LIDAR_API_TOKEN` | -| `LIDAR_MAPS_URL` | — | serveur de tuiles amont (carte déportée) | -| `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | mention servie (TileJSON, WMTS, JOSM, carte) | -| `LIDAR_TILE_PNG_PALETTE` | — | `1` : PNG palettisé (~4× plus léger, écart ~6 niveaux) | -| `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | budget mémoire du cache d'images sources décodées | -| `LIDAR_TILE_WORKERS` | `2` | rendus de tuiles simultanés | -| `LIDAR_TILE_FETCH_WORKERS` | `2` | téléchargements de dalles simultanés (mode `LIDAR_SOURCE_URL`) | -| `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | HTTPS direct (GPS sur téléphone) | +| `LIDAR_OUTPUT_DIR` | `/data/output` | tiles read from, `index_xyz/` cache written to | +| `LIDAR_PORT` | `8975` | listening port | +| `LIDAR_SOURCE_URL` | — | full pipeline server: inventory + tiles pulled in on demand | +| `LIDAR_SOURCE_TOKEN` | — | token if the upstream server requires `LIDAR_API_TOKEN` | +| `LIDAR_MAPS_URL` | — | upstream tile server (remote map) | +| `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | attribution served (TileJSON, WMTS, JOSM, map) | +| `LIDAR_TILE_PNG_PALETTE` | — | `1`: palettized PNG (~4× lighter, ~6-level deviation) | +| `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | memory budget for the decoded source-image cache | +| `LIDAR_TILE_WORKERS` | `2` | concurrent tile renders | +| `LIDAR_TILE_FETCH_WORKERS` | `2` | concurrent tile downloads (`LIDAR_SOURCE_URL` mode) | +| `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | direct HTTPS (GPS on a phone) | -## Dépannage +## Troubleshooting -- **Carte vide, `layers: 0`** (`curl /healthz`) : aucun rendu dans - `output/visualisations/`, ou dossier monté au mauvais endroit. -- **Tuiles transparentes partout** : zoom hors plage (5–19) ou zone sans - dalle ; l'en-tête `X-Tile-Empty` le confirme. -- **Première consultation lente** : normal, chaque tuile est rendue une fois — - pré-chauffer (ci-dessus). -- **JOSM refuse l'URL** : utiliser le gabarit `{zoom}/{x}/{y}` (JOSM) et non +- **Empty map, `layers: 0`** (`curl /healthz`): no render in + `output/visualisations/`, or the folder is mounted at the wrong path. +- **Tiles transparent everywhere**: zoom out of range (5–19) or an area with + no tiles; the `X-Tile-Empty` header confirms this. +- **First visit is slow**: normal, each tile is rendered once — pre-warm + (see above). +- **JOSM rejects the URL**: use the `{zoom}/{x}/{y}` template (JOSM), not `{z}/{x}/{y}` (Leaflet/QGIS). -- **Couche absente du menu** : elle n'existe pas sur disque pour ces dalles — - `/api/map/meta` liste ce qui est réellement disponible. +- **Layer missing from the menu**: it doesn't exist on disk for these tiles — + `/api/map/meta` lists what is actually available. -## Références +## References -- `lidar_pipeline/tiles.py` — grille, reprojection, cache, pré-chauffage. -- `lidar_pipeline/mapserve.py` — routes tuiles/TileJSON/WMTS/JOSM et API carte. -- `lidar_pipeline/web/map.{html,css,js}` — interface (panneau à onglets, Leaflet `L.tileLayer`, LOD natif), relue par `lidar_pipeline/mapui.py`. +- `lidar_pipeline/tiles.py` — grid, reprojection, cache, pre-warming. +- `lidar_pipeline/mapserve.py` — tile/TileJSON/WMTS/JOSM routes and the map API. +- `lidar_pipeline/web/map.{html,css,js}` — interface (tabbed panel, Leaflet `L.tileLayer`, native LOD), re-read by `lidar_pipeline/mapui.py`. - `Dockerfile.maps`, `docker-compose.maps.yml`, `./run.sh --serve-maps`. diff --git a/process_lidar.py b/process_lidar.py deleted file mode 100755 index 5946283..0000000 --- a/process_lidar.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python3 -"""Backward-compatible entry point for the LiDAR archaeological pipeline. - -This file exists for compatibility with existing Docker configurations -and scripts that reference `process_lidar.py` directly. - -Prefer using: python -m lidar_pipeline -""" - -from lidar_pipeline.cli import main - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/transcode_q60.py b/scripts/transcode_q60.py deleted file mode 100644 index 23b42a4..0000000 --- a/scripts/transcode_q60.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Transcode les AVIF q98 des visualisations en AVIF q60 (one-shot). - -Usage (dans le conteneur, volume output monté) : - python3 /tmp/transcode_q60.py [--quality 60] [--workers N] - -Ne remplace un fichier que si la version q60 est plus légère (garde-fou). -""" -import sys -import os -from pathlib import Path -from concurrent.futures import ProcessPoolExecutor - -from PIL import Image - -QUALITY = int(sys.argv[sys.argv.index('--quality') + 1]) if '--quality' in sys.argv else 60 -WORKERS = int(sys.argv[sys.argv.index('--workers') + 1]) if '--workers' in sys.argv else (os.cpu_count() or 4) -ROOT = Path('/data/output/visualisations') - - -def transcode(path): - try: - before = path.stat().st_size - img = Image.open(path) - img.load() - tmp = path.with_name(path.name + '.q60.tmp.avif') - img.save(tmp, format='AVIF', quality=QUALITY) - after = tmp.stat().st_size - if after < before: - os.replace(tmp, path) - return (path.name, before, after, 'remplacé') - tmp.unlink() - return (path.name, before, after, 'conservé (q60 plus lourd)') - except Exception as exc: # noqa: BLE001 - return (path.name, 0, 0, f'ERREUR {exc}') - - -if __name__ == '__main__': - files = sorted(ROOT.rglob('*.avif')) - files = [f for f in files if '.q60.tmp' not in f.name] - print(f'{len(files)} AVIF à transcoder en q{QUALITY}, {WORKERS} workers', flush=True) - total_before = total_after = n_ok = 0 - with ProcessPoolExecutor(max_workers=WORKERS) as pool: - for i, (name, before, after, status) in enumerate(pool.map(transcode, files), 1): - total_before += before - total_after += min(before, after) - if status.startswith('remplacé'): - n_ok += 1 - if i % 50 == 0 or i == len(files): - print(f' {i}/{len(files)} — {n_ok} remplacés — ' - f'{total_before / 2**30:.1f} → {total_after / 2**30:.1f} Gio', flush=True) - elif status.startswith('ERREUR'): - print(f' {name}: {status}', flush=True) - print(f'FIN: {total_before / 2**30:.1f} → {total_after / 2**30:.1f} Gio ' - f'({n_ok} fichiers réencodés)', flush=True) diff --git a/setup.py b/setup.py index f162c09..282c157 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,8 @@ from setuptools import setup, find_packages setup( name='lidar_pipeline', version='2.0.0', - description='Pipeline LiDAR pour détection archéologique', + description='Archaeology-grade relief maps from IGN LiDAR HD point clouds', + license='MIT', packages=find_packages(), package_data={'lidar_pipeline': [ 'web/*',