Add Leaflet interactive map, tile generator compose, auto-sync cache, deploy docs

index.py rewritten as a continuous Leaflet map: rotated L93 tiles, stackable
visualization layers (per-layer opacity, drag-reorder persisted in
localStorage), tile info panel, live rebuild after each tile during a run.
Leaflet is vendored in assets/vendor/ so the map works fully offline;
georeferencing falls back rasterio -> pyproj -> affine so the lightweight
webapp (no GDAL) is supported.

docker-compose.worker.yml adds the tile generator service (full image + GPU)
that remote webapps call via LIDAR_GENERATION_URL, plus a one-shot process
profile. webapp.py gains LIDAR_AUTO_SYNC_SECONDS periodic cache refresh and
LIDAR_REGEN_CIDR restricting generation to the local network. run.sh
--serve-webapp now mounts ~/.ssh read-only so the rsync sync works.

docs/DEPLOY_WEBAPP.md completed for Raspberry Pi deployment: prerequisites,
git clone install, SSH key setup, first sync, update procedure and
troubleshooting.
This commit is contained in:
Antoine Jacquin
2026-09-04 21:36:14 +02:00
parent 422f58d772
commit 23969c9e14
17 changed files with 1828 additions and 336 deletions

View File

@ -3,6 +3,7 @@
- install: `docker build -t lidar-lidar .` (deps baked into image) - install: `docker build -t lidar-lidar .` (deps baked into image)
- build: `docker build -t lidar-lidar .` - build: `docker build -t lidar-lidar .`
- build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU) - build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU)
- build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes)
- test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord) - test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord)
- test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>` - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>`
- test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>` - test case: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>::<TestClass>::<test_method>`

View File

@ -2,7 +2,7 @@
# #
# La carte et les vignettes sont servies localement ; la génération de tuiles # La carte et les vignettes sont servies localement ; la génération de tuiles
# (téléchargement IGN + traitement PDAL/GPU) est déléguée à la machine de # (téléchargement IGN + traitement PDAL/GPU) est déléguée à la machine de
# traitement = service `serve` du docker-compose.yml de cette machine. # traitement = service `worker` du docker-compose.worker.yml de cette machine.
# #
# docker compose -f docker-compose.webapp.yml up -d --build # docker compose -f docker-compose.webapp.yml up -d --build
# docker compose -f docker-compose.webapp.yml logs -f webapp # docker compose -f docker-compose.webapp.yml logs -f webapp
@ -27,11 +27,22 @@ services:
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_OUTPUT_DIR=/data/output - LIDAR_OUTPUT_DIR=/data/output
# Machine de traitement (service `serve` de son docker-compose.yml) # Adresse du backend de génération des tuiles (machine de traitement,
- LIDAR_GENERATION_URL=http://192.168.1.50:8973 # service `worker` de son docker-compose.worker.yml) — à définir :
# LIDAR_GENERATION_URL=http://<ip-machine-puissante>:8973
# Sans cette variable : consultation autonome du cache local, aucune
# génération possible depuis l'interface.
# - LIDAR_GENERATION_URL=http://192.168.1.50:8973
# Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement # Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement
# - LIDAR_REMOTE_TOKEN=change-moi # - LIDAR_REMOTE_TOKEN=change-moi
# Ramène les tuiles traitées + sidecars DTM (les vignettes sont # Ramène les tuiles traitées + sidecars DTM (les vignettes sont
# régénérées localement par /api/sync, ne pas les synchroniser) # régénérées localement par /api/sync, ne pas les synchroniser)
# - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ # - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/
# Cache local auto-entretenu : resynchronisation + vignettes toutes les
# N secondes (0/défaut = seulement à la demande, bouton ↻ ou /api/sync)
# - LIDAR_AUTO_SYNC_SECONDS=600
# Réseau autorisé à lancer les générations depuis la carte (défaut
# 192.168.1.0/24 ; chaîne vide = restriction levée). Les autres clients
# consultent la carte en lecture seule (boutons masqués, API 403).
# - LIDAR_REGEN_CIDR=192.168.1.0/24
restart: unless-stopped restart: unless-stopped

57
docker-compose.worker.yml Normal file
View File

@ -0,0 +1,57 @@
# Machine de traitement — générateur de tuiles (cf. docs/DEPLOY_WEBAPP.md).
#
# Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les
# webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent :
# dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles
# sont relues par la webapp via son LIDAR_SYNC_CMD (rsync).
#
# docker compose -f docker-compose.worker.yml up -d --build
# docker compose -f docker-compose.worker.yml logs -f worker
# docker compose -f docker-compose.worker.yml down
#
# Traitement batch ponctuel des dalles présentes dans input/ :
# docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,0.2 | --force]
services:
# Générateur de tuiles : API de génération (webapp complète, GPU)
worker:
build: .
image: lidar-lidar
container_name: lidar-worker
init: true
user: "1000:1000"
gpus: all # retirer cette ligne sur une machine sans GPU
ports:
- "8973:8973" # joignable par la webapp distante (LIDAR_GENERATION_URL)
volumes:
# input/ en écriture : l'API y télécharge les dalles IGN manquantes
- ./input:/data/input
- ./output:/data/output
environment:
- TZ=Europe/Paris
- LIDAR_INPUT_DIR=/data/input
- LIDAR_OUTPUT_DIR=/data/output
# Les générations lancées depuis une webapp distante utilisent le GPU
- LIDAR_GPU=1
- LIDAR_WORKERS=2
# Protéger l'API si le réseau n'est pas de confiance : même valeur que
# LIDAR_REMOTE_TOKEN sur chaque webapp distante (sinon, laisser commenté)
# - LIDAR_API_TOKEN=change-moi
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
restart: unless-stopped
# Traitement ponctuel des dalles input/ (une passe puis arrêt)
process:
build: .
image: lidar-lidar
container_name: lidar-process
init: true
user: "1000:1000"
gpus: all # retirer cette ligne sur une machine sans GPU
volumes:
- ./input:/data/input
- ./output:/data/output
environment:
- TZ=Europe/Paris
command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.5,0.2", "-g", "all"]
profiles:
- process

View File

@ -12,21 +12,32 @@ Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp)
│ └─ transmis à ──▶ machine de traitement │ └─ transmis à ──▶ machine de traitement
│ /api/sync : rsync output/ ──◀── machine puissante │ /api/sync : rsync output/ ──◀── machine puissante
▼ ▼
Machine puissante (image complète, docker-compose.yml Machine de traitement (image complète,
service `serve`) : téléchargement IGN + pipeline GPU docker-compose.worker.yml service `worker`) :
téléchargement IGN + pipeline GPU (générateur de tuiles)
``` ```
## Machine de traitement (puissante) ## Machine de traitement (générateur de tuiles)
Le service `serve` existant joue le rôle de worker : il accepte les demandes Le service `worker` joue le rôle de générateur : il accepte les demandes
de génération envoyées par la webapp du Pi. de génération envoyées par les webapp distantes (téléchargement IGN +
traitement PDAL/GPU) et expose les tuiles produites pour le rsync.
```bash ```bash
docker compose up -d --build serve # carte + API sur http://<ip-machine>:8973 docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973
docker compose -f docker-compose.worker.yml logs -f worker
``` ```
Optionnel mais recommandé si le réseau n'est pas de confiance : protéger les Traitement batch ponctuel des dalles déjà présentes dans `input/` :
routes mutantes avec un jeton partagé — décommenter dans `docker-compose.yml` :
```bash
docker compose -f docker-compose.worker.yml run --rm --build process [-r 0.5,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` :
```yaml ```yaml
environment: environment:
@ -35,26 +46,85 @@ environment:
## Raspberry Pi (webapp légère) ## Raspberry Pi (webapp légère)
### 1. Copier le code sur le Pi ### 0. Prérequis sur le 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.webapp` : base `python:3.12-slim`, ~200 Mo, sans PDAL/GDAL).
- **Docker + plugin compose** : installation officielle
[docs.docker.com/engine/install](https://docs.docker.com/engine/install/)
(tester avec `docker compose version`).
- **rsync/ssh côté client** : inutile sur l'hôte — ils sont dans l'image —
mais la **clé SSH** de l'hôte est montée dans le conteneur.
- **Espace disque** : prévoir la taille du cache de tuiles (compter la
taille de `output/` sur la machine de traitement, ~quelques dizaines de
Mo par dalle et par résolution).
### 1. Copier le code sur le Pi (git clone)
```bash ```bash
git clone <ce-dépôt> lidar && cd lidar # ou rsync du poste de dev git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar
cd lidar
``` ```
Le clone SSH utilise la même clé que le rsync (`~/.ssh`, voir ci-dessous) ;
si le serveur git n'est pas encore connu, faire une première connexion pour
accepter son empreinte. `output/` et `input/` sont ignorés par git : le
dépôt ne contient que le code, le cache de tuiles se remplit ensuite par
rsync (premier chargement, section 4).
### 2. Accès SSH pour le rsync (la Pi tire les tuiles traitées) ### 2. Accès SSH pour le rsync (la Pi tire les tuiles traitées)
```bash ```bash
ssh-keygen -t ed25519 # si pas encore de clé ssh-keygen -t ed25519 # si pas encore de clé
ssh-copy-id lidar@<ip-machine> # compte lecture sur output/ ssh-copy-id lidar@<ip-machine> # compte lecture sur output/
ssh lidar@<ip-machine> exit # 1re connexion : enregistre known_hosts
``` ```
La dernière commande évite le prompt « authenticity of host » pendant le
rsync (le conteneur ne peut pas répondre interactivement).
Sur la machine puissante, le dossier `output/` doit être lisible par ce Sur la machine puissante, le dossier `output/` doit être lisible par ce
compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez
LIDAR_SYNC_CMD). LIDAR_SYNC_CMD).
Avec `run.sh`, `~/.ssh` (clé + known_hosts) est monté automatiquement en
lecture seule dans le conteneur ; avec `docker compose`, le montage
équivalent est à décommenter dans `docker-compose.webapp.yml` (voir
l'option b ci-dessous).
### 3. Configurer et lancer ### 3. Configurer et lancer
Éditer `docker-compose.webapp.yml` : Deux façons, au choix :
**a. `run.sh` (le plus simple)** — conteneur webapp seul, cache local des
tuiles ; les variables se passent depuis l'environnement hôte, `~/.ssh`
est monté automatiquement :
```bash
LIDAR_GENERATION_URL=http://192.168.1.50:8973 \
LIDAR_REMOTE_TOKEN=un-secret-à-partager \
LIDAR_SYNC_CMD="rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/" \
LIDAR_AUTO_SYNC_SECONDS=600 \
./run.sh --serve-webapp # port 8973, ou --serve-webapp 9000
```
`LIDAR_AUTO_SYNC_SECONDS` entretient le cache tout seul : toutes les N
secondes, rsync ramène les nouvelles tuiles et les vignettes manquantes
sont régénérées (les mtimes évitent tout recalcul inutile). Sans cette
variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run,
ou `POST /api/sync`.
`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST
/api/generate` : zones, complétion, régénération) aux clients dont l'IP
est dans le réseau indiqué — défaut `192.168.1.0/24`, 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.
Les autres clients consultent la carte normalement : les boutons de
génération sont masqués et l'API répond 403.
**b. `docker compose`** — éditer `docker-compose.webapp.yml` :
- `LIDAR_GENERATION_URL` : `http://<ip-machine>:8973` - `LIDAR_GENERATION_URL` : `http://<ip-machine>:8973`
- `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine - `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine
@ -66,6 +136,14 @@ LIDAR_SYNC_CMD).
- LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/
``` ```
Décommenter aussi le montage de la clé SSH (rsync) :
```yaml
volumes:
- ./output:/data/output
- ~/.ssh:/home/lidar/.ssh:ro
```
Les vignettes (`index_thumbs/`, `index_subtiles/`) ne se synchronisent pas : Les vignettes (`index_thumbs/`, `index_subtiles/`) ne se synchronisent pas :
elles sont régénérées sur place par le Pi (`/api/sync` → `build_index`), elles sont régénérées sur place par le Pi (`/api/sync` → `build_index`),
c'est le seul travail lourd qu'il fait. Exclure `*.tif` évite de copier des c'est le seul travail lourd qu'il fait. Exclure `*.tif` évite de copier des
@ -79,6 +157,10 @@ docker compose -f docker-compose.webapp.yml up -d --build
La carte est sur `http://<ip-pi>:8973/`. La carte est sur `http://<ip-pi>:8973/`.
`run.sh` lance le conteneur au premier plan (Ctrl-C arrête tout) : pratique
pour tester. Pour une installation permanente, préférer `docker compose`
(`restart: unless-stopped` : la carte revient après un reboot du Pi).
### 4. Premier chargement ### 4. Premier chargement
Si aucune tuile n'a encore été synchronisée, déclencher une première fois : Si aucune tuile n'a encore été synchronisée, déclencher une première fois :
@ -90,6 +172,42 @@ curl -X POST http://<ip-pi>:8973/api/sync
(puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running": (puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running":
false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes).
## Mise à jour
Sur le Pi, après un `git pull` (ou rsync du code) :
```bash
docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage
```
Avec `run.sh --serve-webapp`, l'image est reconstruite automatiquement à
chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
(`output/`) n'est pas affecté par les mises à jour d'image.
## Dépannage
- **État d'une sync** : `curl http://<ip-pi>:8973/api/sync` → `running`,
`phase` (`sync` puis `index`) et `error` (dernière erreur, ex. fin de
journal rsync en cas d'échec). Les logs du conteneur :
`docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml
logs -f webapp`).
- **`Permission denied (publickey)` pendant la sync** : refaire
`ssh-copy-id lidar@<ip-machine>` et une connexion manuelle pour
renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le
conteneur (automatique avec `run.sh`, à décommenter en compose).
- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du
client n'est pas dans `LIDAR_REGEN_CIDR` (défaut `192.168.1.0/24`).
Adapter la variable ou la vider pour lever la restriction.
- **« Machine de traitement injoignable »** : vérifier le service `worker`
(`docker compose -f docker-compose.worker.yml logs -f worker`) et
`LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte
reste possible avec le cache local.
- **Carte vide après sync** : attendre la fin du rebuild (`GET /api/sync` →
`"running": false`) puis recharger la page ; le bouton ↻ de la carte
enchaîne sync + vignettes automatiquement.
- **Port 8973 déjà pris** : `--serve-webapp 9000` avec `run.sh`, ou changer
le mapping `ports:` en compose (le conteneur écoute toujours sur 8973).
## Fonctionnement ## Fonctionnement
- Dessiner une zone (bouton « + Zone ») sur la carte du Pi envoie la demande - Dessiner une zone (bouton « + Zone ») sur la carte du Pi envoie la demande
@ -98,9 +216,19 @@ false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes).
machine distante en direct. machine distante en direct.
- À la fin du run, le navigateur appelle `/api/sync` : rsync ramène les - À la fin du run, le navigateur appelle `/api/sync` : rsync ramène les
images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la
carte. Le bouton ↻ relance le même cycle à tout moment. carte. Le bouton ↻ relance le même cycle à tout moment. Avec
`LIDAR_AUTO_SYNC_SECONDS`, le conteneur webapp seul maintient aussi son
cache périodiquement (utile quand personne ne consulte la carte).
- Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la
carte en lecture seule depuis `output/` (cache figé, aucun traitement
possible depuis l'interface).
- **Autonomie** : une fois les tuiles chargées dans le cache local, la
webapp fonctionne seule — Leaflet est vendorisé dans `assets/vendor/`
(aucun CDN), tuiles/vignettes/infos sont lues dans `output/`, et un
backend injoignable masque simplement les boutons de génération sans
affecter la consultation.
- Machine de traitement seule (sans Pi) : rien ne change, `--serve` / - Machine de traitement seule (sans Pi) : rien ne change, `--serve` /
`docker compose up serve` fonctionne comme avant en local. `docker compose up -d --build serve` fonctionne comme avant en local (tout-en-un), et `docker-compose.worker.yml` installe le générateur seul.
- Si la machine de traitement est éteinte, la carte du Pi reste consultable - Si la machine de traitement est éteinte, la carte du Pi reste consultable
(données synchronisées) ; seuls les nouveaux traitements sont indisponibles (données synchronisées) ; seuls les nouveaux traitements sont indisponibles
(message « machine de traitement injoignable »). (message « machine de traitement injoignable »).
@ -108,8 +236,11 @@ false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes).
## Références ## Références
- `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`), - `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`),
`/api/sync`, jeton `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`. `/api/sync`, cache local périodique (`LIDAR_AUTO_SYNC_SECONDS`), jeton
`LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`, restriction des générations au
réseau local (`LIDAR_REGEN_CIDR`).
- `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli - `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli
affine), recharge après sync. affine), recharge après sync.
- `Dockerfile.webapp`, `docker-compose.webapp.yml` : image légère ARM64 - `docker-compose.worker.yml` : générateur de tuiles (machine puissante).
- `Dockerfile.webapp`, `docker-compose.webapp.yml` : webapp légère ARM64
(FastAPI + Pillow AVIF natif + pyproj, ~200 Mo). (FastAPI + Pillow AVIF natif + pyproj, ~200 Mo).

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -257,14 +257,29 @@ def test_build_index_generates_html(tmp_path):
assert "t.started === s.started" in app_js assert "t.started === s.started" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes # Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate). # dans input/ (scope all_missing envoyé au preview et au generate).
# « + Zone » et « Compléter » ne vivent que dans l'onglet Génération,
# jamais dans la barre d'outils flottante de la carte.
assert 'id="btnComplete"' in content assert 'id="btnComplete"' in content
map_tools = content.split('id="mapTools"', 1)[1].split('</nav>', 1)[0]
assert 'btnGenZone' not in map_tools and 'btnComplete' not in map_tools
tab_gen = content.split('id="tab-gen"', 1)[1].split('id="tab-tile"', 1)[0]
assert 'id="btnGenZone"' in tab_gen and 'id="btnComplete"' in tab_gen
assert "all_missing" in app_js and app_js.count("all_missing") >= 2 assert "all_missing" in app_js and app_js.count("all_missing") >= 2
# Dessin de zone au 1er plan : pane dédié au-dessus des tuiles (marqueurs
# Leaflet, pane 600) pour rectangle et cellules fantômes.
assert "createPane('genDraw')" in app_js
assert app_js.count("pane: 'genDraw'") >= 3 # rectangle + 2 jeux de cellules
assert 'assets/app.css' in content assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque) : # Bouton d'actualisation des couches (détection des nouveautés sur disque) :
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante). # le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/sync" in app_js assert "/api/layers" in app_js and "/api/sync" in app_js
assert 'assets/app.js' in content assert 'assets/app.js' in content
# Autonomie hors ligne : Leaflet vendorisé localement, aucun CDN
assert 'assets/vendor/leaflet/leaflet.js' in content
assert 'assets/vendor/leaflet/leaflet.css' in content
assert 'unpkg.com' not in content
assert (assets / 'vendor' / 'leaflet' / 'leaflet.js').is_file()
# Fin d'un run de génération : synchronisation puis rechargement (le # Fin d'un run de génération : synchronisation puis rechargement (le
# rechargement direct reste en secours si /api/sync est absent). # rechargement direct reste en secours si /api/sync est absent).
assert "syncThenReload" in app_js assert "syncThenReload" in app_js
@ -284,6 +299,23 @@ def test_build_index_generates_html(tmp_path):
assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile assert len(thumbs) >= 2 # au moins hillshade pour chaque tuile
def test_mid_thumbnails_generated(tmp_path):
"""Vignette intermédiaire 640 px générée et référencée dans l'index."""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
viz = data['tiles'][0]['viz']['hillshade_multi']
assert 'mid' in viz
assert (output_dir / viz['mid']).exists()
assert viz['mid'].endswith("_mid.jpg")
def test_subtiled_viz_covers_generation_choices(): def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide).""" """Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
@ -387,6 +419,8 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif" hill_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0.avif"
aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif" aspect_avif = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_aspect_0_0.avif"
assert hill_avif.exists() and aspect_avif.exists() assert hill_avif.exists() and aspect_avif.exists()
hill_mid = sub_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_hillshade_multi_0_0_mid.webp"
assert hill_mid.exists()
m_hill_1 = hill_avif.stat().st_mtime m_hill_1 = hill_avif.stat().st_mtime
m_aspect_1 = aspect_avif.stat().st_mtime m_aspect_1 = aspect_avif.stat().st_mtime

View File

@ -583,3 +583,81 @@ def test_require_token():
# Sans jeton configuré : tout passe (LAN de confiance) # Sans jeton configuré : tout passe (LAN de confiance)
webapp.API_TOKEN = None webapp.API_TOKEN = None
assert webapp._require_token(None) is None assert webapp._require_token(None) is None
class _FakeRequest:
"""Requête minimale pour tester la restriction par IP (peer + XFF)."""
def __init__(self, host=None, xff=None):
self.client = type("C", (), {"host": host})() if host else None
self.headers = {"x-forwarded-for": xff} if xff else {}
def test_generate_restricted_to_lan_cidr():
"""/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (défaut 192.168.1.0/24)."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
old = webapp.REGEN_CIDR
webapp.REGEN_CIDR = "192.168.1.0/24"
try:
# IP du réseau autorisé
webapp._require_lan_for_generation(_FakeRequest(host="192.168.1.42"))
# Reverse proxy local (pair dans le réseau) : X-Forwarded-For désigne
# le client réel derrière lui — refusé s'il est hors réseau
webapp._require_lan_for_generation(
_FakeRequest(host="192.168.1.50", xff="192.168.1.10, 192.168.1.50"))
for host, xff in (("10.0.0.5", None), ("8.8.8.8", None),
("192.168.3.1", None), ("2001:db8::1", None),
# XFF forgé par un client externe : ignoré (pair hors réseau)
("8.8.8.8", "192.168.1.5"),
# Client réel externe derrière le proxy local
("192.168.1.50", "8.8.8.8"),
# IP absente
(None, None)):
try:
webapp._require_lan_for_generation(_FakeRequest(host=host, xff=xff))
assert False, f"403 attendu pour host={host!r} xff={xff!r}"
except HTTPException as e:
assert e.status_code == 403
assert "réseau local" in e.detail
finally:
webapp.REGEN_CIDR = old
def test_generate_cidr_disabled(monkeypatch):
"""LIDAR_REGEN_CIDR vide = restriction levée (tout client autorisé)."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "REGEN_CIDR", "")
webapp._require_lan_for_generation(_FakeRequest(host="8.8.8.8"))
webapp._require_lan_for_generation(_FakeRequest())
def test_status_exposes_regen_allowed():
"""/api/status indique à l'interface si le client peut générer."""
import lidar_pipeline.webapp as webapp
assert webapp.status(_FakeRequest(host="192.168.1.7"))["regen_allowed"] is True
assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False
def test_auto_sync_once_skips_when_busy(monkeypatch):
"""Le cycle de cache ne lance rien si un rebuild tourne déjà."""
import time as _time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0")
# Un cycle démarre bien (sync + rebuild)
assert webapp._auto_sync_once() is True
for _ in range(200):
if not webapp.rebuild_status()["running"]:
break
_time.sleep(0.05)
assert calls
# Rebuild artificiellement occupé : le cycle suivant est sauté sans erreur
webapp._rebuild["running"] = True
try:
assert webapp._auto_sync_once() is False
assert len(calls) == 1
finally:
webapp._rebuild["running"] = False

View File

@ -41,6 +41,7 @@ carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
""" """
import json import json
import logging
import math import math
import os import os
import subprocess import subprocess
@ -51,7 +52,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import Depends, FastAPI, Header, HTTPException from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -73,6 +74,17 @@ API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync # Commande shell qui ramène les tuiles traitées dans output/ local (rsync
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild. # depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
# CIDR en notation standard, vide = restriction levée. L'IP du client est
# l'IP de connexion (conservée par le DNAT Docker) ; si elle est dans le
# réseau autorisé, X-Forwarded-For est honoré (reverse proxy local qui
# expose la carte au-dehors — un client distant ne peut pas le forger
# puisque sa connexion directe est déjà hors réseau).
REGEN_CIDR = (os.environ.get("LIDAR_REGEN_CIDR", "192.168.1.0/24") or "").strip()
# Cache local périodique : relance la synchronisation + le rebuild toutes
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
# sa copie des tuiles de la machine de traitement.
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
# Résolutions générées par /api/generate (cf. _build_command). La détection des # Résolutions générées par /api/generate (cf. _build_command). La détection des
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète # tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
@ -81,6 +93,27 @@ GENERATE_RESOLUTIONS = (0.5, 0.2)
app = FastAPI(title="Carte LiDAR — génération de zones") app = FastAPI(title="Carte LiDAR — génération de zones")
# Visibilité de la configuration au démarrage (docker compose logs) : le
# backend de génération est TOUJOURS défini par variable d'environnement.
logger = logging.getLogger("lidar")
if not logger.handlers:
# Hors cli.py (uvicorn seul), le logger 'lidar' n'a aucun handler — en
# ajouter un pour que la config soit visible dans les logs du conteneur.
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(_h)
logger.setLevel(logging.INFO)
if GENERATION_URL:
logger.info(f"Backend de génération des tuiles : {GENERATION_URL} "
f"(LIDAR_GENERATION_URL)")
else:
logger.info("Backend de génération des tuiles : local — consultation "
"autonome du cache, sans LIDAR_GENERATION_URL")
if SYNC_CMD:
logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD"
+ (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)"
if AUTO_SYNC_SECONDS > 0 else " (à la demande)"))
# assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur # assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur
# statique soit actif même avant la première génération de l'index. # statique soit actif même avant la première génération de l'index.
_assets_dir = OUTPUT_DIR / "assets" _assets_dir = OUTPUT_DIR / "assets"
@ -116,6 +149,51 @@ def _require_token(x_lidar_token: str = Header(None)):
raise HTTPException(401, "token d'API manquant ou invalide") raise HTTPException(401, "token d'API manquant ou invalide")
def _ip_in_regen_cidr(ip):
"""Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR)."""
if not REGEN_CIDR:
return True # restriction désactivée
if not ip:
return False
import ipaddress
try:
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
except ValueError:
return False
def _client_ip(request):
"""IP d'origine du client pour la restriction de génération.
Le pair direct doit être dans le réseau autorisé ; dans ce cas seulement,
X-Forwarded-For (reverse proxy local) désigne le client réel derrière lui.
Un client externe connecté en direct est refusé sur son IP de connexion,
quel que soit le X-Forwarded-For qu'il annonce.
"""
if request is None:
return None
peer = getattr(request, "client", None)
peer_ip = getattr(peer, "host", None) if peer else None
if not _ip_in_regen_cidr(peer_ip):
return peer_ip # hors réseau (ou inconnu) : jugé sur cette IP
xff = request.headers.get("x-forwarded-for") if hasattr(request, "headers") else None
if xff:
first = xff.split(",")[0].strip()
if first:
return first
return peer_ip
def _require_lan_for_generation(request: Request):
"""Réserve /api/generate aux clients du réseau local (LIDAR_REGEN_CIDR)."""
ip = _client_ip(request)
if not _ip_in_regen_cidr(ip):
raise HTTPException(
403, f"génération de tuiles réservée au réseau local"
f"{f' ({REGEN_CIDR})' if REGEN_CIDR else ''} — "
f"client : {ip or 'IP inconnue'}")
def _proxy_api(method, path, payload=None, timeout=30): def _proxy_api(method, path, payload=None, timeout=30):
"""Transmet un appel d'API à la machine de traitement (mode webapp légère). """Transmet un appel d'API à la machine de traitement (mode webapp légère).
@ -365,11 +443,15 @@ def root():
@app.get("/api/status") @app.get("/api/status")
def status(): def status(request: Request = None):
# Droit de lancer une génération depuis cette IP (guide l'interface)
regen_allowed = _ip_in_regen_cidr(_client_ip(request))
if GENERATION_URL: if GENERATION_URL:
# Webapp légère : l'état vient de la machine de traitement # Webapp légère : l'état vient de la machine de traitement ; le droit
# de génération reste jugé sur l'IP du navigateur (localement).
data = _proxy_api("GET", "/api/status", timeout=10) data = _proxy_api("GET", "/api/status", timeout=10)
data["distant"] = True data["distant"] = True
data["regen_allowed"] = regen_allowed
return data return data
from .progress import progress_snapshot from .progress import progress_snapshot
with _job_lock: with _job_lock:
@ -381,6 +463,8 @@ def status():
"returncode": _job["returncode"], "returncode": _job["returncode"],
"cmd": _job["cmd"], "cmd": _job["cmd"],
"log": _tail_log(40), "log": _tail_log(40),
# L'interface masque les boutons de génération hors réseau local
"regen_allowed": regen_allowed,
# Tuiles une par une (nom court, type, état) — agrégées depuis les # Tuiles une par une (nom court, type, état) — agrégées depuis les
# événements écrits par le pipeline (.generation.events.jsonl) # événements écrits par le pipeline (.generation.events.jsonl)
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()), "tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()),
@ -475,6 +559,36 @@ def _start_rebuild(with_sync):
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)} return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
def _auto_sync_once():
"""Un cycle de cache local : synchronisation + rebuild, si rien ne tourne.
Retourne True si un cycle a été lancé, False si un rebuild était déjà en
cours (le prochain cycle rattrapera).
"""
if _rebuild["running"]:
return False
try:
_start_rebuild(with_sync=True)
return True
except HTTPException:
return False # course perdue contre un lancement manuel : tant pis
def _auto_sync_loop():
"""Boucle du cache local : synchronise les tuiles périodiquement."""
while True:
time.sleep(AUTO_SYNC_SECONDS)
_auto_sync_once()
# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de
# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a
# rien à synchroniser — pas de boucle (juste le rebuild à la demande).
if AUTO_SYNC_SECONDS > 0 and SYNC_CMD:
threading.Thread(target=_auto_sync_loop, daemon=True,
name="cache-tuiles").start()
@app.post("/api/sync", dependencies=[Depends(_require_token)]) @app.post("/api/sync", dependencies=[Depends(_require_token)])
def sync_and_rebuild(): def sync_and_rebuild():
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan. """Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
@ -578,7 +692,8 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
return cmd return cmd
@app.post("/api/generate", dependencies=[Depends(_require_token)]) @app.post("/api/generate", dependencies=[Depends(_require_token),
Depends(_require_lan_for_generation)])
def generate(req: GenerateRequest): def generate(req: GenerateRequest):
if GENERATION_URL: if GENERATION_URL:
# Webapp légère : téléchargement + traitement sur la machine distante, # Webapp légère : téléchargement + traitement sur la machine distante,

79
run.sh
View File

@ -21,6 +21,11 @@
# --skip VIZ... Exclure ces visualisations # --skip VIZ... Exclure ces visualisations
# --file NOM... Traiter un ou plusieurs fichiers LAZ # --file NOM... Traiter un ou plusieurs fichiers LAZ
# --test Exécuter les tests unitaires # --test Exécuter les tests unitaires
# --serve [PORT] Carte + API de génération (image complète, défaut: 8973)
# --serve-webapp [PORT]
# Conteneur webapp seul (image légère) : carte servie depuis
# le cache local de tuiles, génération déléguée à la machine
# de traitement (LIDAR_GENERATION_URL, LIDAR_SYNC_CMD)
# -h Afficher l'aide complète # -h Afficher l'aide complète
set -e set -e
@ -101,6 +106,9 @@ NO_INDEX_FLAG=""
FETCH_TILES_ARGS="" FETCH_TILES_ARGS=""
SERVE_FLAG=0 SERVE_FLAG=0
SERVE_PORT=8973 SERVE_PORT=8973
WEBAPP_FLAG=0
WEBAPP_SERVE_PORT=8973
WEBAPP_IMAGE="lidar-webapp"
# Parse arguments manually (more robust than getopts for mixed short/long options) # Parse arguments manually (more robust than getopts for mixed short/long options)
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@ -142,6 +150,10 @@ while [ $# -gt 0 ]; do
SERVE_FLAG=1; shift SERVE_FLAG=1; shift
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then SERVE_PORT="$1"; shift; fi if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then SERVE_PORT="$1"; shift; fi
;; ;;
--serve-webapp)
WEBAPP_FLAG=1; shift
if [ $# -gt 0 ] && [[ ! "$1" =~ ^- ]]; then WEBAPP_SERVE_PORT="$1"; shift; fi
;;
-h|--help|-help) -h|--help|-help)
echo "Pipeline LiDAR Archéologique" echo "Pipeline LiDAR Archéologique"
echo "" echo ""
@ -171,6 +183,11 @@ while [ $# -gt 0 ]; do
echo " --fetch-tiles COL,ROW..." echo " --fetch-tiles COL,ROW..."
echo " Télécharger ces dalles depuis l'IGN puis les traiter" echo " Télécharger ces dalles depuis l'IGN puis les traiter"
echo " --serve [PORT] Servir la carte + API de génération de zones (défaut: 8973)" echo " --serve [PORT] Servir la carte + API de génération de zones (défaut: 8973)"
echo " --serve-webapp [PORT]"
echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :"
echo " carte servie depuis le cache local de tuiles, génération"
echo " déléguée via LIDAR_GENERATION_URL + LIDAR_SYNC_CMD"
echo " (cf. docs/DEPLOY_WEBAPP.md)"
echo " --test Exécuter les tests unitaires" echo " --test Exécuter les tests unitaires"
echo " -h Afficher cette aide" echo " -h Afficher cette aide"
echo "" echo ""
@ -224,6 +241,16 @@ build_image() {
fi fi
} }
# Image webapp légère (conteneur webapp seul — cf. Dockerfile.webapp)
build_webapp_image() {
echo "Mise à jour de l'image Docker webapp (cache)..."
if ! docker build -f Dockerfile.webapp -t "$WEBAPP_IMAGE" "$SCRIPT_DIR" >/tmp/lidar_webapp_build.log 2>&1; then
cat /tmp/lidar_webapp_build.log >&2
echo "Échec du build de l'image webapp (journal : /tmp/lidar_webapp_build.log)" >&2
exit 1
fi
}
# Check for --test flag first # Check for --test flag first
if [ "$TEST_FLAG" -eq 1 ]; then if [ "$TEST_FLAG" -eq 1 ]; then
build_image build_image
@ -255,17 +282,67 @@ if [ "$SERVE_FLAG" -eq 1 ]; then
if [ -n "$GPU_ARG" ] && [ "$GPU_ARG" != "all" ]; then if [ -n "$GPU_ARG" ] && [ "$GPU_ARG" != "all" ]; then
GPU_ENV_FLAG="-e LIDAR_GPU=1 -e CUDA_VISIBLE_DEVICES=${GPU_ARG} --gpus '""device=${GPU_ARG}""'" GPU_ENV_FLAG="-e LIDAR_GPU=1 -e CUDA_VISIBLE_DEVICES=${GPU_ARG} --gpus '""device=${GPU_ARG}""'"
fi fi
# Réseau autorisé à lancer les générations (/api/generate) : la valeur
# vient de l'environnement hôte LIDAR_REGEN_CIDR (défaut 192.168.1.0/24,
# chaîne vide pour lever la restriction).
REGEN_ENV_FLAG="-e LIDAR_REGEN_CIDR=${LIDAR_REGEN_CIDR-192.168.1.0/24}"
exec docker run --rm --init $GPU_FLAG $CUDA_ENV_FLAG \ exec docker run --rm --init $GPU_FLAG $CUDA_ENV_FLAG \
--user 1000:1000 \ --user 1000:1000 \
-p "${SERVE_PORT}:8973" \ -p "${SERVE_PORT}:8973" \
-v "${INPUT_DIR}:/data/input" \ -v "${INPUT_DIR}:/data/input" \
-v "${OUTPUT_DIR}:/data/output" \ -v "${OUTPUT_DIR}:/data/output" \
-e LIDAR_INPUT_DIR=/data/input -e LIDAR_OUTPUT_DIR=/data/output \ -e LIDAR_INPUT_DIR=/data/input -e LIDAR_OUTPUT_DIR=/data/output \
$GPU_ENV_FLAG \ $GPU_ENV_FLAG $REGEN_ENV_FLAG \
"$IMAGE_NAME" \ "$IMAGE_NAME" \
python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973 python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
fi fi
# Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU).
# La carte est servie depuis le cache local de tuiles (output/) ; la
# génération est déléguée à la machine de traitement et les tuiles reviennent
# par LIDAR_SYNC_CMD (rsync), rafraîchies aussi par LIDAR_AUTO_SYNC_SECONDS.
# Variables passées telles quelles depuis l'environnement hôte :
# LIDAR_GENERATION_URL machine de traitement (ex: http://192.168.1.50:8973)
# LIDAR_REMOTE_TOKEN jeton si la machine exige LIDAR_API_TOKEN
# LIDAR_SYNC_CMD rsync qui remplit le cache local de tuiles
# LIDAR_AUTO_SYNC_SECONDS resynchronisation périodique (0 = off)
# LIDAR_REGEN_CIDR réseau autorisé à lancer les générations
# (défaut 192.168.1.0/24, vide = restriction levée)
if [ "$WEBAPP_FLAG" -eq 1 ]; then
build_webapp_image
mkdir -p "$OUTPUT_DIR"
AUTO_SYNC_INFO="${LIDAR_AUTO_SYNC_SECONDS:-0}"
echo "============================================"
echo " Carte LiDAR — webapp seule (image légère)"
echo "============================================"
echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/"
echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}"
if [ -n "$LIDAR_SYNC_CMD" ]; then
echo " Cache local tuiles : LIDAR_SYNC_CMD$([ "${LIDAR_AUTO_SYNC_SECONDS:-0}" != "0" ] && echo " (auto toutes les ${AUTO_SYNC_INFO}s)")"
else
echo " Cache local tuiles : désactivé (définir LIDAR_SYNC_CMD pour rsync)"
fi
echo "============================================"
WEBAPP_ENV=()
[ -n "$LIDAR_GENERATION_URL" ] && WEBAPP_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL")
[ -n "$LIDAR_REMOTE_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN")
[ -n "$LIDAR_API_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_API_TOKEN="$LIDAR_API_TOKEN")
[ -n "$LIDAR_SYNC_CMD" ] && WEBAPP_ENV+=(-e LIDAR_SYNC_CMD="$LIDAR_SYNC_CMD")
[ -n "${LIDAR_AUTO_SYNC_SECONDS:-}" ] && WEBAPP_ENV+=(-e LIDAR_AUTO_SYNC_SECONDS="$LIDAR_AUTO_SYNC_SECONDS")
[ -n "${LIDAR_REGEN_CIDR:-}" ] && WEBAPP_ENV+=(-e LIDAR_REGEN_CIDR="$LIDAR_REGEN_CIDR")
# Clé SSH du rsync (LIDAR_SYNC_CMD) : montée en lecture seule si présente
WEBAPP_SSH=()
[ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro")
exec docker run --rm --init \
--user 1000:1000 \
-p "${WEBAPP_SERVE_PORT}:8973" \
-v "${OUTPUT_DIR}:/data/output" \
"${WEBAPP_SSH[@]}" \
-e LIDAR_OUTPUT_DIR=/data/output \
"${WEBAPP_ENV[@]}" \
"$WEBAPP_IMAGE"
fi
# Lancer le pipeline # Lancer le pipeline
echo "============================================" echo "============================================"
echo " Pipeline LiDAR Archéologique" echo " Pipeline LiDAR Archéologique"

View File

@ -6,6 +6,11 @@ setup(
version='2.0.0', version='2.0.0',
description='Pipeline LiDAR pour détection archéologique', description='Pipeline LiDAR pour détection archéologique',
packages=find_packages(), packages=find_packages(),
package_data={'lidar_pipeline': [
'assets/vendor/*',
'assets/vendor/leaflet/*',
'assets/vendor/leaflet/images/*',
]},
python_requires='>=3.8', python_requires='>=3.8',
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [