diff --git a/.gitignore b/.gitignore index be19d1c..6082be0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Résultats générés output/ +output-webapp/ input/ # Fichiers temporaires diff --git a/AGENTS.md b/AGENTS.md index 01c32ca..bd591f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.` @@ -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. diff --git a/docker-compose.local-2m.yml b/docker-compose.local-2m.yml new file mode 100644 index 0000000..4ce425a --- /dev/null +++ b/docker-compose.local-2m.yml @@ -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 diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 9f5a35d..c4d2216 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index fc7e985..384ec82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index be065af..a0b2a8d 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -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 diff --git a/lidar_pipeline/cli.py b/lidar_pipeline/cli.py index ed934cd..f481c47 100644 --- a/lidar_pipeline/cli.py +++ b/lidar_pipeline/cli.py @@ -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", diff --git a/lidar_pipeline/export.py b/lidar_pipeline/export.py new file mode 100644 index 0000000..e9269be --- /dev/null +++ b/lidar_pipeline/export.py @@ -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 diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 7c61c1b..390c9e8 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -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' ' @@ -996,6 +1185,7 @@ _HTML_TEMPLATE = """ +
@@ -1013,9 +1203,10 @@ _HTML_TEMPLATE = """
+ + @@ -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
É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
É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' : '') + + ' ' + sub + ''; +} + +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' 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) // ============================================================ diff --git a/lidar_pipeline/rendering.py b/lidar_pipeline/rendering.py index b46dfea..b4815fc 100644 --- a/lidar_pipeline/rendering.py +++ b/lidar_pipeline/rendering.py @@ -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. diff --git a/lidar_pipeline/tests/test_export.py b/lidar_pipeline/tests/test_export.py new file mode 100644 index 0000000..05aec70 --- /dev/null +++ b/lidar_pipeline/tests/test_export.py @@ -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 diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 799bcba..8e2ef53 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -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 diff --git a/lidar_pipeline/tests/test_rendering.py b/lidar_pipeline/tests/test_rendering.py index 55e31fd..b885d82 100644 --- a/lidar_pipeline/tests/test_rendering.py +++ b/lidar_pipeline/tests/test_rendering.py @@ -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'} diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index 35382b6..8f1a950 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -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 diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index 120e605..d71aac0 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -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) diff --git a/webapp.env.example b/webapp.env.example index f32e57e..61cb0f8 100644 --- a/webapp.env.example +++ b/webapp.env.example @@ -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