Tuiles visibles en direct après régénération et déploiement webapp distante
- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et force le rechargement navigateur (cache heuristique contourné), y compris en plein run via /api/tiles ; veille permanente 15 s sur la carte - simulation locale du mode deux machines (docker-compose.local-2m.yml) : worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/ - override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output, rsync vers le worker, labels Traefik (proxy/websecure/myresolver) - purge 0,5 m : worker/process et politique générale passés à 0,2 m seul - intègre le travail parallèle non commité : export mosaïque multi-dalles (export.py + /api/export), sous-tuilage intégral des couches, légendes VIZ_LEGENDS, docs et tests associés (213 tests verts)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
# Résultats générés
|
# Résultats générés
|
||||||
output/
|
output/
|
||||||
|
output-webapp/
|
||||||
input/
|
input/
|
||||||
|
|
||||||
# Fichiers temporaires
|
# Fichiers temporaires
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
- 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)
|
- 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)
|
||||||
|
- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` (rsync local depuis `./output` monté `/data/remote:ro`, auto-sync 120 s). Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS).
|
||||||
- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs|update]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git)
|
- stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs|update]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git)
|
||||||
- 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>`
|
||||||
@ -18,8 +19,10 @@
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `webapp.py`), the compose `process` command and the CLI `-r` default all produce 0.2 m exclusively; 0.5 m stays available via explicit `-r 0.5`. Completeness detection (`complete_cells`) requires the viz at 0.2 m only.
|
||||||
|
- **Sub-tuilage intégral** : `_CARTO_SUBTILED_VIZ` (vide dans `index.py`) découpe TOUTES les couches en quadrants 500 m à 0,2 m ; ortho/topo sont encodées en AVIF q75 (`_SUBTILE_DETAIL_VIZ`) contre q55 pour les rampes de couleur. Une couche qui échoue à la découpe retombe en dalle entière (`_fallback_full_dalle`) sans pénaliser les autres.
|
||||||
- **Bilingual naming**: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
|
- **Bilingual naming**: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
|
||||||
- **Adding a visualization requires 3 edits**: (1) `generate_X()` in `visualizations.py`, (2) entry in `VIZ_STEPS` in `pipeline.py`, (3) entry in `COLORMAPS` in `rendering.py`. Missing any one breaks the pipeline.
|
- **Adding a visualization requires 4 edits**: (1) `generate_X()` in `visualizations.py`, (2) entry in `VIZ_STEPS` in `pipeline.py`, (3) entry in `COLORMAPS` in `rendering.py`, (4) entry in `VIZ_LEGENDS` in `index.py` (title/legend/description + sampled cmap gradient — single text source merged into `COLORMAPS` at import, also used by the export mosaic legend in `export.py`). Missing any one breaks the pipeline.
|
||||||
- **`generate_*` signature is strict**: `(dem_file, basename, vis_dir, resolution, shared=None)` returning `Path` on success, `None` on failure. IGN overlays (`ortho`, `topo`) omit `shared`.
|
- **`generate_*` signature is strict**: `(dem_file, basename, vis_dir, resolution, shared=None)` returning `Path` on success, `None` on failure. IGN overlays (`ortho`, `topo`) omit `shared`.
|
||||||
- **Return `None` on failure, never raise**: `dtm.py`, `visualizations.py`, and `ign.py` all return `None` to let the pipeline continue. Raising aborts the entire file.
|
- **Return `None` on failure, never raise**: `dtm.py`, `visualizations.py`, and `ign.py` all return `None` to let the pipeline continue. Raising aborts the entire file.
|
||||||
- **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it.
|
- **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it.
|
||||||
@ -34,8 +37,9 @@
|
|||||||
### Module structure & data flow
|
### Module structure & data flow
|
||||||
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
|
- `cli.py` → `pipeline.py` (LidarArchaeoPipeline) → per-file: `dtm.py` (classify + rasterize) → `visualizations.py` (17 products) → `rendering.py` (GeoTIFF→AVIF) → `index.py` (Leaflet map)
|
||||||
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
|
- `gpu.py` provides CuPy/NumPy proxy (`xp`), lazy init, OOM fallback. `safe_gpu_call` wraps all non-IGN viz calls.
|
||||||
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker.
|
- `webapp.py` (FastAPI) serves the map + `/api/generate` launches the pipeline as a subprocess. Two-machine mode: `LIDAR_GENERATION_URL` delegates to remote worker. `/api/export` assembles adjacent tiles into an image/PDF via `export.py` (local cache, no delegation).
|
||||||
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
|
- `progress.py` writes JSONL events (O_APPEND, atomic) read by webapp for live progress.
|
||||||
|
- `export.py` stitches adjacent tile visualizations into a seamless mosaic (PNG/JPEG/WebP) or multi-page PDF, Pillow-only for the lightweight webapp.
|
||||||
|
|
||||||
### Key design decisions (intentional, do not "fix")
|
### Key design decisions (intentional, do not "fix")
|
||||||
- **`_res_suffix` hardcodes 0.5 as "no suffix"**: coupled to `index.py` parsing (`_strip_res_suffix` defaults to 0.5 when no suffix). Changing requires sidecar metadata.
|
- **`_res_suffix` hardcodes 0.5 as "no suffix"**: coupled to `index.py` parsing (`_strip_res_suffix` defaults to 0.5 when no suffix). Changing requires sidecar metadata.
|
||||||
|
|||||||
72
docker-compose.local-2m.yml
Normal file
72
docker-compose.local-2m.yml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Simulation LOCALE du mode deux machines (cf. docs/DEPLOY_WEBAPP.md).
|
||||||
|
#
|
||||||
|
# Fait tourner sur une même machine les deux rôles normalement répartis sur
|
||||||
|
# deux machines, dans des conteneurs/images indépendants :
|
||||||
|
# - worker : machine de traitement (image complète, GPU) — API de
|
||||||
|
# génération sur le port hôte 8974 ;
|
||||||
|
# - webapp : interface légère (image Dockerfile.webapp, sans PDAL/GPU) —
|
||||||
|
# carte sur le port hôte 8973, avec son PROPRE cache de tuiles
|
||||||
|
# (./output-webapp) alimenté par rsync depuis ./output du
|
||||||
|
# worker (monté en lecture seule dans la webapp), comme le
|
||||||
|
# Raspberry Pi le fait par SSH vers la machine de traitement.
|
||||||
|
#
|
||||||
|
# Intérêt en local : mettre à jour le code de l'interface (rebuild webapp)
|
||||||
|
# sans toucher au worker ni aux générations en cours, et réciproquement.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.local-2m.yml up -d --build
|
||||||
|
# docker compose -f docker-compose.local-2m.yml logs -f webapp worker
|
||||||
|
# docker compose -f docker-compose.local-2m.yml down
|
||||||
|
# Retour au mode tout-en-un : docker compose up -d --build serve
|
||||||
|
services:
|
||||||
|
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:
|
||||||
|
- "8974:8973" # API joignable en direct (debug) et par la webapp
|
||||||
|
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
|
||||||
|
- LIDAR_GPU=1
|
||||||
|
- LIDAR_WORKERS=2
|
||||||
|
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
webapp:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.webapp
|
||||||
|
image: lidar-webapp
|
||||||
|
container_name: lidar-webapp
|
||||||
|
init: true
|
||||||
|
user: "1000:1000"
|
||||||
|
ports:
|
||||||
|
- "8973:8973"
|
||||||
|
volumes:
|
||||||
|
# Cache local propre à la webapp (équivalent du disque du Raspberry
|
||||||
|
# Pi) — PAS le ./output du worker : la séparation rendu/interface
|
||||||
|
# doit être réelle, les tuiles arrivent par le rsync ci-dessous.
|
||||||
|
- ./output-webapp:/data/output
|
||||||
|
# Sortie du worker, en lecture seule : source du rsync local qui
|
||||||
|
# remplace le "rsync par SSH vers la machine de traitement".
|
||||||
|
- ./output:/data/remote:ro
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
- LIDAR_OUTPUT_DIR=/data/output
|
||||||
|
# Backend de génération = worker du même réseau compose (DNS docker)
|
||||||
|
- LIDAR_GENERATION_URL=http://lidar-worker:8973
|
||||||
|
# Rapatriement des tuiles : mêmes exclusions que la prod (les
|
||||||
|
# vignettes/sous-tuiles sont régénérées localement par /api/sync)
|
||||||
|
- LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles /data/remote/ /data/output/
|
||||||
|
# Cache auto-entretenu : resynchronisation + vignettes toutes les
|
||||||
|
# 2 min (plus réactif que la prod pour tester en local)
|
||||||
|
- LIDAR_AUTO_SYNC_SECONDS=120
|
||||||
|
restart: unless-stopped
|
||||||
@ -52,6 +52,6 @@ services:
|
|||||||
- ./output:/data/output
|
- ./output:/data/output
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.5,0.2", "-g", "all"]
|
command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.2", "-g", "all"]
|
||||||
profiles:
|
profiles:
|
||||||
- process
|
- process
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
# docker compose logs -f serve # journal du serveur
|
# docker compose logs -f serve # journal du serveur
|
||||||
# docker compose down # arrêt
|
# docker compose down # arrêt
|
||||||
# Traitement ponctuel (sans serveur) — --build pour la même raison :
|
# Traitement ponctuel (sans serveur) — --build pour la même raison :
|
||||||
# docker compose run --rm --build process [-r 0.5,0.2 | --force | --file ...]
|
# docker compose run --rm --build process [-r 0.2 | --force | --file ...]
|
||||||
# Jupyter (opt-in) :
|
# Jupyter (opt-in) :
|
||||||
# docker compose --profile interactive up -d jupyter
|
# docker compose --profile interactive up -d jupyter
|
||||||
|
|
||||||
@ -35,6 +35,10 @@ services:
|
|||||||
# décommenter et définir la même valeur en LIDAR_REMOTE_TOKEN là-bas
|
# décommenter et définir la même valeur en LIDAR_REMOTE_TOKEN là-bas
|
||||||
# (protège /api/generate, /api/preview, /api/rebuild, /api/sync).
|
# (protège /api/generate, /api/preview, /api/rebuild, /api/sync).
|
||||||
# - LIDAR_API_TOKEN=change-moi
|
# - LIDAR_API_TOKEN=change-moi
|
||||||
|
# Réseaux autorisés à lancer les générations depuis la carte (défaut :
|
||||||
|
# localhost + plages privées RFC1918 ; liste de CIDR séparés par
|
||||||
|
# virgules, vide = tout autoriser).
|
||||||
|
# - LIDAR_REGEN_CIDR=192.168.1.0/24
|
||||||
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
|
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@ -51,7 +55,7 @@ services:
|
|||||||
- ./output:/data/output
|
- ./output:/data/output
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Paris
|
- TZ=Europe/Paris
|
||||||
command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.5,0.2", "-g", "all"]
|
command: ["python3", "-m", "lidar_pipeline", "/data/input", "-o", "/data/output", "-r", "0.2", "-g", "all"]
|
||||||
profiles:
|
profiles:
|
||||||
- process
|
- process
|
||||||
|
|
||||||
|
|||||||
@ -131,11 +131,14 @@ variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run,
|
|||||||
ou `POST /api/sync`.
|
ou `POST /api/sync`.
|
||||||
|
|
||||||
`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST
|
`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
|
/api/generate` : zones, sélection, complétion, régénération) aux clients
|
||||||
est dans le réseau indiqué — défaut `192.168.1.0/24`, chaîne vide pour
|
dont l'IP est dans un des réseaux indiqués — liste de CIDR séparés par
|
||||||
lever la restriction. L'IP est celle de la connexion (conservée par le
|
virgules, chaîne vide pour lever la restriction. Défaut :
|
||||||
DNAT Docker pour les clients du LAN) ; derrière un reverse proxy local
|
`127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (boucle locale
|
||||||
(dans le réseau autorisé), `X-Forwarded-For` désigne le client réel.
|
+ plages privées RFC1918 : LAN, hôte Docker via la passerelle 172.x ; les
|
||||||
|
IP publiques restent refusées). 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
|
Les autres clients consultent la carte normalement : les boutons de
|
||||||
génération sont masqués et l'API répond 403.
|
génération sont masqués et l'API répond 403.
|
||||||
|
|
||||||
@ -219,8 +222,9 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
|
|||||||
renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le
|
renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le
|
||||||
conteneur (automatique avec `run.sh`, à décommenter en compose).
|
conteneur (automatique avec `run.sh`, à décommenter en compose).
|
||||||
- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du
|
- **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`).
|
client n'est dans aucun des réseaux de `LIDAR_REGEN_CIDR` (défaut :
|
||||||
Adapter la variable ou la vider pour lever la restriction.
|
localhost + plages privées). Adapter la variable ou la vider pour lever
|
||||||
|
la restriction.
|
||||||
- **« Machine de traitement injoignable »** : vérifier le service `worker`
|
- **« Machine de traitement injoignable »** : vérifier le service `worker`
|
||||||
(`docker compose -f docker-compose.worker.yml logs -f worker`) et
|
(`docker compose -f docker-compose.worker.yml logs -f worker`) et
|
||||||
`LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte
|
`LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte
|
||||||
|
|||||||
@ -110,8 +110,8 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-r", "--resolution",
|
"-r", "--resolution",
|
||||||
type=str,
|
type=str,
|
||||||
default="0.5",
|
default="0.2",
|
||||||
help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.5, ex: 0.5,0.2)"
|
help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.2)"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-w", "--workers",
|
"-w", "--workers",
|
||||||
|
|||||||
429
lidar_pipeline/export.py
Normal file
429
lidar_pipeline/export.py
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
"""Export multi-dalles : mosaïque d'images adjacentes pour consultation terrain.
|
||||||
|
|
||||||
|
Assemble les visualisations de plusieurs dalles LiDAR contiguës (1 km chacune,
|
||||||
|
Lambert 93) en une seule image (PNG/JPEG/WebP) ou un PDF à une page par
|
||||||
|
visualisation, avec habillage (bandeau titre, échelle, nord) et légende de la
|
||||||
|
couche exportée : titre, dégradé de la colormap avec bornes, lecture du rendu
|
||||||
|
couleurs ↔ terrain et méthode de calcul (textes de VIZ_LEGENDS, index.py).
|
||||||
|
Les dalles sont collées bord à bord, sans aucun trait ni espace entre elles :
|
||||||
|
les images sources (tif_to_crop) sont des carrés purs de 1 km qui se
|
||||||
|
joignent parfaitement.
|
||||||
|
|
||||||
|
Pensé pour un usage sur téléphone depuis la carte (webapp.py /api/export) :
|
||||||
|
Pillow seul, aucune dépendance au pipeline — fonctionne sur la webapp légère
|
||||||
|
(Raspberry Pi) depuis le cache local des tuiles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger("lidar")
|
||||||
|
|
||||||
|
# Formats de sortie acceptés (clé API → format Pillow).
|
||||||
|
EXPORT_FORMATS = {"png": "PNG", "jpeg": "JPEG", "webp": "WEBP", "pdf": "PDF"}
|
||||||
|
# Extension de fichier par format.
|
||||||
|
EXPORT_EXTS = {"png": "png", "jpeg": "jpg", "webp": "webp", "pdf": "pdf"}
|
||||||
|
|
||||||
|
# Garde-fou mémoire : mosaïque RGB au-delà de ~1,6 Go (Raspberry Pi) refusée
|
||||||
|
# en pleine résolution — l'utilisateur choisit alors une taille réduite.
|
||||||
|
_MAX_CANVAS_BYTES = 1_600_000_000
|
||||||
|
# PDF multi-pages : toutes les pages vivent en mémoire au moment de
|
||||||
|
# l'enregistrement (save_all) — côté maximal ramené à cette valeur.
|
||||||
|
_PDF_PAGE_MAX_PX = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font(size, bold=False):
|
||||||
|
"""Police TrueType si disponible, sinon police embarquée de Pillow."""
|
||||||
|
from PIL import ImageFont
|
||||||
|
names = ("DejaVuSans-Bold.ttf", "DejaVuSans.ttf") if bold else ("DejaVuSans.ttf",)
|
||||||
|
for name in names:
|
||||||
|
for directory in ("/usr/share/fonts/truetype/dejavu/",
|
||||||
|
"/usr/share/fonts/dejavu/",
|
||||||
|
"/usr/share/fonts/TTF/"):
|
||||||
|
path = directory + name
|
||||||
|
if Path(path).exists():
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return ImageFont.load_default(size=size) # Pillow >= 10.1
|
||||||
|
except TypeError:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_rgb(hx):
|
||||||
|
"""'#rrggbb' → (r, g, b)."""
|
||||||
|
hx = hx.lstrip('#')
|
||||||
|
return tuple(int(hx[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def _gradient_bar(gradient, width, height):
|
||||||
|
"""Barre de dégradé interpolée linéairement entre les arrêts de colormap."""
|
||||||
|
from PIL import Image
|
||||||
|
stops = [_hex_rgb(c) for c in gradient]
|
||||||
|
bar = Image.new("RGB", (width, height))
|
||||||
|
n = len(stops) - 1
|
||||||
|
for x in range(width):
|
||||||
|
t = x / max(width - 1, 1) * n
|
||||||
|
i = min(int(t), n - 1)
|
||||||
|
f = t - i
|
||||||
|
color = tuple(int(round(stops[i][k] + (stops[i + 1][k] - stops[i][k]) * f))
|
||||||
|
for k in range(3))
|
||||||
|
for y in range(height):
|
||||||
|
bar.putpixel((x, y), color)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_text(measure, text, font, max_w):
|
||||||
|
"""Découpe un texte en lignes ≤ max_w px (les \\n explicites sont conservés)."""
|
||||||
|
lines = []
|
||||||
|
for para in text.split("\n"):
|
||||||
|
words = para.split()
|
||||||
|
if not words:
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
cur = words[0]
|
||||||
|
for word in words[1:]:
|
||||||
|
if measure.textlength(cur + " " + word, font=font) <= max_w:
|
||||||
|
cur += " " + word
|
||||||
|
else:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
lines.append(cur)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _find_tile_images(vis_dir, cells, viz_keys, resolution):
|
||||||
|
"""Chemins d'image par dalle : {(col, row): {viz_key: Path}}.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: dalles absentes à la résolution demandée, ou visualisations
|
||||||
|
manquantes sur certaines dalles.
|
||||||
|
"""
|
||||||
|
from .index import scan_tiles
|
||||||
|
by_cell = {(t["col"], t["row"]): t for t in scan_tiles(vis_dir)
|
||||||
|
if t["resolution"] == resolution}
|
||||||
|
missing = [f"{c},{r}" for (c, r) in cells if (c, r) not in by_cell]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"dale(s) sans visualisation à {str(resolution).replace('.', ',')} m/px : "
|
||||||
|
f"{', '.join(missing)}")
|
||||||
|
images, missing_viz = {}, []
|
||||||
|
for cell in cells:
|
||||||
|
t = by_cell[cell]
|
||||||
|
per_viz = {}
|
||||||
|
for key in viz_keys:
|
||||||
|
info = t["viz"].get(key)
|
||||||
|
if not info:
|
||||||
|
missing_viz.append(f"{cell[0]},{cell[1]} ({key})")
|
||||||
|
continue
|
||||||
|
per_viz[key] = Path(t["dir_path"]) / info["filename"]
|
||||||
|
images[cell] = per_viz
|
||||||
|
if missing_viz:
|
||||||
|
raise ValueError("visualisation manquante sur : " + ", ".join(missing_viz))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _nice_scale_m(width_m):
|
||||||
|
"""Longueur d'échelle ronde (m) tenant dans ~25 % de la largeur."""
|
||||||
|
for cand in (25, 50, 100, 200, 250, 500, 1000, 2000, 5000, 10000):
|
||||||
|
if cand <= 0.25 * width_m:
|
||||||
|
return cand
|
||||||
|
return 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_scale(v):
|
||||||
|
return f"{v / 1000:g} km" if v >= 1000 else f"{v} m"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mosaic(images, viz_key, tile_px, n_cols, n_rows, col_min, row_max):
|
||||||
|
"""Colle les dalles bord à bord — aucun trait, aucun espace entre elles."""
|
||||||
|
from PIL import Image
|
||||||
|
canvas = Image.new("RGB", (n_cols * tile_px, n_rows * tile_px), "white")
|
||||||
|
for (col, row), per_viz in images.items():
|
||||||
|
img = Image.open(str(per_viz[viz_key]))
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
if img.size != (tile_px, tile_px):
|
||||||
|
img = img.resize((tile_px, tile_px))
|
||||||
|
# Dalle (col, row) : X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
|
||||||
|
# Ligne 0 de l'image = nord → les rows décroissantes descendent.
|
||||||
|
x = (col - col_min) * tile_px
|
||||||
|
y = (row_max - row) * tile_px
|
||||||
|
canvas.paste(img, (x, y))
|
||||||
|
img.close()
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def _legend_layout(legend, mosaic, w):
|
||||||
|
"""Mise en page du bandeau légende (polices, lignes, hauteur), ou None.
|
||||||
|
|
||||||
|
Mesure le texte sur la mosaïque (sans la modifier) pour dimensionner le
|
||||||
|
bandeau avant la création du canevas final.
|
||||||
|
"""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
measure = ImageDraw.Draw(mosaic)
|
||||||
|
# Police corps du bandeau : généreuse (lecture de terrain sur téléphone),
|
||||||
|
# bornée pour rester proportionnée aux petits exports réduits.
|
||||||
|
body = max(16, min(30, w // 100))
|
||||||
|
gap = max(8, int(body * 0.55))
|
||||||
|
line_h = int(body * 1.45)
|
||||||
|
x0 = max(18, w // 60)
|
||||||
|
max_w = max(40, w - 2 * x0)
|
||||||
|
text_font = _load_font(body)
|
||||||
|
lines = _wrap_text(measure, legend["legend"], text_font, max_w)
|
||||||
|
desc_lines = _wrap_text(measure, "Calcul : " + legend["description"],
|
||||||
|
text_font, max_w)
|
||||||
|
has_bar = bool(legend.get("gradient") and legend.get("ticks"))
|
||||||
|
bar_w = max(100, min(w // 3, 640))
|
||||||
|
bar_h = max(10, int(body * 0.9))
|
||||||
|
height = (gap + int(body * 1.75) + gap
|
||||||
|
+ (bar_h + line_h if has_bar else 0) + gap
|
||||||
|
+ len(lines) * line_h + gap + len(desc_lines) * line_h + gap)
|
||||||
|
return {"body": body, "gap": gap, "line_h": line_h, "x0": x0,
|
||||||
|
"title_font": _load_font(int(body * 1.35), bold=True),
|
||||||
|
"text_font": text_font, "lines": lines, "desc_lines": desc_lines,
|
||||||
|
"has_bar": has_bar, "bar_w": bar_w, "bar_h": bar_h, "height": height}
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_legend(out, draw, legend, layout, y_base):
|
||||||
|
"""Dessine le bandeau légende sous le pied : titre, dégradé + bornes,
|
||||||
|
lecture du rendu (lignes de la légende) et méthode de calcul."""
|
||||||
|
y = y_base + layout["gap"]
|
||||||
|
draw.text((layout["x0"], y), legend["title"], fill=(20, 20, 20),
|
||||||
|
font=layout["title_font"], anchor="la")
|
||||||
|
y += int(layout["body"] * 1.75) + layout["gap"]
|
||||||
|
if layout["has_bar"]:
|
||||||
|
bar = _gradient_bar(legend["gradient"], layout["bar_w"], layout["bar_h"])
|
||||||
|
out.paste(bar, (layout["x0"], y))
|
||||||
|
# Cadre dessiné juste à l'extérieur de la barre : les pixels
|
||||||
|
# extrêmes du dégradé (bornes de l'échelle) restent visibles.
|
||||||
|
draw.rectangle((layout["x0"] - 1, y - 1,
|
||||||
|
layout["x0"] + layout["bar_w"], y + layout["bar_h"]),
|
||||||
|
outline=(90, 90, 90), width=1)
|
||||||
|
tick_y = y + layout["bar_h"] + 2
|
||||||
|
draw.text((layout["x0"], tick_y), legend["ticks"][0], fill=(20, 20, 20),
|
||||||
|
font=layout["text_font"], anchor="la")
|
||||||
|
draw.text((layout["x0"] + layout["bar_w"], tick_y), legend["ticks"][1],
|
||||||
|
fill=(20, 20, 20), font=layout["text_font"], anchor="ra")
|
||||||
|
y = tick_y + layout["line_h"]
|
||||||
|
y += layout["gap"]
|
||||||
|
for line in layout["lines"]:
|
||||||
|
draw.text((layout["x0"], y), line, fill=(20, 20, 20),
|
||||||
|
font=layout["text_font"], anchor="la")
|
||||||
|
y += layout["line_h"]
|
||||||
|
y += layout["gap"]
|
||||||
|
for line in layout["desc_lines"]:
|
||||||
|
draw.text((layout["x0"], y), line, fill=(90, 90, 90),
|
||||||
|
font=layout["text_font"], anchor="la")
|
||||||
|
y += layout["line_h"]
|
||||||
|
|
||||||
|
|
||||||
|
def _decorate(mosaic, m_per_px, title, subtitle, legend=None):
|
||||||
|
"""Ajoute un bandeau de titre au-dessus et un pied (échelle + nord) dessous.
|
||||||
|
|
||||||
|
Avec `legend` (entrée de VIZ_LEGENDS), un bandeau explicatif de la couche
|
||||||
|
est ajouté sous le pied : titre, dégradé de la colormap avec bornes,
|
||||||
|
lecture du rendu et méthode de calcul.
|
||||||
|
|
||||||
|
L'habillage ne touche jamais la mosaïque : les dalles restent jointives.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
w, h = mosaic.size
|
||||||
|
top_h = max(90, h // 16)
|
||||||
|
foot_h = max(84, h // 20)
|
||||||
|
layout = _legend_layout(legend, mosaic, w) if legend else None
|
||||||
|
legend_h = layout["height"] if layout else 0
|
||||||
|
out = Image.new("RGB", (w, h + top_h + foot_h + legend_h), "white")
|
||||||
|
out.paste(mosaic, (0, top_h))
|
||||||
|
d = ImageDraw.Draw(out)
|
||||||
|
|
||||||
|
title_font = _load_font(max(22, int(top_h * 0.34)))
|
||||||
|
sub_font = _load_font(max(14, int(top_h * 0.20)))
|
||||||
|
foot_font = _load_font(max(14, int(foot_h * 0.26)))
|
||||||
|
for text, font, cy in ((title, title_font, top_h * 0.40),
|
||||||
|
(subtitle, sub_font, top_h * 0.76)):
|
||||||
|
tw = d.textlength(text, font=font)
|
||||||
|
d.text(((w - tw) / 2, cy), text, fill=(20, 20, 20), font=font,
|
||||||
|
anchor="lm")
|
||||||
|
|
||||||
|
# Pied : échelle à gauche, nord à droite.
|
||||||
|
width_m = w * m_per_px
|
||||||
|
scale_m = _nice_scale_m(width_m)
|
||||||
|
bar_px = max(40, int(scale_m / m_per_px))
|
||||||
|
bar_y = h + top_h + foot_h * 0.42
|
||||||
|
x0, y0 = max(18, w // 60), bar_y - foot_h * 0.16
|
||||||
|
seg = bar_px / 4
|
||||||
|
for i in range(4):
|
||||||
|
cx = x0 + i * seg
|
||||||
|
d.rectangle((cx, y0, cx + seg, y0 + foot_h * 0.20),
|
||||||
|
fill=(15, 15, 15) if i % 2 == 0 else "white",
|
||||||
|
outline=(15, 15, 15), width=2)
|
||||||
|
d.text((x0 + bar_px / 2, y0 - foot_h * 0.22), _fmt_scale(scale_m),
|
||||||
|
fill=(20, 20, 20), font=foot_font, anchor="ms")
|
||||||
|
|
||||||
|
nx = w - max(30, w // 24)
|
||||||
|
ny = h + top_h + foot_h * 0.55
|
||||||
|
s = foot_h * 0.30
|
||||||
|
d.polygon([(nx, ny - s * 1.5), (nx - s, ny + s), (nx, ny + s * 0.4),
|
||||||
|
(nx + s, ny + s)], fill=(15, 15, 15))
|
||||||
|
d.text((nx, ny - s * 2.2), "N", fill=(20, 20, 20), font=foot_font,
|
||||||
|
anchor="ms")
|
||||||
|
if layout:
|
||||||
|
y_base = h + top_h + foot_h
|
||||||
|
d.line((0, y_base, w, y_base), fill=(210, 210, 210), width=1)
|
||||||
|
_draw_legend(out, d, legend, layout, y_base)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def export_filename(cells, resolution, viz_keys, fmt):
|
||||||
|
"""Nom de fichier déterministe + horodatage (évite les collisions)."""
|
||||||
|
cols = [c for c, _ in cells]
|
||||||
|
rows = [r for _, r in cells]
|
||||||
|
res = str(resolution).replace(".", "p")
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
viz = "-".join(viz_keys)[:60]
|
||||||
|
return (f"export_{min(cols)}-{max(cols)}_{min(rows)}-{max(rows)}"
|
||||||
|
f"_r{res}_{viz}_{stamp}.{EXPORT_EXTS[fmt]}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_export(vis_dir, cells, viz_keys, resolution, fmt, out_dir,
|
||||||
|
max_side=0):
|
||||||
|
"""Assemble des dalles adjacentes en une image ou un PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vis_dir: dossier output/visualisations (dalles scannées via index).
|
||||||
|
cells: liste (col, row) des dalles à assembler (km L93).
|
||||||
|
viz_keys: mots-clés de visualisation (ex: slope, hillshade_multi).
|
||||||
|
Plusieurs clés → un export par couche : PDF multi-pages (une page
|
||||||
|
par visualisation) ou une image par visualisation.
|
||||||
|
resolution: résolution des dalles en m/px (0.5 ou 0.2).
|
||||||
|
fmt: png, jpeg, webp ou pdf.
|
||||||
|
out_dir: dossier de sortie (output/exports, créé si besoin).
|
||||||
|
max_side: réduire le plus grand côté de la mosaïque à N pixels
|
||||||
|
(0 = pleine résolution).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{file: Path du premier fichier, width, height, pages,
|
||||||
|
files: [{file, width, height, pages}, …] — 1 par couche en image,
|
||||||
|
1 entrée pour le PDF multi-pages}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: dalles ou visualisations manquantes, format invalide,
|
||||||
|
zone trop grande en mémoire.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
resample = getattr(Image, 'LANCZOS', 1)
|
||||||
|
if fmt not in EXPORT_FORMATS:
|
||||||
|
raise ValueError(f"format invalide : {fmt!r} "
|
||||||
|
f"(attendus : {', '.join(EXPORT_FORMATS)})")
|
||||||
|
viz_keys = [str(k) for k in viz_keys if k]
|
||||||
|
if not viz_keys:
|
||||||
|
raise ValueError("aucune visualisation demandée")
|
||||||
|
|
||||||
|
images = _find_tile_images(vis_dir, cells, viz_keys, resolution)
|
||||||
|
cols = [c for c, _ in cells]
|
||||||
|
rows = [r for _, r in cells]
|
||||||
|
col_min, col_max = min(cols), max(cols)
|
||||||
|
row_min, row_max = min(rows), max(rows)
|
||||||
|
n_cols = col_max - col_min + 1
|
||||||
|
n_rows = row_max - row_min + 1
|
||||||
|
# Côté réel d'une dalle (2000 px à 0,5 m, 5000 à 0,2 m) : lu sur la
|
||||||
|
# première image plutôt que recalculé, pour coller aux fichiers réels.
|
||||||
|
first_path = next(iter(images.values()))[viz_keys[0]]
|
||||||
|
with Image.open(str(first_path)) as _im:
|
||||||
|
tile_px = max(_im.size)
|
||||||
|
tile_px = max(1, tile_px)
|
||||||
|
canvas_bytes = n_cols * tile_px * n_rows * tile_px * 3
|
||||||
|
if canvas_bytes > _MAX_CANVAS_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"zone trop grande en pleine résolution "
|
||||||
|
f"({n_cols}×{n_rows} dalles à {resolution} m/px ≈ "
|
||||||
|
f"{canvas_bytes / 1e9:.1f} Go) — choisissez une taille réduite")
|
||||||
|
|
||||||
|
from .index import VIZ_LABELS, VIZ_LEGENDS
|
||||||
|
res_txt = str(resolution).replace(".", ",")
|
||||||
|
date_txt = datetime.now().strftime("%d/%m/%Y")
|
||||||
|
subtitle = (f"Lambert 93 · X {col_min}–{col_max + 1} km · "
|
||||||
|
f"Y {row_min - 1}–{row_max} km · {res_txt} m/px · "
|
||||||
|
f"{len(cells)} dalle{'s' if len(cells) > 1 else ''} · {date_txt}")
|
||||||
|
|
||||||
|
out_dir = Path(out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"Export : {n_cols}×{n_rows} dalles à {res_txt} m/px, "
|
||||||
|
f"{' × '.join(viz_keys)} ({fmt})")
|
||||||
|
|
||||||
|
# PDF multi-pages : toutes les pages sont retenues pour save_all —
|
||||||
|
# côté plafonné pour rester en mémoire raisonnable.
|
||||||
|
page_cap = _PDF_PAGE_MAX_PX if fmt == "pdf" and len(viz_keys) > 1 else None
|
||||||
|
|
||||||
|
# Options d'enregistrement hétérogènes (str/int/float/bool)
|
||||||
|
save_kw: dict = {"format": EXPORT_FORMATS[fmt]}
|
||||||
|
if fmt == "jpeg":
|
||||||
|
save_kw["quality"] = 90
|
||||||
|
elif fmt == "webp":
|
||||||
|
save_kw["quality"] = 90
|
||||||
|
elif fmt == "pdf":
|
||||||
|
save_kw["resolution"] = 150.0
|
||||||
|
|
||||||
|
# Une couche = un export : page du PDF multi-pages, ou fichier image
|
||||||
|
# dédié (chacun porte la légende de SA couche).
|
||||||
|
pages = [] # pages PDF décorées (save_all)
|
||||||
|
files = [] # fichiers écrits (1 par couche en image, le PDF sinon)
|
||||||
|
try:
|
||||||
|
for key in viz_keys:
|
||||||
|
mosaic = _build_mosaic(images, key, tile_px, n_cols, n_rows,
|
||||||
|
col_min, row_max)
|
||||||
|
m_per_px = resolution
|
||||||
|
caps = [c for c in (int(max_side), page_cap) if c]
|
||||||
|
cap = min(caps) if caps else 0
|
||||||
|
if cap and max(mosaic.size) > cap:
|
||||||
|
ratio = cap / max(mosaic.size)
|
||||||
|
size = (max(1, round(mosaic.size[0] * ratio)),
|
||||||
|
max(1, round(mosaic.size[1] * ratio)))
|
||||||
|
mosaic = mosaic.resize(size, resample)
|
||||||
|
m_per_px = resolution / ratio
|
||||||
|
decorated = _decorate(mosaic, m_per_px, VIZ_LABELS.get(key, key),
|
||||||
|
subtitle, legend=VIZ_LEGENDS.get(key))
|
||||||
|
mosaic.close()
|
||||||
|
if fmt != "pdf":
|
||||||
|
key_file = out_dir / export_filename(cells, resolution, [key], fmt)
|
||||||
|
decorated.save(str(key_file), **save_kw)
|
||||||
|
decorated.close()
|
||||||
|
files.append(key_file)
|
||||||
|
else:
|
||||||
|
pages.append(decorated)
|
||||||
|
if fmt == "pdf":
|
||||||
|
out_file = out_dir / export_filename(cells, resolution, viz_keys, fmt)
|
||||||
|
if len(pages) > 1:
|
||||||
|
save_kw["save_all"] = True
|
||||||
|
save_kw["append_images"] = pages[1:]
|
||||||
|
pages[0].save(str(out_file), **save_kw)
|
||||||
|
files.append(out_file)
|
||||||
|
finally:
|
||||||
|
for p in pages:
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
result_files = []
|
||||||
|
for f in files:
|
||||||
|
entry = {"file": f, "width": 0, "height": 0, "pages": 1}
|
||||||
|
if fmt == "pdf":
|
||||||
|
# Pages de même dimension que la première mosaïque décorée.
|
||||||
|
first = pages[0] if pages else None
|
||||||
|
entry["width"], entry["height"] = first.size if first else (0, 0)
|
||||||
|
entry["pages"] = len(pages)
|
||||||
|
else:
|
||||||
|
with Image.open(str(f)) as img:
|
||||||
|
entry["width"], entry["height"] = img.size
|
||||||
|
result_files.append(entry)
|
||||||
|
head = result_files[0] if result_files else {
|
||||||
|
"file": None, "width": 0, "height": 0, "pages": 0}
|
||||||
|
result = {"file": head["file"], "width": head["width"],
|
||||||
|
"height": head["height"], "pages": head["pages"],
|
||||||
|
"files": result_files}
|
||||||
|
total = sum(f.stat().st_size for f in files)
|
||||||
|
logger.info(f"✓ Export terminé : {len(files)} fichier"
|
||||||
|
f"{'s' if len(files) > 1 else ''} ({total / 1e6:.1f} Mo)")
|
||||||
|
return result
|
||||||
@ -14,6 +14,9 @@ Fonctionnalités :
|
|||||||
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
|
(lue depuis output/DTM/*_dtm_method.txt), dates et tailles des fichiers.
|
||||||
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
|
- Génération de nouvelles zones via l'API web (./run.sh --serve) :
|
||||||
dessin d'un rectangle → téléchargement IGN + traitement.
|
dessin d'un rectangle → téléchargement IGN + traitement.
|
||||||
|
- Onglet Export : sélection de dalles sur la carte → mosaïque jointive
|
||||||
|
(image ou PDF multi-couches) téléchargeable pour consultation sur
|
||||||
|
téléphone (/api/export de la webapp, module export.py).
|
||||||
|
|
||||||
Intégration:
|
Intégration:
|
||||||
- Appelé automatiquement à la fin de process_all() dans pipeline.py
|
- Appelé automatiquement à la fin de process_all() dans pipeline.py
|
||||||
@ -50,13 +53,158 @@ VIZ_LABELS = {
|
|||||||
'topo': 'Carte topographique IGN',
|
'topo': 'Carte topographique IGN',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Légendes des couches : titre, lecture du rendu (couleurs), méthode de calcul
|
||||||
|
# et dégradé de colormap. Source unique partagée entre rendering.py (images
|
||||||
|
# unitaires) et export.py (bandeau légende des mosaïques multi-dalles) — ce
|
||||||
|
# module reste volontairement sans dépendance lourde (webapp légère : ni
|
||||||
|
# matplotlib, ni GDAL).
|
||||||
|
# 'gradient' : 9 arrêts échantillonnés sur la colormap matplotlib (plt.get_cmap
|
||||||
|
# à i/8) pour dessiner une barre de dégradé sans matplotlib ; la cohérence
|
||||||
|
# avec rendering.COLORMAPS est vérifiée par les tests (test_export.py).
|
||||||
|
# 'ticks' : libellés des extrémités du dégradé (None = pas de barre).
|
||||||
|
VIZ_LEGENDS = {
|
||||||
|
'hillshade_multi': {
|
||||||
|
'title': 'Hillshade Multidirectionnel',
|
||||||
|
'legend': 'Illumination combinée de 8 directions (échelle fixe 0–1)\nBlanc = Face éclairée | Noir = Zone d\'ombre\nCouleurs homogènes entre tuiles',
|
||||||
|
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
|
||||||
|
'cmap': 'gray',
|
||||||
|
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
|
||||||
|
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
|
||||||
|
'ticks': ('0', '1'),
|
||||||
|
},
|
||||||
|
'slope': {
|
||||||
|
'title': 'Pente (Inclinaison du terrain)',
|
||||||
|
'legend': 'Inclinaison en degrés\nÉchelle fixe 0–30° — couleurs homogènes entre tuiles\nJaune = Forte pente | Violet foncé = Terrain plat',
|
||||||
|
'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
|
||||||
|
'cmap': 'inferno',
|
||||||
|
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
|
||||||
|
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
|
||||||
|
'ticks': ('0°', '30°'),
|
||||||
|
},
|
||||||
|
'aspect': {
|
||||||
|
'title': 'Aspect (Direction des pentes)',
|
||||||
|
'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)',
|
||||||
|
'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles',
|
||||||
|
'cmap': 'twilight',
|
||||||
|
'gradient': ('#e2d9e2', '#95b5c7', '#6276ba', '#592a8f', '#2f1436',
|
||||||
|
'#741e4f', '#b25652', '#cca389', '#e2d9e2'),
|
||||||
|
'ticks': ('Nord 0°', 'Nord 360°'),
|
||||||
|
},
|
||||||
|
'mslrm': {
|
||||||
|
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
|
||||||
|
'legend': 'Relief combiné multi-échelles (σ locales, échelle fixe ±3σ)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nCouleurs homogènes entre tuiles\nDétecte du micro au macro',
|
||||||
|
'description': 'Combine LRM à 5–7 échelles — détecte structures de 5m à 100m simultanément',
|
||||||
|
'cmap': 'seismic',
|
||||||
|
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
|
||||||
|
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
|
||||||
|
'ticks': ('-3σ', '+3σ'),
|
||||||
|
},
|
||||||
|
'sailore': {
|
||||||
|
'title': 'SAILORE - LRM Auto-Adaptatif',
|
||||||
|
'legend': 'Relief local adaptatif (σ locales, échelle fixe ±3σ)\nRouge = Surélévation | Bleu = Dépression\nCouleurs homogènes entre tuiles\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)',
|
||||||
|
'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau',
|
||||||
|
'cmap': 'seismic',
|
||||||
|
'gradient': ('#00004c', '#0000a6', '#0101ff', '#8181ff', '#fffdfd',
|
||||||
|
'#ff7d7d', '#fe0000', '#be0000', '#800000'),
|
||||||
|
'ticks': ('-3σ', '+3σ'),
|
||||||
|
},
|
||||||
|
'positive_openness': {
|
||||||
|
'title': 'Openness Positive (ouverture vers le haut)',
|
||||||
|
'legend': 'Angle d\'ouverture vers le ciel (z-score local)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles',
|
||||||
|
'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets',
|
||||||
|
'cmap': 'YlOrBr',
|
||||||
|
'gradient': ('#ffffe5', '#fff7bc', '#fee390', '#fec34f', '#fe9829',
|
||||||
|
'#eb6f14', '#cb4b02', '#983404', '#662506'),
|
||||||
|
'ticks': ('-3σ', '+3σ'),
|
||||||
|
},
|
||||||
|
'negative_openness': {
|
||||||
|
'title': 'Openness Negative (ouverture vers le bas)',
|
||||||
|
'legend': 'Angle d\'ouverture vers le bas (z-score local)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nMeilleur détecteur de cavités et dolines',
|
||||||
|
'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains',
|
||||||
|
'cmap': 'PuBu',
|
||||||
|
'gradient': ('#fff7fb', '#ece7f2', '#d0d1e6', '#a5bddb', '#73a9cf',
|
||||||
|
'#358fc0', '#056faf', '#04598c', '#023858'),
|
||||||
|
'ticks': ('-3σ', '+3σ'),
|
||||||
|
},
|
||||||
|
'svf': {
|
||||||
|
'title': 'Sky-View Factor (fraction de ciel visible)',
|
||||||
|
'legend': 'Proportion de ciel visible (échelle physique fixe 0–1)\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nCouleurs homogènes entre tuiles\nLes fossés ressortent en vif — excellent pour structures linéaires',
|
||||||
|
'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
|
||||||
|
'cmap': 'hot_r',
|
||||||
|
'gradient': ('#ffffff', '#ffff81', '#ffff03', '#ffad00', '#ff5900',
|
||||||
|
'#ff0500', '#b00000', '#5c0000', '#0b0000'),
|
||||||
|
'ticks': ('0', '1'),
|
||||||
|
},
|
||||||
|
'roughness': {
|
||||||
|
'title': 'Rugosité Multi-Échelle (3m + 15m)',
|
||||||
|
'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)',
|
||||||
|
'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses',
|
||||||
|
'cmap': 'plasma',
|
||||||
|
'gradient': ('#0d0887', '#4c02a1', '#7e03a8', '#aa2395', '#cc4778',
|
||||||
|
'#e66c5c', '#f89540', '#fdc527', '#f0f921'),
|
||||||
|
'ticks': ('lisse', 'rugueux'),
|
||||||
|
},
|
||||||
|
'wavelet': {
|
||||||
|
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
|
||||||
|
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
|
||||||
|
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
|
||||||
|
'cmap': 'inferno',
|
||||||
|
'gradient': ('#000004', '#210c4a', '#57106e', '#8a226a', '#bc3754',
|
||||||
|
'#e45a31', '#f98e09', '#f9cb35', '#fcffa4'),
|
||||||
|
'ticks': ('bruit', 'structure'),
|
||||||
|
},
|
||||||
|
'flow_acc': {
|
||||||
|
'title': 'Accumulation d\'Écoulement (Flow Accumulation)',
|
||||||
|
'legend': 'Log10 du nombre de cellules amont\nVert foncé = Forte accumulation (fossé, chenal, drainage)\nJaune = Faible accumulation (terrain plat)\n\nDétection fossés et linéaires hydrologiques',
|
||||||
|
'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages',
|
||||||
|
'cmap': 'YlGn',
|
||||||
|
'gradient': ('#ffffe5', '#f7fcb9', '#d9f0a3', '#acdd8e', '#77c679',
|
||||||
|
'#40aa5c', '#228343', '#006737', '#004529'),
|
||||||
|
'ticks': ('faible', 'forte'),
|
||||||
|
},
|
||||||
|
'solar': {
|
||||||
|
'title': 'Éclairage Solaire',
|
||||||
|
'legend': "Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d'ombre",
|
||||||
|
'description': "Simulation de l'éclairage solaire matinal",
|
||||||
|
'cmap': 'gray',
|
||||||
|
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
|
||||||
|
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
|
||||||
|
'ticks': ('0', '1'),
|
||||||
|
},
|
||||||
|
'anomaly': {
|
||||||
|
'title': 'Carte d\'Anomalies (détection automatique)',
|
||||||
|
'legend': 'Score d\'anomalie composite (0–1)\nRouge = Haute anomalie (structures suspectes)\nJaune = Anomalie modérée\nBlanc = Aucun signal (terrain naturel)\n\nSeuil auto: pixels > 2σ de la moyenne locale\nCombiné: MSRM, SVF, Ondelette, Openness, Rugosité',
|
||||||
|
'description': 'Détection automatique — cible à vérifier sur terrain',
|
||||||
|
'cmap': 'YlOrRd',
|
||||||
|
'gradient': ('#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8c3c',
|
||||||
|
'#fc4d2a', '#e2191c', '#bb0026', '#800026'),
|
||||||
|
'ticks': ('0', '1'),
|
||||||
|
},
|
||||||
|
'ortho': {
|
||||||
|
'title': 'Photographie Aérienne IGN',
|
||||||
|
'legend': 'Orthophotographie\nImage aérienne',
|
||||||
|
'description': 'Photographie aérienne IGN (Orthophoto)',
|
||||||
|
'cmap': None,
|
||||||
|
'gradient': None,
|
||||||
|
'ticks': None,
|
||||||
|
},
|
||||||
|
'topo': {
|
||||||
|
'title': 'Carte Topographique IGN',
|
||||||
|
'legend': 'Carte IGN\nPlan topographique',
|
||||||
|
'description': 'Carte topographique IGN (Plan IGN)',
|
||||||
|
'cmap': None,
|
||||||
|
'gradient': None,
|
||||||
|
'ticks': None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Couche activée par défaut à l'ouverture de la carte.
|
# Couche activée par défaut à l'ouverture de la carte.
|
||||||
DEFAULT_VIZ = 'hillshade_multi'
|
DEFAULT_VIZ = 'hillshade_multi'
|
||||||
|
|
||||||
# Visualisations proposées dans le panneau de couches et le sélecteur de
|
# Restriction optionnelle du panneau de couches : None = proposer toutes
|
||||||
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
|
# les visualisations présentes sur disque (aucune restriction). Le sélecteur
|
||||||
# les couches réellement proposées à l'affichage (voir _render_html).
|
# de génération/régénération est piloté par le même registre (voir _render_html).
|
||||||
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
|
PANEL_VIZ = None
|
||||||
|
|
||||||
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
|
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
|
||||||
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
|
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
|
||||||
@ -95,11 +243,10 @@ def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
|
|||||||
return {cell for cell, per_res in by_cell.items()
|
return {cell for cell, per_res in by_cell.items()
|
||||||
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
|
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
|
||||||
|
|
||||||
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
|
# Toutes les visualisations sont découpées en sous-tuiles (quadrants 500 m)
|
||||||
# carte — les autres viz restent disponibles en repli dalle entière.
|
# pour alléger la carte. Renseigner un tuple pour restreindre la découpe —
|
||||||
# Liste vide = découper toutes les visualisations disponibles.
|
# les viz exclues retombent en repli dalle entière.
|
||||||
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
_CARTO_SUBTILED_VIZ = ()
|
||||||
'wavelet')
|
|
||||||
|
|
||||||
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
|
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
|
||||||
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
|
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
|
||||||
@ -108,6 +255,12 @@ _CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness'
|
|||||||
_SUBTILE_AVIF_QUALITY = 55
|
_SUBTILE_AVIF_QUALITY = 55
|
||||||
_SUBTILE_AVIF_SPEED = 8
|
_SUBTILE_AVIF_SPEED = 8
|
||||||
|
|
||||||
|
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
|
||||||
|
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
|
||||||
|
# relevée pour ces couches uniquement.
|
||||||
|
_SUBTILE_AVIF_QUALITY_DETAIL = 75
|
||||||
|
_SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
|
||||||
|
|
||||||
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
|
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
|
||||||
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
|
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
|
||||||
# complet dès qu'une tuile dépasse ~300 px à l'écran.
|
# complet dès qu'une tuile dépasse ~300 px à l'écran.
|
||||||
@ -423,6 +576,20 @@ def _cached_file_fresh(path, src_mtime):
|
|||||||
return src_mtime is None or cached_mtime >= src_mtime
|
return src_mtime is None or cached_mtime >= src_mtime
|
||||||
|
|
||||||
|
|
||||||
|
def _url_version(mtime):
|
||||||
|
"""Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu.
|
||||||
|
|
||||||
|
Les images de la carte sont servies par des montages statiques sans
|
||||||
|
Cache-Control : après une régénération, le cache heuristique du navigateur
|
||||||
|
peut continuer d'afficher l'ancien rendu (même URL = image considérée
|
||||||
|
fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un
|
||||||
|
recalcul change l'URL et force le rechargement — y compris en direct
|
||||||
|
pendant un run : pollLiveTiles fusionne les nouvelles données et
|
||||||
|
updateImages() voit un src différent.
|
||||||
|
"""
|
||||||
|
return f"?v={int(mtime * 1000)}" if mtime is not None else ""
|
||||||
|
|
||||||
|
|
||||||
def generate_thumbnail(src_path, thumb_path, max_size=256, mid_path=None,
|
def generate_thumbnail(src_path, thumb_path, max_size=256, mid_path=None,
|
||||||
mid_size=640):
|
mid_size=640):
|
||||||
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
|
"""Génère une vignette JPEG depuis une image AVIF/WebP existante.
|
||||||
@ -513,6 +680,13 @@ def _subtile_corners(corners, i, j, k):
|
|||||||
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
|
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_full_dalle(entries, viz_key, info):
|
||||||
|
"""Repli dalle entière pour une couche non découpable : elle reste
|
||||||
|
superposable en couche (images plus lourdes, mais fonctionnelles)."""
|
||||||
|
for entry in entries.values():
|
||||||
|
entry['viz'][viz_key] = dict(info)
|
||||||
|
|
||||||
|
|
||||||
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
||||||
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
|
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
|
||||||
|
|
||||||
@ -555,11 +729,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
info = tile['viz'].get(viz_key)
|
info = tile['viz'].get(viz_key)
|
||||||
if not info:
|
if not info:
|
||||||
continue
|
continue
|
||||||
|
avif_quality = (_SUBTILE_AVIF_QUALITY_DETAIL
|
||||||
|
if viz_key in _SUBTILE_DETAIL_VIZ
|
||||||
|
else _SUBTILE_AVIF_QUALITY)
|
||||||
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
|
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
|
||||||
for key in entries}
|
for key in entries}
|
||||||
# Régénère si au moins un fichier manque ou est périmé (dalle
|
# Régénère si au moins un fichier manque ou est périmé (dalle
|
||||||
# source recalculée depuis — comme les vignettes)
|
# source recalculée depuis — comme les vignettes). L'URL pleine
|
||||||
src = output_dir / info['full']
|
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
|
||||||
|
src = output_dir / info['full'].split('?')[0]
|
||||||
src_mtime = _mtime(src)
|
src_mtime = _mtime(src)
|
||||||
all_exist = all(
|
all_exist = all(
|
||||||
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
|
_cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
|
||||||
@ -574,10 +752,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
img.load()
|
img.load()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
|
logger.debug(f"Sous-tuilage {viz_key} impossible ({src.name}): {e}")
|
||||||
return None
|
_fallback_full_dalle(entries, viz_key, info)
|
||||||
|
continue
|
||||||
if img.mode not in ('RGB', 'L'):
|
if img.mode not in ('RGB', 'L'):
|
||||||
img = img.convert('RGB')
|
img = img.convert('RGB')
|
||||||
W, H = img.size
|
W, H = img.size
|
||||||
|
cut_failed = False
|
||||||
for (i, j), stem in stems.items():
|
for (i, j), stem in stems.items():
|
||||||
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
|
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
|
||||||
left, right = round(W * i / k), round(W * (i + 1) / k)
|
left, right = round(W * i / k), round(W * (i + 1) / k)
|
||||||
@ -586,12 +766,13 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
quad = img.crop((left, top, right, bottom))
|
quad = img.crop((left, top, right, bottom))
|
||||||
try:
|
try:
|
||||||
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
|
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
|
||||||
format='AVIF', quality=_SUBTILE_AVIF_QUALITY,
|
format='AVIF', quality=avif_quality,
|
||||||
speed=_SUBTILE_AVIF_SPEED)
|
speed=_SUBTILE_AVIF_SPEED)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Encodage AVIF impossible ({stem}), "
|
logger.warning(f"Encodage AVIF impossible ({stem}), "
|
||||||
f"sous-tuilage abandonné : {e}")
|
f"repli dalle entière pour {viz_key} : {e}")
|
||||||
return None
|
cut_failed = True
|
||||||
|
break
|
||||||
# Supprime l'ancien crop .webp d'une génération précédente
|
# Supprime l'ancien crop .webp d'une génération précédente
|
||||||
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
|
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
|
||||||
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
|
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
|
||||||
@ -607,11 +788,15 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
max(1, int(quad.size[1] * scale))), resample)
|
max(1, int(quad.size[1] * scale))), resample)
|
||||||
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
|
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
|
||||||
format='WEBP', quality=80)
|
format='WEBP', quality=80)
|
||||||
|
if cut_failed:
|
||||||
|
_fallback_full_dalle(entries, viz_key, info)
|
||||||
|
continue
|
||||||
|
v = _url_version(src_mtime)
|
||||||
for (i, j), stem in stems.items():
|
for (i, j), stem in stems.items():
|
||||||
entries[(i, j)]['viz'][viz_key] = {
|
entries[(i, j)]['viz'][viz_key] = {
|
||||||
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
|
'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
|
||||||
'mid': f"{sub_dir_name}/{stem}_mid.webp",
|
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
|
||||||
'full': f"{sub_dir_name}/{stem}.avif",
|
'full': f"{sub_dir_name}/{stem}.avif{v}",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
|
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")
|
||||||
@ -620,13 +805,12 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
|
|||||||
usable = [e for e in entries.values() if e['viz']]
|
usable = [e for e in entries.values() if e['viz']]
|
||||||
if not usable:
|
if not usable:
|
||||||
return None
|
return None
|
||||||
# Repli dalle entière pour les visualisations non découpées : elles restent
|
# Repli dalle entière pour les visualisations hors sélection : elles
|
||||||
# superposables en couche (images plus lourdes, mais fonctionnelles).
|
# restent superposables en couche (images plus lourdes, mais fonctionnelles).
|
||||||
sub_keys = set(offered_viz_keys)
|
sub_keys = set(offered_viz_keys)
|
||||||
for entry in usable:
|
for viz_key, info in tile['viz'].items():
|
||||||
for viz_key, info in tile['viz'].items():
|
if viz_key not in sub_keys:
|
||||||
if viz_key not in sub_keys:
|
_fallback_full_dalle(entries, viz_key, info)
|
||||||
entry['viz'][viz_key] = dict(info)
|
|
||||||
return usable
|
return usable
|
||||||
|
|
||||||
|
|
||||||
@ -799,12 +983,13 @@ def build_index(output_dir, output_format='avif'):
|
|||||||
else:
|
else:
|
||||||
thumbs_generated += 1
|
thumbs_generated += 1
|
||||||
viz_dir_name = _viz_src_dir(t, info).name
|
viz_dir_name = _viz_src_dir(t, info).name
|
||||||
|
v = _url_version(src_mtime)
|
||||||
viz_thumbs[viz_key] = {
|
viz_thumbs[viz_key] = {
|
||||||
'thumb': f"index_thumbs/{thumb_name}",
|
'thumb': f"index_thumbs/{thumb_name}{v}",
|
||||||
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
|
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}",
|
||||||
}
|
}
|
||||||
if mid_path.is_file():
|
if mid_path.is_file():
|
||||||
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}"
|
viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}"
|
||||||
|
|
||||||
if regen:
|
if regen:
|
||||||
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
|
logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — "
|
||||||
@ -909,9 +1094,11 @@ def _panel_selection(all_viz_keys):
|
|||||||
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
|
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
|
||||||
décrire le panneau exactement comme la page au moment du rebuild).
|
décrire le panneau exactement comme la page au moment du rebuild).
|
||||||
"""
|
"""
|
||||||
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
|
# Panneau complet : toutes les couches du registre (VIZ_LABELS), même
|
||||||
# les autres visualisations existent sur disque mais ne sont pas proposées.
|
# absentes du disque — activables dès leur apparition — plus les couches
|
||||||
panel_keys = set(all_viz_keys)
|
# historiques éventuellement trouvées sur disque (libellé = clé brute).
|
||||||
|
# PANEL_VIZ permet toujours de restreindre volontairement la sélection.
|
||||||
|
panel_keys = set(VIZ_LABELS) | set(all_viz_keys)
|
||||||
if PANEL_VIZ is not None:
|
if PANEL_VIZ is not None:
|
||||||
panel_keys &= set(PANEL_VIZ)
|
panel_keys &= set(PANEL_VIZ)
|
||||||
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
|
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
|
||||||
@ -919,10 +1106,12 @@ def _panel_selection(all_viz_keys):
|
|||||||
for v in sorted(panel_keys):
|
for v in sorted(panel_keys):
|
||||||
if v not in ordered_viz:
|
if v not in ordered_viz:
|
||||||
ordered_viz.append(v)
|
ordered_viz.append(v)
|
||||||
# Couche activée par défaut : préférence globale, sinon aspect (base
|
# Couche activée par défaut : préférence globale puis aspect, à condition
|
||||||
# complète), sinon la 1re proposée.
|
# d'exister sur le disque ; sinon la 1re proposée présente, sinon la 1re.
|
||||||
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
|
default_viz = next(
|
||||||
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
|
(v for v in (DEFAULT_VIZ, 'aspect') if v in all_viz_keys),
|
||||||
|
next((v for v in ordered_viz if v in all_viz_keys),
|
||||||
|
ordered_viz[0] if ordered_viz else DEFAULT_VIZ))
|
||||||
return ordered_viz, default_viz
|
return ordered_viz, default_viz
|
||||||
|
|
||||||
|
|
||||||
@ -932,13 +1121,13 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
|||||||
|
|
||||||
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
||||||
|
|
||||||
# Options du sélecteur de génération/régénération : uniquement les couches
|
# Options du sélecteur de génération/régénération : TOUTES les couches du
|
||||||
# réellement proposées dans le panneau de visualisation (mêmes couches,
|
# registre (mêmes libellés que le panneau ; valeurs = noms d'étapes --only
|
||||||
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Toutes
|
# du pipeline), y compris celles absentes du disque — pour pouvoir les
|
||||||
# pré-sélectionnées : une régénération refait la dalle complète du panneau,
|
# générer. Toutes pré-sélectionnées : une régénération refait la dalle
|
||||||
# l'utilisateur pouvant désélectionner pour cibler. Repli sur aspect si
|
# complète, l'utilisateur pouvant désélectionner pour cibler.
|
||||||
# aucune couche n'est encore disponible.
|
gen_keys = [v for v in _VIZ_FALLBACK_ORDER if v in VIZ_LABELS]
|
||||||
gen_keys = ordered_viz if ordered_viz else ['aspect']
|
gen_keys += sorted(k for k in VIZ_LABELS if k not in gen_keys)
|
||||||
gen_viz_options = "\n".join(
|
gen_viz_options = "\n".join(
|
||||||
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
|
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
|
||||||
f'{VIZ_LABELS.get(v, v)}</option>'
|
f'{VIZ_LABELS.get(v, v)}</option>'
|
||||||
@ -996,6 +1185,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
<button class="tab active" data-tab="layers">Couches</button>
|
<button class="tab active" data-tab="layers">Couches</button>
|
||||||
<button class="tab" data-tab="gen">Génération<span id="genBadge" class="tab-badge" hidden></span></button>
|
<button class="tab" data-tab="gen">Génération<span id="genBadge" class="tab-badge" hidden></span></button>
|
||||||
<button class="tab" data-tab="tile">Tuile</button>
|
<button class="tab" data-tab="tile">Tuile</button>
|
||||||
|
<button class="tab" data-tab="export">Export</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-page" id="tab-layers">
|
<div class="tab-page" id="tab-layers">
|
||||||
@ -1013,9 +1203,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-page" id="tab-gen" hidden>
|
<div class="tab-page" id="tab-gen" hidden>
|
||||||
<!-- Actions Zone / Compléter : réservées à cet onglet -->
|
<!-- Actions Zone / Sélection / Compléter : réservées à cet onglet -->
|
||||||
<div class="gen-actions">
|
<div class="gen-actions">
|
||||||
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
|
||||||
|
<button id="btnGenSel" title="Régénérer des tuiles précises : cliquer des dalles sur la carte pour les ajouter ou les retirer" hidden>↻ Sélectionner des tuiles</button>
|
||||||
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
|
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1027,7 +1218,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</label>
|
</label>
|
||||||
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
|
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
|
||||||
<span>Visualisations :</span>
|
<span>Visualisations :</span>
|
||||||
<select id="genViz" class="gen-select" multiple size="4">
|
<select id="genViz" class="gen-select" multiple size="8">
|
||||||
{gen_viz_options}
|
{gen_viz_options}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@ -1070,6 +1261,53 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
|||||||
<div class="tile-empty">Cliquez sur une tuile de la carte pour afficher ses informations de génération.</div>
|
<div class="tile-empty">Cliquez sur une tuile de la carte pour afficher ses informations de génération.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-page" id="tab-export" hidden>
|
||||||
|
<div class="export-intro">
|
||||||
|
Assemblez des dalles adjacentes en une image ou un PDF consultable
|
||||||
|
hors ligne sur téléphone (collage bord à bord, sans séparation).
|
||||||
|
</div>
|
||||||
|
<div class="gen-actions">
|
||||||
|
<button id="btnExportSel" title="Activer la sélection : cliquer des dalles sur la carte pour les ajouter ou les retirer">⤓ Sélectionner des dalles</button>
|
||||||
|
<button id="exportClear" hidden>Effacer la sélection</button>
|
||||||
|
</div>
|
||||||
|
<div id="exportBar">
|
||||||
|
<div id="exportCount" class="export-count"></div>
|
||||||
|
<label class="gen-opt col">
|
||||||
|
<span>Résolution :</span>
|
||||||
|
<select id="exportRes" class="gen-select"></select>
|
||||||
|
</label>
|
||||||
|
<label class="gen-opt col">
|
||||||
|
<span>Visualisations <em>(une image/page par couche)</em> :</span>
|
||||||
|
<select id="exportViz" class="gen-select" multiple size="5"></select>
|
||||||
|
</label>
|
||||||
|
<label class="gen-opt col">
|
||||||
|
<span>Format :</span>
|
||||||
|
<select id="exportFormat" class="gen-select">
|
||||||
|
<option value="jpeg" selected>JPEG — image légère</option>
|
||||||
|
<option value="png">PNG — image sans perte</option>
|
||||||
|
<option value="webp">WebP — image</option>
|
||||||
|
<option value="pdf">PDF — une page par visualisation</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="gen-opt col">
|
||||||
|
<span>Taille (plus grand côté) :</span>
|
||||||
|
<select id="exportSize" class="gen-select">
|
||||||
|
<option value="0">Pleine résolution</option>
|
||||||
|
<option value="4096" selected>4096 px — téléphone</option>
|
||||||
|
<option value="2048">2048 px — aperçu</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="gen-run">
|
||||||
|
<button id="exportGo" class="primary">Exporter</button>
|
||||||
|
</div>
|
||||||
|
<div id="exportMsg"></div>
|
||||||
|
</div>
|
||||||
|
<div id="exportDone" hidden>
|
||||||
|
<div class="tile-info-sub">Exports de la session</div>
|
||||||
|
<div id="exportDoneList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -1377,8 +1615,8 @@ body.zooming .ltile { will-change: transform; }
|
|||||||
/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */
|
/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */
|
||||||
.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
.gen-actions button { width: 100%; font-size: 12.5px; }
|
.gen-actions button { width: 100%; font-size: 12.5px; }
|
||||||
#btnGenZone { background: var(--accent-dim); border-color: var(--accent-dim); }
|
#btnGenZone, #btnGenSel { background: var(--accent-dim); border-color: var(--accent-dim); }
|
||||||
#btnGenZone:hover { background: var(--accent); border-color: var(--accent); }
|
#btnGenZone:hover, #btnGenSel:hover { background: var(--accent); border-color: var(--accent); }
|
||||||
#btnComplete {
|
#btnComplete {
|
||||||
font-weight: 600; letter-spacing: 0.02em;
|
font-weight: 600; letter-spacing: 0.02em;
|
||||||
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
|
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
|
||||||
@ -1474,6 +1712,46 @@ body.zooming .ltile { will-change: transform; }
|
|||||||
.gen-tile.done .gen-tile-state { color: #39d98a; }
|
.gen-tile.done .gen-tile-state { color: #39d98a; }
|
||||||
.gen-tile.failed .gen-tile-state { color: var(--danger); }
|
.gen-tile.failed .gen-tile-state { color: var(--danger); }
|
||||||
|
|
||||||
|
/* --- Onglet Export : mosaïque de dalles adjacentes (téléphone) --- */
|
||||||
|
.export-intro {
|
||||||
|
font-size: 12px; color: var(--muted); line-height: 1.5; padding: 4px 2px 0;
|
||||||
|
}
|
||||||
|
#btnExportSel { background: var(--accent-dim); border-color: var(--accent-dim); }
|
||||||
|
#btnExportSel:hover { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.export-count { font-size: 12.5px; font-weight: 600; }
|
||||||
|
.export-count .sub { font-weight: 400; color: var(--muted); font-size: 11px; }
|
||||||
|
#exportBar {
|
||||||
|
display: none; flex-direction: column; gap: 8px;
|
||||||
|
margin-top: 8px; padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--panel-border); border-radius: 8px;
|
||||||
|
}
|
||||||
|
#exportBar.show { display: flex; }
|
||||||
|
#exportMsg { font-size: 12.5px; line-height: 1.45; }
|
||||||
|
#exportDone { margin-top: 10px; border-top: 1px solid var(--panel-border); padding-top: 8px; }
|
||||||
|
#exportDoneList { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
#exportDoneList a {
|
||||||
|
font-size: 11.5px; color: var(--accent); text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
#exportDoneList a:hover { text-decoration: underline; }
|
||||||
|
#exportDoneList .dl-ic { margin-right: 4px; }
|
||||||
|
/* Surlignage vert des dalles retenues pour l'export (surcouche écran
|
||||||
|
uniquement — l'image exportée colle les dalles sans aucun trait). */
|
||||||
|
.ltile.exportSel {
|
||||||
|
border-color: #39d98a;
|
||||||
|
box-shadow: 0 0 0 1px #39d98a, 0 0 12px rgba(57, 217, 138, 0.5);
|
||||||
|
}
|
||||||
|
body.exporting, body.exporting #viewport { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Surlignage bleu des dalles retenues pour la régénération (mode
|
||||||
|
« Sélectionner des tuiles » de l'onglet Génération). */
|
||||||
|
.ltile.genSel {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5);
|
||||||
|
}
|
||||||
|
body.genSel, body.genSel #viewport { cursor: pointer; }
|
||||||
|
|
||||||
/* Barres de défilement fines et discrètes */
|
/* Barres de défilement fines et discrètes */
|
||||||
.tab-page::-webkit-scrollbar,
|
.tab-page::-webkit-scrollbar,
|
||||||
.gen-task-log::-webkit-scrollbar { width: 8px; }
|
.gen-task-log::-webkit-scrollbar { width: 8px; }
|
||||||
@ -1617,7 +1895,11 @@ function registerTile(t) {
|
|||||||
interactive: true,
|
interactive: true,
|
||||||
keyboard: false
|
keyboard: false
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
mk.on('click', () => { if (!genMode) showTileInfo(t); });
|
mk.on('click', () => {
|
||||||
|
if (exportMode) { toggleExportSel(t); return; }
|
||||||
|
if (genSelMode) { toggleGenSel(t); return; }
|
||||||
|
if (!genMode) showTileInfo(t);
|
||||||
|
});
|
||||||
const el = mk.getElement();
|
const el = mk.getElement();
|
||||||
markers.set(tileKey(t), {
|
markers.set(tileKey(t), {
|
||||||
mk: mk, t: t,
|
mk: mk, t: t,
|
||||||
@ -2105,7 +2387,11 @@ function hideTileInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') hideTileInfo();
|
if (e.key === 'Escape') {
|
||||||
|
hideTileInfo();
|
||||||
|
if (exportMode) setExportMode(false);
|
||||||
|
if (genSelMode) setGenSelMode(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function regenerateCurrentTile() {
|
function regenerateCurrentTile() {
|
||||||
@ -2126,6 +2412,7 @@ function regenerateCurrentTile() {
|
|||||||
let apiAvailable = false;
|
let apiAvailable = false;
|
||||||
let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR)
|
let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR)
|
||||||
const genBtn = document.getElementById('btnGenZone');
|
const genBtn = document.getElementById('btnGenZone');
|
||||||
|
const btnGenSel = document.getElementById('btnGenSel');
|
||||||
const btnComplete = document.getElementById('btnComplete');
|
const btnComplete = document.getElementById('btnComplete');
|
||||||
const genBar = document.getElementById('genBar');
|
const genBar = document.getElementById('genBar');
|
||||||
const genMsg = document.getElementById('genMsg');
|
const genMsg = document.getElementById('genMsg');
|
||||||
@ -2144,6 +2431,8 @@ const genQueueCount = document.getElementById('genQueueCount');
|
|||||||
let genMode = false, drawStart = null, drawRect = null;
|
let genMode = false, drawStart = null, drawRect = null;
|
||||||
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
|
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
|
||||||
const ghostCells = [];
|
const ghostCells = [];
|
||||||
|
let genSelMode = false;
|
||||||
|
const genSel = new Set(); // clés "col,row" des dalles retenues pour la régénération
|
||||||
|
|
||||||
// Affiche la barre de confirmation de génération, dans l'onglet Génération
|
// Affiche la barre de confirmation de génération, dans l'onglet Génération
|
||||||
// (bascule automatique sur cet onglet).
|
// (bascule automatique sur cet onglet).
|
||||||
@ -2155,17 +2444,16 @@ function showGenBar(msg) {
|
|||||||
|
|
||||||
fetch('/api/status').then(r => {
|
fetch('/api/status').then(r => {
|
||||||
apiAvailable = r.ok;
|
apiAvailable = r.ok;
|
||||||
|
if (!apiAvailable) hideExportTab();
|
||||||
return r.ok ? r.json() : null;
|
return r.ok ? r.json() : null;
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
if (!d) { if (apiAvailable) { genBtn.hidden = false; btnComplete.hidden = false; } return; }
|
const genBtns = [genBtn, btnGenSel, btnComplete];
|
||||||
|
if (!d) { if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = false; }); return; }
|
||||||
// Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) :
|
// Génération réservée au réseau local (LIDAR_REGEN_CIDR, cf. webapp.py) :
|
||||||
// les boutons de traitement restent masqués pour les autres clients.
|
// les boutons de traitement restent masqués pour les autres clients.
|
||||||
regenAllowed = d.regen_allowed !== false;
|
regenAllowed = d.regen_allowed !== false;
|
||||||
if (apiAvailable) {
|
if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; });
|
||||||
genBtn.hidden = !regenAllowed;
|
}).catch(() => { if (!apiAvailable) hideExportTab(); });
|
||||||
btnComplete.hidden = !regenAllowed;
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
|
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
|
||||||
|
|
||||||
@ -2187,6 +2475,7 @@ function exitGenMode() {
|
|||||||
genBtn.addEventListener('click', () => {
|
genBtn.addEventListener('click', () => {
|
||||||
if (genMode) { exitGenMode(); return; }
|
if (genMode) { exitGenMode(); return; }
|
||||||
clearGhosts();
|
clearGhosts();
|
||||||
|
clearGenSel();
|
||||||
genBar.classList.remove('show');
|
genBar.classList.remove('show');
|
||||||
genMode = true;
|
genMode = true;
|
||||||
map.dragging.disable();
|
map.dragging.disable();
|
||||||
@ -2239,12 +2528,92 @@ map.on('mouseup', e => {
|
|||||||
}).catch(() => { showGenBar('API indisponible'); });
|
}).catch(() => { showGenBar('API indisponible'); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// « Sélectionner des tuiles » : mode régénération ciblée — le clic sur une
|
||||||
|
// dalle l'ajoute ou la retire (surlignage bleu), toutes résolutions confondues
|
||||||
|
// (les sous-tuiles 0,2 m comptent pour une seule dalle). La barre de
|
||||||
|
// confirmation permet ensuite de choisir les couches et les options avant de
|
||||||
|
// lancer la régénération (--force --force-classification) sur la sélection.
|
||||||
|
function genSelKey(col, row) { return col + ',' + row; }
|
||||||
|
|
||||||
|
function refreshGenSelHighlights() {
|
||||||
|
for (const entry of markers.values()) {
|
||||||
|
const div = entryDiv(entry);
|
||||||
|
if (!div) continue;
|
||||||
|
div.classList.toggle('genSel', genSel.has(genSelKey(entry.t.col, entry.t.row)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genSelCells() {
|
||||||
|
return [...genSel].map(k => k.split(',').map(Number));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGenSelBar() {
|
||||||
|
showGenBar(genSel.size
|
||||||
|
? genSel.size + ' tuile(s) sélectionnée(s) — options puis Lancer'
|
||||||
|
: 'Mode sélection : clic sur dalle = ajouter / retirer');
|
||||||
|
genGo.style.display = genSel.size ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGenSel(t) {
|
||||||
|
const key = genSelKey(t.col, t.row);
|
||||||
|
if (genSel.has(key)) genSel.delete(key);
|
||||||
|
else genSel.add(key);
|
||||||
|
refreshGenSelHighlights();
|
||||||
|
pendingTiles = genSelCells();
|
||||||
|
pendingRegen = true;
|
||||||
|
pendingCompleteAll = false;
|
||||||
|
updateGenSelBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGenSelMode(on) {
|
||||||
|
genSelMode = on;
|
||||||
|
document.body.classList.toggle('genSel', on);
|
||||||
|
if (btnGenSel) {
|
||||||
|
btnGenSel.classList.toggle('primary', on);
|
||||||
|
btnGenSel.innerHTML = on
|
||||||
|
? '✓ Sélection en cours — cliquer des dalles'
|
||||||
|
: '↻ Sélectionner des tuiles';
|
||||||
|
}
|
||||||
|
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
|
||||||
|
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider).
|
||||||
|
if (on && document.body.classList.contains('menu-open')) {
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
if (hintChip) {
|
||||||
|
hintChip.innerHTML = on
|
||||||
|
? 'Mode sélection : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
|
||||||
|
: hintDefaultHtml;
|
||||||
|
}
|
||||||
|
if (on) {
|
||||||
|
if (genMode) exitGenMode();
|
||||||
|
clearGhosts();
|
||||||
|
pendingCompleteAll = false;
|
||||||
|
genRegen.checked = true; // régénération ciblée : retraitement forcé
|
||||||
|
switchTab('gen');
|
||||||
|
updateGenSelBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quitte le mode sélection et vide la sélection (changement de mode).
|
||||||
|
function clearGenSel() {
|
||||||
|
if (genSelMode) setGenSelMode(false);
|
||||||
|
if (genSel.size) {
|
||||||
|
genSel.clear();
|
||||||
|
refreshGenSelHighlights();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnGenSel) btnGenSel.addEventListener('click', () => {
|
||||||
|
setGenSelMode(!genSelMode);
|
||||||
|
});
|
||||||
|
|
||||||
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
|
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
|
||||||
// moins une des visualisations choisies (pas de téléchargement) — la barre de
|
// moins une des visualisations choisies (pas de téléchargement) — la barre de
|
||||||
// confirmation reste utilisable (choix des couches, classification, Lancer).
|
// confirmation reste utilisable (choix des couches, classification, Lancer).
|
||||||
btnComplete.addEventListener('click', () => {
|
btnComplete.addEventListener('click', () => {
|
||||||
if (!apiAvailable) return;
|
if (!apiAvailable) return;
|
||||||
clearGhosts();
|
clearGhosts();
|
||||||
|
clearGenSel();
|
||||||
genGo.style.display = 'none';
|
genGo.style.display = 'none';
|
||||||
showGenBar('Analyse des tuiles présentes dans input/...');
|
showGenBar('Analyse des tuiles présentes dans input/...');
|
||||||
fetch('/api/preview', {
|
fetch('/api/preview', {
|
||||||
@ -2274,6 +2643,7 @@ genGo.addEventListener('click', () => {
|
|||||||
clearGhosts();
|
clearGhosts();
|
||||||
const nTiles = pendingTiles.length;
|
const nTiles = pendingTiles.length;
|
||||||
const isRegen = genRegen.checked || pendingRegen;
|
const isRegen = genRegen.checked || pendingRegen;
|
||||||
|
const fromSel = genSel.size > 0;
|
||||||
fetch('/api/generate', {
|
fetch('/api/generate', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ tiles: pendingTiles,
|
body: JSON.stringify({ tiles: pendingTiles,
|
||||||
@ -2288,6 +2658,8 @@ genGo.addEventListener('click', () => {
|
|||||||
const verb = isRegen ? 'régénération'
|
const verb = isRegen ? 'régénération'
|
||||||
: pendingCompleteAll ? 'complétion' : 'génération';
|
: pendingCompleteAll ? 'complétion' : 'génération';
|
||||||
pendingCompleteAll = false;
|
pendingCompleteAll = false;
|
||||||
|
pendingRegen = false;
|
||||||
|
if (fromSel) clearGenSel();
|
||||||
return startGenTask(nTiles + ' tuile(s) — ' + verb);
|
return startGenTask(nTiles + ' tuile(s) — ' + verb);
|
||||||
})
|
})
|
||||||
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
|
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
|
||||||
@ -2297,9 +2669,219 @@ genCancel.addEventListener('click', () => {
|
|||||||
genBar.classList.remove('show');
|
genBar.classList.remove('show');
|
||||||
clearGhosts();
|
clearGhosts();
|
||||||
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
|
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
|
||||||
|
clearGenSel();
|
||||||
if (genMode) exitGenMode();
|
if (genMode) exitGenMode();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Export : mosaïque de dalles adjacentes (image ou PDF téléphone)
|
||||||
|
// ============================================================
|
||||||
|
// Mode sélection : le clic sur une dalle l'ajoute ou la retire (surlignage
|
||||||
|
// vert), les 4 sous-tuiles 0,2 m comptant pour une seule dalle. La
|
||||||
|
// validation assemble les images pleine résolution côté serveur
|
||||||
|
// (/api/export) : collage bord à bord, aucun trait entre les dalles.
|
||||||
|
const btnExportSel = document.getElementById('btnExportSel');
|
||||||
|
const exportClearBtn = document.getElementById('exportClear');
|
||||||
|
const exportBarEl = document.getElementById('exportBar');
|
||||||
|
const exportCountEl = document.getElementById('exportCount');
|
||||||
|
const exportResSel = document.getElementById('exportRes');
|
||||||
|
const exportVizSel = document.getElementById('exportViz');
|
||||||
|
const exportFormatSel = document.getElementById('exportFormat');
|
||||||
|
const exportSizeSel = document.getElementById('exportSize');
|
||||||
|
const exportGoBtn = document.getElementById('exportGo');
|
||||||
|
const exportMsgEl = document.getElementById('exportMsg');
|
||||||
|
const exportDoneEl = document.getElementById('exportDone');
|
||||||
|
const exportDoneList = document.getElementById('exportDoneList');
|
||||||
|
const hintChip = document.getElementById('hint');
|
||||||
|
const hintDefaultHtml = hintChip ? hintChip.innerHTML : '';
|
||||||
|
let exportMode = false;
|
||||||
|
const exportSel = new Set(); // clés "col,row,résolution" des dalles retenues
|
||||||
|
|
||||||
|
// L'export passe par l'API : sans serveur (carte statique), l'onglet disparaît.
|
||||||
|
function hideExportTab() {
|
||||||
|
const b = document.querySelector('.tab[data-tab="export"]');
|
||||||
|
if (b) b.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportKey(col, row, res) { return col + ',' + row + ',' + res; }
|
||||||
|
|
||||||
|
function refreshExportHighlights() {
|
||||||
|
for (const entry of markers.values()) {
|
||||||
|
const div = entryDiv(entry);
|
||||||
|
if (!div) continue;
|
||||||
|
const t = entry.t;
|
||||||
|
div.classList.toggle('exportSel',
|
||||||
|
exportSel.has(exportKey(t.col, t.row, t.resolution)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExportSel(t) {
|
||||||
|
const key = exportKey(t.col, t.row, t.resolution);
|
||||||
|
if (exportSel.has(key)) exportSel.delete(key);
|
||||||
|
else exportSel.add(key);
|
||||||
|
refreshExportHighlights();
|
||||||
|
updateExportPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExportMode(on) {
|
||||||
|
exportMode = on;
|
||||||
|
document.body.classList.toggle('exporting', on);
|
||||||
|
btnExportSel.classList.toggle('primary', on);
|
||||||
|
btnExportSel.innerHTML = on
|
||||||
|
? '✓ Sélection en cours — cliquer des dalles'
|
||||||
|
: '⤓ Sélectionner des dalles';
|
||||||
|
// Mobile : le tiroir latéral couvre la carte — on le referme pour laisser
|
||||||
|
// la place aux clics (l'utilisateur le rouvre via ☰ pour valider l'export).
|
||||||
|
if (on && document.body.classList.contains('menu-open')) {
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
if (hintChip) {
|
||||||
|
hintChip.innerHTML = on
|
||||||
|
? 'Mode export : clic sur dalle = ajouter / retirer<br>Échap pour terminer la sélection'
|
||||||
|
: hintDefaultHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dalles retenues pour une résolution donnée
|
||||||
|
function exportSelCells(res) {
|
||||||
|
const cells = [];
|
||||||
|
for (const key of exportSel) {
|
||||||
|
const p = key.split(',');
|
||||||
|
if (Number(p[2]) === res) cells.push([Number(p[0]), Number(p[1])]);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couches communes aux dalles sélectionnées (à la résolution choisie) :
|
||||||
|
// seules celles proposées par le panneau (VIZ_META) sont exportables.
|
||||||
|
function exportCommonViz(res) {
|
||||||
|
let common = null;
|
||||||
|
for (const key of exportSel) {
|
||||||
|
const p = key.split(',');
|
||||||
|
if (Number(p[2]) !== res) continue;
|
||||||
|
const keys = new Set();
|
||||||
|
for (const t of TILES) {
|
||||||
|
if (t.col === Number(p[0]) && t.row === Number(p[1])
|
||||||
|
&& t.resolution === res) {
|
||||||
|
for (const k in t.viz) if (VIZ_META[k]) keys.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
common = common === null ? keys : new Set([...common].filter(k => keys.has(k)));
|
||||||
|
}
|
||||||
|
return common || new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportResOptions() {
|
||||||
|
const resList = [...new Set([...exportSel].map(k => Number(k.split(',')[2])))].sort();
|
||||||
|
const prev = exportResSel.value !== '' ? Number(exportResSel.value) : null;
|
||||||
|
exportResSel.innerHTML = '';
|
||||||
|
for (const r of resList) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(r);
|
||||||
|
o.textContent = String(r).replace('.', ',') + ' m/px';
|
||||||
|
exportResSel.appendChild(o);
|
||||||
|
}
|
||||||
|
// Résolution conservée si encore valide, sinon la plus fine
|
||||||
|
exportResSel.value = (prev !== null && resList.includes(prev))
|
||||||
|
? String(prev) : String(resList[resList.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportVizOptions() {
|
||||||
|
const res = Number(exportResSel.value);
|
||||||
|
const common = [...exportCommonViz(res)].sort((a, b) => layerPos(a) - layerPos(b));
|
||||||
|
const prev = Array.from(exportVizSel.selectedOptions).map(o => o.value)
|
||||||
|
.filter(k => common.includes(k));
|
||||||
|
exportVizSel.innerHTML = '';
|
||||||
|
for (const k of common) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = k;
|
||||||
|
o.textContent = VIZ_META[k] ? VIZ_META[k].label : k;
|
||||||
|
exportVizSel.appendChild(o);
|
||||||
|
}
|
||||||
|
// Défaut : la couche active la plus haute, sinon la première proposée
|
||||||
|
const sel = prev.length ? prev
|
||||||
|
: [activeLayers().filter(k => common.includes(k)).pop() || common[0]].filter(Boolean);
|
||||||
|
[...exportVizSel.options].forEach(o => { o.selected = sel.includes(o.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportPanel() {
|
||||||
|
const has = exportSel.size > 0;
|
||||||
|
exportBarEl.classList.toggle('show', has);
|
||||||
|
exportClearBtn.hidden = !has;
|
||||||
|
exportMsgEl.textContent = '';
|
||||||
|
if (!has) return;
|
||||||
|
updateExportResOptions();
|
||||||
|
updateExportVizOptions();
|
||||||
|
updateExportCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExportCount() {
|
||||||
|
const res = Number(exportResSel.value);
|
||||||
|
const n = exportSelCells(res).length;
|
||||||
|
const cols = exportSelCells(res).map(c => c[0]);
|
||||||
|
const rows = exportSelCells(res).map(c => c[1]);
|
||||||
|
let sub = '';
|
||||||
|
if (n) {
|
||||||
|
sub = ' · X ' + Math.min(...cols) + '–' + (Math.max(...cols) + 1)
|
||||||
|
+ ' · Y ' + (Math.min(...rows) - 1) + '–' + Math.max(...rows) + ' km';
|
||||||
|
}
|
||||||
|
exportCountEl.innerHTML = n + ' dalle' + (n > 1 ? 's' : '')
|
||||||
|
+ ' sélectionnée' + (n > 1 ? 's' : '')
|
||||||
|
+ ' <span class="sub">' + sub + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
btnExportSel.addEventListener('click', () => {
|
||||||
|
setExportMode(!exportMode);
|
||||||
|
if (exportMode) switchTab('export');
|
||||||
|
});
|
||||||
|
exportClearBtn.addEventListener('click', () => {
|
||||||
|
exportSel.clear();
|
||||||
|
refreshExportHighlights();
|
||||||
|
updateExportPanel();
|
||||||
|
});
|
||||||
|
exportResSel.addEventListener('change', () => { updateExportVizOptions(); updateExportCount(); });
|
||||||
|
|
||||||
|
// Plusieurs couches sélectionnées : une image par couche (comme le PDF fait
|
||||||
|
// une page par couche) — chaque fichier porte la légende de SA couche.
|
||||||
|
|
||||||
|
exportGoBtn.addEventListener('click', () => {
|
||||||
|
const res = Number(exportResSel.value);
|
||||||
|
const cells = exportSelCells(res);
|
||||||
|
const viz = Array.from(exportVizSel.selectedOptions).map(o => o.value);
|
||||||
|
if (!cells.length || !viz.length) {
|
||||||
|
exportMsgEl.textContent = 'Sélectionnez au moins une dalle et une visualisation.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportGoBtn.disabled = true;
|
||||||
|
exportMsgEl.textContent = 'Assemblage en cours… (' + cells.length + ' dalle' + (cells.length > 1 ? 's' : '') + ')';
|
||||||
|
fetch('/api/export', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tiles: cells, viz: viz, resolution: res,
|
||||||
|
format: exportFormatSel.value,
|
||||||
|
max_side: Number(exportSizeSel.value) })
|
||||||
|
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
|
||||||
|
.then(d => {
|
||||||
|
const files = d.fichiers || [];
|
||||||
|
exportMsgEl.textContent = files.length
|
||||||
|
? '✓ Export prêt' + (files.length > 1
|
||||||
|
? ' — ' + files.length + ' fichiers'
|
||||||
|
: (files[0].pages > 1 ? ' — ' + files[0].pages + ' pages' : ''))
|
||||||
|
: '✓ Export prêt';
|
||||||
|
files.forEach((f, i) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = f.url;
|
||||||
|
a.download = f.nom;
|
||||||
|
a.title = 'Télécharger (' + (f.largeur || '?') + '×' + (f.hauteur || '?') + ' px)';
|
||||||
|
a.textContent = '⤓ ' + f.nom + ' · ' + humanSize(f.taille);
|
||||||
|
exportDoneList.prepend(a);
|
||||||
|
if (i === 0) a.click(); // premier fichier : téléchargement direct
|
||||||
|
});
|
||||||
|
exportDoneEl.hidden = false;
|
||||||
|
})
|
||||||
|
.catch(msg => { exportMsgEl.textContent = 'Erreur : ' + msg; })
|
||||||
|
.finally(() => { exportGoBtn.disabled = false; });
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// File de génération : panneau latéral persistant (sessionStorage).
|
// File de génération : panneau latéral persistant (sessionStorage).
|
||||||
// Les tâches survivent au rechargement de la carte : une tâche finie
|
// Les tâches survivent au rechargement de la carte : une tâche finie
|
||||||
@ -2440,7 +3022,10 @@ function finishRunningTasks(d) {
|
|||||||
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
|
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
|
||||||
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
|
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
|
||||||
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
|
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
|
||||||
// de son rendu, sans attendre la fin de la file.
|
// de son rendu, sans attendre la fin de la file. Les URLs images portent
|
||||||
|
// un suffixe ?v= (mtime de la source) : une tuile régénérée change d'URL,
|
||||||
|
// l'<img> se recharge et le nouveau rendu remplace l'ancien à l'écran —
|
||||||
|
// cache navigateur compris.
|
||||||
let liveTilesStamp = null;
|
let liveTilesStamp = null;
|
||||||
function mergeTilesData(data) {
|
function mergeTilesData(data) {
|
||||||
if (!data || !Array.isArray(data.tiles)) return;
|
if (!data || !Array.isArray(data.tiles)) return;
|
||||||
@ -2607,6 +3192,16 @@ syncTileLayers();
|
|||||||
layoutTiles();
|
layoutTiles();
|
||||||
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
||||||
|
|
||||||
|
// Veille permanente : même sans file affichée dans cet onglet (génération
|
||||||
|
// lancée depuis un autre onglet, un autre appareil, ou index reconstruit
|
||||||
|
// côté serveur), la carte sonde /api/tiles toutes les 15 s — la réponse
|
||||||
|
// allégée par ?stamp= la rend quasi gratuite quand rien n'a changé — et
|
||||||
|
// fusionne les tuiles nouvelles ou régénérée dès que l'index bouge. Pendant
|
||||||
|
// un run suivi par cet onglet, pollGenStatus() sonde déjà toutes les 2 s.
|
||||||
|
setInterval(() => {
|
||||||
|
if (!document.hidden && !genPollTimer) pollLiveTiles();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Détection de nouvelles couches (run de génération en cours)
|
// Détection de nouvelles couches (run de génération en cours)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -74,92 +74,65 @@ _FRANCE_OUTLINE_L93 = np.array([
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Colormap registry
|
# Colormap registry
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Each entry: keyword → (cmap, title, legend_label, description, vmin_mode, vmax_mode)
|
# Each entry: keyword → (cmap, vmin_mode, vmax_mode)
|
||||||
# vmin_mode/vmax_mode: 'percentile_X_Y' or '0_max_X' or 'symmetric_X_Y' or 'fixed_0_1'
|
# vmin_mode/vmax_mode: 'percentile_X_Y' or '0_max_X' or 'symmetric_X_Y' or 'fixed_0_1'
|
||||||
# For RGB images (ortho/topo), special handling is done in tif_to_png.
|
# For RGB images (ortho/topo), special handling is done in tif_to_png.
|
||||||
|
# Les textes (title/legend/description) viennent de VIZ_LEGENDS (index.py,
|
||||||
|
# source unique partagée avec l'export multi-dalles) — fusion ci-dessous.
|
||||||
|
|
||||||
COLORMAPS = {
|
COLORMAPS = {
|
||||||
# === Famille RELIEF : rouge=surélévation, bleu=dépression ===
|
# === Famille RELIEF : rouge=surélévation, bleu=dépression ===
|
||||||
# Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
|
# Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
|
||||||
'mslrm': {
|
'mslrm': {
|
||||||
'cmap': 'seismic',
|
'cmap': 'seismic',
|
||||||
'title': 'MSRM - Multi-Scale Relief Model (échelles adaptatives)',
|
|
||||||
'legend': 'Relief combiné multi-échelles (σ locales, échelle fixe ±3σ)\nRouge = Surélévation (mur, tumulus, levée)\nBleu = Dépression (fossé, douve)\n\nÉchelles : 2 à 200m, pondérées 5–20m\nCouleurs homogènes entre tuiles\nDétecte du micro au macro',
|
|
||||||
'description': 'Combine LRM à 5–7 échelles — détecte structures de 5m à 100m simultanément',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
'sailore': {
|
'sailore': {
|
||||||
'cmap': 'seismic',
|
'cmap': 'seismic',
|
||||||
'title': 'SAILORE - LRM Auto-Adaptatif',
|
|
||||||
'legend': 'Relief local adaptatif (σ locales, échelle fixe ±3σ)\nRouge = Surélévation | Bleu = Dépression\nCouleurs homogènes entre tuiles\n\nNoyau adapté à la pente locale\nPlat = grand noyau (25m) | Pente = petit noyau (2m)',
|
|
||||||
'description': 'Noyau qui s\'adapte à la pente locale — terrain plat=grand noyau, pente=petit noyau',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
|
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
|
||||||
'positive_openness': {
|
'positive_openness': {
|
||||||
'cmap': 'YlOrBr',
|
'cmap': 'YlOrBr',
|
||||||
'title': 'Openness Positive (ouverture vers le haut)',
|
|
||||||
'legend': 'Angle d\'ouverture vers le ciel (z-score local)\nClair = Vue dégagée vers le ciel (sommets, plateaux)\nSombre = Vue bloquée (vallées encaissées)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles',
|
|
||||||
'description': 'Ray-tracing 8 directions, multi-rayon — détecte crêtes et sommets',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
'negative_openness': {
|
'negative_openness': {
|
||||||
'cmap': 'PuBu',
|
'cmap': 'PuBu',
|
||||||
'title': 'Openness Negative (ouverture vers le bas)',
|
|
||||||
'legend': 'Angle d\'ouverture vers le bas (z-score local)\nClair = Surplomb (bords de fossé, grottes)\nSombre = Terrain plat (fonds de vallée)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nMeilleur détecteur de cavités et dolines',
|
|
||||||
'description': 'Ray-tracing 8 directions, multi-rayon — détecte fossés, dolines, souterrains',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': -3,
|
'vmin_mode': 'fixed', 'vmin_val': -3,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 3,
|
'vmax_mode': 'fixed', 'vmax_val': 3,
|
||||||
},
|
},
|
||||||
'svf': {
|
'svf': {
|
||||||
'cmap': 'hot_r',
|
'cmap': 'hot_r',
|
||||||
'title': 'Sky-View Factor (fraction de ciel visible)',
|
|
||||||
'legend': 'Proportion de ciel visible (échelle physique fixe 0–1)\nBlanc/jaune = Ciel masqué (vallée, fossé, tranchée)\nNoir = Ciel dégagé (sommet, plateau)\nCouleurs homogènes entre tuiles\nLes fossés ressortent en vif — excellent pour structures linéaires',
|
|
||||||
'description': 'Détection de micro-relief — fossés en jaune/blanc, levées en sombre',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 1,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
# === Famille SCALAIRE : propriétés non divergentes ===
|
# === Famille SCALAIRE : propriétés non divergentes ===
|
||||||
'hillshade': {
|
# Clé = mot-clé exact du nom de fichier de sortie (hillshade_multi).
|
||||||
|
'hillshade_multi': {
|
||||||
'cmap': 'gray',
|
'cmap': 'gray',
|
||||||
'title': 'Hillshade Multidirectionnel',
|
|
||||||
'legend': 'Illumination combinée de 8 directions (échelle fixe 0–1)\nBlanc = Face éclairée | Noir = Zone d\'ombre\nCouleurs homogènes entre tuiles',
|
|
||||||
'description': 'Ombres portées révélant micro-relief (murs, fossés, terrasses)',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 1,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
'slope': {
|
'slope': {
|
||||||
'cmap': 'inferno',
|
'cmap': 'inferno',
|
||||||
'title': 'Pente (Inclinaison du terrain)',
|
|
||||||
'legend': 'Inclinaison en degrés\nÉchelle fixe 0–30° — couleurs homogènes entre tuiles\nJaune = Forte pente | Violet foncé = Terrain plat',
|
|
||||||
'description': 'Murs, talus et bords ressortent en jaune — terrain plat en sombre',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 30,
|
'vmax_mode': 'fixed', 'vmax_val': 30,
|
||||||
},
|
},
|
||||||
'aspect': {
|
'aspect': {
|
||||||
'cmap': 'twilight',
|
'cmap': 'twilight',
|
||||||
'title': 'Aspect (Direction des pentes)',
|
|
||||||
'legend': 'Direction vers laquelle le terrain descend\nCycle continu : Nord→Est→Sud→Ouest→Nord\nCouleurs perceptuellement uniformes (pas de saut de teinte)',
|
|
||||||
'description': 'Orientation des pentes — utile pour distinguer structures des formes naturelles',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 360,
|
'vmax_mode': 'fixed', 'vmax_val': 360,
|
||||||
},
|
},
|
||||||
'roughness': {
|
'roughness': {
|
||||||
'cmap': 'plasma',
|
'cmap': 'plasma',
|
||||||
'title': 'Rugosité Multi-Échelle (3m + 15m)',
|
|
||||||
'legend': 'Irrégularité du terrain combinée fine + large\nViolet foncé = Surface lisse (route, mur, sol plat)\nJaune vif = Surface rugueuse (végétation, ruines, pierres)\nCombine rugosité fine 3m (70%) + large 15m (30%)',
|
|
||||||
'description': 'Mesure la variabilité locale — surfaces anthropiques lisses vs naturelles rugueuses',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
||||||
},
|
},
|
||||||
'wavelet': {
|
'wavelet': {
|
||||||
'cmap': 'inferno',
|
'cmap': 'inferno',
|
||||||
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
|
|
||||||
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
|
|
||||||
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
|
|
||||||
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
|
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
|
||||||
# résolution avec l'algorithme actuel (détendage 35 m, échelles
|
# résolution avec l'algorithme actuel (détendage 35 m, échelles
|
||||||
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
|
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
|
||||||
@ -179,25 +152,16 @@ COLORMAPS = {
|
|||||||
},
|
},
|
||||||
'flow_acc': {
|
'flow_acc': {
|
||||||
'cmap': 'YlGn',
|
'cmap': 'YlGn',
|
||||||
'title': 'Accumulation d\'Écoulement (Flow Accumulation)',
|
|
||||||
'legend': 'Log10 du nombre de cellules amont\nVert foncé = Forte accumulation (fossé, chenal, drainage)\nJaune = Faible accumulation (terrain plat)\n\nDétection fossés et linéaires hydrologiques',
|
|
||||||
'description': 'Priority-flood + D8 — détecte fossés archéologiques et drainages',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
'vmax_mode': 'percentile', 'vmax_pct': 98,
|
||||||
},
|
},
|
||||||
'anomaly': {
|
'anomaly': {
|
||||||
'cmap': 'YlOrRd',
|
'cmap': 'YlOrRd',
|
||||||
'title': 'Carte d\'Anomalies (détection automatique)',
|
|
||||||
'legend': 'Score d\'anomalie composite (0–1)\nRouge = Haute anomalie (structures suspectes)\nJaune = Anomalie modérée\nBlanc = Aucun signal (terrain naturel)\n\nSeuil auto: pixels > 2σ de la moyenne locale\nCombiné: MSRM, SVF, Ondelette, Openness, Rugosité',
|
|
||||||
'description': 'Détection automatique — cible à vérifier sur terrain',
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 1,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
'solar': {
|
'solar': {
|
||||||
'cmap': 'gray',
|
'cmap': 'gray',
|
||||||
'title': 'Éclairage Solaire',
|
|
||||||
'legend': "Illumination solaire (azimut 90°, altitude 30°)\nClair = Face éclairée | Sombre = Zone d'ombre",
|
|
||||||
'description': "Simulation de l'éclairage solaire matinal",
|
|
||||||
'vmin_mode': 'fixed', 'vmin_val': 0,
|
'vmin_mode': 'fixed', 'vmin_val': 0,
|
||||||
'vmax_mode': 'fixed', 'vmax_val': 1,
|
'vmax_mode': 'fixed', 'vmax_val': 1,
|
||||||
},
|
},
|
||||||
@ -205,18 +169,20 @@ COLORMAPS = {
|
|||||||
|
|
||||||
# RGB entries (ortho/topo) are handled specially
|
# RGB entries (ortho/topo) are handled specially
|
||||||
RGB_LEGENDS = {
|
RGB_LEGENDS = {
|
||||||
'ortho': {
|
'ortho': {},
|
||||||
'title': 'Photographie Aérienne IGN',
|
'topo': {},
|
||||||
'legend': 'Orthophotographie\nImage aérienne',
|
|
||||||
'description': 'Photographie aérienne IGN (Orthophoto)',
|
|
||||||
},
|
|
||||||
'topo': {
|
|
||||||
'title': 'Carte Topographique IGN',
|
|
||||||
'legend': 'Carte IGN\nPlan topographique',
|
|
||||||
'description': 'Carte topographique IGN (Plan IGN)',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fusion des textes de légende (titre / lecture du rendu / méthode de calcul)
|
||||||
|
# depuis la source unique VIZ_LEGENDS (index.py, sans dépendance lourde).
|
||||||
|
from .index import VIZ_LEGENDS
|
||||||
|
|
||||||
|
for _key, _info in COLORMAPS.items():
|
||||||
|
_info.update(VIZ_LEGENDS[_key])
|
||||||
|
for _key, _info in RGB_LEGENDS.items():
|
||||||
|
_info.update(VIZ_LEGENDS[_key])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_colormap(data, tif_file, resolution=None):
|
def _apply_colormap(data, tif_file, resolution=None):
|
||||||
"""Apply the registered colormap normalization to data based on filename.
|
"""Apply the registered colormap normalization to data based on filename.
|
||||||
|
|||||||
276
lidar_pipeline/tests/test_export.py
Normal file
276
lidar_pipeline/tests/test_export.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""Tests de l'export multi-dalles (mosaïque jointive image/PDF) et de son API."""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tile_dir(tmp_path, col, row, viz_keys, resolution=0.5, tile_px=16,
|
||||||
|
color=(255, 0, 0)):
|
||||||
|
"""Crée le dossier de visualisations d'une dalle (WebP lossless unis).
|
||||||
|
|
||||||
|
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
|
||||||
|
le basename nu (cf. _expected_output_path dans le pipeline).
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
|
||||||
|
suffix = "" if resolution == 0.5 else "_r" + str(resolution).replace(".", "p")
|
||||||
|
vis = tmp_path / "visualisations" / (base + suffix)
|
||||||
|
vis.mkdir(parents=True, exist_ok=True)
|
||||||
|
for key in viz_keys:
|
||||||
|
Image.new("RGB", (tile_px, tile_px), color).save(
|
||||||
|
str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _grid2x2(tmp_path):
|
||||||
|
"""Quatre dalles adjacentes de couleurs distinctes (row 20 au nord)."""
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope"], color=(255, 0, 0)) # nord-ouest
|
||||||
|
_make_tile_dir(tmp_path, 11, 20, ["slope"], color=(0, 255, 0)) # nord-est
|
||||||
|
_make_tile_dir(tmp_path, 10, 19, ["slope"], color=(0, 0, 255)) # sud-ouest
|
||||||
|
_make_tile_dir(tmp_path, 11, 19, ["slope"], color=(255, 255, 0)) # sud-est
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_places_tiles_and_is_seamless(tmp_path):
|
||||||
|
"""Grille 2×2 : placement exact (row haute au nord) et collage bord à bord.
|
||||||
|
|
||||||
|
Aucun trait ni espace entre les dalles : les pixels de part et d'autre de
|
||||||
|
chaque couture appartiennent aux dalles voisines, jamais au fond blanc.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_grid2x2(tmp_path)
|
||||||
|
out = tmp_path / "exports"
|
||||||
|
res = build_export(tmp_path / "visualisations",
|
||||||
|
[(10, 19), (11, 19), (10, 20), (11, 20)],
|
||||||
|
["slope"], 0.5, "png", out)
|
||||||
|
with Image.open(str(res["file"])) as img:
|
||||||
|
# 2 dalles de 16 px + habillage → largeur = 32 exactement (le bandeau
|
||||||
|
# légende, comme bandeau/pied, n'élargit pas l'image)
|
||||||
|
assert img.width == 32
|
||||||
|
# Haut de la mosaïque : premier pixel rouge pur (dalles au-dessus des
|
||||||
|
# bandeaux texte blancs, insensible à la hauteur du bandeau légende)
|
||||||
|
def rgb(x, y):
|
||||||
|
return img.getpixel((x, y))
|
||||||
|
mosaic_top = next(y for y in range(img.height)
|
||||||
|
if rgb(4, y) == (255, 0, 0))
|
||||||
|
mid = mosaic_top + 16
|
||||||
|
# Nord : row 20 en haut (rouge à l'ouest, vert à l'est)
|
||||||
|
assert rgb(4, mosaic_top + 4) == (255, 0, 0)
|
||||||
|
assert rgb(28, mosaic_top + 4) == (0, 255, 0)
|
||||||
|
# Sud : row 19 en bas (bleu à l'ouest, jaune à l'est)
|
||||||
|
assert rgb(4, mosaic_top + 28) == (0, 0, 255)
|
||||||
|
assert rgb(28, mosaic_top + 28) == (255, 255, 0)
|
||||||
|
# Couture verticale (x=15|16) : couleurs des dalles, pas de blanc
|
||||||
|
assert rgb(15, mosaic_top + 8) == (255, 0, 0)
|
||||||
|
assert rgb(16, mosaic_top + 8) == (0, 255, 0)
|
||||||
|
assert rgb(15, mid) == (0, 0, 255)
|
||||||
|
assert rgb(16, mid) == (255, 255, 0)
|
||||||
|
# Couture horizontale (y=+15|+16) : couleurs des dalles, pas de blanc
|
||||||
|
assert rgb(4, mosaic_top + 15) == (255, 0, 0)
|
||||||
|
assert rgb(4, mosaic_top + 16) == (0, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_pdf_one_page_per_viz(tmp_path):
|
||||||
|
"""PDF multi-couches : une page par visualisation demandée."""
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
|
||||||
|
res = build_export(tmp_path / "visualisations", [(10, 20)],
|
||||||
|
["slope", "aspect"], 0.5, "pdf", tmp_path / "exports")
|
||||||
|
assert res["file"].suffix == ".pdf"
|
||||||
|
data = res["file"].read_bytes()
|
||||||
|
assert data.startswith(b"%PDF")
|
||||||
|
pages = data.count(b"/Type /Page") - data.count(b"/Type /Pages")
|
||||||
|
assert pages == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_image_one_file_per_viz(tmp_path):
|
||||||
|
"""Image + plusieurs couches : un fichier par couche, chacun avec SA
|
||||||
|
légende (comme le PDF fait une page par couche)."""
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
|
||||||
|
res = build_export(tmp_path / "visualisations", [(10, 20)],
|
||||||
|
["slope", "aspect"], 0.5, "png", tmp_path / "exports")
|
||||||
|
files = res["files"]
|
||||||
|
assert len(files) == 2
|
||||||
|
names = [e["file"].name for e in files]
|
||||||
|
assert any("_slope_" in n for n in names)
|
||||||
|
assert any("_aspect_" in n for n in names)
|
||||||
|
for e in files:
|
||||||
|
assert e["file"].is_file() and e["file"].suffix == ".png"
|
||||||
|
assert e["pages"] == 1
|
||||||
|
# Clés de compatibilité : premier fichier
|
||||||
|
assert res["file"] == files[0]["file"]
|
||||||
|
assert res["width"] == files[0]["width"] and res["height"] == files[0]["height"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_missing_tile_or_viz(tmp_path):
|
||||||
|
"""Dalle absente à la résolution demandée ou visualisation manquante."""
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope"])
|
||||||
|
try:
|
||||||
|
build_export(tmp_path / "visualisations", [(10, 20), (12, 20)],
|
||||||
|
["slope"], 0.5, "png", tmp_path / "exports")
|
||||||
|
assert False, "une ValueError était attendue"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "12,20" in str(e)
|
||||||
|
try:
|
||||||
|
build_export(tmp_path / "visualisations", [(10, 20)],
|
||||||
|
["wavelet"], 0.5, "png", tmp_path / "exports")
|
||||||
|
assert False, "une ValueError était attendue"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "wavelet" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_includes_layer_legend(tmp_path):
|
||||||
|
"""Chaque export porte la légende de sa couche : dégradé de la colormap
|
||||||
|
(extrémités exactes) dans un bandeau sous le pied, en plus de l'habillage."""
|
||||||
|
from PIL import Image
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=256)
|
||||||
|
res = build_export(tmp_path / "visualisations", [(10, 20)],
|
||||||
|
["slope"], 0.5, "png", tmp_path / "exports")
|
||||||
|
with Image.open(str(res["file"])) as img:
|
||||||
|
# Bandeau légende présent : hauteur au-delà de mosaïque + bandeau + pied
|
||||||
|
assert img.height > 256 + 90 + 84
|
||||||
|
zone = [c for c in (img.getpixel((x, y))
|
||||||
|
for y in range(256 + 90 + 84, img.height)
|
||||||
|
for x in range(img.width))
|
||||||
|
if isinstance(c, tuple)]
|
||||||
|
# Extrémités du dégradé inferno de la pente (0° → 30°) : #000004 → #fcffa4
|
||||||
|
assert any(abs(c[0]) <= 1 and abs(c[1]) <= 1 and abs(c[2] - 4) <= 1
|
||||||
|
for c in zone)
|
||||||
|
assert any(abs(c[0] - 252) <= 1 and abs(c[1] - 255) <= 1
|
||||||
|
and abs(c[2] - 164) <= 1 for c in zone)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_legend_per_pdf_page():
|
||||||
|
"""PDF multi-couches : chaque page porte la légende de SA couche."""
|
||||||
|
from lidar_pipeline.export import _legend_layout
|
||||||
|
from lidar_pipeline.index import VIZ_LEGENDS
|
||||||
|
from PIL import Image
|
||||||
|
layout_slope = _legend_layout(VIZ_LEGENDS["slope"], Image.new("RGB", (600, 600)), 600)
|
||||||
|
layout_aspect = _legend_layout(VIZ_LEGENDS["aspect"], Image.new("RGB", (600, 600)), 600)
|
||||||
|
# Bandeaux distincts (dégradés/textes différents) et dimensionnés
|
||||||
|
assert layout_slope["height"] > 0 and layout_aspect["height"] > 0
|
||||||
|
assert layout_slope["has_bar"] and layout_aspect["has_bar"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_viz_legends_registry_complete():
|
||||||
|
"""VIZ_LEGENDS couvre toutes les couches proposées (VIZ_LABELS)."""
|
||||||
|
from lidar_pipeline.index import VIZ_LABELS, VIZ_LEGENDS
|
||||||
|
assert set(VIZ_LABELS) == set(VIZ_LEGENDS)
|
||||||
|
for key, info in VIZ_LEGENDS.items():
|
||||||
|
assert info["title"], key
|
||||||
|
assert info["legend"], key
|
||||||
|
assert info["description"], key
|
||||||
|
if info.get("gradient") is not None:
|
||||||
|
assert len(info["gradient"]) >= 2, key
|
||||||
|
assert info["ticks"] and len(info["ticks"]) == 2, key
|
||||||
|
else: # couches photo/carte : pas de dégradé, pas de bornes
|
||||||
|
assert info["ticks"] is None, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_viz_legends_gradients_match_colormaps():
|
||||||
|
"""Les dégradés de VIZ_LEGENDS restent calés sur les colormaps réelles
|
||||||
|
(un cmap changé dans rendering.py doit rafraîchir le dégradé)."""
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from lidar_pipeline.index import VIZ_LEGENDS
|
||||||
|
from lidar_pipeline.rendering import COLORMAPS, RGB_LEGENDS
|
||||||
|
for key, info in VIZ_LEGENDS.items():
|
||||||
|
cmap_name = info.get("cmap")
|
||||||
|
if not cmap_name:
|
||||||
|
continue
|
||||||
|
ref = COLORMAPS.get(key) or RGB_LEGENDS.get(key)
|
||||||
|
assert ref is not None, key
|
||||||
|
assert ref["cmap"] == cmap_name, key
|
||||||
|
cm = plt.get_cmap(cmap_name)
|
||||||
|
stops = info["gradient"]
|
||||||
|
for i, hx in enumerate(stops):
|
||||||
|
r, g, b = (int(hx[j:j + 2], 16) for j in (1, 3, 5))
|
||||||
|
mr, mg, mb = (round(v * 255) for v in cm(i / (len(stops) - 1))[:3])
|
||||||
|
assert abs(r - mr) <= 1 and abs(g - mg) <= 1 and abs(b - mb) <= 1, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_max_side_downsizes(tmp_path):
|
||||||
|
"""max_side réduit le plus grand côté de la mosaïque."""
|
||||||
|
from PIL import Image
|
||||||
|
from lidar_pipeline.export import build_export
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=64)
|
||||||
|
_make_tile_dir(tmp_path, 11, 20, ["slope"], tile_px=64)
|
||||||
|
res = build_export(tmp_path / "visualisations", [(10, 20), (11, 20)],
|
||||||
|
["slope"], 0.5, "png", tmp_path / "exports",
|
||||||
|
max_side=64)
|
||||||
|
with Image.open(str(res["file"])) as img:
|
||||||
|
assert img.width == 64 # mosaïque 128 px ramenée à 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_endpoint(tmp_path, monkeypatch):
|
||||||
|
"""/api/export assemble et renvoie un fichier téléchargeable."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
_grid2x2(tmp_path)
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
req = webapp.ExportRequest(tiles=[[10, 20], [11, 20], [10, 19], [11, 19]],
|
||||||
|
viz=["slope"], resolution=0.5,
|
||||||
|
format="jpeg", max_side=0)
|
||||||
|
d = webapp.export_tiles(req)
|
||||||
|
files = d["fichiers"]
|
||||||
|
assert len(files) == 1
|
||||||
|
f = files[0]
|
||||||
|
assert f["url"].startswith("/api/export/file/")
|
||||||
|
assert f["nom"].endswith(".jpg")
|
||||||
|
assert (tmp_path / "exports" / f["nom"]).is_file()
|
||||||
|
assert f["taille"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_endpoint_multi_viz_image(tmp_path, monkeypatch):
|
||||||
|
"""/api/export en image avec 2 couches → 2 fichiers téléchargeables."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
req = webapp.ExportRequest(tiles=[[10, 20]], viz=["slope", "aspect"],
|
||||||
|
resolution=0.5, format="jpeg", max_side=0)
|
||||||
|
d = webapp.export_tiles(req)
|
||||||
|
assert len(d["fichiers"]) == 2
|
||||||
|
for f in d["fichiers"]:
|
||||||
|
assert f["url"].startswith("/api/export/file/")
|
||||||
|
assert (tmp_path / "exports" / f["nom"]).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_endpoint_rejects_bad_requests(tmp_path, monkeypatch):
|
||||||
|
"""/api/export valide format, visualisations et tuiles."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
_grid2x2(tmp_path)
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
|
||||||
|
def expect_400(req):
|
||||||
|
try:
|
||||||
|
webapp.export_tiles(req)
|
||||||
|
assert False, "une HTTPException était attendue"
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 400
|
||||||
|
|
||||||
|
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
|
||||||
|
resolution=0.5, format="tiff", max_side=0))
|
||||||
|
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=[],
|
||||||
|
resolution=0.5, format="png", max_side=0))
|
||||||
|
# Dalle inexistante à cette résolution
|
||||||
|
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
|
||||||
|
resolution=0.2, format="png", max_side=0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_file_route(tmp_path, monkeypatch):
|
||||||
|
"""Le fichier exporté est servi en pièce jointe ; traversée refusée."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
exports = tmp_path / "exports"
|
||||||
|
exports.mkdir(parents=True)
|
||||||
|
(exports / "ok.png").write_bytes(b"x")
|
||||||
|
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
|
||||||
|
resp = webapp.export_file("ok.png")
|
||||||
|
assert isinstance(resp, FileResponse)
|
||||||
|
for bad in ("../visualisations/secret", "nope.png", "a/b.png"):
|
||||||
|
try:
|
||||||
|
webapp.export_file(bad)
|
||||||
|
assert False, f"une HTTPException était attendue pour {bad}"
|
||||||
|
except HTTPException as e:
|
||||||
|
assert e.status_code == 404
|
||||||
@ -237,18 +237,17 @@ def test_build_index_generates_html(tmp_path):
|
|||||||
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
|
||||||
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
app_js = (assets / "app.js").read_text(encoding='utf-8')
|
||||||
assert "Couches" in app_js or "layers" in app_js
|
assert "Couches" in app_js or "layers" in app_js
|
||||||
# Barre de génération : sélecteur limité aux couches proposées dans le
|
# Barre de génération : sélecteur proposant TOUTES les couches du registre
|
||||||
# panneau de visualisation (aspect + slope + openness + ondelette,
|
# (valeurs = noms d'étapes --only), y compris celles absentes du panneau
|
||||||
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
|
# (svf, hillshade) pour pouvoir les générer. La sélection est envoyée à
|
||||||
# (détection des tuiles incomplètes) comme à /api/generate.
|
# /api/preview (détection des tuiles incomplètes) comme à /api/generate.
|
||||||
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
|
|
||||||
assert 'id="genViz"' in content
|
assert 'id="genViz"' in content
|
||||||
assert 'value="wavelet"' in content
|
assert 'value="wavelet"' in content
|
||||||
assert 'value="aniso_open"' not in content
|
assert 'value="aniso_open"' not in content
|
||||||
assert 'value="pos_open"' in content
|
assert 'value="pos_open"' in content
|
||||||
assert 'value="slope"' in content
|
assert 'value="slope"' in content
|
||||||
assert 'value="svf"' not in content
|
assert 'value="svf"' in content
|
||||||
assert 'value="hillshade"' not in content
|
assert 'value="hillshade"' in content
|
||||||
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
|
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
|
||||||
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
|
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
|
||||||
# Adoption du dernier run au chargement (redémarrage du serveur) :
|
# Adoption du dernier run au chargement (redémarrage du serveur) :
|
||||||
@ -312,29 +311,67 @@ def test_mid_thumbnails_generated(tmp_path):
|
|||||||
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||||
viz = data['tiles'][0]['viz']['hillshade_multi']
|
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||||
assert 'mid' in viz
|
assert 'mid' in viz
|
||||||
assert (output_dir / viz['mid']).exists()
|
# L'URL porte un suffixe ?v= (invalidation cache) : chemin sans lui
|
||||||
assert viz['mid'].endswith("_mid.jpg")
|
assert (output_dir / viz['mid'].split('?')[0]).exists()
|
||||||
|
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
|
||||||
|
|
||||||
|
|
||||||
def test_subtiled_viz_covers_generation_choices():
|
def test_subtiles_cover_all_layers(tmp_path):
|
||||||
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
|
"""Toutes les couches d'une dalle 0,2 m sont sous-tuilées (plus de liste courte)."""
|
||||||
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
|
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ, build_index
|
||||||
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
|
||||||
'wavelet'):
|
assert _CARTO_SUBTILED_VIZ == ()
|
||||||
assert key in _CARTO_SUBTILED_VIZ
|
|
||||||
|
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', 'svf', 'topo'), res_suffix='_r0p2')
|
||||||
|
|
||||||
|
assert build_index(output_dir) is not None
|
||||||
|
sub_dir = output_dir / "index_subtiles"
|
||||||
|
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||||||
|
avif = sub_dir / f"LHD_FXX_1000_6881_PTS_LAMB93_IGN69_r0p2_{viz}_0_0.avif"
|
||||||
|
assert avif.exists(), viz
|
||||||
|
# L'index référence les sous-tuiles pour chaque couche, pas le repli dalle
|
||||||
|
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||||
|
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||||||
|
assert len(subs) == 4
|
||||||
|
for t in subs:
|
||||||
|
for viz in ('hillshade_multi', 'svf', 'topo'):
|
||||||
|
assert t['viz'][viz]['full'].startswith("index_subtiles/"), viz
|
||||||
|
|
||||||
|
|
||||||
def test_panel_restricted_to_requested_layers():
|
def test_panel_offers_all_layers():
|
||||||
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
|
"""Le panneau n'est plus restreint : toutes les couches connues sont proposées."""
|
||||||
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
|
from lidar_pipeline.index import PANEL_VIZ, VIZ_LABELS, KEYWORD_TO_STEP
|
||||||
from lidar_pipeline.pipeline import VIZ_STEPS
|
from lidar_pipeline.pipeline import VIZ_STEPS
|
||||||
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
|
assert PANEL_VIZ is None
|
||||||
# Chaque couche du panneau correspond à une étape --only valide du pipeline
|
# Chaque couche connue du registre correspond à une étape --only valide
|
||||||
steps = {name for name, _ in VIZ_STEPS}
|
steps = {name for name, _ in VIZ_STEPS}
|
||||||
for key in PANEL_VIZ:
|
assert set(VIZ_LABELS)
|
||||||
|
for key in VIZ_LABELS:
|
||||||
assert KEYWORD_TO_STEP.get(key, key) in steps
|
assert KEYWORD_TO_STEP.get(key, key) in steps
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_selection_lists_full_registry():
|
||||||
|
"""Le panneau propose le registre complet, même sans données sur disque.
|
||||||
|
|
||||||
|
Une installation neuve (ou une couche jamais générée) doit quand même
|
||||||
|
afficher toutes les couches connues ; les couches historiques présentes
|
||||||
|
sur disque restent proposées (libellé = clé brute).
|
||||||
|
"""
|
||||||
|
from lidar_pipeline.index import _panel_selection, VIZ_LABELS
|
||||||
|
ordered, default = _panel_selection({'aspect'})
|
||||||
|
assert set(VIZ_LABELS) <= set(ordered)
|
||||||
|
assert default == 'aspect'
|
||||||
|
ordered, default = _panel_selection(set())
|
||||||
|
assert set(VIZ_LABELS) <= set(ordered)
|
||||||
|
assert default # toujours une couche par défaut
|
||||||
|
ordered, default = _panel_selection({'aniso_open'})
|
||||||
|
assert 'aniso_open' in ordered
|
||||||
|
|
||||||
|
|
||||||
def test_build_index_merges_cross_resolution_viz(tmp_path):
|
def test_build_index_merges_cross_resolution_viz(tmp_path):
|
||||||
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
|
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
|
||||||
|
|
||||||
@ -365,8 +402,8 @@ def test_build_index_merges_cross_resolution_viz(tmp_path):
|
|||||||
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
|
||||||
for t in tiles:
|
for t in tiles:
|
||||||
for v in t['viz'].values():
|
for v in t['viz'].values():
|
||||||
assert (output_dir / v['thumb']).exists(), v['thumb']
|
assert (output_dir / v['thumb'].split('?')[0]).exists(), v['thumb']
|
||||||
assert (output_dir / v['full']).exists(), v['full']
|
assert (output_dir / v['full'].split('?')[0]).exists(), v['full']
|
||||||
|
|
||||||
|
|
||||||
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
def test_build_index_regenerates_stale_thumbnails(tmp_path):
|
||||||
@ -433,6 +470,72 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path):
|
|||||||
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
|
assert aspect_avif.stat().st_mtime == m_aspect_1 # aspect intact
|
||||||
|
|
||||||
|
|
||||||
|
def test_urls_versioned_for_cache_busting(tmp_path):
|
||||||
|
"""Les URLs images changent quand une tuile est régénérée.
|
||||||
|
|
||||||
|
Les images sont servies par des montages statiques sans Cache-Control :
|
||||||
|
sans version d'URL, le navigateur garde son ancien rendu en cache même
|
||||||
|
après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la
|
||||||
|
mtime de sa source en ?v= — un recalcul change l'URL et force le
|
||||||
|
rechargement, y compris en direct pendant un run (--incremental-index).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from lidar_pipeline.index import build_index
|
||||||
|
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
vis_dir = output_dir / "visualisations"
|
||||||
|
vis_dir.mkdir(parents=True)
|
||||||
|
tile_dir = _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',))
|
||||||
|
|
||||||
|
assert build_index(output_dir) is not None
|
||||||
|
|
||||||
|
def read_urls():
|
||||||
|
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||||
|
viz = data['tiles'][0]['viz']['hillshade_multi']
|
||||||
|
return viz['thumb'], viz['mid'], viz['full']
|
||||||
|
|
||||||
|
thumb1, mid1, full1 = read_urls()
|
||||||
|
for url in (thumb1, mid1, full1):
|
||||||
|
assert re.search(r"\?v=\d+$", url), url # versionnée (mtime ms)
|
||||||
|
assert (output_dir / url.split('?')[0]).exists(), url
|
||||||
|
|
||||||
|
# Même source, rebuild → URLs identiques (cache navigateur conservé)
|
||||||
|
assert build_index(output_dir) is not None
|
||||||
|
assert read_urls() == (thumb1, mid1, full1)
|
||||||
|
|
||||||
|
# Régénération : mtime de la source plus récente → nouvelles URLs
|
||||||
|
src = tile_dir / "LHD_FXX_1000_6881_PTS_LAMB93_IGN69_hillshade_multi.webp"
|
||||||
|
m = src.stat().st_mtime
|
||||||
|
os.utime(src, (m + 10, m + 10))
|
||||||
|
assert build_index(output_dir) is not None
|
||||||
|
thumb2, mid2, full2 = read_urls()
|
||||||
|
assert (thumb2, mid2, full2) != (thumb1, mid1, full1)
|
||||||
|
assert thumb2.split('?')[0] == thumb1.split('?')[0] # même fichier, version neuve
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtile_urls_versioned(tmp_path):
|
||||||
|
"""Les URLs des sous-tuiles 0,2 m sont aussi versionnées (?v=)."""
|
||||||
|
import re
|
||||||
|
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',),
|
||||||
|
res_suffix='_r0p2')
|
||||||
|
|
||||||
|
assert build_index(output_dir) is not None
|
||||||
|
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
|
||||||
|
subs = [t for t in data['tiles'] if t.get('sub_k') == 2]
|
||||||
|
assert subs
|
||||||
|
for t in subs:
|
||||||
|
for v in t['viz'].values():
|
||||||
|
for url in (v['thumb'], v['mid'], v['full']):
|
||||||
|
assert re.search(r"\?v=\d+$", url), url
|
||||||
|
assert (output_dir / url.split('?')[0]).exists(), url
|
||||||
|
|
||||||
|
|
||||||
def test_build_index_empty_returns_none(tmp_path):
|
def test_build_index_empty_returns_none(tmp_path):
|
||||||
"""Aucune tuile → build_index retourne None sans crash."""
|
"""Aucune tuile → build_index retourne None sans crash."""
|
||||||
from lidar_pipeline.index import build_index
|
from lidar_pipeline.index import build_index
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class TestColormaps:
|
|||||||
name_map = {
|
name_map = {
|
||||||
'pos_open': 'positive_openness',
|
'pos_open': 'positive_openness',
|
||||||
'neg_open': 'negative_openness',
|
'neg_open': 'negative_openness',
|
||||||
|
'hillshade': 'hillshade_multi',
|
||||||
}
|
}
|
||||||
# IGN overlays (ortho, topo) are RGB images — no colormap needed
|
# IGN overlays (ortho, topo) are RGB images — no colormap needed
|
||||||
skip = {'ortho', 'topo'}
|
skip = {'ortho', 'topo'}
|
||||||
|
|||||||
@ -80,8 +80,8 @@ def test_missing_cells_includes_incomplete_tile(tmp_path):
|
|||||||
assert [(t['col'], t['row']) for t in todo] == [(1054, 6882)]
|
assert [(t['col'], t['row']) for t in todo] == [(1054, 6882)]
|
||||||
|
|
||||||
|
|
||||||
def test_missing_cells_requires_both_resolutions(tmp_path):
|
def test_missing_cells_requires_generation_resolution(tmp_path):
|
||||||
"""Une tuile complète à 0.5 mais sans 0.2 reste à traiter."""
|
"""Une tuile complète à 0.5 mais sans 0.2 reste à traiter (seule 0.2 est générée)."""
|
||||||
from lidar_pipeline.webapp import missing_cells_with_corners
|
from lidar_pipeline.webapp import missing_cells_with_corners
|
||||||
_make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.5,))
|
_make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.5,))
|
||||||
assert len(missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"])) == 1
|
assert len(missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"])) == 1
|
||||||
@ -594,7 +594,7 @@ class _FakeRequest:
|
|||||||
|
|
||||||
|
|
||||||
def test_generate_restricted_to_lan_cidr():
|
def test_generate_restricted_to_lan_cidr():
|
||||||
"""/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (défaut 192.168.1.0/24)."""
|
"""/api/generate n'est autorisé que depuis LIDAR_REGEN_CIDR (si défini)."""
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import lidar_pipeline.webapp as webapp
|
import lidar_pipeline.webapp as webapp
|
||||||
old = webapp.REGEN_CIDR
|
old = webapp.REGEN_CIDR
|
||||||
@ -639,6 +639,39 @@ def test_status_exposes_regen_allowed():
|
|||||||
assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False
|
assert webapp.status(_FakeRequest(host="8.8.8.8"))["regen_allowed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_regen_cidr_default_covers_local_and_private():
|
||||||
|
"""Défaut : localhost + plages privées (LAN, hôte Docker), pas Internet."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
# Miroir du défaut de webapp.REGEN_CIDR (multidiffusion interdite ici :
|
||||||
|
# on le fixe explicitement pour tester la valeur de liste multi-CIDR).
|
||||||
|
old = webapp.REGEN_CIDR
|
||||||
|
webapp.REGEN_CIDR = "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||||
|
try:
|
||||||
|
for host in ("127.0.0.1", "::1", # boucle locale
|
||||||
|
"172.17.0.1", "172.26.0.5", # ponts Docker
|
||||||
|
"192.168.1.42", "192.168.122.7", # LAN privé
|
||||||
|
"10.0.0.5"):
|
||||||
|
assert webapp._ip_in_regen_cidr(host), host
|
||||||
|
for host in ("8.8.8.8", "2001:db8::1", "169.254.1.1", None):
|
||||||
|
assert not webapp._ip_in_regen_cidr(host), host
|
||||||
|
finally:
|
||||||
|
webapp.REGEN_CIDR = old
|
||||||
|
|
||||||
|
|
||||||
|
def test_regen_cidr_accepts_comma_separated_list():
|
||||||
|
"""LIDAR_REGEN_CIDR accepte plusieurs réseaux (entrées invalides ignorées)."""
|
||||||
|
import lidar_pipeline.webapp as webapp
|
||||||
|
old = webapp.REGEN_CIDR
|
||||||
|
webapp.REGEN_CIDR = "192.168.1.0/24, 10.0.0.0/8, pas-un-cidr"
|
||||||
|
try:
|
||||||
|
assert webapp._ip_in_regen_cidr("192.168.1.9")
|
||||||
|
assert webapp._ip_in_regen_cidr("10.1.2.3")
|
||||||
|
assert not webapp._ip_in_regen_cidr("192.168.3.9")
|
||||||
|
assert not webapp._ip_in_regen_cidr("172.17.0.1")
|
||||||
|
finally:
|
||||||
|
webapp.REGEN_CIDR = old
|
||||||
|
|
||||||
|
|
||||||
def test_auto_sync_once_skips_when_busy(monkeypatch):
|
def test_auto_sync_once_skips_when_busy(monkeypatch):
|
||||||
"""Le cycle de cache ne lance rien si un rebuild tourne déjà."""
|
"""Le cycle de cache ne lance rien si un rebuild tourne déjà."""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|||||||
@ -24,6 +24,13 @@ le téléchargement IGN). Endpoints :
|
|||||||
dans input/ qui manquent les visualisations demandées, sans
|
dans input/ qui manquent les visualisations demandées, sans
|
||||||
téléchargement ; sans --force, le pipeline ne génère que
|
téléchargement ; sans --force, le pipeline ne génère que
|
||||||
les visualisations manquantes des tuiles existantes)
|
les visualisations manquantes des tuiles existantes)
|
||||||
|
POST /api/export → assemble des dalles adjacentes en une image (PNG/JPEG/
|
||||||
|
WebP) ou un PDF multi-couches, consultable sur téléphone
|
||||||
|
(module export.py — mosaïque sans couture, habillage
|
||||||
|
titre/échelle/nord) ; GET /api/export/file/{nom} sert le
|
||||||
|
fichier en téléchargement. Marche aussi sur la webapp
|
||||||
|
légère : les tuiles viennent du cache local, aucune
|
||||||
|
délégation à la machine de traitement.
|
||||||
|
|
||||||
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
|
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
|
||||||
/visualisations, /DTM.
|
/visualisations, /DTM.
|
||||||
@ -63,6 +70,7 @@ LOG_FILE = OUTPUT_DIR / ".generation.log"
|
|||||||
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
|
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
|
||||||
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
|
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
|
||||||
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
|
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
|
||||||
|
MAX_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire)
|
||||||
|
|
||||||
# Machine de traitement distante (webapp légère type Raspberry Pi) : si
|
# Machine de traitement distante (webapp légère type Raspberry Pi) : si
|
||||||
# définie, la génération (téléchargement IGN + traitement) y est déléguée.
|
# définie, la génération (téléchargement IGN + traitement) y est déléguée.
|
||||||
@ -75,21 +83,27 @@ API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
|
|||||||
# 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) :
|
# 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
|
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
|
||||||
# l'IP de connexion (conservée par le DNAT Docker) ; si elle est dans le
|
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
|
||||||
# réseau autorisé, X-Forwarded-For est honoré (reverse proxy local qui
|
# Docker (connexions via la passerelle 172.x) et localhost, tout en refusant
|
||||||
# expose la carte au-dehors — un client distant ne peut pas le forger
|
# les clients venus d'Internet (IP publiques non routables vers le privé).
|
||||||
# puisque sa connexion directe est déjà hors réseau).
|
# L'IP du client est l'IP de connexion (conservée par le DNAT Docker) ; si
|
||||||
REGEN_CIDR = (os.environ.get("LIDAR_REGEN_CIDR", "192.168.1.0/24") or "").strip()
|
# 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",
|
||||||
|
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") or "").strip()
|
||||||
# Cache local périodique : relance la synchronisation + le rebuild toutes
|
# Cache local périodique : relance la synchronisation + le rebuild toutes
|
||||||
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
|
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
|
||||||
# sa copie des tuiles de la machine de traitement.
|
# sa copie des tuiles de la machine de traitement.
|
||||||
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
|
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ésolution générée par /api/generate (cf. _build_command) : 0,2 m seule,
|
||||||
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
|
# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
|
||||||
# que si elle possède les visualisations demandées aux deux résolutions.
|
# cette même résolution : une tuile n'est complète que si elle possède les
|
||||||
GENERATE_RESOLUTIONS = (0.5, 0.2)
|
# visualisations demandées en 0,2 m.
|
||||||
|
GENERATE_RESOLUTIONS = (0.2,)
|
||||||
|
|
||||||
app = FastAPI(title="Carte LiDAR — génération de zones")
|
app = FastAPI(title="Carte LiDAR — génération de zones")
|
||||||
|
|
||||||
@ -150,16 +164,30 @@ def _require_token(x_lidar_token: str = Header(None)):
|
|||||||
|
|
||||||
|
|
||||||
def _ip_in_regen_cidr(ip):
|
def _ip_in_regen_cidr(ip):
|
||||||
"""Vrai si l'IP appartient au CIDR autorisé (LIDAR_REGEN_CIDR)."""
|
"""Vrai si l'IP appartient à un des CIDR autorisés (LIDAR_REGEN_CIDR).
|
||||||
|
|
||||||
|
LIDAR_REGEN_CIDR accepte plusieurs réseaux séparés par des virgules
|
||||||
|
(ex. « 192.168.1.0/24,10.0.0.0/8 ») ; les entrées invalides sont ignorées.
|
||||||
|
"""
|
||||||
if not REGEN_CIDR:
|
if not REGEN_CIDR:
|
||||||
return True # restriction désactivée
|
return True # restriction désactivée
|
||||||
if not ip:
|
if not ip:
|
||||||
return False
|
return False
|
||||||
import ipaddress
|
import ipaddress
|
||||||
try:
|
try:
|
||||||
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
|
addr = ipaddress.ip_address(ip)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
for part in REGEN_CIDR.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if addr in ipaddress.ip_network(part, strict=False):
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request):
|
def _client_ip(request):
|
||||||
@ -297,6 +325,21 @@ class GenerateRequest(BaseModel):
|
|||||||
"demandées (sans téléchargement)")
|
"demandées (sans téléchargement)")
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
tiles: list = Field(..., description="liste [col, row] des dalles adjacentes "
|
||||||
|
"à assembler (entiers km L93)")
|
||||||
|
viz: list = Field(..., description="mots-clés de visualisation (ex: slope, "
|
||||||
|
"hillshade_multi) ; plusieurs possibles en PDF "
|
||||||
|
"(une page par visualisation), une seule pour une image")
|
||||||
|
resolution: float = Field(0.5, description="résolution des dalles en m/px "
|
||||||
|
"(0.5 ou 0.2)")
|
||||||
|
format: str = Field("jpeg", description="format de sortie : png, jpeg, "
|
||||||
|
"webp ou pdf")
|
||||||
|
max_side: int = Field(0, description="réduire le plus grand côté de la "
|
||||||
|
"mosaïque à N pixels (0 = pleine résolution) — "
|
||||||
|
"recommandé sur téléphone (4096)")
|
||||||
|
|
||||||
|
|
||||||
# --- État du job de génération -------------------------------------------
|
# --- État du job de génération -------------------------------------------
|
||||||
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
|
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
|
||||||
_job_lock = threading.Lock()
|
_job_lock = threading.Lock()
|
||||||
@ -391,7 +434,8 @@ def laz_cells(input_dir):
|
|||||||
|
|
||||||
|
|
||||||
def complete_cells(output_dir, viz_steps):
|
def complete_cells(output_dir, viz_steps):
|
||||||
"""Cellules ayant TOUTES les visualisations demandées, aux deux résolutions.
|
"""Cellules ayant TOUTES les visualisations demandées à la résolution de
|
||||||
|
génération (0,2 m).
|
||||||
|
|
||||||
viz_steps : noms d'étapes du pipeline (ex: 'aspect', 'pos_open'), convertis
|
viz_steps : noms d'étapes du pipeline (ex: 'aspect', 'pos_open'), convertis
|
||||||
en mots-clés de fichiers de sortie avant comparaison avec le disque.
|
en mots-clés de fichiers de sortie avant comparaison avec le disque.
|
||||||
@ -475,15 +519,15 @@ def status(request: Request = None):
|
|||||||
def available_layers():
|
def available_layers():
|
||||||
"""Couches proposées dans le panneau (clé → label), sans rebuild de l'index.
|
"""Couches proposées dans le panneau (clé → label), sans rebuild de l'index.
|
||||||
|
|
||||||
Restreintes à PANEL_VIZ (base aspect + visualisations demandées) pour
|
Toutes les couches connues du registre (VIZ_LABELS) détectées sur disque.
|
||||||
rester cohérent avec le menu. Permet à la carte de détecter celles
|
Permet à la carte de détecter celles apparues depuis le dernier
|
||||||
apparues depuis le dernier build_index et de proposer une actualisation.
|
build_index et de proposer une actualisation.
|
||||||
"""
|
"""
|
||||||
from .index import PANEL_VIZ, VIZ_LABELS
|
from .index import VIZ_LABELS
|
||||||
vis = OUTPUT_DIR / "visualisations"
|
vis = OUTPUT_DIR / "visualisations"
|
||||||
found = {}
|
found = {}
|
||||||
if vis.is_dir():
|
if vis.is_dir():
|
||||||
for key in PANEL_VIZ:
|
for key in VIZ_LABELS:
|
||||||
for ext in ("avif", "webp"):
|
for ext in ("avif", "webp"):
|
||||||
if next(vis.glob(f"*/*_{key}.{ext}"), None) is not None:
|
if next(vis.glob(f"*/*_{key}.{ext}"), None) is not None:
|
||||||
found[key] = VIZ_LABELS.get(key, key)
|
found[key] = VIZ_LABELS.get(key, key)
|
||||||
@ -760,6 +804,76 @@ def generate(req: GenerateRequest):
|
|||||||
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
|
return {"demarré": True, "tuiles": len(tiles), "commande": " ".join(cmd)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Export multi-dalles (mosaïque image/PDF pour téléphone) ---------------
|
||||||
|
_export_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/export")
|
||||||
|
def export_tiles(req: ExportRequest):
|
||||||
|
"""Assemble des dalles adjacentes en image ou PDF (export.py).
|
||||||
|
|
||||||
|
Local par nature : les tuiles assemblées sont celles du cache output/
|
||||||
|
(la webapp légère possède sa copie via rsync) — aucune délégation à la
|
||||||
|
machine de traitement. Un seul export à la fois (CPU/mémoire limités sur
|
||||||
|
Raspberry Pi).
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
from .export import EXPORT_FORMATS, build_export
|
||||||
|
fmt = (req.format or "").lower()
|
||||||
|
if fmt not in EXPORT_FORMATS:
|
||||||
|
raise HTTPException(400, f"format invalide : {req.format!r} "
|
||||||
|
f"(attendus : {', '.join(EXPORT_FORMATS)})")
|
||||||
|
viz = [str(v) for v in (req.viz or []) if v]
|
||||||
|
if not viz:
|
||||||
|
raise HTTPException(400, "aucune visualisation demandée")
|
||||||
|
cells = []
|
||||||
|
for pair in req.tiles:
|
||||||
|
if not (isinstance(pair, list) and len(pair) == 2):
|
||||||
|
raise HTTPException(400, f"tuile invalide : {pair!r} (attendu [col, row])")
|
||||||
|
cell = (int(pair[0]), int(pair[1]))
|
||||||
|
if cell not in cells:
|
||||||
|
cells.append(cell)
|
||||||
|
if not cells:
|
||||||
|
raise HTTPException(400, "aucune tuile fournie")
|
||||||
|
if len(cells) > MAX_EXPORT_TILES:
|
||||||
|
raise HTTPException(400, f"trop de dalles ({len(cells)}) — max {MAX_EXPORT_TILES}")
|
||||||
|
if not _export_lock.acquire(blocking=False):
|
||||||
|
raise HTTPException(409, "un export est déjà en cours — réessayez dans un instant")
|
||||||
|
try:
|
||||||
|
out_dir = OUTPUT_DIR / "exports"
|
||||||
|
result = build_export(OUTPUT_DIR / "visualisations", cells, viz,
|
||||||
|
float(req.resolution), fmt, out_dir,
|
||||||
|
max_side=int(req.max_side))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
finally:
|
||||||
|
_export_lock.release()
|
||||||
|
# Une couche = un fichier image (ou une page du PDF) : la réponse liste
|
||||||
|
# tous les fichiers générés, chacun avec SA légende.
|
||||||
|
return {"fichiers": [{
|
||||||
|
"url": f"/api/export/file/{e['file'].name}",
|
||||||
|
"nom": e["file"].name,
|
||||||
|
"taille": e["file"].stat().st_size,
|
||||||
|
"largeur": e["width"],
|
||||||
|
"hauteur": e["height"],
|
||||||
|
"pages": e["pages"],
|
||||||
|
} for e in result["files"]]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/export/file/{name}")
|
||||||
|
def export_file(name: str):
|
||||||
|
"""Sert un fichier exporté en téléchargement (pièce jointe)."""
|
||||||
|
import re as _re
|
||||||
|
if not _re.fullmatch(r"[A-Za-z0-9_.-]+", name):
|
||||||
|
raise HTTPException(404, "nom de fichier invalide")
|
||||||
|
base = (OUTPUT_DIR / "exports").resolve()
|
||||||
|
path = (base / name).resolve()
|
||||||
|
if base not in path.parents or not path.is_file():
|
||||||
|
raise HTTPException(404, f"export introuvable : {name}")
|
||||||
|
return FileResponse(str(path), filename=name,
|
||||||
|
headers={"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
# Cache auto-entretenu : resync + vignettes toutes les N secondes (0 = off)
|
# Cache auto-entretenu : resync + vignettes toutes les N secondes (0 = off)
|
||||||
#LIDAR_AUTO_SYNC_SECONDS=600
|
#LIDAR_AUTO_SYNC_SECONDS=600
|
||||||
|
|
||||||
# Réseau autorisé à lancer les générations depuis la carte
|
# Réseaux autorisés à lancer les générations depuis la carte
|
||||||
# (défaut 192.168.1.0/24 ; vide pour lever la restriction)
|
# (liste de CIDR séparés par virgules ; défaut : localhost + plages
|
||||||
|
# privées 127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ;
|
||||||
|
# vide pour lever la restriction)
|
||||||
#LIDAR_REGEN_CIDR=192.168.1.0/24
|
#LIDAR_REGEN_CIDR=192.168.1.0/24
|
||||||
|
|||||||
Reference in New Issue
Block a user