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:
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
21
LICENSE
Normal 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
512
README.md
@ -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)
|
||||

|
||||

|
||||
-76B900?logo=nvidia&logoColor=white)
|
||||

|
||||

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

|
||||
|
||||
**Hartmannswillerkopf** (dalle 1011_6760) — versant forestier du champ de bataille de 1915 : chemins, tranchées et trous d'obus sous le couvert.
|
||||
|
||||

|
||||
|
||||
**Planche PDF** exportée depuis la carte (onglet PDF, A4 paysage, 1:5 000) : quadrillage Lambert 93, légende, encart qualité des données.
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
707
docs/MAPS.md
707
docs/MAPS.md
@ -1,414 +1,413 @@
|
||||
# Carte à tuiles XYZ (image `lidar-maps`)
|
||||
# XYZ tile map (`lidar-maps` image)
|
||||
|
||||
Carte « slippy » classique — même schéma de tuiles que Google Maps et
|
||||
**OpenStreetMap** — servie par une image légère dédiée. Les rendus du pipeline
|
||||
deviennent ainsi un **fond d'imagerie réutilisable** dans JOSM, iD, QGIS, uMap,
|
||||
MapLibre ou OsmAnd, en plus de l'interface de consultation fournie.
|
||||
A classic "slippy" map — the same tile scheme as Google Maps and
|
||||
**OpenStreetMap** — served by a dedicated lightweight image. Pipeline
|
||||
renders thus become a **reusable imagery basemap** in JOSM, iD, QGIS, uMap,
|
||||
MapLibre or OsmAnd, on top of the browsing interface it also provides.
|
||||
|
||||
C'est la SEULE interface web du projet (l'ancienne webapp historique a été
|
||||
retirée) : elle embarque aussi la **génération de tuiles** — locale sur
|
||||
l'image complète (worker, `./run.sh --serve`), déléguée via
|
||||
`LIDAR_GENERATION_URL` sur la machine légère (cf. `docs/DEPLOY_WEBAPP.md`).
|
||||
This is the ONLY web interface in the project (the old historical webapp has
|
||||
been removed): it also embeds **tile generation** — local on the full image
|
||||
(the full pipeline server, port 8973, `./run.sh --serve`), delegated via
|
||||
`LIDAR_GENERATION_URL` on the lightweight machine (see
|
||||
`docs/DEPLOY_WEBAPP.md`).
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.maps.yml up -d --build # http://localhost:8975/
|
||||
docker compose -f docker-compose.maps.yml logs -f maps
|
||||
./run.sh --serve-maps # équivalent en conteneur au premier plan
|
||||
./run.sh --serve-maps # equivalent, foreground container
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
Un **panneau unique à onglets** (`web/map.{html,css,js}`) regroupe tous les
|
||||
réglages, à la place de l'ancienne pile de blocs empilés : **Affichage**
|
||||
(couche principale, mode relief/précision, fond de carte), **Export PDF**,
|
||||
**Génération** (masqué si le générateur est indisponible ou non autorisé
|
||||
pour ce navigateur) et **Partager**. Sur ordinateur, le panneau occupe une
|
||||
colonne fixe, repliable en bande d'icônes (**‹**, toujours utilisable : un
|
||||
clic sur un onglet redéplie le panneau). Sous 720 px de large, il devient un
|
||||
**volet en bas d'écran** à trois hauteurs — fermé, mi-hauteur, plein — que
|
||||
l'on change en glissant la poignée, en la touchant simplement (un cran), ou
|
||||
en retouchant l'onglet déjà actif.
|
||||
A **single tabbed panel** (`web/map.{html,css,js}`) groups all settings,
|
||||
replacing the old stack of stacked blocks: **Affichage** (Display) (main
|
||||
layer, relief/precision mode, basemap), **Export PDF**, **Génération**
|
||||
(Generation) (hidden if the generator is unavailable or not authorized for
|
||||
that browser) and **Partager** (Share). On desktop, the panel occupies a
|
||||
fixed column, collapsible into an icon strip (**‹**, always usable: clicking
|
||||
a tab redeploys the panel). Below 720 px wide, it becomes a **bottom sheet**
|
||||
with three heights — closed, half, full — changed by dragging the handle, by
|
||||
simply tapping it (one notch), or by tapping the already-active tab again.
|
||||
|
||||
Un clic sur la carte **sélectionne** la dalle LiDAR HD sous le curseur
|
||||
(contour en pointillés) et remplit l'onglet **Dalle** (emprise, IGN, recalage
|
||||
des passes) sans changer l'onglet affiché ; re-cliquer la même dalle
|
||||
désélectionne, cliquer une autre déplace la sélection. Raccourcis clavier :
|
||||
**1–5** (onglets du panneau, sans effet si l'onglet est masqué), **P**
|
||||
(mode d'affichage suivant), **Échap** (désélectionne la dalle, puis
|
||||
replie le panneau). Deux thèmes clair/sombre (bouton ◐/☀/☾, `auto` par
|
||||
défaut = suit le système) ; réglages et état du panneau retenus dans
|
||||
`localStorage` (`lidarMapView_v2`, `lidar-print`, `lidar-panel`,
|
||||
`lidar-theme` — silencieusement ignorés en navigation privée).
|
||||
Clicking the map **selects** the LiDAR HD tile under the cursor (dashed
|
||||
outline) and fills the **Dalle** (Tile) tab (footprint, IGN info, pass
|
||||
alignment) without changing the displayed tab; clicking the same tile again
|
||||
deselects it, clicking another moves the selection. Keyboard shortcuts:
|
||||
**1–5** (panel tabs, no effect if the tab is hidden), **P** (next display
|
||||
mode), **Escape** (deselects the tile, then collapses the panel). Two
|
||||
light/dark themes (◐/☀/☾ button, `auto` by default = follows the system);
|
||||
settings and panel state are kept in `localStorage` (`lidarMapView_v2`,
|
||||
`lidar-print`, `lidar-panel`, `lidar-theme` — silently ignored in private
|
||||
browsing).
|
||||
|
||||
## Générer des tuiles depuis la carte
|
||||
## Generating tiles from the map
|
||||
|
||||
- **+ Zone** (onglet Génération) — dessiner un rectangle : les dalles LHD de
|
||||
1 km qui l'intersectent sont téléchargées depuis la géoplateforme IGN puis
|
||||
traitées (0,2 m, options ci-dessous) ; les zones et clics successifs
|
||||
s'additionnent ;
|
||||
- **⤒ Compléter** — toutes les dalles déjà présentes dans `input/` qui
|
||||
manquent au moins une des couches demandées ;
|
||||
- **↻ Générer/Régénérer cette dalle** (bouton de la fiche de dalle, onglet
|
||||
Dalle) — une dalle précise, même sans données existantes.
|
||||
- **+ Zone** (Génération tab) — draw a rectangle: the 1 km LHD tiles it
|
||||
intersects are downloaded from the IGN geoplatform and then processed
|
||||
(0.2 m, options below); successive zones and clicks are additive;
|
||||
- **⤒ Compléter** (Complete) — all tiles already present in `input/` that are
|
||||
missing at least one of the requested layers;
|
||||
- **↻ Générer/Régénérer cette dalle** (Generate/Regenerate this tile) (button
|
||||
on the tile sheet, Dalle tab) — a specific tile, even with no existing data.
|
||||
|
||||
Options du run : couches visées (défaut : les couches du panneau) et
|
||||
régénération forcée. La classification du sol (IGN, sol seul) et le raccord
|
||||
des bords (bande de 100 m prise aux dalles voisines) sont imposés : aucun
|
||||
réglage. Une demande lancée pendant un run part en **file
|
||||
d'attente** (persistée, jamais de coupure du travail en place) ; la
|
||||
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu =
|
||||
en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis
|
||||
SIGKILL). Pendant le run, chaque dalle terminée apparaît sur la carte : l'inventaire
|
||||
est mis à jour après chaque dalle et la maintenance de pyramide traite ses
|
||||
tuiles en priorité (surveillance toutes les 10 s).
|
||||
Run options: target layers (default: the panel layers) and forced
|
||||
regeneration. Ground classification (IGN, ground only) and edge stitching (a
|
||||
100 m band taken from neighboring tiles) are enforced: no setting available.
|
||||
A request submitted while a run is already in progress goes into a
|
||||
**persisted queue** (never interrupting work in progress); progress is shown
|
||||
tile by tile (orange frames = rendering in progress, blue = waiting, red =
|
||||
failed) with a log and a **Stop** button (SIGTERM then SIGKILL). During a
|
||||
run, each completed tile appears on the map as it finishes: the inventory is
|
||||
updated after each tile and the pyramid maintenance job prioritizes its tiles
|
||||
(polled every 10 s).
|
||||
|
||||
Les boutons sont masqués si le navigateur vient d'une IP hors
|
||||
`LIDAR_REGEN_CIDR` (défaut : localhost + plages privées RFC1918) ou si aucun
|
||||
backend de génération n'existe (image légère sans `LIDAR_GENERATION_URL`).
|
||||
Sur la machine légère, tout est transmis au worker
|
||||
(`LIDAR_GENERATION_URL`), qui exécute le pipeline complet ; ses dalles sont
|
||||
rapatriées à la demande par l'inventaire `/api/tiles` + les statiques
|
||||
versionnées (`LIDAR_SOURCE_URL`).
|
||||
The buttons are hidden if the browser's IP falls outside
|
||||
`LIDAR_REGEN_CIDR` (default: localhost + private RFC1918 ranges) or if no
|
||||
generation backend exists (lightweight image without `LIDAR_GENERATION_URL`).
|
||||
On the lightweight machine, everything is forwarded to the worker
|
||||
(`LIDAR_GENERATION_URL`), which runs the full pipeline; its tiles are then
|
||||
pulled back on demand via the `/api/tiles` inventory plus versioned static
|
||||
assets (`LIDAR_SOURCE_URL`).
|
||||
|
||||
## Contrat de tuilage
|
||||
## Tiling contract
|
||||
|
||||
| Point | Valeur |
|
||||
| Point | Value |
|
||||
|---|---|
|
||||
| Projection | EPSG:3857, schéma XYZ OSM (origine nord-ouest, `y` vers le sud) |
|
||||
| URL canonique | `/tiles/{couche}/{z}/{x}/{y}.png` — 256 px, PNG RGBA |
|
||||
| Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) |
|
||||
| Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client |
|
||||
| Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` |
|
||||
| En attente (cache seule) | tuile transparente, en-têtes `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, jamais mémorisée par le navigateur |
|
||||
| CORS | `Access-Control-Allow-Origin: *` sur `/tiles/*` |
|
||||
| Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » |
|
||||
| Projection | EPSG:3857, OSM XYZ scheme (north-west origin, `y` toward the south) |
|
||||
| Canonical URL | `/tiles/{layer}/{z}/{x}/{y}.png` — 256 px, PNG RGBA |
|
||||
| High-density variant | `/tiles/{layer}/{z}/{x}/{y}@2x.webp` — 512 px (internal interface) |
|
||||
| Zoom levels | 5 → 19 native (0.2 m/px ≈ z19 in France); beyond that, client-side over-zoom |
|
||||
| Outside coverage | fully transparent tile (overlayable), header `X-Tile-Empty: 1` |
|
||||
| Pending (cache-only mode) | transparent tile, headers `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, never cached by the browser |
|
||||
| CORS | `Access-Control-Allow-Origin: *` on `/tiles/*` |
|
||||
| Attribution | `LIDAR_ATTRIBUTION`, default "LiDAR HD © IGN — Licence Ouverte 2.0" |
|
||||
|
||||
Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
|
||||
(WMTS 1.0.0, grille `GoogleMapsCompatible`), `/tiles/josm.imagery.xml`
|
||||
(toutes les couches d'un coup dans JOSM).
|
||||
Discovery: `/tiles/{layer}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
|
||||
(WMTS 1.0.0, `GoogleMapsCompatible` grid), `/tiles/josm.imagery.xml`
|
||||
(all layers at once in JOSM).
|
||||
|
||||
## Utiliser les tuiles ailleurs
|
||||
## Using the tiles elsewhere
|
||||
|
||||
- **JOSM** — *Imagery → Imagery preferences → + TMS*, coller
|
||||
`http://<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`.
|
||||
|
||||
@ -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()
|
||||
@ -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)
|
||||
3
setup.py
3
setup.py
@ -4,7 +4,8 @@ from setuptools import setup, find_packages
|
||||
setup(
|
||||
name='lidar_pipeline',
|
||||
version='2.0.0',
|
||||
description='Pipeline LiDAR pour détection archéologique',
|
||||
description='Archaeology-grade relief maps from IGN LiDAR HD point clouds',
|
||||
license='MIT',
|
||||
packages=find_packages(),
|
||||
package_data={'lidar_pipeline': [
|
||||
'web/*',
|
||||
|
||||
Reference in New Issue
Block a user