Files
lidar_rendu/lidar_pipeline/tests/test_export.py
Antoine Jacquin 68272cabf0 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)
2026-09-12 17:48:37 +02:00

277 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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