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:
Antoine Jacquin
2026-09-12 17:48:37 +02:00
parent 78a2c328c3
commit 68272cabf0
16 changed files with 1769 additions and 165 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
# Résultats générés
output/
output-webapp/
input/
# Fichiers temporaires

View File

@ -4,6 +4,7 @@
- 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 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)
- 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>`
@ -18,8 +19,10 @@
## 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.
- **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`.
- **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.
@ -34,8 +37,9 @@
### 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)
- `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.
- `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")
- **`_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.

View 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

View File

@ -52,6 +52,6 @@ services:
- ./output:/data/output
environment:
- 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:
- process

View File

@ -5,7 +5,7 @@
# docker compose logs -f serve # journal du serveur
# docker compose down # arrêt
# 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) :
# 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
# (protège /api/generate, /api/preview, /api/rebuild, /api/sync).
# - 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
restart: unless-stopped
@ -51,7 +55,7 @@ services:
- ./output:/data/output
environment:
- 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:
- process

View File

@ -131,11 +131,14 @@ variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run,
ou `POST /api/sync`.
`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST
/api/generate` : zones, complétion, régénération) aux clients dont l'IP
est dans le réseau indiqué — défaut `192.168.1.0/24`, chaîne vide pour
lever la restriction. L'IP est celle de la connexion (conservée par le
DNAT Docker pour les clients du LAN) ; derrière un reverse proxy local
(dans le réseau autorisé), `X-Forwarded-For` désigne le client réel.
/api/generate` : zones, sélection, complétion, régénération) aux clients
dont l'IP est dans un des réseaux indiqués — liste de CIDR séparés par
virgules, chaîne vide pour lever la restriction. Défaut :
`127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (boucle locale
+ 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
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
conteneur (automatique avec `run.sh`, à décommenter en compose).
- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du
client n'est pas dans `LIDAR_REGEN_CIDR` (défaut `192.168.1.0/24`).
Adapter la variable ou la vider pour lever la restriction.
client n'est dans aucun des réseaux de `LIDAR_REGEN_CIDR` (défaut :
localhost + plages privées). Adapter la variable ou la vider pour lever
la restriction.
- **« Machine de traitement injoignable »** : vérifier le service `worker`
(`docker compose -f docker-compose.worker.yml logs -f worker`) et
`LIDAR_GENERATION_URL` (IP + port 8973). La consultation de la carte

View File

@ -110,8 +110,8 @@ def main():
parser.add_argument(
"-r", "--resolution",
type=str,
default="0.5",
help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.5, ex: 0.5,0.2)"
default="0.2",
help="Résolution en m/px, ou multiples séparées par virgules (défaut: 0.2)"
)
parser.add_argument(
"-w", "--workers",

429
lidar_pipeline/export.py Normal file
View 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

View File

@ -14,6 +14,9 @@ Fonctionnalités :
(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) :
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:
- Appelé automatiquement à la fin de process_all() dans pipeline.py
@ -50,13 +53,158 @@ VIZ_LABELS = {
'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.
DEFAULT_VIZ = 'hillshade_multi'
# Visualisations proposées dans le panneau de couches et le sélecteur de
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
# les couches réellement proposées à l'affichage (voir _render_html).
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
# Restriction optionnelle du panneau de couches : None = proposer toutes
# les visualisations présentes sur disque (aucune restriction). Le sélecteur
# de génération/régénération est piloté par le même registre (voir _render_html).
PANEL_VIZ = None
# 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,
@ -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()
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
# carte — les autres viz restent disponibles en repli dalle entière.
# Liste vide = découper toutes les visualisations disponibles.
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'wavelet')
# Toutes les visualisations sont découpées en sous-tuiles (quadrants 500 m)
# pour alléger la carte. Renseigner un tuple pour restreindre la découpe —
# les viz exclues retombent en repli dalle entière.
_CARTO_SUBTILED_VIZ = ()
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# 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_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
# 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.
@ -423,6 +576,20 @@ def _cached_file_fresh(path, 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,
mid_size=640):
"""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)]
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):
"""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)
if not info:
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]}"
for key in entries}
# Régénère si au moins un fichier manque ou est périmé (dalle
# source recalculée depuis — comme les vignettes)
src = output_dir / info['full']
# source recalculée depuis — comme les vignettes). L'URL pleine
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
src = output_dir / info['full'].split('?')[0]
src_mtime = _mtime(src)
all_exist = all(
_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()
except Exception as 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'):
img = img.convert('RGB')
W, H = img.size
cut_failed = False
for (i, j), stem in stems.items():
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
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))
try:
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)
except Exception as e:
logger.warning(f"Encodage AVIF impossible ({stem}), "
f"sous-tuilage abandonné : {e}")
return None
f"repli dalle entière pour {viz_key} : {e}")
cut_failed = True
break
# Supprime l'ancien crop .webp d'une génération précédente
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
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)
quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
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():
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}_thumb.webp",
'mid': f"{sub_dir_name}/{stem}_mid.webp",
'full': f"{sub_dir_name}/{stem}.avif",
'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
'full': f"{sub_dir_name}/{stem}.avif{v}",
}
except Exception as 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']]
if not usable:
return None
# Repli dalle entière pour les visualisations non découpées : elles restent
# superposables en couche (images plus lourdes, mais fonctionnelles).
# Repli dalle entière pour les visualisations hors sélection : elles
# restent superposables en couche (images plus lourdes, mais fonctionnelles).
sub_keys = set(offered_viz_keys)
for entry in usable:
for viz_key, info in tile['viz'].items():
if viz_key not in sub_keys:
entry['viz'][viz_key] = dict(info)
for viz_key, info in tile['viz'].items():
if viz_key not in sub_keys:
_fallback_full_dalle(entries, viz_key, info)
return usable
@ -799,12 +983,13 @@ def build_index(output_dir, output_format='avif'):
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
v = _url_version(src_mtime)
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
'thumb': f"index_thumbs/{thumb_name}{v}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}",
}
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:
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
décrire le panneau exactement comme la page au moment du rebuild).
"""
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
# les autres visualisations existent sur disque mais ne sont pas proposées.
panel_keys = set(all_viz_keys)
# Panneau complet : toutes les couches du registre (VIZ_LABELS), même
# absentes du disque — activables dès leur apparition — plus les couches
# 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:
panel_keys &= set(PANEL_VIZ)
# 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):
if v not in ordered_viz:
ordered_viz.append(v)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
# Couche activée par défaut : préférence globale puis aspect, à condition
# 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 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
@ -932,13 +1121,13 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
tiles_json = json.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de génération/régénération : uniquement les couches
# réellement proposées dans le panneau de visualisation (mêmes couches,
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Toutes
# pré-sélectionnées : une régénération refait la dalle complète du panneau,
# l'utilisateur pouvant désélectionner pour cibler. Repli sur aspect si
# aucune couche n'est encore disponible.
gen_keys = ordered_viz if ordered_viz else ['aspect']
# Options du sélecteur de génération/régénération : TOUTES les couches du
# registre (mêmes libellés que le panneau ; valeurs = noms d'étapes --only
# du pipeline), y compris celles absentes du disque — pour pouvoir les
# générer. Toutes pré-sélectionnées : une régénération refait la dalle
# complète, l'utilisateur pouvant désélectionner pour cibler.
gen_keys = [v for v in _VIZ_FALLBACK_ORDER if v in VIZ_LABELS]
gen_keys += sorted(k for k in VIZ_LABELS if k not in gen_keys)
gen_viz_options = "\n".join(
f' <option value="{KEYWORD_TO_STEP.get(v, v)}" selected>'
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" 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="export">Export</button>
</div>
<div class="tab-page" id="tab-layers">
@ -1013,9 +1203,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</div>
<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">
<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>
</div>
@ -1027,7 +1218,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</label>
<label class="gen-opt col" title="Toutes pré-sélectionnées — Ctrl+clic pour modifier la sélection">
<span>Visualisations :</span>
<select id="genViz" class="gen-select" multiple size="4">
<select id="genViz" class="gen-select" multiple size="8">
{gen_viz_options}
</select>
</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>
</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>
</aside>
@ -1377,8 +1615,8 @@ body.zooming .ltile { will-change: transform; }
/* Actions Zone / Compléter : propres à l'onglet, pleine largeur */
.gen-actions { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.gen-actions button { width: 100%; font-size: 12.5px; }
#btnGenZone { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnGenZone:hover { background: var(--accent); border-color: var(--accent); }
#btnGenZone, #btnGenSel { background: var(--accent-dim); border-color: var(--accent-dim); }
#btnGenZone:hover, #btnGenSel:hover { background: var(--accent); border-color: var(--accent); }
#btnComplete {
font-weight: 600; letter-spacing: 0.02em;
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.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 */
.tab-page::-webkit-scrollbar,
.gen-task-log::-webkit-scrollbar { width: 8px; }
@ -1617,7 +1895,11 @@ function registerTile(t) {
interactive: true,
keyboard: false
}).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();
markers.set(tileKey(t), {
mk: mk, t: t,
@ -2105,7 +2387,11 @@ function hideTileInfo() {
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') hideTileInfo();
if (e.key === 'Escape') {
hideTileInfo();
if (exportMode) setExportMode(false);
if (genSelMode) setGenSelMode(false);
}
});
function regenerateCurrentTile() {
@ -2126,6 +2412,7 @@ function regenerateCurrentTile() {
let apiAvailable = false;
let regenAllowed = true; // /api/status → regen_allowed (réseau local LIDAR_REGEN_CIDR)
const genBtn = document.getElementById('btnGenZone');
const btnGenSel = document.getElementById('btnGenSel');
const btnComplete = document.getElementById('btnComplete');
const genBar = document.getElementById('genBar');
const genMsg = document.getElementById('genMsg');
@ -2144,6 +2431,8 @@ const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
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
// (bascule automatique sur cet onglet).
@ -2155,17 +2444,16 @@ function showGenBar(msg) {
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (!apiAvailable) hideExportTab();
return r.ok ? r.json() : null;
}).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) :
// les boutons de traitement restent masqués pour les autres clients.
regenAllowed = d.regen_allowed !== false;
if (apiAvailable) {
genBtn.hidden = !regenAllowed;
btnComplete.hidden = !regenAllowed;
}
}).catch(() => {});
if (apiAvailable) genBtns.forEach(b => { if (b) b.hidden = !regenAllowed; });
}).catch(() => { if (!apiAvailable) hideExportTab(); });
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
@ -2187,6 +2475,7 @@ function exitGenMode() {
genBtn.addEventListener('click', () => {
if (genMode) { exitGenMode(); return; }
clearGhosts();
clearGenSel();
genBar.classList.remove('show');
genMode = true;
map.dragging.disable();
@ -2239,12 +2528,92 @@ map.on('mouseup', e => {
}).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
// moins une des visualisations choisies (pas de téléchargement) — la barre de
// confirmation reste utilisable (choix des couches, classification, Lancer).
btnComplete.addEventListener('click', () => {
if (!apiAvailable) return;
clearGhosts();
clearGenSel();
genGo.style.display = 'none';
showGenBar('Analyse des tuiles présentes dans input/...');
fetch('/api/preview', {
@ -2274,6 +2643,7 @@ genGo.addEventListener('click', () => {
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
const fromSel = genSel.size > 0;
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
@ -2288,6 +2658,8 @@ genGo.addEventListener('click', () => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
pendingRegen = false;
if (fromSel) clearGenSel();
return startGenTask(nTiles + ' tuile(s) — ' + verb);
})
.catch(msg => { genGo.style.display = 'none'; showGenBar('Erreur : ' + msg); });
@ -2297,9 +2669,219 @@ genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
clearGenSel();
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).
// 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
// (--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
// 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;
function mergeTilesData(data) {
if (!data || !Array.isArray(data.tiles)) return;
@ -2607,6 +3192,16 @@ syncTileLayers();
layoutTiles();
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)
// ============================================================

View File

@ -74,92 +74,65 @@ _FRANCE_OUTLINE_L93 = np.array([
# ============================================================
# 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'
# 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 = {
# === Famille RELIEF : rouge=surélévation, bleu=dépression ===
# Diverging: rouge vif=positif, bleu vif=négatif, blanc=plat
'mslrm': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
'sailore': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
'positive_openness': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
'negative_openness': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
'svf': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
# === Famille SCALAIRE : propriétés non divergentes ===
'hillshade': {
# Clé = mot-clé exact du nom de fichier de sortie (hillshade_multi).
'hillshade_multi': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
'slope': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 30,
},
'aspect': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 360,
},
'roughness': {
'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,
'vmax_mode': 'percentile', 'vmax_pct': 98,
},
'wavelet': {
'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
# résolution avec l'algorithme actuel (détendage 35 m, échelles
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
@ -179,25 +152,16 @@ COLORMAPS = {
},
'flow_acc': {
'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,
'vmax_mode': 'percentile', 'vmax_pct': 98,
},
'anomaly': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
'solar': {
'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,
'vmax_mode': 'fixed', 'vmax_val': 1,
},
@ -205,18 +169,20 @@ COLORMAPS = {
# RGB entries (ortho/topo) are handled specially
RGB_LEGENDS = {
'ortho': {
'title': 'Photographie Aérienne IGN',
'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)',
},
'ortho': {},
'topo': {},
}
# 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):
"""Apply the registered colormap normalization to data based on filename.

View 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

View File

@ -237,18 +237,17 @@ def test_build_index_generates_html(tmp_path):
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
app_js = (assets / "app.js").read_text(encoding='utf-8')
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur limité aux couches proposées dans le
# panneau de visualisation (aspect + slope + openness + ondelette,
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
# (détection des tuiles incomplètes) comme à /api/generate.
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
# Barre de génération : sélecteur proposant TOUTES les couches du registre
# (valeurs = noms d'étapes --only), y compris celles absentes du panneau
# (svf, hillshade) pour pouvoir les générer. La sélection est envoyée à
# /api/preview (détection des tuiles incomplètes) comme à /api/generate.
assert 'id="genViz"' in content
assert 'value="wavelet"' in content
assert 'value="aniso_open"' not in content
assert 'value="pos_open"' in content
assert 'value="slope"' in content
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert 'value="svf"' in content
assert 'value="hillshade"' in content
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
# 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'))
viz = data['tiles'][0]['viz']['hillshade_multi']
assert 'mid' in viz
assert (output_dir / viz['mid']).exists()
assert viz['mid'].endswith("_mid.jpg")
# L'URL porte un suffixe ?v= (invalidation cache) : chemin sans lui
assert (output_dir / viz['mid'].split('?')[0]).exists()
assert viz['mid'].split('?')[0].endswith("_mid.jpg")
def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'wavelet'):
assert key in _CARTO_SUBTILED_VIZ
def test_subtiles_cover_all_layers(tmp_path):
"""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, build_index
assert _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():
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
def test_panel_offers_all_layers():
"""Le panneau n'est plus restreint : toutes les couches connues sont proposées."""
from lidar_pipeline.index import PANEL_VIZ, VIZ_LABELS, KEYWORD_TO_STEP
from lidar_pipeline.pipeline import VIZ_STEPS
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
# Chaque couche du panneau correspond à une étape --only valide du pipeline
assert PANEL_VIZ is None
# Chaque couche connue du registre correspond à une étape --only valide
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
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):
"""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()
for t in tiles:
for v in t['viz'].values():
assert (output_dir / v['thumb']).exists(), v['thumb']
assert (output_dir / v['full']).exists(), v['full']
assert (output_dir / v['thumb'].split('?')[0]).exists(), v['thumb']
assert (output_dir / v['full'].split('?')[0]).exists(), v['full']
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
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):
"""Aucune tuile → build_index retourne None sans crash."""
from lidar_pipeline.index import build_index

View File

@ -37,6 +37,7 @@ class TestColormaps:
name_map = {
'pos_open': 'positive_openness',
'neg_open': 'negative_openness',
'hillshade': 'hillshade_multi',
}
# IGN overlays (ortho, topo) are RGB images — no colormap needed
skip = {'ortho', 'topo'}

View File

@ -80,8 +80,8 @@ def test_missing_cells_includes_incomplete_tile(tmp_path):
assert [(t['col'], t['row']) for t in todo] == [(1054, 6882)]
def test_missing_cells_requires_both_resolutions(tmp_path):
"""Une tuile complète à 0.5 mais sans 0.2 reste à traiter."""
def test_missing_cells_requires_generation_resolution(tmp_path):
"""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
_make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.5,))
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():
"""/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
import lidar_pipeline.webapp as webapp
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
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):
"""Le cycle de cache ne lance rien si un rebuild tourne déjà."""
import time as _time

View File

@ -24,6 +24,13 @@ le téléchargement IGN). Endpoints :
dans input/ qui manquent les visualisations demandées, sans
téléchargement ; sans --force, le pipeline ne génère que
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,
/visualisations, /DTM.
@ -63,6 +70,7 @@ LOG_FILE = OUTPUT_DIR / ".generation.log"
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
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_EXPORT_TILES = 64 # garde-fou : dalles assemblables par export (mémoire)
# 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.
@ -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.
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
# Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) :
# CIDR en notation standard, vide = restriction levée. L'IP du client est
# l'IP de connexion (conservée par le DNAT Docker) ; si elle est dans le
# réseau autorisé, X-Forwarded-For est honoré (reverse proxy local qui
# expose la carte au-dehors — un client distant ne peut pas le forger
# puisque sa connexion directe est déjà hors réseau).
REGEN_CIDR = (os.environ.get("LIDAR_REGEN_CIDR", "192.168.1.0/24") or "").strip()
# liste de CIDR séparés par virgules, vide = restriction levée. Par défaut :
# boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte
# Docker (connexions via la passerelle 172.x) et localhost, tout en refusant
# les clients venus d'Internet (IP publiques non routables vers le privé).
# L'IP du client est l'IP de connexion (conservée par le DNAT Docker) ; si
# elle est dans le réseau autorisé, X-Forwarded-For est honoré (reverse proxy
# local qui expose la carte au-dehors — un client distant ne peut pas le
# forger puisque sa connexion directe est déjà hors réseau).
REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR",
"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
# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient
# sa copie des tuiles de la machine de traitement.
AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0)
# Résolutions générées par /api/generate (cf. _build_command). La détection des
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
# que si elle possède les visualisations demandées aux deux résolutions.
GENERATE_RESOLUTIONS = (0.5, 0.2)
# Résolution générée par /api/generate (cf. _build_command) : 0,2 m seule,
# la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
# cette même résolution : une tuile n'est complète que si elle possède les
# visualisations demandées en 0,2 m.
GENERATE_RESOLUTIONS = (0.2,)
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):
"""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:
return True # restriction désactivée
if not ip:
return False
import ipaddress
try:
return ipaddress.ip_address(ip) in ipaddress.ip_network(REGEN_CIDR, strict=False)
addr = ipaddress.ip_address(ip)
except ValueError:
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):
@ -297,6 +325,21 @@ class GenerateRequest(BaseModel):
"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 -------------------------------------------
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
_job_lock = threading.Lock()
@ -391,7 +434,8 @@ def laz_cells(input_dir):
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
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():
"""Couches proposées dans le panneau (clé → label), sans rebuild de l'index.
Restreintes à PANEL_VIZ (base aspect + visualisations demandées) pour
rester cohérent avec le menu. Permet à la carte de détecter celles
apparues depuis le dernier build_index et de proposer une actualisation.
Toutes les couches connues du registre (VIZ_LABELS) détectées sur disque.
Permet à la carte de détecter celles apparues depuis le dernier
build_index et de proposer une actualisation.
"""
from .index import PANEL_VIZ, VIZ_LABELS
from .index import VIZ_LABELS
vis = OUTPUT_DIR / "visualisations"
found = {}
if vis.is_dir():
for key in PANEL_VIZ:
for key in VIZ_LABELS:
for ext in ("avif", "webp"):
if next(vis.glob(f"*/*_{key}.{ext}"), None) is not None:
found[key] = VIZ_LABELS.get(key, key)
@ -760,6 +804,76 @@ def generate(req: GenerateRequest):
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__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)

View File

@ -20,6 +20,8 @@
# Cache auto-entretenu : resync + vignettes toutes les N secondes (0 = off)
#LIDAR_AUTO_SYNC_SECONDS=600
# Réseau autorisé à lancer les générations depuis la carte
# (défaut 192.168.1.0/24 ; vide pour lever la restriction)
# Réseaux autorisés à lancer les générations depuis la carte
# (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