Rewrite README and docs in English for GitHub, add MIT license, remove internal files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 22:31:54 +02:00
parent cef070ad77
commit d3704d6714
11 changed files with 810 additions and 923 deletions

4
.gitignore vendored
View File

@ -61,3 +61,7 @@ output-test/
# Maquettes et suivi des sessions de brainstorming / SDD # Maquettes et suivi des sessions de brainstorming / SDD
.superpowers/ .superpowers/
# Plans et specs de sessions de travail (internes)
docs/superpowers/
.playwright-mcp/

View File

@ -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. - **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) - 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` - 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 <hôte> "cd <checkout> && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"` (procédure dans `docs/DEPLOY_WEBAPP.md`).
- rattrapage des sidecars qualité (dalles traitées avant l'ajout du sidecar) : la commande fixe de `process` (compose) est remplacée en entier dès qu'un argument suit le nom du service, donc `--quality-backfill` seul ne fonctionne pas — utiliser `docker compose run --rm --build process python3 -m lidar_pipeline /data/input -o /data/output --quality-backfill`. - 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 ## Conventions

View File

@ -48,9 +48,6 @@ RUN pip3 install --no-cache-dir .
# paquet installé (dist-packages) qu'utilise le conteneur au runtime. # 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)" 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 # Create user with uid/gid 1000:1000 and run as that user
RUN groupadd -g 1000 lidar && \ RUN groupadd -g 1000 lidar && \

21
LICENSE Normal file
View File

@ -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.

512
README.md
View File

@ -1,318 +1,256 @@
# Pipeline LiDAR Archéologique <div align="center">
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) [![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f.svg)](LICENSE)
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. ![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 ...`). <img src="docs/img/neuf-brisach.png" alt="Neuf-Brisach fortress in oriented relief" width="900">
### Visualisations principales <sub>Neuf-Brisach (Haut-Rhin), Vauban's star fortress (UNESCO World Heritage), rendered from one 1 km LiDAR HD tile.
| # | Visualisation | Utilité archéologique | Every bastion, moat and ravelin reads at a glance; buildings are black (no ground points).</sub>
|---|--------------|----------------------|
| 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) |
### Visualisations avancées </div>
| # | 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 `lidar_rendu` is an end-to-end pipeline that solves exactly that:
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
### 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 | ## Gallery
|---------|------|-------|---------|
| **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 |
L'auto-détection analyse le ratio de retours uniques du nuage de points : ratio > 0.6 = milieu urbain → PMF, sinon → SMRF. <table>
<tr>
<td width="50%"><img src="docs/img/hartmannswillerkopf.png" alt="Hartmannswillerkopf battlefield"></td>
<td width="50%"><img src="docs/img/neuf-brisach-a4.png" alt="A4 PDF field sheet of Neuf-Brisach"></td>
</tr>
<tr>
<td><b>Hartmannswillerkopf</b> (tile 1011_6760) — the WWI battlefield of 1915 under dense
forest: roads, trench lines and shell craters appear on the slope.</td>
<td><b>PDF field sheet</b> exported from the map (A4 landscape, 1:5,000): Lambert 93 grid,
true-north arrow, scale bar, legend and data-quality inset.</td>
</tr>
</table>
### 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/<you>/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<br/>COPC .laz tile] --> B[Ground points<br/>IGN classes, laspy]
N[8 neighbour tiles<br/>100 m buffer] --> B
B --> C[Strip & scan-line<br/>calibration]
C --> D[DTM 0.2 m<br/>+ bounded gap fill]
D --> E[Oriented relief<br/>+ precision layer]
E --> F[AVIF tiles<br/>cropped to 1 km]
F --> G[Inventory +<br/>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://<host>:8973/tiles/relief_oriente/{z}/{x}/{y}.png` |
| JOSM (TMS) | `http://<host>:8973/tiles/relief_oriente/{zoom}/{x}/{y}.png` |
| JOSM (all layers at once) | `http://<host>:8973/tiles/josm.imagery.xml` |
| TileJSON 3.0 | `http://<host>:8973/tiles/relief_oriente.json` |
| WMTS 1.0 | `http://<host>: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/ lidar_pipeline/
├── __init__.py # Exports publics ├── cli.py command line
├── __main__.py # Point d'entrée: python -m lidar_pipeline ├── pipeline.py orchestration, worker pool, VRAM-aware GPU slots
├── cli.py # argparse + logging + main() ├── fetch_ign.py IGN catalogue and downloads
├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*) ├── dtm.py ground extraction, strip calibration, DTM, gap filling
├── dtm.py # Classification PDAL (SMRF/PMF/CSF + auto) + génération DTM ├── visualizations.py generate_* functions (relief, openness, SVF, …)
├── visualizations.py # Fonctions generate_* (19 visualisations) ├── rendering.py colormaps, GeoTIFF → AVIF, 1 km crop
├── ign.py # Téléchargement tuiles IGN + overlay ├── index.py catalogue, thumbnails, sub-tiles, inventory, layer registry
├── rendering.py # Colormaps, tif_to_png, rapport PDF ├── quality.py per-tile quality sidecar (density, acquisition dates)
├── pipeline.py # LidarArchaeoPipeline (orchestration + registry) ├── tiles.py XYZ pyramid: reprojection, cache, background maintenance
└── tests/ # Tests unitaires (pytest) ├── 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`. ## Development
## 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
```bash ```bash
git clone <ce dépôt> && cd lidar_rendu ./run.sh --test # rebuild the image and run the full test suite
./start.sh # construit l'image, démarre la carte sur http://localhost:8973/ docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.test_tiles
./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
``` ```
`start.sh` crée `input/` et `output/` à votre nom et détecte le GPU : sans GPU Adding a visualisation takes four edits: a `generate_X()` in `visualizations.py`,
NVIDIA utilisable par Docker, il ajoute `docker-compose.cpu.yml` et le an entry in `VIZ_STEPS` (`pipeline.py`), a colormap in `rendering.py`, and a legend
traitement tourne sur le CPU (plus lent). Docker Compose ≥ 2.24 requis. 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 ## Documentation
cd /votre/dossier/lidar
mkdir -p input
# Copiez vos fichiers .laz dans input/ - [docs/MAPS.md](docs/MAPS.md) — map UI, tile contract, cache, PDF export, environment variables
cp /chemin/vos/fichiers/*.laz input/ - [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 ## Data and licences
docker build -t lidar-lidar .
```
## 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é) Built on [PDAL](https://pdal.io), [laspy](https://laspy.readthedocs.io),
```bash [rasterio](https://rasterio.readthedocs.io), [CuPy](https://cupy.dev),
./run.sh -g [numba](https://numba.pydata.org), [FastAPI](https://fastapi.tiangolo.com),
``` [Leaflet](https://leafletjs.com) and [reportlab](https://www.reportlab.com).
### 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.

View File

@ -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, Two-machine architecture: the **map** (interface, XYZ tile pyramid, API) runs
API) tourne sur un Raspberry Pi ; la **génération de tuiles** (téléchargement on a Raspberry Pi; **tile generation** (IGN downloads, PDAL, GPU) runs on a
IGN, PDAL, GPU) tourne sur une machine puissante. Les deux exécutent le même powerful machine. Both run the same server (`lidar_pipeline.mapserve`, image
serveur (`lidar_pipeline.mapserve`, image `lidar-maps`), la machine puissante `lidar-maps`), the powerful machine in its full version (pipeline included).
en version complète (pipeline inclus).
``` ```
Navigateur ──HTTP──▶ Raspberry Pi (image légère lidar-maps, port 8975) Browser ──HTTP──▶ Raspberry Pi (lightweight lidar-maps image, port 8975)
│ sert la carte + la pyramide (cache seule + maintenance │ serves the map + the pyramid (cache-only + background
│ de fond, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND) │ maintenance, LIDAR_TILE_CACHE_ONLY / LIDAR_TILE_BACKGROUND)
│ /api/generate, /api/preview, /api/status │ /api/generate, /api/preview, /api/status
│ └─ transmis à ──▶ machine de traitement │ └─ forwarded to ──▶ processing worker
│ dalles rapatriées du worker (LIDAR_SOURCE_URL : │ tiles fetched back from the worker (LIDAR_SOURCE_URL:
│ inventaire /api/tiles + statiques versionnées) │ /api/tiles inventory + versioned static assets)
▼ ▼
Machine de traitement (image complète, Processing worker (full image,
docker-compose.worker.yml service `worker`) : docker-compose.worker.yml service `worker`):
téléchargement IGN + pipeline GPU (générateur de tuiles) IGN download + GPU pipeline (tile generator)
+ pyramide de tuiles + inventaire des dalles + 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 ## Processing worker (tile generator)
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 The `worker` service acts as the generator: it accepts generation requests
(`/api/tiles` + `visualisations/`, `index_thumbs/`, `index_subtiles/` en sent by remote maps (IGN download + PDAL/GPU processing), serves its own tile
statique) que les machines légères rapatrient à la demande. pyramid and tile inventory (`/api/tiles` + static `visualisations/`,
`index_thumbs/`, `index_subtiles/`) that lightweight machines fetch on demand.
```bash ```bash
docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973 docker compose -f docker-compose.worker.yml up -d --build # API at http://<worker-ip>:8973
docker compose -f docker-compose.worker.yml logs -f worker 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 ```bash
docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.2 | --force] 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, On a machine without a GPU: remove the `gpus: all` lines (CPU processing,
plus lent). Optionnel mais recommandé si le réseau n'est pas de confiance : slower). Optional but recommended if the network isn't trusted: protect the
protéger l'API avec un jeton partagé — décommenter dans API with a shared token — uncomment in `docker-compose.worker.yml`:
`docker-compose.worker.yml` :
```yaml ```yaml
environment: 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 - **Architecture**: the image is built natively on the machine (ARM64 or
x86_64, vérifier avec `uname -m`). Le build se fait sur le Pi lui-même x86_64, check with `uname -m`). The build happens on the Pi itself
(`Dockerfile.maps` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL). (`Dockerfile.maps`: `python:3.12-slim` base, ~200 MB, no PDAL/GDAL).
- **Docker + plugin compose** : installation officielle - **Docker + compose plugin**: official install
[docs.docker.com/engine/install](https://docs.docker.com/engine/install/) [docs.docker.com/engine/install](https://docs.docker.com/engine/install/)
(tester avec `docker compose version`). (verify with `docker compose version`).
- **Espace disque** : prévoir la taille du cache (dalles rapatriées + tuiles - **Disk space**: plan for the cache size (fetched tiles + rendered tiles;
rendues ; compter la taille de `output/` sur la machine de traitement). 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 ```bash
git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar git clone https://github.com/<you>/lidar_rendu.git lidar
cd lidar cd lidar
``` ```
`output/` et `input/` sont ignorés par git : le dépôt ne contient que le `output/` and `input/` are git-ignored: the repository only holds the code,
code, le cache se remplit à la demande depuis la machine de traitement. 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 `docker-compose.maps.yml` (versioned) serves as the base; the Pi's local
du Pi vit dans un **override** `docker-compose.maps.override.yml` (non configuration lives in an **override** file `docker-compose.maps.override.yml`
versionné) : (not versioned):
```yaml ```yaml
name: lidar-maps name: lidar-maps
services: services:
maps: maps:
volumes: !override # Compose >= 2.24 (remplace ./output) volumes: !override # Compose >= 2.24 (replaces ./output)
- /srv/lidar/output:/data/output - /srv/lidar/output:/data/output
mem_limit: 1g mem_limit: 1g
memswap_limit: 1g memswap_limit: 1g
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_SOURCE_URL=http://192.168.1.50:8973 # worker (dalles + inventaire) - LIDAR_SOURCE_URL=http://192.168.1.50:8973 # worker (tiles + inventory)
- LIDAR_GENERATION_URL=http://192.168.1.50:8973 # worker (génération déléguée) - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # worker (delegated generation)
# - LIDAR_REMOTE_TOKEN=un-secret-à-partager # si LIDAR_API_TOKEN côté worker # - LIDAR_REMOTE_TOKEN=a-secret-to-share # if LIDAR_API_TOKEN is set on the worker
- LIDAR_TILE_WORKERS=1 - LIDAR_TILE_WORKERS=1
- LIDAR_TILE_SOURCE_CACHE_MB=64 - LIDAR_TILE_SOURCE_CACHE_MB=64
- LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend RIEN - LIDAR_TILE_CACHE_ONLY=1 # browsing renders NOTHING
- LIDAR_TILE_BACKGROUND=1 # la pyramide est entretenue en tâche de fond - LIDAR_TILE_BACKGROUND=1 # the pyramid is maintained as a background task
- LIDAR_TILE_BACKGROUND_PAUSE=1.0 - LIDAR_TILE_BACKGROUND_PAUSE=1.0
networks: [webapp, proxy] networks: [webapp, proxy]
labels: # routage Traefik éventuel labels: # optional Traefik routing
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.lidar-maps.rule=Host(`lidar.example.fr`)" - "traefik.http.routers.lidar-maps.rule=Host(`lidar.example.fr`)"
- "traefik.http.routers.lidar-maps.entrypoints=websecure" - "traefik.http.routers.lidar-maps.entrypoints=websecure"
@ -115,76 +114,60 @@ networks:
``` ```
```bash ```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 docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build
``` ```
La carte est sur `http://<ip-pi>:8975/` (et via Traefik si configuré). The map is available at `http://<pi-host>:8975/` (and via Traefik if
configured).
`LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` inversent la charge sur `LIDAR_TILE_CACHE_ONLY=1` + `LIDAR_TILE_BACKGROUND=1` flip the load pattern on
un petit Pi : la navigation ne rend rien (tuile absente = transparente, a small Pi: browsing renders nothing (a missing tile is served transparent,
`X-Tile-Pending`), une tâche de fond surveille les dalles nouvelles ou with `X-Tile-Pending`), and a background task watches for new or regenerated
régénérées et entretient la pyramide à basse priorité — cf. `docs/MAPS.md` tiles and maintains the pyramid at low priority — see `docs/MAPS.md`, section
§ « Cache seule + maintenance de fond ». "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 The **+ Zone** ("Add area": draw a rectangle → IGN download + run on the
le worker), **⤒ Compléter** (dalles déjà téléchargées incomplètes) et worker), **⤒ Compléter** ("Complete": already-downloaded but incomplete
**↻ Générer cette dalle** (fiche d'infos au clic) sont masqués si : 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 - the browser's IP is outside `LIDAR_REGEN_CIDR` (default: loopback + private
locale + plages privées RFC1918 ; liste de CIDR séparés par virgules, RFC1918 ranges; a comma-separated list of CIDRs, or an empty string to lift
chaîne vide pour lever la restriction). L'IP est celle de la connexion the restriction). The IP is the one seen on the connection (preserved by
(conservée par le DNAT Docker pour les clients du LAN) ; derrière un Docker's DNAT for LAN clients); behind a local reverse proxy (itself inside
reverse proxy local (dans le réseau autorisé), `X-Forwarded-For` désigne the allowed network), `X-Forwarded-For` designates the real client;
le client réel ; - the worker is unreachable AND no local pipeline exists.
- le worker est injoignable ET aucun pipeline local n'existe.
Une demande lancée pendant un run part en **file d'attente** côté worker A request submitted while a run is already in progress is placed in a
(jamais de coupure du travail en place) ; la progression s'affiche dalle par **queue** on the worker side (an in-progress job is never interrupted);
dalle (cadres orange/bleu/rouge sur la carte) et le bouton **Arrêter** envoie progress is displayed tile by tile (orange/blue/red frames on the map) and the
un SIGTERM au pipeline. À la fin du run, la carte se rafraîchit et la **Arrêter** ("Stop") button sends a SIGTERM to the pipeline. At the end of the
maintenance de pyramide repart automatiquement. 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é** Browsers only expose the Geolocation API in a **secure context** (HTTPS):
(HTTPS) : servir la carte derrière Traefik (TLS) comme ci-dessus, ou en serve the map behind Traefik (TLS) as above, or, as a last resort, directly
dernier recours en HTTPS direct (l'entrée autonome `python -m over HTTPS (the standalone entry point `python -m lidar_pipeline.mapserve`
lidar_pipeline.mapserve` honore `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE`). honors `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE`).
## Mise à jour ## Updating
### Machine de traitement ### Processing machine
```bash ```bash
cd <checkout> && git pull && docker compose -f docker-compose.worker.yml up -d --build cd <checkout> && git pull && docker compose -f docker-compose.worker.yml up -d --build
``` ```
### Pi (depuis le poste de pilotage) ### Pi
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 :
```bash ```bash
#!/bin/bash ssh <pi-host> "cd <checkout> && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"
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" "$@"
``` ```
Mise à jour complète du Pi (checkout git en `/srv/lidar_rendu`, The interface code is baked into the image (`Dockerfile.maps`): **ANY**
avec l'override Traefik) : interface change requires a rebuild (`--build`) — without it, the old code
keeps running.
```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.

View File

@ -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. > **Status (up to date)**: the pipeline default is `--ground-classification ign`
Contexte : tiles LiDAR HD IGN (Lambert 93), zones de relief fort / rochers / > with `--ign-classes sol` (IGN vendor ground class 2), extracted directly with
forêt dense où le sol est sous-classifié et le MNT présente de grands trous. > 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` Synthesis note for choosing/improving the ground-detection algorithm.
- 65 047 919 points, ~1 km², résolutions 0.5 m et 0.2 m. Context: IGN LiDAR HD tiles (Lambert 93), areas of strong relief / rock outcrops /
- Répartition classes (pré-classification fournisseur) : dense forest where the ground is under-classified and the DTM shows large holes.
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).
## 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 % | | **IGN** (vendor pre-classification) | **9.4 s** | 25.8% | 84.6% | 15.4% |
| **SMRF** | 326.3 s | 36.1 % | 90.4 % | 9.6 % | | **SMRF** | 326.3 s | 36.1% | 90.4% | 9.6% |
| **CSF** | 355.2 s | 17.6 % | 46.9 % | 53.1 % | | **CSF** | 355.2 s | 17.6% | 46.9% | 53.1% |
\* « Surface sol » calculée sur l'emprise des points (bounding box du nuage), \* "Ground surface" computed over the point extent (point cloud bounding box),
à 0.5 m. Les % de trous du MNT final (bornes du header, plus grandes) sont at 0.5 m. Final DTM hole percentages (header bounds, larger) are higher: see
supérieurs : voir le tile de référence ci-dessus. 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). - **IGN** (vendor pre-classification, class 2): the fastest (~9 s). Reliable
Fiable là où le fournisseur a confiance ; trous sous forêt dense / relief. where the vendor has confidence; holes under dense forest / steep relief.
Aucun paramètre à régler. No parameter to tune.
- **SMRF** — Pingel, Clarke & McBride 2013, *ISPRS J. Photogramm. Remote - **SMRF** — Pingel, Clarke & McBride 2013, *ISPRS J. Photogramm. Remote
Sens.* 77:21-30. Filtre **raster** (opère sur un DSM, pas sur les points), Sens.* 77:21-30. **Raster-based** filter (operates on a DSM, not on points),
donc plus rapide que les filtres point-based ; **minimise les erreurs de hence faster than point-based filters; **minimizes type I errors**
type I** (omission de sol) → bien adapté quand le sol est rare (forêt). (ground omission) → well suited when ground is scarce (forest). Best
Meilleure couverture des trois ici (90.4 %) mais ~5.4 min/tile. coverage of the three here (90.4%) but ~5.4 min/tile.
- **CSF** — Zhang et al. 2016, *Remote Sensing* 8(6):501. Toile inversée - **CSF** — Zhang et al. 2016, *Remote Sensing* 8(6):501. Inverted cloth
drapée sur le nuage ; simple, précis, mais **la toile ne touche plus le sol draped over the point cloud; simple, accurate, but **the cloth no longer
en terrain raide/vallonné** → mauvaise classification. Le plus lent ici et touches the ground on steep/hilly terrain** → poor classification. Slowest
le pire sur ce tile. À réserver aux zones urbaines. here and worst on this tile. Best reserved for urban areas.
- **PTD/PTIN** (Progressive TIN Densification) — Axelsson 2000, ISPRS - **PTD/PTIN** (Progressive TIN Densification) — Axelsson 2000, ISPRS
Congress. **Gagnant de la littérature** : le plus robuste sur terrain Congress. **Literature's winner**: most robust on complex terrain +
complexe + forêt (Moudrý et al. 2020, *Measurement* 150:107047 ; Cai et al. forest (Moudrý et al. 2020, *Measurement* 150:107047; Cai et al. 2019,
2019, *Remote Sensing* 11(9):1037) et le plus rapide (benchmark lidR : *Remote Sensing* 11(9):1037) and the fastest (lidR benchmark: PTD ~20 s
PTD ~20 s vs CSF ~156 s vs PMF ~1800 s). **NON disponible dans la version vs CSF ~156 s vs PMF ~1800 s). **NOT available in the PDAL version
PDAL de cette image** (`filters.ground` / TIN absents) — à ajouter pour bundled in this image** (`filters.ground` / TIN missing) — would need to
l'utiliser (ou via lidR / une implémentation maison). 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* **PTD / Wack & Wimmer** principle (Wack & Wimmer 2002, *ISPRS Archives*
XXXIV/3A:293-296 : MNT par retour le plus bas, en excluant le 1 % le plus bas XXXIV/3A:293-296: DTM from lowest return, excluding the lowest 1% per cell
par cellule pour écarter les outliers) : to discard outliers):
1. **Base = pré-classification IGN** (classe 2, ~9 s, fiable et officielle). 1. **Base = IGN pre-classification** (class 2, ~9 s, reliable and official).
2. **Comblement mesuré des trous** : pour chaque cellule sans point sol, 2. **Measured gap filling**: for each cell with no ground point, take the
prendre le **retour le plus bas robuste** (min du 99 % des points de la **robust lowest return** (min of the 99% of points in the cell) → adds
cellule) → ajoute du sol *mesuré* là où le fournisseur a échoué *measured* ground where the vendor failed (rock outcrops, clearings,
(rochers, clairières, sol forestier). forest floor).
3. **Inpainting topographique** des derniers vides (interpolation 3. **Topographic inpainting** of the remaining gaps (terrain-aware
terrain-aware déjà implémentée dans `dtm.py:_interpolate_holes`). interpolation already implemented in `dtm.py:_interpolate_holes`).
Attendu : MNT **continu** (0 % de trous), robuste en forêt/relief, Expected: **continuous** DTM (0% holes), robust in forest/relief,
**~10-15 s/tile** au lieu de 326-355 s. Aucune dépendance GPU, aucun **~10-15 s/tile** instead of 326-355 s. No GPU dependency, no training.
entraînement.
## 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.* Caveat (Qin et al. 2023, *ISPRS J. Photogramm. Remote Sens.*
202:246-261) : **tout est supervisé** ; le principal risque est la 202:246-261): **everything is supervised**; the main risk is
**généralisation** — un modèle entraîné sur une région dégrade ailleurs. **generalization** — a model trained on one region degrades elsewhere.
Aucun filtre DL entièrement non-supervisé publié à date. 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 | | 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 du coût PointNet++ | | 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 | param-free au transfert | | 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 | DNN voxel 9x9x9 | bat CSF en F-score | — | | 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 | | 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) | RMSE MNT < 1 m, filtre + interp en 1 passe | | 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) | masque non-sol + hauteur sol/pixel | | 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, **Training datasets**: OpenGF (Qin et al., CVPRW 2021,
arXiv:2101.09641 — 47.7 km², 542 M pts) ; ALS2DTM (Lê et al. 2022, 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). arXiv:2206.03778 — 52 km², 1.66 billion pts, urban/forest/mountain).
**Coûts / obstacles pour notre cas** : (1) labels → à générer en **Costs / obstacles for our case**: (1) labels → to be generated as
pseudo-labels (sortie SMRF/PTD haute qualité sur un échantillon représentatif pseudo-labels (high-quality SMRF/PTD output on a representative sample of
de nos tiles) ou pré-entraînement OpenGF/ALS2DTM ; (2) généralisation sur le our tiles) or pre-training on OpenGF/ALS2DTM; (2) generalization across the
terrain divers de LiDAR HD (plaine/forêt/montagne/urbain) ; (3) infra : diverse terrain of LiDAR HD (plain/forest/mountain/urban); (3)
checkpoint + chemin d'inférence GPU dans l'image Docker. infrastructure: checkpoint + GPU inference path in the Docker image.
**Meilleur fit si on part sur l'IA** : un **U-Net rasterisé (style **Best fit if going the AI route**: a **rasterized U-Net (DSM2DTM-style)** —
DSM2DTM)** — rasteriser le nuage en grilles multi-canaux (altitude, pente, rasterize the point cloud into multi-channel grids (elevation, slope,
courbure, densité, stats de retours), sortir masque sol + hauteur sol. curvature, density, return statistics), output a ground mask + ground
2D = très rapide et trivial à déployer sur GPU, fusionne filtrage + height. 2D = very fast and trivial to deploy on GPU, merges filtering +
interpolation. Le KPConv/RandLA-Net est plus précis en 3D pur mais plus lourd interpolation. KPConv/RandLA-Net is more accurate in pure 3D but heavier to
à déployer. deploy.
## Synthèse / décision ## Synthesis / decision
- « Rapide » contrainte dure + faible maintenance → **hybride (B)** - "Fast" hard constraint + low maintenance → **fast hybrid (B)**
(~10-40 s/tile, zéro entraînement, zéro GPU). ← **choix retenu, IMPLÉMENTÉ** (~10-40 s/tile, zero training, zero GPU). ← **chosen approach, IMPLEMENTED**
- Base = pré-classification IGN (rapide, ~10 s). `auto` la préfère dès que - Base = IGN pre-classification (fast, ~10 s). `auto` prefers it as soon
≥ 20 % des points sont classés sol (seuil abaissé de 30 % à 20 %, car le as ≥ 20% of points are classified as ground (threshold lowered from 30%
MNT est ensuite complété — voir ci-dessous). to 20%, since the DTM is subsequently completed — see below).
- Le MNT n'est complété que pour les petits trous (< 1 m, `fillnodata`) : - **This gap-filling note is superseded**: gap filling in the DTM is no
les grands trous (forêt dense, relief raide où le sol est sous-classé) longer a distance-based `fillnodata` pass over small holes. It is now
restent en nodata (noir dans les rendus). Volontairement pas de plancher a morphological closing bounded to the point envelope
au retour le plus bas : sous canopée dense ce retour est la végétation, (`_fill_small_gaps` in `dtm.py`): the closing radius follows the local
qui imprimerait les arbres dans le MNT. point spacing (measured over 5 m, staged at 1/1.5/2/3 m), nothing is
- Qualité max dans les cas durs (raide + dense), ~1-2 min/tile + GPU + extended beyond measured pixels, and islands under 1 m² are removed.
entraînement acceptés → **U-Net rasterisé (C)**. (non implémenté) Large holes (dense forest, steep relief where ground is
- Meilleur filtre géométrique disponible dans PDAL → **SMRF (A)** (meilleure under-classified) still remain as nodata (black in the renders).
couverture 90.4 % mais 5.4 min/tile). Sélectionnable via `--ground-classification smrf`. Deliberately no floor at the lowest return: under dense canopy that
- « Gagnant » absolu de la littérature (rapide + robuste) → **PTD/PTIN return is vegetation, which would print trees into the DTM.
(A)** : à intégrer (pas dans la stack PDAL actuelle). - 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. - Axelsson (2000), PTIN/PTD, ISPRS Congress.
- Pingel, Clarke & McBride (2013), SMRF, ISPRS J. P&RS 77:21-30. - Pingel, Clarke & McBride (2013), SMRF, ISPRS J. P&RS 77:21-30.
- Zhang et al. (2016), CSF, Remote Sensing 8(6):501. - Zhang et al. (2016), CSF, Remote Sensing 8(6):501.
- Wack & Wimmer (2002), DMT par retour le plus bas, ISPRS Archives XXXIV/3A. - Wack & Wimmer (2002), lowest-return DTM, ISPRS Archives XXXIV/3A.
- Moudrý et al. (2020), comparaison CSF/PTIN/PMF/SMRF, Measurement 150:107047. - Moudrý et al. (2020), CSF/PTIN/PMF/SMRF comparison, Measurement 150:107047.
- Cai et al. (2019), CS+PTD, Remote Sensing 11(9):1037. - Cai et al. (2019), CS+PTD, Remote Sensing 11(9):1037.
- Qin et al. (2021), OpenGF, CVPR Workshops (arXiv:2101.09641). - Qin et al. (2021), OpenGF, CVPR Workshops (arXiv:2101.09641).
- Qin et al. (2023), dataset + evaluation + survey, ISPRS J. P&RS 202:246-261. - 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). - 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. - 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

View File

@ -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 A classic "slippy" map — the same tile scheme as Google Maps and
**OpenStreetMap** — servie par une image légère dédiée. Les rendus du pipeline **OpenStreetMap** — served by a dedicated lightweight image. Pipeline
deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap, renders thus become a **reusable imagery basemap** in JOSM, iD, QGIS, uMap,
MapLibre ou OsmAnd, en plus de l'interface de consultation fournie. 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é This is the ONLY web interface in the project (the old historical webapp has
retirée) : elle embarque aussi la **génération de tuiles** — locale sur been removed): it also embeds **tile generation** — local on the full image
l'image complète (worker, `./run.sh --serve`), déléguée via (the full pipeline server, port 8973, `./run.sh --serve`), delegated via
`LIDAR_GENERATION_URL` sur la machine légère (cf. `docs/DEPLOY_WEBAPP.md`). `LIDAR_GENERATION_URL` on the lightweight machine (see
`docs/DEPLOY_WEBAPP.md`).
```bash ```bash
docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/ docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/
docker compose -f docker-compose.maps.yml logs -f maps 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 ## Interface
Un **panneau unique à onglets** (`web/map.{html,css,js}`) regroupe tous les A **single tabbed panel** (`web/map.{html,css,js}`) groups all settings,
réglages, à la place de l'ancienne pile de blocs empilés : **Affichage** replacing the old stack of stacked blocks: **Affichage** (Display) (main
(couche principale, mode relief/précision, fond de carte), **Export PDF**, layer, relief/precision mode, basemap), **Export PDF**, **Génération**
**Génération** (masqué si le générateur est indisponible ou non autorisé (Generation) (hidden if the generator is unavailable or not authorized for
pour ce navigateur) et **Partager**. Sur ordinateur, le panneau occupe une that browser) and **Partager** (Share). On desktop, the panel occupies a
colonne fixe, repliable en bande d'icônes (**‹**, toujours utilisable : un fixed column, collapsible into an icon strip (**‹**, always usable: clicking
clic sur un onglet redéplie le panneau). Sous 720 px de large, il devient un a tab redeploys the panel). Below 720 px wide, it becomes a **bottom sheet**
**volet en bas d'écran** à trois hauteurs — fermé, mi-hauteur, plein — que with three heights — closed, half, full — changed by dragging the handle, by
l'on change en glissant la poignée, en la touchant simplement (un cran), ou simply tapping it (one notch), or by tapping the already-active tab again.
en retouchant l'onglet déjà actif.
Un clic sur la carte **sélectionne** la dalle LiDAR HD sous le curseur Clicking the map **selects** the LiDAR HD tile under the cursor (dashed
(contour en pointillés) et remplit l'onglet **Dalle** (emprise, IGN, recalage outline) and fills the **Dalle** (Tile) tab (footprint, IGN info, pass
des passes) sans changer l'onglet affiché ; re-cliquer la même dalle alignment) without changing the displayed tab; clicking the same tile again
désélectionne, cliquer une autre déplace la sélection. Raccourcis clavier : deselects it, clicking another moves the selection. Keyboard shortcuts:
**1–5** (onglets du panneau, sans effet si l'onglet est masqué), **P** **1–5** (panel tabs, no effect if the tab is hidden), **P** (next display
(mode d'affichage suivant), **Échap** (désélectionne la dalle, puis mode), **Escape** (deselects the tile, then collapses the panel). Two
replie le panneau). Deux thèmes clair/sombre (bouton ◐/☀/☾, `auto` par light/dark themes (◐/☀/☾ button, `auto` by default = follows the system);
défaut = suit le système) ; réglages et état du panneau retenus dans settings and panel state are kept in `localStorage` (`lidarMapView_v2`,
`localStorage` (`lidarMapView_v2`, `lidar-print`, `lidar-panel`, `lidar-print`, `lidar-panel`, `lidar-theme` — silently ignored in private
`lidar-theme` — silencieusement ignorés en navigation privée). 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 - **+ Zone** (Génération tab) — draw a rectangle: the 1 km LHD tiles it
1 km qui l'intersectent sont téléchargées depuis la géoplateforme IGN puis intersects are downloaded from the IGN geoplatform and then processed
traitées (0,2 m, options ci-dessous) ; les zones et clics successifs (0.2 m, options below); successive zones and clicks are additive;
s'additionnent ; - **⤒ Compléter** (Complete) — all tiles already present in `input/` that are
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui missing at least one of the requested layers;
manquent au moins une des couches demandées ; - **↻ Générer/Régénérer cette dalle** (Generate/Regenerate this tile) (button
- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, onglet on the tile sheet, Dalle tab) — a specific tile, even with no existing data.
Dalle) — une dalle précise, même sans données existantes.
Options du run : couches visées (défaut : les couches du panneau) et Run options: target layers (default: the panel layers) and forced
régénération forcée. La classification du sol (IGN, sol seul) et le raccord regeneration. Ground classification (IGN, ground only) and edge stitching (a
des bords (bande de 100 m prise aux dalles voisines) sont imposés : aucun 100 m band taken from neighboring tiles) are enforced: no setting available.
réglage. Une demande lancée pendant un run part en **file A request submitted while a run is already in progress goes into a
d'attente** (persistée, jamais de coupure du travail en place) ; la **persisted queue** (never interrupting work in progress); progress is shown
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu = tile by tile (orange frames = rendering in progress, blue = waiting, red =
en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis failed) with a log and a **Stop** button (SIGTERM then SIGKILL). During a
SIGKILL). Pendant le run, chaque dalle terminée apparaît sur la carte : l'inventaire run, each completed tile appears on the map as it finishes: the inventory is
est mis à jour après chaque dalle et la maintenance de pyramide traite ses updated after each tile and the pyramid maintenance job prioritizes its tiles
tuiles en priorité (surveillance toutes les 10 s). (polled every 10 s).
Les boutons sont masqués si le navigateur vient d'une IP hors The buttons are hidden if the browser's IP falls outside
`LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun `LIDAR_REGEN_CIDR` (default: localhost + private RFC1918 ranges) or if no
backend de génération n'existe (image légère sans `LIDAR_GENERATION_URL`). generation backend exists (lightweight image without `LIDAR_GENERATION_URL`).
Sur la machine légère, tout est transmis au worker On the lightweight machine, everything is forwarded to the worker
(`LIDAR_GENERATION_URL`), qui exécute le pipeline complet ; ses dalles sont (`LIDAR_GENERATION_URL`), which runs the full pipeline; its tiles are then
rapatriées à la demande par l'inventaire `/api/tiles` + les statiques pulled back on demand via the `/api/tiles` inventory plus versioned static
versionnées (`LIDAR_SOURCE_URL`). 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) | | Projection | EPSG:3857, OSM XYZ scheme (north-west origin, `y` toward the south) |
| URL canonique | `/tiles/{couche}/{z}/{x}/{y}.png` — 256 px, PNG RGBA | | Canonical URL | `/tiles/{layer}/{z}/{x}/{y}.png` — 256 px, PNG RGBA |
| Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) | | High-density variant | `/tiles/{layer}/{z}/{x}/{y}@2x.webp` — 512 px (internal interface) |
| Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client | | Zoom levels | 5 → 19 native (0.2 m/px ≈ z19 in France); beyond that, client-side over-zoom |
| Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` | | Outside coverage | fully transparent tile (overlayable), header `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 | | 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: *` sur `/tiles/*` | | CORS | `Access-Control-Allow-Origin: *` on `/tiles/*` |
| Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » | | Attribution | `LIDAR_ATTRIBUTION`, default "LiDAR HD © IGN — Licence Ouverte 2.0" |
Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml` Discovery: `/tiles/{layer}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
(WMTS 1.0.0, grille `GoogleMapsCompatible`), `/tiles/josm.imagery.xml` (WMTS 1.0.0, `GoogleMapsCompatible` grid), `/tiles/josm.imagery.xml`
(toutes les couches d'un coup dans JOSM). (all layers at once in JOSM).
## Utiliser les tuiles ailleurs ## Using the tiles elsewhere
- **JOSM** — *Imagery → Imagery preferences → + TMS*, coller - **JOSM** — *Imagery → Imagery preferences → + TMS*, paste
`http://<hôte>:8975/tiles/slope/{zoom}/{x}/{y}.png`. Pour tout ajouter d'un `http://<host>:8975/tiles/slope/{zoom}/{x}/{y}.png`. To add everything at
coup : *Imagery preferences → Offline/Custom → Add imagery source XML* avec once: *Imagery preferences → Offline/Custom → Add imagery source XML* with
`http://<hôte>:8975/tiles/josm.imagery.xml`. `http://<host>:8975/tiles/josm.imagery.xml`.
- **iD** — *Fond de carte → Personnalisé*, coller - **iD** — *Background → Custom*, paste
`http://<hôte>:8975/tiles/slope/{z}/{x}/{y}.png`. `http://<host>:8975/tiles/slope/{z}/{x}/{y}.png`.
- **QGIS** — *XYZ Tiles → Nouvelle connexion* (même URL, zoom max 19), ou - **QGIS** — *XYZ Tiles → New Connection* (same URL, max zoom 19), or
*WMS/WMTS → Nouveau* avec `http://<hôte>:8975/tiles/wmts.xml`. *WMS/WMTS → New* with `http://<host>:8975/tiles/wmts.xml`.
- **uMap / MapLibre / Leaflet** — même gabarit XYZ, ou le TileJSON. - **uMap / MapLibre / Leaflet** — same XYZ template, or the TileJSON.
- **OsmAnd** — source de tuiles en ligne, gabarit XYZ, zoom max 19. - **OsmAnd** — online tile source, XYZ template, max zoom 19.
Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces The **"Utiliser dans JOSM / QGIS"** ("Use in JOSM / QGIS") button on the map
URL pour la couche choisie. 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 By default, **all** levels up to native (19 in standard OSM numbering =
standard = 0,2 m/px, servi en `18@2x` par l'interface) sont écrits sur 0.2 m/px, served as `18@2x` by the interface) are written to disk in AVIF
disque en AVIF et générés d'avance par la maintenance de fond (sur une carte and pre-generated by the background maintenance job (on a map fed by an
alimentée par l'amont : téléchargés depuis `LIDAR_MAPS_URL`). Aucun niveau upstream source: downloaded from `LIDAR_MAPS_URL`). No level is rendered
n'est rendu à la volée : le rendu à la demande d'un Raspberry Pi (~170 ms par on the fly: on-demand rendering on a Raspberry Pi (~170 ms per tile, 3 at a
tuile, 3 à la fois) donnait 1,5 à 6 s par écran aux zooms 17–19. L'interface time) took 1.5 to 6 s per screen at zoom levels 17–19. The interface is
est plafonnée au zoom 19 (1 px écran = 1 px LiDAR) : jamais de tuile capped at zoom 19 (1 screen pixel = 1 LiDAR pixel): never an upscaled tile.
agrandie. Ordre de grandeur mesuré : ~110 000 tuiles @2x pour 3 240 dalles, Measured order of magnitude: ~110,000 @2x tiles for 3,240 tiles, of which
dont 81 000 au niveau natif, ~5 Go. 81,000 at native level, ~5 GB.
Stockage réduit (disque compté) : `LIDAR_TILE_CACHE_MAX_Z` plus bas et/ou Reduced storage (measured on disk): a lower `LIDAR_TILE_CACHE_MAX_Z` and/or
`LIDAR_TILE_EVEN_LEVELS=1` (niveaux pairs seuls ; l'interface réduit alors `LIDAR_TILE_EVEN_LEVELS=1` (even levels only; the interface then downsamples
les tuiles du niveau supérieur aux zooms impairs, les autres niveaux sont the level above's tiles at odd zooms, other levels are rendered on the fly
rendus à la volée avec un cache mémoire `LIDAR_TILE_MEMORY_CACHE_MB`). with an in-memory cache, `LIDAR_TILE_MEMORY_CACHE_MB`).
Une carte alimentée par un serveur de dalles amont (`LIDAR_SOURCE_URL`, cas A map fed by an upstream tile-source server (`LIDAR_SOURCE_URL`, the Pi's
du Pi) rapatrie et **garde localement** les sources de chaque dalle dès case) pulls in and **keeps locally** the sources for each tile as soon as it
qu'elle apparaît (vignettes et quadrants 500 m ; la dalle entière, doublon appears (thumbnails and 500 m quadrants; the full tile, a duplicate of its
de ses quadrants, n'est pas rapatriée), sans attendre de visite. Tous les quadrants, is not pulled in), without waiting for a visit. All levels remain
niveaux restent disponibles quand le conteneur de rendu est éteint ; une available while the rendering container is off; a source missed while it was
source manquée pendant qu'il était éteint est reprise au scan suivant. 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 Clicking the map selects the LiDAR HD tile under the cursor (dashed yellow
jaune en pointillés) et remplit l'onglet **Dalle** — nom, emprise Lambert 93, frame) and fills the **Dalle** (Tile) tab — name, Lambert 93 footprint, then,
puis, si la dalle est rendue, la résolution, la date de génération et le if the tile has been rendered, resolution, generation date and vertical pass
recalage vertical des passes (faisceaux, décalages, correction des lignes) — alignment (beams, offsets, line correction) — whether it has been generated
qu'elle soit générée ou non, sans changer l'onglet affiché. Les informations or not, without changing the displayed tab. IGN information arrives
IGN arrivent à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache separately (`GET /api/map/ign?col&row`, STAC catalog cached in
dans `output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission, `output/ign_meta/`): LiDAR scan date and time, sensors, mission, operator,
opérateur, date d'édition, procédé de classement, nombre de points et lien de edit date, classification process, point count and a download link for the
téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent `.copc.laz` point cloud on the geoplatform. A slow or unreachable catalog
ou injoignable n'empêche jamais la sélection. Re-cliquer la même dalle (ou never blocks selection. Clicking the same tile again (or Escape) deselects
Échap) désélectionne. it.
Quand le relief orienté est affiché, une rose des vents donne la couleur de When the oriented relief layer is displayed, a compass rose shows the color
chaque orientation de pente (même formule CIELAB que le rendu) ; la clarté of each slope orientation (same CIELAB formula as the render); lightness
porte le relief local (clair = bosse, sombre = creux). 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 The map serves only the layers in `PANEL_VIZ` (`index.py`): the **relief
orienté** (couche d'affichage principal, qui fusionne openness locale et orienté** (oriented relief, the main display layer, which merges local
orientation des pentes) et la **précision** (`densite_sol` : densité des points openness and slope orientation) and the **précision** (precision) layer
sol retenus pour le MNT). Les autres visualisations présentes sur disque ne (`densite_sol`: density of the ground points retained for the DTM). Other
sont ni listées ni servies en tuiles. 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 There is no more layer stack. The panel offers three modes, one click each,
chacun, ou la touche **P** pour passer au suivant : or the **P** key to cycle to the next:
- **Relief** — le relief orienté seul ; - **Relief** — the oriented relief alone;
- **Précision** — la densité seule, en 16 gris (échelle log fixe : niveau k à - **Précision** (Precision) — density alone, in 16 shades of gray (fixed log
partir de 0,25 × 2^(k/2) pts/m², noir ≤ 0,35 ou aucun point, blanc ≥ 45) ; scale: level k starting at 0.25 × 2^(k/2) pts/m², black ≤ 0.35 or no
se lit comme une carte de fiabilité géométrique ; points, white ≥ 45); reads as a geometric-reliability map;
- **Comparer** — une barre glissante (souris ou doigt) sépare deux couches - **Comparer** (Compare) — a slider (mouse or touch) separates two layers
choisies dans deux menus (relief, précision ou fond OSM seul) de part et chosen from two menus (relief, precision, or bare OSM background) on
d'autre du curseur ; la même couche des deux côtés n'y ajoute aucune either side of the cursor; the same layer on both sides adds no split.
découpe.
L'onglet Affichage porte aussi une **intensité du relief** (curseur 0,5×–2×, The Affichage (Display) tab also carries a **relief intensity** slider
1× par défaut) : un contraste de confort posé sur le conteneur de la couche (0.5×–2×, 1× by default): a comfort contrast applied to the displayed
affichée (`contrast()` CSS), mémorisé et partagé dans le lien (`&I=`, écrit layer's container (CSS `contrast()`), remembered and shared in the link
seulement si ≠ 1×) mais jamais figé comme défaut serveur ni appliqué au PDF (`&I=`, written only if ≠ 1×) but never fixed as a server default nor
exporté (qui garde le rendu standard). Un bloc **Comment lire la carte**, applied to the exported PDF (which keeps the standard render). A
repliable, reprend pour la ou les couches affichées le texte de lecture de collapsible **Comment lire la carte** (How to read the map) block reuses,
`VIZ_LEGENDS` (aussi servi par `/api/map/meta` et le TileJSON). 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 The precision legend (16 levels, tooltip in pts/m² on each level) is shown
palier) s'affiche dès que la précision est visible, seule ou d'un côté de la as soon as precision is visible, either alone or on one side of the Compare
barre Comparer. Les couches LiDAR vivent dans un conteneur **isolé** slider. LiDAR layers live in an **isolated** container (`isolation:
(`isolation: isolate`) : la barre ne découpe que les calques LiDAR, jamais le isolate`): the slider only splits the LiDAR layers, never the basemap.
fond de carte.
Le lien de partage transporte la couche principale, le mode, la comparaison et The share link carries the main layer, the mode, the comparison and the
l'intensité : intensity:
`#z/lat/lng&M=relief_oriente&P=compare&C=relief:precision:30&I=1.4&B=1:85:1` `#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 (`&C=left:right:position%` only in Compare mode). Old links from the layer
de la pile (`&L=…`) et de l'ancien mode « les deux » (`&P=both:opacité`, stack (`&L=…`) and the old "both" mode (`&P=both:opacity`, opacity then
opacité alors ignorée) s'ouvrent sans erreur, en mode relief. 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 The **★ Définir par défaut** ("Set as default") button saves the current
principale, mode, fond de carte — dans `output/.map-defaults.json` (jamais display — main layer, mode, basemap — to `output/.map-defaults.json` (never
l'intensité, réglage de confort propre à chaque navigateur). Tout navigateur the intensity, a per-browser comfort setting). Any browser with no local
sans réglage local part alors de cette configuration ; **↺ Réinitialiser** setting then starts from this configuration; **↺ Réinitialiser** (Reset)
oublie l'état local et y revient. forgets the local state and reverts to it.
```bash ```bash
curl http://localhost:8975/api/map/defaults # configuration servie curl http://localhost:8975/api/map/defaults # served configuration
curl -X DELETE http://localhost:8975/api/map/defaults # retour au registre curl -X DELETE http://localhost:8975/api/map/defaults # revert to the registry
``` ```
Sans fichier enregistré, les défauts viennent du registre du pipeline With no saved file, defaults come from the pipeline registry (`DEFAULT_VIZ`,
(`DEFAULT_VIZ`, `PRECISION_VIZ`, `DEFAULT_VIEW_MODE` dans `index.py`). Les `PRECISION_VIZ`, `DEFAULT_VIEW_MODE` in `index.py`). Received values are
valeurs reçues sont filtrées : couche inconnue (ou la précision elle-même) filtered: an unknown layer (or precision itself) is rejected as the main
refusée comme principale, mode validé, opacité du fond bornée à 0–1. Un layer, the mode is validated, basemap opacity is clamped to 0–1. A file from
fichier de l'ancienne pile (`order`/`on`/`blend`) est ignoré, sauf le fond. 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, The **Export PDF** tab shows the settings (A4/A3 format, landscape/portrait,
échelle 1:1 000 à 1:10 000, titre optionnel) et, tant qu'il est ouvert, un scale 1:1,000 to 1:10,000, optional title) and, while it stays open, a
**cadre jaune en pointillés** qui montre la zone qui sera imprimée (il **dashed yellow frame** showing the area that will be printed (it disappears
disparaît en changeant d'onglet). Le cadre est **posé sur le when switching tabs). The frame is **anchored to the terrain**: it is
terrain** : il se place au centre de la vue à l'ouverture (ou garde sa position placed at the center of the view when opened (or keeps its previous
précédente si elle est encore visible), la carte zoome pour le montrer en position if it's still visible), the map zooms to show it in full above the
entier au-dessus du panneau, puis on navigue librement sans qu'il bouge. On le panel, and one can then navigate freely without it moving. It is moved by
déplace en faisant glisser sa **poignée ✥** (souris ou doigt) ; au relâchement, dragging its **✥ handle** (mouse or touch); on release, the exact Lambert 93
la géométrie Lambert 93 exacte est recalculée (`GET /api/export/frame`) — un geometry is recomputed (`GET /api/export/frame`) — a release immediately
relâchement suivi d'un clic immédiat ne sélectionne pas de dalle (garde de followed by a click does not select a tile (300 ms guard). "⌖ Centrer ici"
300 ms). « ⌖ Centrer ici » le ramène au centre de la vue, « ⤢ Voir le cadre » ("Center here") brings it back to the center of the view, "⤢ Voir le cadre"
zoome dessus ; changer de format, d'orientation ou d'échelle recadre la vue. ("View the frame") zooms onto it; changing format, orientation or scale
Les réglages et la position du cadre sont mémorisés dans `localStorage` du recenters the view. Settings and frame position are kept in the browser's
navigateur. **Exporter le PDF** télécharge la planche (`GET `localStorage`. **Exporter le PDF** ("Export PDF") downloads the sheet (`GET
/api/export/pdf`), nommée `relief_{x_km}_{y_km}_1-{échelle}.pdf` (centre /api/export/pdf`), named `relief_{x_km}_{y_km}_1-{scale}.pdf` (Lambert 93
Lambert 93 en km, à trois décimales). center in km, to three decimal places).
La planche (module `lidar_pipeline/export_pdf.py`) est composée directement The sheet (module `lidar_pipeline/export_pdf.py`) is composed directly in
en Lambert 93 depuis les sources déjà rendues (pas de passage par les tuiles Lambert 93 from the already-rendered sources (no pass through the XYZ
XYZ), puis dessinée en vectoriel (texte, grille, légende) avec `reportlab` — tiles), then drawn vectorially (text, grid, legend) with `reportlab` —
Pillow + pyproj + reportlab uniquement, **sans numpy** : elle tourne aussi Pillow + pyproj + reportlab only, **no numpy**: it runs equally well on the
bien sur l'image complète que sur l'image légère du Pi seul. Contenu : 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 - the **oriented relief** map, cropped to the requested scale (300 dpi at
A4, 250 dpi en A3 — borne la mémoire du Pi, ~36 Mo en A3) ; hors emprise des A4, 250 dpi at A3 — bounds Pi memory usage, ~36 MB at A3); outside the
dalles disponibles, la zone reste blanche et hachurée ; available tiles' coverage, the area stays white and hatched;
- un **quadrillage Lambert 93** (pas 100 m aux échelles 1:1 000/1:2 000, 500 m - a **Lambert 93 grid** (100 m spacing at scales 1:1,000/1:2,000, 500 m at
au 1:5 000, 1 000 m au 1:10 000) gradué en marge, et les **coins WGS84** de 1:5,000, 1,000 m at 1:10,000) graduated in the margin, and the printed
la zone imprimée aux quatre angles ; area's **WGS84 corners** at all four angles;
- une **flèche du nord géographique** tenant compte de la convergence du - a **geographic north arrow** accounting for meridian convergence (the map
méridien (la carte est orientée sur le nord du quadrillage L93, pas le nord is oriented to the L93 grid north, not geographic north — the difference
géographique — l'écart est indiqué en degrés) ; is shown in degrees);
- une **échelle graphique** (barre alternée) et l'**échelle numérique** ; - a **graphic scale bar** (alternating bar) and the **numeric scale**;
- une **rose des orientations à 8 points** (N/NE/E/SE/S/SO/O/NO), même - an **8-point orientation rose** (N/NE/E/SE/S/SW/W/NW), same CIELAB formula
formule CIELAB que la rose de la fiche de dalle ; as the tile sheet's rose;
- le texte de légende du relief orienté, repris de `VIZ_LEGENDS` (source - the oriented relief's legend text, reused from `VIZ_LEGENDS` (single
unique avec l'interface et le TileJSON) ; source shared with the interface and the TileJSON);
- un **encart qualité** : miniature de la densité de points sol (mailles - a **quality panel**: a thumbnail of ground-point density (50 m cells,
50 m, classes de couleur), chiffres clés (densité moyenne, maille la plus color classes), key figures (average density, weakest cell, share of
faible, part de surface interpolée, période d'acquisition), zones **hachurées interpolated area, acquisition period), areas **hatched in white** where
en blanc** là où la qualité n'est pas renseignée, et une ligne « **Donnée quality data is not available, and a "**Donnée manquante (sans relief)**"
manquante (sans relief)** » listant les dalles de la zone qui n'ont pas ("Missing data (no relief)") line listing the tiles in the area that
encore été générées ; haven't been generated yet;
- un cartouche : titre (par défaut, liste des dalles couvertes), échelle, - a title block: title (by default, the list of covered tiles), scale,
format, dpi, centre L93, taille de la zone, date d'export et mention de format, dpi, L93 center, area size, export date and IGN source mention.
source IGN.
Un seul export PDF à la fois : un second appel pendant qu'un export tourne Only one PDF export at a time: a second call while an export is running gets
reçoit `429` (réessayer). L'export n'est **jamais délégué** à `429` (retry). Export is **never delegated** to `LIDAR_GENERATION_URL`:
`LIDAR_GENERATION_URL` : contrairement à la génération de tuiles, la carte unlike tile generation, the lightweight map alone (a Pi with no worker) can
légère seule (Pi sans worker) sait exporter par elle-même, à partir des export by itself, from the sources already pulled to disk.
sources déjà rapatriées sur disque.
> **Licence** — LiDAR HD est diffusé sous **Licence Ouverte 2.0** : > **License** — LiDAR HD is distributed under the **Licence Ouverte 2.0**
> l'attribution IGN est obligatoire et doit rester visible chez le client. > ("Open License 2.0"): IGN attribution is mandatory and must remain visible
> Avant d'utiliser ces rendus comme calque de **saisie** dans OpenStreetMap, > to the end user. Before using these renders as an OpenStreetMap **survey**
> vérifier la position de la communauté (OSM-FR) sur la source concernée. > 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 1. The tile's footprint is converted to Lambert 93 (5×5 sampling of the
contour : les bords ne sont pas droits en L93). outline: edges aren't straight in L93).
2. Les dalles de 1 km qui l'intersectent sont retrouvées par leur nom 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). (`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 3. For each one, the coarsest **source tier** that is still sufficient is
parmi ceux que le pipeline produit déjà : vignette `index_thumbs` chosen among those the pipeline already produces: `index_thumbs`
(≈3,9 m/px), vignette intermédiaire `_mid` (1,56 m/px), quadrants thumbnail (≈3.9 m/px), intermediate `_mid` thumbnail (1.56 m/px),
`index_subtiles` (2500 px, 4× moins à décoder que la dalle) puis la dalle. `index_subtiles` quadrants (2500 px, 4× less to decode than the full
Les trois dossiers sont scannés **indépendamment** : un cache partiel — une tile), then the full tile. The three folders are scanned
machine légère ne rapatrie que quadrants et vignettes, jamais les dalles **independently**: a partial cache — a lightweight machine that only
entières — reste entièrement exploitable. pulls in quadrants and thumbnails, never full tiles — remains fully
4. La fenêtre utile est découpée puis reprojetée par **transformation usable.
projective** (`Image.transform(..., PERSPECTIVE)`), dalle par dalle : le 4. The useful window is cropped, then reprojected via a **perspective
calage mesuré est inférieur au pixel. transform** (`Image.transform(..., PERSPECTIVE)`), tile by tile: the
5. Le résultat est encodé (PNG ou WebP) et écrit dans measured alignment is sub-pixel.
`output/index_xyz/{couche}/{z}/{x}/{y}[@2x].{ext}`. 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 - A cached tile is re-served as long as **no contributing tile is more
plus récente qu'elle** : régénérer une dalle n'invalide que ses tuiles. recent than it**: regenerating a tile only invalidates its own tiles.
- Une tuile sans donnée est mémorisée par un marqueur `.empty` — jamais - A tile with no data is memoized with an `.empty` marker — never
recalculée. recomputed.
- Les images sources décodées sont gardées dans un petit cache LRU : le - Decoded source images are kept in a small LRU cache: AVIF decoding
décodage AVIF domine le coût, les tuiles voisines le réutilisent. dominates the cost, and neighboring tiles reuse it.
- Côté navigateur, l'interface ajoute `?v=<stamp>` (mtime la plus récente) et - Client-side, the interface appends `?v=<stamp>` (most recent mtime) and
reçoit alors un cache immuable ; les clients OSM utilisent l'URL nue, servie then gets an immutable cache; OSM clients use the bare URL, served with
en revalidation courte. 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 | | 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 RGBA (default) | 210 KB | lossless |
| PNG palettisé (`LIDAR_TILE_PNG_PALETTE=1`) | 55 Ko | écart moyen 5,8 niveaux | | Palettized PNG (`LIDAR_TILE_PNG_PALETTE=1`) | 55 KB | average deviation 5.8 levels |
| WebP q78 (`…/{y}.webp`) | 38 Ko | avec perte, visuellement propre | | WebP q78 (`…/{y}.webp`) | 38 KB | lossy, visually clean |
Le PNG canonique reste **sans perte** par défaut : ces rendus servent à The canonical PNG stays **lossless** by default: these renders are meant for
l'interprétation, pas à l'illustration. Pour un usage en fond d'imagerie où le interpretation, not illustration. For use as an imagery basemap where
débit compte, deux leviers : demander l'URL `.webp` (les clients qui la bandwidth matters, two levers: request the `.webp` URL (supported by QGIS,
supportent : QGIS, iD, MapLibre, uMap) ou activer `LIDAR_TILE_PNG_PALETTE=1`. 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 Alignment checks performed on real data: an OSM path overlays exactly onto
exactement à la trace visible dans la couche de pente, et l'écart de couture the trace visible in the slope layer, and the seam gap between neighboring
entre tuiles voisines reste **inférieur au bruit naturel du terrain** (écart tiles stays **below the terrain's natural noise** (measured gap 35–53
mesuré 35–53 niveaux contre une médiane de 49–53 entre deux colonnes voisines levels against a median of 49–53 between two random neighboring columns
prises au hasard dans la même tuile). 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` | | Concurrent renders | 2 | `LIDAR_TILE_WORKERS` |
| Téléchargements de dalles simultanés | 2 | `LIDAR_TILE_FETCH_WORKERS` | | Concurrent tile downloads | 2 | `LIDAR_TILE_FETCH_WORKERS` |
| Même tuile demandée en parallèle | 1 rendu, les autres attendent son résultat | — | | Same tile requested in parallel | 1 render, others wait for its result | — |
| Mémoire des sources décodées | 192 Mo | `LIDAR_TILE_SOURCE_CACHE_MB` | | 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 Excess requests wait on the semaphore **before** any decoding: they consume
consomment ni CPU ni mémoire. Un navigateur en HTTP/1.1 n'ouvre de toute façon neither CPU nor memory. A browser over HTTP/1.1 only opens 6 connections per
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. 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 Pre-warming (`/api/map/warm`) is sequential: it cannot saturate the machine,
machine, seulement prendre du temps. only take time.
Sur un Raspberry Pi 2 Go, `LIDAR_TILE_WORKERS=1` et On a 2 GB Raspberry Pi, `LIDAR_TILE_WORKERS=1` and
`LIDAR_TILE_SOURCE_CACHE_MB=64` restent confortables. `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 On a machine that must never compute while someone is browsing (a Pi that
tient aussi d'autres services), deux variables inversent la charge : also hosts other services), two variables invert the load:
```yaml ```yaml
environment: environment:
- LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend plus rien - LIDAR_TILE_CACHE_ONLY=1 # browsing no longer renders anything
- LIDAR_TILE_BACKGROUND=1 # une tâche de fond entretient la pyramide - LIDAR_TILE_BACKGROUND=1 # a background task maintains the pyramid
``` ```
- **`LIDAR_TILE_CACHE_ONLY=1`** — une tuile absente ou périmée est servie - **`LIDAR_TILE_CACHE_ONLY=1`** — a missing or stale tile is served
transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le transparent with `X-Tile-Pending: 1` and `Cache-Control: no-store` (the
navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît). browser re-requests it: as soon as maintenance has rendered it, it
Plus aucun rendu local sur le chemin des requêtes ; avec `LIDAR_MAPS_URL`, appears). No more local rendering on the request path; with
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 `LIDAR_MAPS_URL`, a missing tile (not yet handled by maintenance) is
cache — puis servie : la navigation couvre tous les zooms sans jamais pulled in from there — a download of a few dozen KB, cached — then
calculer sur la petite machine. served: browsing covers all zoom levels without ever computing on the
- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle small machine.
régulier (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s) ; chaque dalle nouvelle ou - **`LIDAR_TILE_BACKGROUND=1`** — a poller rescans tiles at a regular
régénérée (le worker vient de produire, le cache à la demande vient de interval (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s); each new or
rapatrier) met sa pyramide en file d'attente. Des rendeurs à basse priorité regenerated tile (the worker just produced it, or the on-demand cache just
(`os.nice`) la vident au rythme d'une tuile par pulled it in) queues its pyramid. Low-priority renderers (`os.nice`) drain
`LIDAR_TILE_BACKGROUND_PAUSE` seconde (1 s). Premier démarrage : TOUTES les it at a rate of one tile per `LIDAR_TILE_BACKGROUND_PAUSE` second (1 s).
dalles sont inconnues, la pyramide se reconstruit entière — les tuiles déjà First start-up: ALL tiles are unknown, the whole pyramid is rebuilt —
fraîches ne coûtent qu'un `stat`. Les niveaux partent en file du plus petit already-fresh tiles only cost a `stat`. Levels are queued from the
zoom au plus grand : la carte se remplit d'abord grossièrement. 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_MAX_Z` | native (`18` at @2x, `19` at 256 px) | maximum level maintained in the background, URL numbering |
| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) | | `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tiles maintained: 2 = 512 px (the interface's) |
| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) | | `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format of maintained tiles (the interface's) |
| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion | | `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) between two renders — the discretion lever |
| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles | | `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | seconds between two tile scans |
| `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_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 Control: `GET /api/map/background` (state, counters, queue), `POST
/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel /api/map/background` (immediate scan), `POST /api/map/warm` (manual
exhaustif, tous zooms/formats, cf. ci-dessous). Enfin, plafonner le conteneur exhaustive pre-computation, all zooms/formats, see below). Finally, capping
lui-même (`mem_limit` + `memswap_limit` dans un override compose) garantit the container itself (`mem_limit` + `memswap_limit` in a compose override)
qu'un rendu déréglé ne peut plus emporter la machine : le tueur OOM ne guarantees a misbehaving render can no longer bring down the machine: the
toucherait que `lidar-maps`. OOM killer would only ever hit `lidar-maps`.
## Pré-chauffage ## Pre-warming
```bash ```bash
curl -X POST http://localhost:8975/api/map/warm \ curl -X POST http://localhost:8975/api/map/warm \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"layers": ["slope"], "z_min": 10, "z_max": 16}' -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 Without `bounds`, the coverage of available tiles is used. Useful after a
gros run pour que la première consultation soit instantanée. 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 `LIDAR_SOURCE_URL` designates **the full pipeline server** (port 8973): the
des dalles vient de son `/api/tiles` et chaque image source est rapatriée au tile inventory comes from its `/api/tiles`, and each source image is pulled
premier rendu qui en a besoin. Le conteneur carte démarre alors sans aucune in on the first render that needs it. The map container then starts with no
donnée locale et sert tout le catalogue distant : local data at all and serves the entire remote catalog:
```bash ```bash
docker run -d --name lidar-maps --network host \ 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 -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 Measured on 849 real tiles served over an SSH tunnel: first tile in an area
zone 0,9–3,2 s (téléchargement d'un quadrant de 1,8 Mo compris), puis **~1 ms** 0.9–3.2 s (including downloading a 1.8 MB quadrant), then **~1 ms** from the
depuis le cache local, qui ne garde que ce qui a été consulté. local cache, which only keeps what has been consulted.
`LIDAR_MAPS_URL` désigne un serveur de tuiles amont (machine de traitement) : `LIDAR_MAPS_URL` designates an upstream tile server (the processing
une tuile absente localement y est rapatriée (quelques dizaines de Ko) puis machine): a tile missing locally is pulled in from there (a few dozen KB)
mise en cache — au lieu de rapatrier les dalles entières. Un disjoncteur then cached — instead of pulling in whole tiles. A circuit breaker suspends
suspend les tentatives 2 minutes après 3 échecs consécutifs : carte attempts for 2 minutes after 3 consecutive failures: the map stays usable
consultable même worker éteint. 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_OUTPUT_DIR` | `/data/output` | tiles read from, `index_xyz/` cache written to |
| `LIDAR_PORT` | `8975` | port d'écoute | | `LIDAR_PORT` | `8975` | listening port |
| `LIDAR_SOURCE_URL` | — | webapp du pipeline : inventaire + dalles rapatriées à la demande | | `LIDAR_SOURCE_URL` | — | full pipeline server: inventory + tiles pulled in on demand |
| `LIDAR_SOURCE_TOKEN` | — | jeton si la webapp amont exige `LIDAR_API_TOKEN` | | `LIDAR_SOURCE_TOKEN` | — | token if the upstream server requires `LIDAR_API_TOKEN` |
| `LIDAR_MAPS_URL` | — | serveur de tuiles amont (carte déportée) | | `LIDAR_MAPS_URL` | — | upstream tile server (remote map) |
| `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | mention servie (TileJSON, WMTS, JOSM, carte) | | `LIDAR_ATTRIBUTION` | LiDAR HD © IGN | attribution served (TileJSON, WMTS, JOSM, map) |
| `LIDAR_TILE_PNG_PALETTE` | — | `1` : PNG palettisé (~4× plus léger, écart ~6 niveaux) | | `LIDAR_TILE_PNG_PALETTE` | — | `1`: palettized PNG (~4× lighter, ~6-level deviation) |
| `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | budget mémoire du cache d'images sources décodées | | `LIDAR_TILE_SOURCE_CACHE_MB` | `192` | memory budget for the decoded source-image cache |
| `LIDAR_TILE_WORKERS` | `2` | rendus de tuiles simultanés | | `LIDAR_TILE_WORKERS` | `2` | concurrent tile renders |
| `LIDAR_TILE_FETCH_WORKERS` | `2` | téléchargements de dalles simultanés (mode `LIDAR_SOURCE_URL`) | | `LIDAR_TILE_FETCH_WORKERS` | `2` | concurrent tile downloads (`LIDAR_SOURCE_URL` mode) |
| `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | HTTPS direct (GPS sur téléphone) | | `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` | — | direct HTTPS (GPS on a phone) |
## Dépannage ## Troubleshooting
- **Carte vide, `layers: 0`** (`curl /healthz`) : aucun rendu dans - **Empty map, `layers: 0`** (`curl /healthz`): no render in
`output/visualisations/`, ou dossier monté au mauvais endroit. `output/visualisations/`, or the folder is mounted at the wrong path.
- **Tuiles transparentes partout** : zoom hors plage (5–19) ou zone sans - **Tiles transparent everywhere**: zoom out of range (5–19) or an area with
dalle ; l'en-tête `X-Tile-Empty` le confirme. no tiles; the `X-Tile-Empty` header confirms this.
- **Première consultation lente** : normal, chaque tuile est rendue une fois — - **First visit is slow**: normal, each tile is rendered once — pre-warm
pré-chauffer (ci-dessus). (see above).
- **JOSM refuse l'URL** : utiliser le gabarit `{zoom}/{x}/{y}` (JOSM) et non - **JOSM rejects the URL**: use the `{zoom}/{x}/{y}` template (JOSM), not
`{z}/{x}/{y}` (Leaflet/QGIS). `{z}/{x}/{y}` (Leaflet/QGIS).
- **Couche absente du menu** : elle n'existe pas sur disque pour ces dalles — - **Layer missing from the menu**: it doesn't exist on disk for these tiles —
`/api/map/meta` liste ce qui est réellement disponible. `/api/map/meta` lists what is actually available.
## Références ## References
- `lidar_pipeline/tiles.py` — grille, reprojection, cache, pré-chauffage. - `lidar_pipeline/tiles.py` — grid, reprojection, cache, pre-warming.
- `lidar_pipeline/mapserve.py` — routes tuiles/TileJSON/WMTS/JOSM et API carte. - `lidar_pipeline/mapserve.py` — tile/TileJSON/WMTS/JOSM routes and the map API.
- `lidar_pipeline/web/map.{html,css,js}` — interface (panneau à onglets, Leaflet `L.tileLayer`, LOD natif), relue par `lidar_pipeline/mapui.py`. - `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`. - `Dockerfile.maps`, `docker-compose.maps.yml`, `./run.sh --serve-maps`.

View File

@ -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()

View File

@ -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)

View File

@ -4,7 +4,8 @@ from setuptools import setup, find_packages
setup( setup(
name='lidar_pipeline', name='lidar_pipeline',
version='2.0.0', 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(), packages=find_packages(),
package_data={'lidar_pipeline': [ package_data={'lidar_pipeline': [
'web/*', 'web/*',