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
.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.
- test rapide sans rebuild (code monté par-dessus l'image) : `docker run --rm -e PYTHONPATH=/app -v $(pwd)/lidar_pipeline:/app/lidar_pipeline lidar-lidar python3 -m pytest --pyargs lidar_pipeline.tests -q` (~3 min ; ajouter `timeout 600` devant, et PAS de pipe `| tail` qui masque la progression)
- debug: `./run.sh --debug` (file:line logging); container shell: `docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output --entrypoint bash lidar-lidar`
- mise à jour du Pi de prod (192.168.3.10, checkout `/srv/lidar_rendu`, override maps + Traefik) : `ssh` — `ssh 192.168.3.10 "cd /srv/lidar_rendu && git pull && docker compose -f docker-compose.maps.yml -f docker-compose.maps.override.yml up -d --build"` (procédure dans `docs/DEPLOY_WEBAPP.md`).
- mise à jour d'une carte légère distante (checkout git + override maps local) : `ssh <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`.
## Conventions

View File

@ -48,9 +48,6 @@ RUN pip3 install --no-cache-dir .
# paquet installé (dist-packages) qu'utilise le conteneur au runtime.
RUN cd /tmp && python3 -c "import pathlib, lidar_pipeline.mapui as m; m.write_map_assets(pathlib.Path(m.__file__).resolve().parent / m.ASSETS_DIRNAME)"
# Copy backward-compatible entry point
COPY process_lidar.py /usr/local/bin/
RUN chmod +x /usr/local/bin/process_lidar.py
# Create user with uid/gid 1000:1000 and run as that user
RUN groupadd -g 1000 lidar && \

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)
Une seule image fusionne le micro-relief et l'orientation des pentes : la **clarté** porte le relief local (openness positive sur MNT détendancé, rayons 5–20 m, plus un léger ombrage), la **teinte** porte l'orientation (aspect, cercle CIELAB à clarté constante : aucune couleur ne crée de faux relief). Échelle fixe : les dalles voisines se raccordent sans couture.
[![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f.svg)](LICENSE)
![Docker](https://img.shields.io/badge/run%20with-Docker%20Compose-2496ED?logo=docker&logoColor=white)
![Python](https://img.shields.io/badge/python-3.10+-3776AB?logo=python&logoColor=white)
![GPU](https://img.shields.io/badge/GPU-optional%20(CUDA%20%2F%20CuPy)-76B900?logo=nvidia&logoColor=white)
![Data](https://img.shields.io/badge/data-IGN%20LiDAR%20HD-0b6fb7)
![Tiles](https://img.shields.io/badge/tiles-XYZ%20%C2%B7%20TileJSON%20%C2%B7%20WMTS-7952b3)
C'est la **seule couche produite et affichée par défaut** (`PANEL_VIZ` dans `index.py`) : un traitement sans `--only`, la génération lancée depuis la carte et la carte elle-même (panneau, tuiles XYZ, TileJSON, WMTS) se limitent au relief orienté. Les visualisations ci-dessous restent calculables explicitement (`--only slope aspect ...`).
<img src="docs/img/neuf-brisach.png" alt="Neuf-Brisach fortress in oriented relief" width="900">
### Visualisations principales
| # | Visualisation | Utilité archéologique |
|---|--------------|----------------------|
| 1 | **Hillshade multidirectionnel** | Murs, terrasses, structures linéaires, routes |
| 2 | **Pente (Slope)** | Murs de soutènement, talus, changements brusques |
| 3 | **Aspect (Orientation)** | Direction des pentes, exposition |
| 4 | **Courbure (Curvature)** | Fossés, terrasses, talus, concavité/convexité |
| 5 | **Sky-View Factor** | Structures, tumulus, fondations (ray-tracing 16 azimuts) |
| 6 | **Local Relief Model** | Micro-reliefs, fossés, levées de terrain |
| 7 | **Positive Openness** | Élévations, tumulus, bâtiments (ray-tracing 8 directions) |
| 8 | **Negative Openness** | Cavités, fossés, souterrains (ray-tracing 8 directions) |
<sub>Neuf-Brisach (Haut-Rhin), Vauban's star fortress (UNESCO World Heritage), rendered from one 1 km LiDAR HD tile.
Every bastion, moat and ravelin reads at a glance; buildings are black (no ground points).</sub>
### Visualisations avancées
| # | Visualisation | Description | Détection |
|---|--------------|-------------|-----------|
| 9 | **MSRM** | Multi-Scale Relief Model (sigma 5/10/25/50/100m) | Tumulus, fossés, murs à toutes les échelles |
| 10 | **TPI multi-échelle** | Topographic Position Index (5m + 100m) | Crêtes, vallées, plateformes |
| 11 | **Dépressions** | Remplissage cuvettes + différence | Dolines, sinkholes, zones inondables |
| 12 | **SAILORE** | LRM adaptatif (noyau = f(pente)) | Terrain hétérogène, tout relief |
| 13 | **Rugosité** | Écart-type de l'élévation | Surfaces anthropiques vs naturelles |
| 14 | **Anomalies statistiques** | Z-score + Local Moran's I | Anomalies topographiques significatives |
| 15 | **Ondelette Mexican Hat** | CWT 2D multi-échelle | Tumulus, fossés circulaires |
| 16 | **Accumulation de flux** | Algorithme D8 hydrologique | Fossés d'enceinte, routes antiques |
</div>
### Cartes de référence IGN
| # | Visualisation | Source |
|---|--------------|--------|
| 17 | **Photographie aérienne IGN** | Orthophotographie WMTS |
| 18 | **Carte topographique IGN** | Plan IGN V2 WMTS |
---
## Classification du sol
## Why
Le pipeline classifie automatiquement les points sol à partir du nuage de points bruit. Le pré-traitement suit le workflow recommandé par PDAL :
The French mapping agency (IGN) publishes **LiDAR HD**: a nationwide airborne laser
scan, ~10 points/m², free under the *Licence Ouverte 2.0*. Hidden under forest
canopy and fields are hollow ways, trenches, burial mounds, field systems and
forgotten walls — but raw point clouds are hard to read, and naïve DEM renderings
show seams at every tile edge, stripes from the flight lines and colour blotches
that look like relief but aren't.
1. **Filtre ReturnNumber** — élimine les points avec numéros de retour invalides
2. **Réinitialisation Classification** — remet toutes les classifications à 0
3. **ELM** (Extended Local Minimum) — marque les points bas aberrants comme bruit (Classification=7)
4. **Outlier statistique** — supprime les points isolés (mean_k=8, multiplier=3.0)
5. **Classification sol** — SMRF, PMF ou CSF
6. **Extraction** — ne conserve que les points Classification=2
`lidar_rendu` is an end-to-end pipeline that solves exactly that:
### Méthodes de classification
- **One command** from an IGN tile ID to a browsable map.
- **One carefully designed layer** — *oriented relief* — instead of fifteen
colormaps to flip through.
- **Seamless at any scale**: fixed scales, no per-tile statistics, 100 m
overlap borrowed from the eight neighbouring tiles.
- **Measurement-aware**: a companion *precision* layer shows where the terrain is
measured and where it is interpolated, so you never mistake a gap for a feature.
| Méthode | Mode | Usage | Vitesse |
|---------|------|-------|---------|
| **SMRF** | Auto (défaut) | Terrain naturel, forêt, rocaille | Rapide |
| **PMF** | Auto (si urbain) | Zones urbaines, bâtiments, routes | Rapide |
| **CSF** | Manuel uniquement | Terrain très complexe, falaises | Lent |
## Gallery
L'auto-détection analyse le ratio de retours uniques du nuage de points : ratio > 0.6 = milieu urbain → PMF, sinon → SMRF.
<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/
├── __init__.py # Exports publics
├── __main__.py # Point d'entrée: python -m lidar_pipeline
├── cli.py # argparse + logging + main()
├── gpu.py # CuPy/numpy abstraction (HAS_GPU, to_gpu, to_cpu, xp_*)
├── dtm.py # Classification PDAL (SMRF/PMF/CSF + auto) + génération DTM
├── visualizations.py # Fonctions generate_* (19 visualisations)
├── ign.py # Téléchargement tuiles IGN + overlay
├── rendering.py # Colormaps, tif_to_png, rapport PDF
├── pipeline.py # LidarArchaeoPipeline (orchestration + registry)
└── tests/ # Tests unitaires (pytest)
├── cli.py command line
├── pipeline.py orchestration, worker pool, VRAM-aware GPU slots
├── fetch_ign.py IGN catalogue and downloads
├── dtm.py ground extraction, strip calibration, DTM, gap filling
├── visualizations.py generate_* functions (relief, openness, SVF, …)
├── rendering.py colormaps, GeoTIFF → AVIF, 1 km crop
├── index.py catalogue, thumbnails, sub-tiles, inventory, layer registry
├── quality.py per-tile quality sidecar (density, acquisition dates)
├── tiles.py XYZ pyramid: reprojection, cache, background maintenance
├── mapserve.py FastAPI server: map, tiles, TileJSON/WMTS, generation API
├── export_pdf.py PDF field sheets (Pillow + pyproj + reportlab)
├── gpu.py CuPy / NumPy abstraction with CPU fallback
├── web/ map UI (HTML, CSS, JS)
└── tests/ ~400 pytest tests
```
Ajouter une visualisation = 1 fonction + 1 entrée dans `VIZ_STEPS` + 1 entrée dans `COLORMAPS`.
## Exemples
Relief orienté sur deux dalles LiDAR HD, rendues par ce pipeline (`./start.sh process --fetch-tiles 1037,6779 1011,6760 ...`).
**Neuf-Brisach** (dalle 1037_6779) — l'enceinte bastionnée de Vauban, ses fossés et ses demi-lunes ; en noir, les bâtiments (aucun point sol).
![Neuf-Brisach, relief orienté](docs/img/neuf-brisach.png)
**Hartmannswillerkopf** (dalle 1011_6760) — versant forestier du champ de bataille de 1915 : chemins, tranchées et trous d'obus sous le couvert.
![Hartmannswillerkopf, relief orienté](docs/img/hartmannswillerkopf.png)
**Planche PDF** exportée depuis la carte (onglet PDF, A4 paysage, 1:5 000) : quadrillage Lambert 93, légende, encart qualité des données.
![Planche PDF de Neuf-Brisach](docs/img/neuf-brisach-a4.png)
## Démarrage rapide
## Development
```bash
git clone <ce dépôt> && cd lidar_rendu
./start.sh # construit l'image, démarre la carte sur http://localhost:8973/
./start.sh process --fetch-tiles 1037,6779 --file LHD_FXX_1037_6779_PTS_LAMB93_IGN69.copc.laz
# télécharge une dalle IGN et la traite
./start.sh logs # journal du serveur
./start.sh stop # arrêt
./run.sh --test # rebuild the image and run the full test suite
docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.test_tiles
```
`start.sh` crée `input/` et `output/` à votre nom et détecte le GPU : sans GPU
NVIDIA utilisable par Docker, il ajoute `docker-compose.cpu.yml` et le
traitement tourne sur le CPU (plus lent). Docker Compose ≥ 2.24 requis.
Adding a visualisation takes four edits: a `generate_X()` in `visualizations.py`,
an entry in `VIZ_STEPS` (`pipeline.py`), a colormap in `rendering.py`, and a legend
in `VIZ_LEGENDS` (`index.py`). Design decisions and conventions are documented in
[AGENTS.md](AGENTS.md) (French).
## Installation Docker
Contributions are welcome: open an issue or a pull request.
```bash
cd /votre/dossier/lidar
mkdir -p input
## Documentation
# Copiez vos fichiers .laz dans input/
cp /chemin/vos/fichiers/*.laz input/
- [docs/MAPS.md](docs/MAPS.md) — map UI, tile contract, cache, PDF export, environment variables
- [docs/DEPLOY_WEBAPP.md](docs/DEPLOY_WEBAPP.md) — two-machine deployment (Raspberry Pi + worker)
- [docs/GROUND_CLASSIFICATION.md](docs/GROUND_CLASSIFICATION.md) — ground filters benchmark and literature
# Build l'image Docker
docker build -t lidar-lidar .
```
## Data and licences
## Utilisation
- **Code**: [MIT](LICENSE).
- **LiDAR HD, orthophotos, maps**: © IGN, [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/).
Attribution is required and is shown on every map, tile endpoint and PDF.
- **Base map**: © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors.
Before using these renderings as a tracing source in OpenStreetMap, check the
community's position on the source.
### Traitement complet avec GPU (recommandé)
```bash
./run.sh -g
```
### Traitement standard (CPU seul)
```bash
./run.sh
```
### Options du script run.sh
```
./run.sh [options]
-r RESOLUTION Résolution en m/px (défaut: 0.5)
-w WORKERS Nombre de workers parallèles (défaut: 1)
-g Activer l'accélération GPU NVIDIA
-v Mode verbeux (timestamps + niveaux)
--debug Mode debug (détails internes fichier:ligne)
-f / --force Régénérer tous les fichiers même si les WebP existent
--force-classification Reclassifier le sol même si le fichier .las existe déjà
--ground-classification Méthode de classification: ign, auto, smrf, csf (défaut: ign — imposé par la carte)
--edge-buffer M Raccord des bords avec les dalles voisines (défaut: 100 m — imposé par la carte)
--file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques
--test Exécuter les tests unitaires
-h Afficher l'aide
```
### Exemples
```bash
# Traitement standard avec GPU
./run.sh -g
# GPU + mode verbeux
./run.sh -g -v
# GPU + 4 workers parallèles
./run.sh -g -w 4
# Haute résolution (0.2m/px)
./run.sh -g -r 0.2
# Forcer la régénération de tous les fichiers
./run.sh -g --force
# Reclassifier le sol seulement (sans régénérer les visualisations)
./run.sh -g --force-classification
# Forcer la classification PMF au lieu de l'auto-détection
./run.sh -g --ground-classification pmf
# Forcer la classification CSF (lent mais robuste sur terrain complexe)
./run.sh -g --ground-classification csf
# Traiter un fichier spécifique (test rapide)
./run.sh -g --file LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc
# Traiter deux fichiers spécifiques
./run.sh -g --file LHD_FXX_1000_6881_PTS_LAMB93_IGN69.copc LHD_FXX_1000_6882_PTS_LAMB93_IGN69.copc
# Exécuter les tests unitaires
./run.sh --test
```
### Utilisation directe Docker
```bash
# Traitement standard
docker run --rm -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output lidar-lidar
# Avec GPU + classification forcée
docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \
lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output \
--ground-classification pmf
# Forcer la reclassification du sol
docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \
lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output \
--force-classification
# Mode verbeux
docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data/output \
lidar-lidar python3 -m lidar_pipeline /data/input -o /data/output -v
```
## Structure des dossiers
```
.
├── input/ # Fichiers .laz (monté en read-only dans Docker)
├── output/ # Résultats générés
│ ├── DTM/ # Modèles numériques de terrain (GeoTIFF)
│ ├── temp/ # Fichiers temporaires (classification .las)
│ ├── visualisations/ # Images WebP par fichier LAZ
│ │ ├── fichier_6881/ # Un sous-dossier par fichier LAZ
│ │ │ ├── ..._hillshade_multi.webp
│ │ │ ├── ..._svf.webp
│ │ │ ├── ..._mslrm.webp
│ │ │ └── ... (19 visualisations)
│ │ └── fichier_6882/
│ │ └── ...
│ └── rapports/ # Rapports PDF A3 par fichier
│ ├── fichier_6881_rapport.pdf
│ └── fichier_6882_rapport.pdf
├── lidar_pipeline/ # Package Python modulaire
│ ├── cli.py # Arguments CLI + logging
│ ├── gpu.py # Abstraction CuPy/numpy
│ ├── dtm.py # Classification sol + DTM
│ ├── visualizations.py # 19 fonctions generate_*
│ ├── ign.py # Tuiles IGN
│ ├── rendering.py # Colormaps, WebP, PDF
│ ├── pipeline.py # Orchestration
│ └── tests/ # Tests unitaires
├── process_lidar.py # Point d'entrée compatible
├── Dockerfile
├── run.sh
└── README.md
```
## Paramètres
| Paramètre | Option | Défaut | Description |
|-----------|--------|--------|-------------|
| Résolution | `-r` | 0.5 | Résolution en mètres par pixel |
| Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle |
| GPU | `-g` | off | Activer l'accélération NVIDIA GPU |
| Classification sol | `--ground-classification` | ign | Méthode : ign, auto, smrf, csf (la carte impose ign) |
| Forcer classification | `--force-classification` | off | Reclassifier le sol même si .las existe |
| Output | `-o` | /data/output | Dossier de sortie |
| Force | `-f/--force` | off | Régénérer même si les WebP existent |
| File | `--file` | tous | Traiter un ou plusieurs fichiers LAZ |
| Verbose | `-v` | off | Mode verbeux (timestamps + niveaux) |
| Debug | `--debug` | off | Mode debug (détails internes) |
### Résolution recommandée
- `0.2` — Très fine, bâtiments individuels (lent)
- `0.5` — Recommandée archéologie (équilibre vitesse/détail)
- `1.0` — Rapide, grandes structures uniquement
## Interprétation archéologique
### Pour détecter les cavités et souterrains
1. **Negative Openness** — Zones sombres = creux profonds
2. **Dépressions** — Carte spécifique des dolines et sinkholes
3. **Local Relief Model** — Zones bleues = dépressions
4. **Hillshade** — Ombres inhabituelles en forme de trous
### Pour détecter structures et bâtiments anciens
1. **MSRM** — Détection multi-échelle de tous les reliefs
2. **Sky-View Factor** — Structures géométriques claires
3. **SAILORE** — LRM adaptatif pour terrain hétérogène
4. **Anomalies statistiques** — Anomalies topographiques significatives
### Pour hydrologie et fossés
1. **Accumulation de flux** — Fossés d'enceinte, routes antiques
2. **Dépressions** — Zones de collecte d'eau, dolines
3. **Negative Openness** — Fossés et tranchées
## Tests
```bash
./run.sh --test
```
Les tests tournent dans le conteneur Docker et couvrent la classification du sol (SMRF/PMF/CSF), l'auto-détection, le rendu, et les visualisations.
## Dépannage
```bash
# Vérifier Docker
docker --version
# Shell dans le conteneur
docker run --rm -it -v $(pwd)/input:/data/input -v $(pwd)/output:/data/output \
--entrypoint bash lidar-lidar
# Reconstruire l'image
docker build --no-cache -t lidar-lidar .
# Nettoyer
docker system prune -a
```
### Erreur mémoire
Augmenter la mémoire Docker à 16Go+ pour les gros fichiers LiDAR HD.
### Données LiDAR HD (IGN)
Les fichiers COPC (.laz) de l'IGN sont supportés directement. Le pipeline détecte automatiquement la méthode de classification du sol (SMRF/PMF) en analysant le ratio de retours uniques du nuage de points.
Built on [PDAL](https://pdal.io), [laspy](https://laspy.readthedocs.io),
[rasterio](https://rasterio.readthedocs.io), [CuPy](https://cupy.dev),
[numba](https://numba.pydata.org), [FastAPI](https://fastapi.tiangolo.com),
[Leaflet](https://leafletjs.com) and [reportlab](https://www.reportlab.com).

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

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

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

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(
name='lidar_pipeline',
version='2.0.0',
description='Pipeline LiDAR pour détection archéologique',
description='Archaeology-grade relief maps from IGN LiDAR HD point clouds',
license='MIT',
packages=find_packages(),
package_data={'lidar_pipeline': [
'web/*',