Tuiles visibles en direct après régénération et déploiement webapp distante

- URLs images versionnées (?v=mtime) : une tuile recalculée change d'URL et
  force le rechargement navigateur (cache heuristique contourné), y compris
  en plein run via /api/tiles ; veille permanente 15 s sur la carte
- simulation locale du mode deux machines (docker-compose.local-2m.yml) :
  worker GPU :8974 + webapp légère :8973 au cache séparé output-webapp/
- override webapp pour le Pi 5 (192.168.3.3) : volume /srv/lidar/output,
  rsync vers le worker, labels Traefik (proxy/websecure/myresolver)
- purge 0,5 m : worker/process et politique générale passés à 0,2 m seul
- intègre le travail parallèle non commité : export mosaïque multi-dalles
  (export.py + /api/export), sous-tuilage intégral des couches, légendes
  VIZ_LEGENDS, docs et tests associés (213 tests verts)
This commit is contained in:
Antoine Jacquin
2026-09-12 17:48:37 +02:00
parent 78a2c328c3
commit 68272cabf0
16 changed files with 1769 additions and 165 deletions

View File

@ -0,0 +1,276 @@
"""Tests de l'export multi-dalles (mosaïque jointive image/PDF) et de son API."""
def _make_tile_dir(tmp_path, col, row, viz_keys, resolution=0.5, tile_px=16,
color=(255, 0, 0)):
"""Crée le dossier de visualisations d'une dalle (WebP lossless unis).
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
le basename nu (cf. _expected_output_path dans le pipeline).
"""
from PIL import Image
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
suffix = "" if resolution == 0.5 else "_r" + str(resolution).replace(".", "p")
vis = tmp_path / "visualisations" / (base + suffix)
vis.mkdir(parents=True, exist_ok=True)
for key in viz_keys:
Image.new("RGB", (tile_px, tile_px), color).save(
str(vis / f"{base}_{key}.webp"), format="WEBP", lossless=True)
def _grid2x2(tmp_path):
"""Quatre dalles adjacentes de couleurs distinctes (row 20 au nord)."""
_make_tile_dir(tmp_path, 10, 20, ["slope"], color=(255, 0, 0)) # nord-ouest
_make_tile_dir(tmp_path, 11, 20, ["slope"], color=(0, 255, 0)) # nord-est
_make_tile_dir(tmp_path, 10, 19, ["slope"], color=(0, 0, 255)) # sud-ouest
_make_tile_dir(tmp_path, 11, 19, ["slope"], color=(255, 255, 0)) # sud-est
def test_export_places_tiles_and_is_seamless(tmp_path):
"""Grille 2×2 : placement exact (row haute au nord) et collage bord à bord.
Aucun trait ni espace entre les dalles : les pixels de part et d'autre de
chaque couture appartiennent aux dalles voisines, jamais au fond blanc.
"""
from PIL import Image
from lidar_pipeline.export import build_export
_grid2x2(tmp_path)
out = tmp_path / "exports"
res = build_export(tmp_path / "visualisations",
[(10, 19), (11, 19), (10, 20), (11, 20)],
["slope"], 0.5, "png", out)
with Image.open(str(res["file"])) as img:
# 2 dalles de 16 px + habillage → largeur = 32 exactement (le bandeau
# légende, comme bandeau/pied, n'élargit pas l'image)
assert img.width == 32
# Haut de la mosaïque : premier pixel rouge pur (dalles au-dessus des
# bandeaux texte blancs, insensible à la hauteur du bandeau légende)
def rgb(x, y):
return img.getpixel((x, y))
mosaic_top = next(y for y in range(img.height)
if rgb(4, y) == (255, 0, 0))
mid = mosaic_top + 16
# Nord : row 20 en haut (rouge à l'ouest, vert à l'est)
assert rgb(4, mosaic_top + 4) == (255, 0, 0)
assert rgb(28, mosaic_top + 4) == (0, 255, 0)
# Sud : row 19 en bas (bleu à l'ouest, jaune à l'est)
assert rgb(4, mosaic_top + 28) == (0, 0, 255)
assert rgb(28, mosaic_top + 28) == (255, 255, 0)
# Couture verticale (x=15|16) : couleurs des dalles, pas de blanc
assert rgb(15, mosaic_top + 8) == (255, 0, 0)
assert rgb(16, mosaic_top + 8) == (0, 255, 0)
assert rgb(15, mid) == (0, 0, 255)
assert rgb(16, mid) == (255, 255, 0)
# Couture horizontale (y=+15|+16) : couleurs des dalles, pas de blanc
assert rgb(4, mosaic_top + 15) == (255, 0, 0)
assert rgb(4, mosaic_top + 16) == (0, 0, 255)
def test_export_pdf_one_page_per_viz(tmp_path):
"""PDF multi-couches : une page par visualisation demandée."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope", "aspect"], 0.5, "pdf", tmp_path / "exports")
assert res["file"].suffix == ".pdf"
data = res["file"].read_bytes()
assert data.startswith(b"%PDF")
pages = data.count(b"/Type /Page") - data.count(b"/Type /Pages")
assert pages == 2
def test_export_image_one_file_per_viz(tmp_path):
"""Image + plusieurs couches : un fichier par couche, chacun avec SA
légende (comme le PDF fait une page par couche)."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope", "aspect"], 0.5, "png", tmp_path / "exports")
files = res["files"]
assert len(files) == 2
names = [e["file"].name for e in files]
assert any("_slope_" in n for n in names)
assert any("_aspect_" in n for n in names)
for e in files:
assert e["file"].is_file() and e["file"].suffix == ".png"
assert e["pages"] == 1
# Clés de compatibilité : premier fichier
assert res["file"] == files[0]["file"]
assert res["width"] == files[0]["width"] and res["height"] == files[0]["height"]
def test_export_missing_tile_or_viz(tmp_path):
"""Dalle absente à la résolution demandée ou visualisation manquante."""
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"])
try:
build_export(tmp_path / "visualisations", [(10, 20), (12, 20)],
["slope"], 0.5, "png", tmp_path / "exports")
assert False, "une ValueError était attendue"
except ValueError as e:
assert "12,20" in str(e)
try:
build_export(tmp_path / "visualisations", [(10, 20)],
["wavelet"], 0.5, "png", tmp_path / "exports")
assert False, "une ValueError était attendue"
except ValueError as e:
assert "wavelet" in str(e)
def test_export_includes_layer_legend(tmp_path):
"""Chaque export porte la légende de sa couche : dégradé de la colormap
(extrémités exactes) dans un bandeau sous le pied, en plus de l'habillage."""
from PIL import Image
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=256)
res = build_export(tmp_path / "visualisations", [(10, 20)],
["slope"], 0.5, "png", tmp_path / "exports")
with Image.open(str(res["file"])) as img:
# Bandeau légende présent : hauteur au-delà de mosaïque + bandeau + pied
assert img.height > 256 + 90 + 84
zone = [c for c in (img.getpixel((x, y))
for y in range(256 + 90 + 84, img.height)
for x in range(img.width))
if isinstance(c, tuple)]
# Extrémités du dégradé inferno de la pente (0° → 30°) : #000004 → #fcffa4
assert any(abs(c[0]) <= 1 and abs(c[1]) <= 1 and abs(c[2] - 4) <= 1
for c in zone)
assert any(abs(c[0] - 252) <= 1 and abs(c[1] - 255) <= 1
and abs(c[2] - 164) <= 1 for c in zone)
def test_export_legend_per_pdf_page():
"""PDF multi-couches : chaque page porte la légende de SA couche."""
from lidar_pipeline.export import _legend_layout
from lidar_pipeline.index import VIZ_LEGENDS
from PIL import Image
layout_slope = _legend_layout(VIZ_LEGENDS["slope"], Image.new("RGB", (600, 600)), 600)
layout_aspect = _legend_layout(VIZ_LEGENDS["aspect"], Image.new("RGB", (600, 600)), 600)
# Bandeaux distincts (dégradés/textes différents) et dimensionnés
assert layout_slope["height"] > 0 and layout_aspect["height"] > 0
assert layout_slope["has_bar"] and layout_aspect["has_bar"]
def test_viz_legends_registry_complete():
"""VIZ_LEGENDS couvre toutes les couches proposées (VIZ_LABELS)."""
from lidar_pipeline.index import VIZ_LABELS, VIZ_LEGENDS
assert set(VIZ_LABELS) == set(VIZ_LEGENDS)
for key, info in VIZ_LEGENDS.items():
assert info["title"], key
assert info["legend"], key
assert info["description"], key
if info.get("gradient") is not None:
assert len(info["gradient"]) >= 2, key
assert info["ticks"] and len(info["ticks"]) == 2, key
else: # couches photo/carte : pas de dégradé, pas de bornes
assert info["ticks"] is None, key
def test_viz_legends_gradients_match_colormaps():
"""Les dégradés de VIZ_LEGENDS restent calés sur les colormaps réelles
(un cmap changé dans rendering.py doit rafraîchir le dégradé)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from lidar_pipeline.index import VIZ_LEGENDS
from lidar_pipeline.rendering import COLORMAPS, RGB_LEGENDS
for key, info in VIZ_LEGENDS.items():
cmap_name = info.get("cmap")
if not cmap_name:
continue
ref = COLORMAPS.get(key) or RGB_LEGENDS.get(key)
assert ref is not None, key
assert ref["cmap"] == cmap_name, key
cm = plt.get_cmap(cmap_name)
stops = info["gradient"]
for i, hx in enumerate(stops):
r, g, b = (int(hx[j:j + 2], 16) for j in (1, 3, 5))
mr, mg, mb = (round(v * 255) for v in cm(i / (len(stops) - 1))[:3])
assert abs(r - mr) <= 1 and abs(g - mg) <= 1 and abs(b - mb) <= 1, key
def test_export_max_side_downsizes(tmp_path):
"""max_side réduit le plus grand côté de la mosaïque."""
from PIL import Image
from lidar_pipeline.export import build_export
_make_tile_dir(tmp_path, 10, 20, ["slope"], tile_px=64)
_make_tile_dir(tmp_path, 11, 20, ["slope"], tile_px=64)
res = build_export(tmp_path / "visualisations", [(10, 20), (11, 20)],
["slope"], 0.5, "png", tmp_path / "exports",
max_side=64)
with Image.open(str(res["file"])) as img:
assert img.width == 64 # mosaïque 128 px ramenée à 64
def test_export_endpoint(tmp_path, monkeypatch):
"""/api/export assemble et renvoie un fichier téléchargeable."""
import lidar_pipeline.webapp as webapp
_grid2x2(tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
req = webapp.ExportRequest(tiles=[[10, 20], [11, 20], [10, 19], [11, 19]],
viz=["slope"], resolution=0.5,
format="jpeg", max_side=0)
d = webapp.export_tiles(req)
files = d["fichiers"]
assert len(files) == 1
f = files[0]
assert f["url"].startswith("/api/export/file/")
assert f["nom"].endswith(".jpg")
assert (tmp_path / "exports" / f["nom"]).is_file()
assert f["taille"] > 0
def test_export_endpoint_multi_viz_image(tmp_path, monkeypatch):
"""/api/export en image avec 2 couches → 2 fichiers téléchargeables."""
import lidar_pipeline.webapp as webapp
_make_tile_dir(tmp_path, 10, 20, ["slope", "aspect"])
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
req = webapp.ExportRequest(tiles=[[10, 20]], viz=["slope", "aspect"],
resolution=0.5, format="jpeg", max_side=0)
d = webapp.export_tiles(req)
assert len(d["fichiers"]) == 2
for f in d["fichiers"]:
assert f["url"].startswith("/api/export/file/")
assert (tmp_path / "exports" / f["nom"]).is_file()
def test_export_endpoint_rejects_bad_requests(tmp_path, monkeypatch):
"""/api/export valide format, visualisations et tuiles."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
_grid2x2(tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
def expect_400(req):
try:
webapp.export_tiles(req)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 400
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
resolution=0.5, format="tiff", max_side=0))
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=[],
resolution=0.5, format="png", max_side=0))
# Dalle inexistante à cette résolution
expect_400(webapp.ExportRequest(tiles=[[10, 20]], viz=["slope"],
resolution=0.2, format="png", max_side=0))
def test_export_file_route(tmp_path, monkeypatch):
"""Le fichier exporté est servi en pièce jointe ; traversée refusée."""
from fastapi import HTTPException
from fastapi.responses import FileResponse
import lidar_pipeline.webapp as webapp
exports = tmp_path / "exports"
exports.mkdir(parents=True)
(exports / "ok.png").write_bytes(b"x")
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
resp = webapp.export_file("ok.png")
assert isinstance(resp, FileResponse)
for bad in ("../visualisations/secret", "nope.png", "a/b.png"):
try:
webapp.export_file(bad)
assert False, f"une HTTPException était attendue pour {bad}"
except HTTPException as e:
assert e.status_code == 404