Ajouter la fiche de dalle IGN et la rose des vents, imposer la génération

Un clic sélectionne toujours la dalle sous le curseur (cadre sur la carte),
générée ou non : emprise, rendu et recalage des passes s'affichent tout de
suite ; la fiche IGN (date et heure du scan LiDAR, capteurs, mission,
édition, nombre de points, lien de téléchargement du .copc.laz) arrive à
part via /api/map/ign, mise en cache, sans jamais bloquer la sélection.

Une rose des vents donne la couleur de chaque orientation de pente du
relief orienté, avec la même formule CIELAB que le rendu.

La génération impose la classification IGN (sol) et le raccord de 100 m
avec les dalles voisines : plus de réglage dans la carte, défauts CLI
alignés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 01:57:04 +02:00
parent d32502b74e
commit f836408d6f
7 changed files with 427 additions and 109 deletions

View File

@ -119,7 +119,8 @@ docker build -t lidar-lidar .
--debug Mode debug (détails internes fichier:ligne) --debug Mode debug (détails internes fichier:ligne)
-f / --force Régénérer tous les fichiers même si les WebP existent -f / --force Régénérer tous les fichiers même si les WebP existent
--force-classification Reclassifier le sol même si le fichier .las existe déjà --force-classification Reclassifier le sol même si le fichier .las existe déjà
--ground-classification Méthode de classification: auto, smrf, pmf, csf (défaut: auto) --ground-classification Méthode de classification: ign, auto, smrf, csf (défaut: ign — imposé par la carte)
--edge-buffer M Raccord des bords avec les dalles voisines (défaut: 100 m — imposé par la carte)
--file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques --file NOM... Traiter un ou plusieurs fichiers LAZ spécifiques
--test Exécuter les tests unitaires --test Exécuter les tests unitaires
-h Afficher l'aide -h Afficher l'aide
@ -222,7 +223,7 @@ docker run --rm --gpus all -v $(pwd)/input:/data/input:ro -v $(pwd)/output:/data
| Résolution | `-r` | 0.5 | Résolution en mètres par pixel | | Résolution | `-r` | 0.5 | Résolution en mètres par pixel |
| Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle | | Workers | `-w` | 1 | Nombre de CPU pour traitement parallèle |
| GPU | `-g` | off | Activer l'accélération NVIDIA GPU | | GPU | `-g` | off | Activer l'accélération NVIDIA GPU |
| Classification sol | `--ground-classification` | auto | Méthode : auto, smrf, pmf, csf | | Classification sol | `--ground-classification` | ign | Méthode : ign, auto, smrf, csf (la carte impose ign) |
| Forcer classification | `--force-classification` | off | Reclassifier le sol même si .las existe | | Forcer classification | `--force-classification` | off | Reclassifier le sol même si .las existe |
| Output | `-o` | /data/output | Dossier de sortie | | Output | `-o` | /data/output | Dossier de sortie |
| Force | `-f/--force` | off | Régénérer même si les WebP existent | | Force | `-f/--force` | off | Régénérer même si les WebP existent |

View File

@ -27,10 +27,10 @@ docker compose -f docker-compose.maps.yml logs -f maps
- **↻ Générer cette dalle** (fiche d'infos au clic) — une dalle précise, - **↻ Générer cette dalle** (fiche d'infos au clic) — une dalle précise,
même sans données existantes. même sans données existantes.
Options du run : couches visées (défaut : les couches du panneau), Options du run : couches visées (défaut : les couches du panneau) et
classification du sol (`ign`/`auto`/`smrf`/`csf`), classes LAS IGN régénération forcée. La classification du sol (IGN, sol seul) et le raccord
(`sol,unclassified…`), régénération forcée, reclassification, raccord des des bords (bande de 100 m prise aux dalles voisines) sont imposés : aucun
bords (bande 100 m). Une demande lancée pendant un run part en **file réglage. Une demande lancée pendant un run part en **file
d'attente** (persistée, jamais de coupure du travail en place) ; la d'attente** (persistée, jamais de coupure du travail en place) ; la
progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu = progression s'affiche dalle par dalle (cadres orange = rendu en cours, bleu =
en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis en attente, rouge = échec) avec journal et bouton **Arrêter** (SIGTERM puis
@ -78,6 +78,23 @@ Découverte : `/tiles/{couche}.json` (TileJSON 3.0.0), `/tiles/wmts.xml`
Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces Le bouton **« Utiliser dans JOSM / QGIS »** de la carte affiche et copie ces
URL pour la couche choisie. URL pour la couche choisie.
## Fiche de dalle et rose des vents
Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre
jaune en pointillés), qu'elle soit générée ou non. La fiche s'affiche tout de
suite avec le nom, l'emprise Lambert 93, puis, si la dalle est rendue, la
résolution, la date de génération et le recalage vertical des passes
(faisceaux, décalages, correction des lignes). Les informations IGN arrivent
à part (`GET /api/map/ign?col&row`, catalogue STAC mis en cache dans
`output/ign_meta/`) : date et heure du scan LiDAR, capteurs, mission,
opérateur, date d'édition, procédé de classement, nombre de points et lien de
téléchargement du nuage `.copc.laz` sur la géoplateforme. Un catalogue lent
ou injoignable n'empêche jamais la sélection.
Quand le relief orienté est affiché, une rose des vents donne la couleur de
chaque orientation de pente (même formule CIELAB que le rendu) ; la clarté
porte le relief local (clair = bosse, sombre = creux).
## Pile de couches ## Pile de couches
La carte ne sert que les couches de `PANEL_VIZ` (`index.py`) : aujourd'hui le La carte ne sert que les couches de `PANEL_VIZ` (`index.py`) : aujourd'hui le

View File

@ -157,14 +157,14 @@ def main():
parser.add_argument( parser.add_argument(
"--edge-buffer", "--edge-buffer",
type=float, type=float,
default=0.0, default=100.0,
metavar="METRES", metavar="METRES",
help="Raccord des bords : étendre le MNT d'une bande de N mètres remplie avec " help="Raccord des bords : étendre le MNT d'une bande de N mètres remplie avec "
"les points sol des 8 tuiles LAZ voisines, pour que les rendus à grand " "les points sol des 8 tuiles LAZ voisines, pour que les rendus à grand "
"noyau (openness, SVF, LRM) soient continus d'une tuile à l'autre. " "noyau (openness, SVF, LRM) soient continus d'une tuile à l'autre. "
"100 m couvre tous les rayons ; les images finales sont recadrées sur " "100 m couvre tous les rayons ; les images finales sont recadrées sur "
"la dalle 1 km exacte. 0 = désactivé (défaut). Un changement de valeur " "la dalle 1 km exacte. Défaut : 100 (toujours appliqué en production) ; "
"régénère les DTM concernés." "0 = désactivé. Un changement de valeur régénère les DTM concernés."
) )
parser.add_argument( parser.add_argument(
"--no-strip-align", "--no-strip-align",
@ -184,12 +184,13 @@ def main():
parser.add_argument( parser.add_argument(
"--ground-classification", "--ground-classification",
choices=["auto", "ign", "smrf", "csf"], choices=["auto", "ign", "smrf", "csf"],
default="auto", default="ign",
help="Méthode de classification du sol : auto (préfère la pré-classification IGN si " help="Méthode de classification du sol : auto (préfère la pré-classification IGN si "
"présente — base rapide — sinon détection SMRF/CSF), ign, smrf, csf. " "présente — base rapide — sinon détection SMRF/CSF), ign, smrf, csf. "
"Avec ign, le MNT est la rasterisation pure des classes choisies " "Avec ign, le MNT est la rasterisation pure des classes choisies "
"(--ign-classes) sans aucune retouche ; avec smrf/csf, il est complété " "(--ign-classes) sans aucune retouche ; avec smrf/csf, il est complété "
"par le retour le plus bas par cellule + interpolation des trous. (défaut: auto)" "par le retour le plus bas par cellule + interpolation des trous. "
"(défaut: ign, imposé en production)"
) )
parser.add_argument( parser.add_argument(
"--ign-classes", "--ign-classes",

View File

@ -70,6 +70,17 @@ def _bbox_wgs84(col, row):
ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2 ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys) lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
except Exception: except Exception:
lons = None
if lons is None:
try: # image légère (carte) : pyproj sans rasterio
from pyproj import Transformer
tr = Transformer.from_crs("EPSG:2154", "EPSG:4326", always_xy=True)
xs = [col * 1000, (col + 1) * 1000, col * 1000, (col + 1) * 1000]
ys = [(row - 1) * 1000] * 2 + [row * 1000] * 2
lons, lats = tr.transform(xs, ys)
except Exception:
lons = None
if lons is None:
from .index import _approx_l93_to_wgs84 from .index import _approx_l93_to_wgs84
pts = [_approx_l93_to_wgs84(x, y) pts = [_approx_l93_to_wgs84(x, y)
for x in (col * 1000, (col + 1) * 1000) for x in (col * 1000, (col + 1) * 1000)
@ -93,12 +104,48 @@ def match_feature(features, col, row):
def find_tile_url(col, row, timeout=20, max_pages=5): def find_tile_url(col, row, timeout=20, max_pages=5):
"""Cherche l'URL de téléchargement de la dalle (col,row) dans le catalogue STAC. """Cherche l'URL de téléchargement de la dalle (col,row) dans le catalogue STAC.
Suit la pagination du catalogue (lien rel=next) : sans elle, une dalle
au-delà de la première page de résultats semblait « introuvable ».
Returns: Returns:
URL (str) ou None si la dalle n'est pas (encore) publiée par l'IGN. URL (str) ou None si la dalle n'est pas (encore) publiée par l'IGN.
""" """
feature = find_tile_feature(col, row, timeout=timeout, max_pages=max_pages)
return feature.get("assets", {}).get("data", {}).get("href") if feature else None
def tile_ign_metadata(col, row, timeout=10):
"""Fiche IGN d'une dalle : acquisition, capteurs, édition, téléchargement.
Returns:
dict normalisé (found=False si la dalle n'est pas publiée).
"""
feature = find_tile_feature(col, row, timeout=timeout)
if not feature:
return {"found": False}
p = feature.get("properties", {})
self_link = next((link.get("href") for link in feature.get("links", [])
if link.get("rel") == "self"), None)
return {
"found": True,
"download_url": feature.get("assets", {}).get("data", {}).get("href"),
"acquisition_start": p.get("start_datetime") or p.get("lidarhd:date_debut_acquisition"),
"acquisition_end": p.get("end_datetime") or p.get("lidarhd:date_fin_acquisition"),
"sensors": p.get("lidarhd:capteur") or [],
"mission": p.get("lidarhd:code_mission"),
"acquisition_operator": p.get("lidarhd:moe_acquisition"),
"acquisition_owner": p.get("lidarhd:moa_acquisition"),
"edition_date": p.get("lidarhd:date_edition"),
"classification_process": p.get("lidarhd:procede_classement"),
"points": p.get("lidarhd:nombre_points") or p.get("pc:count"),
"altimetry": p.get("lidarhd:systeme_altimetrique"),
"stac_url": self_link,
}
def find_tile_feature(col, row, timeout=20, max_pages=5):
"""Item STAC de la dalle (col,row), ou None si non publiée.
Suit la pagination du catalogue (lien rel=next) : sans elle, une dalle
au-delà de la première page de résultats semblait « introuvable ».
"""
w, s, e, n = _bbox_wgs84(col, row) w, s, e, n = _bbox_wgs84(col, row)
query = urllib.parse.urlencode({"bbox": f"{w:.6f},{s:.6f},{e:.6f},{n:.6f}", "limit": 50}) query = urllib.parse.urlencode({"bbox": f"{w:.6f},{s:.6f},{e:.6f},{n:.6f}", "limit": 50})
url = f"{_STAC_ITEMS_URL}?{query}" url = f"{_STAC_ITEMS_URL}?{query}"
@ -108,7 +155,7 @@ def find_tile_url(col, row, timeout=20, max_pages=5):
data = json.loads(response.read().decode("utf-8")) data = json.loads(response.read().decode("utf-8"))
feature = match_feature(data.get("features", []), col, row) feature = match_feature(data.get("features", []), col, row)
if feature: if feature:
return feature.get("assets", {}).get("data", {}).get("href") return feature
nxt = next((link.get("href") for link in data.get("links", []) nxt = next((link.get("href") for link in data.get("links", [])
if link.get("rel") == "next" and link.get("href")), None) if link.get("rel") == "next" and link.get("href")), None)
if not nxt: if not nxt:

View File

@ -860,12 +860,86 @@ def map_meta():
} }
# Fiche IGN (catalogue STAC) mise en cache par dalle : un seul appel réseau,
# une dalle non publiée est revérifiée au plus une fois par jour.
IGN_META_RETRY_S = 86400
def _ign_tile_meta(col, row):
"""Métadonnées IGN d'une dalle (acquisition, lien de téléchargement)."""
from .fetch_ign import tile_ign_metadata
meta_dir = OUTPUT_DIR / "ign_meta"
path = meta_dir / f"{col:04d}_{row:04d}.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("found") or time.time() - data.get("checked", 0) < IGN_META_RETRY_S:
return data
except (OSError, ValueError):
pass
try:
data = tile_ign_metadata(col, row)
except Exception as e:
logger.warning(f"Catalogue IGN injoignable pour {col}_{row} : {e}")
return {"found": False, "error": "catalogue IGN injoignable"}
data["checked"] = time.time()
try:
meta_dir.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
except OSError:
pass
return data
def _calage_summary(basename, suffix):
"""Résumé du recalage vertical des passes (sidecar du MNT), si présent."""
path = OUTPUT_DIR / "DTM" / f"{basename}_dtm{suffix}_stripalign.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
offsets = data.get("offsets") or {}
lines = data.get("lines") or {}
return {
"beams": sorted(set(offsets) | set(lines), key=lambda k: int(k) if str(k).isdigit() else 0),
"offsets_cm": {k: round(v * 100, 1) for k, v in offsets.items()},
"lines_rms_cm": {k: round(v.get("rms_m", 0) * 100, 1) for k, v in lines.items()},
"model": data.get("line_model"),
}
@app.get("/api/map/ign")
def map_tile_ign(col: int, row: int):
"""Fiche IGN d'une dalle (acquisition, capteurs, lien de téléchargement).
Séparée de /api/map/tile : la sélection d'une dalle s'affiche tout de
suite, même si le catalogue IGN est lent ou injoignable.
"""
return _ign_tile_meta(col, row)
@app.get("/api/map/tile") @app.get("/api/map/tile")
def map_tile_info(lat: float, lng: float): def map_tile_info(lat: float, lng: float):
"""Dalle LHD sous un point WGS84 + métadonnées de génération.""" """Dalle LHD sous un point WGS84 : emprise, génération, recalage.
Répond toujours (dalle générée ou non) et sans appel réseau ; la fiche
IGN se charge à part (/api/map/ign).
"""
from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles from .index import _collect_tile_metadata, _res_suffix_str, scan_tiles
x, y = tiles_mod.wgs84_to_l93(lng, lat) x, y = tiles_mod.wgs84_to_l93(lng, lat)
col, row = int(x // 1000), int(y // 1000) + 1 col, row = int(x // 1000), int(y // 1000) + 1
x0, y0, x1, y1 = col * 1000, (row - 1) * 1000, (col + 1) * 1000, row * 1000
lons, lats = tiles_mod._transformer("EPSG:2154", "EPSG:4326").transform(
[x0, x1, x1, x0], [y1, y1, y0, y0])
base = {
"col": col,
"row": row,
"name": f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69",
"l93_bbox": [x0, y0, x1, y1],
# coins NO, NE, SE, SO (lat, lng) : cadre de sélection sur la carte
"corners": [[round(la, 7), round(lo, 7)] for lo, la in zip(lons, lats)],
}
best = None best = None
for t in scan_tiles(OUTPUT_DIR / "visualisations"): for t in scan_tiles(OUTPUT_DIR / "visualisations"):
if (t["col"], t["row"]) != (col, row): if (t["col"], t["row"]) != (col, row):
@ -873,18 +947,19 @@ def map_tile_info(lat: float, lng: float):
if best is None or t["resolution"] < best["resolution"]: if best is None or t["resolution"] < best["resolution"]:
best = t best = t
if best is None: if best is None:
return {"found": False, "col": col, "row": row} return {"found": False, **base}
meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM") meta = _collect_tile_metadata(best, OUTPUT_DIR / "DTM")
suffix = _res_suffix_str(best["resolution"])
return { return {
"found": True, "found": True,
"col": col, **base,
"row": row,
"name": best["basename"], "name": best["basename"],
"resolution": best["resolution"], "resolution": best["resolution"],
"method": meta.get("method"), "method": meta.get("method"),
"generated": meta.get("generated"), "generated": meta.get("generated"),
"layers": sorted(best["viz"]), "layers": sorted(best["viz"]),
"suffix": _res_suffix_str(best["resolution"]), "suffix": suffix,
"calage": _calage_summary(best["basename"], suffix),
} }
@ -1116,28 +1191,9 @@ class GenerateRequest(BaseModel):
tiles: list = Field(..., description="liste [col, row] (entiers km L93)") tiles: list = Field(..., description="liste [col, row] (entiers km L93)")
regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées " regenerate: bool = Field(False, description="Régénérer les tuiles déjà générées "
"(visualisations refaites, classification conservée)") "(visualisations refaites, classification conservée)")
reclassify: bool = Field(False, # Classification (IGN, sol seul) et raccord des bords (bande de 100 m prise
description="Relancer la classification du sol même si " # aux dalles voisines) sont imposés : plus de réglage côté carte. Les
"la méthode demandée est déjà en cache " # anciens champs éventuellement envoyés par un client sont ignorés.
"(--force-classification). Défaut : conserver "
"la classification existante ; choisir une "
"autre méthode reclassifie de toute façon "
"les tuiles concernées")
ground_class: str = Field("ign",
description="Méthode de classification du sol : "
"auto, ign, smrf, csf")
ign_classes: str = Field("sol",
description="Classes LAS pour le MNT IGN : liste noms ou "
"codes séparés par virgules — sol(2), "
"unclassified(1), eau(9), virtuel(66), "
"pont(17), sursol(64). Mode pur, "
"aucune retouche. (défaut: sol)")
edge_buffer: bool = Field(False,
description="Raccord des bords : étendre le MNT d'une "
"bande de 100 m remplie avec les points sol "
"des 8 tuiles voisines — rendus continus "
"d'une tuile à l'autre (recadrés sur la "
"dalle 1 km exacte)")
viz: list = Field(None, viz: list = Field(None,
description="Visualisations à générer, noms d'étapes du " description="Visualisations à générer, noms d'étapes du "
"pipeline (ex: aspect, wavelet, slope) ; " "pipeline (ex: aspect, wavelet, slope) ; "
@ -1349,15 +1405,13 @@ def _queue_summary():
EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m) EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m)
def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol", def _build_command(tiles, regenerate=False, viz=None):
viz=None, reclassify=False, edge_buffer=False):
"""Commande de génération : téléchargement IGN + traitement des fichiers. """Commande de génération : téléchargement IGN + traitement des fichiers.
Avec regenerate=True, les visualisations des tuiles déjà présentes sont Classification IGN (sol seul) et raccord des bords (bande de
refaites (--force) mais leur classification est conservée. Avec EDGE_BUFFER_METERS remplie avec les points sol des 8 dalles voisines)
reclassify=True, --force-classification relance la classification même à toujours appliqués. Avec regenerate=True, les visualisations des tuiles
méthode inchangée. Avec edge_buffer=True, le MNT est étendu d'une bande de déjà présentes sont refaites (--force), classification conservée.
EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines.
""" """
from .fetch_ign import tile_filename from .fetch_ign import tile_filename
# -u : sortie non bufferisée — le journal .generation.log doit être # -u : sortie non bufferisée — le journal .generation.log doit être
@ -1366,14 +1420,11 @@ def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol
"-o", str(OUTPUT_DIR), "-o", str(OUTPUT_DIR),
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS), "-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
"--only", *(viz or _panel_viz_steps()), "--only", *(viz or _panel_viz_steps()),
"--ground-classification", ground_class, "--ground-classification", "ign",
"--ign-classes", ign_classes] "--ign-classes", "sol",
if edge_buffer: "--edge-buffer", f"{EDGE_BUFFER_METERS:g}"]
cmd += ["--edge-buffer", f"{EDGE_BUFFER_METERS:g}"]
if regenerate: if regenerate:
cmd += ["--force"] cmd += ["--force"]
if reclassify:
cmd += ["--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1": if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all"] cmd += ["-g", "all"]
workers = os.environ.get("LIDAR_WORKERS", "auto").strip() workers = os.environ.get("LIDAR_WORKERS", "auto").strip()
@ -1424,10 +1475,6 @@ def _resolve_request(req):
raise HTTPException(400, "aucune tuile fournie") raise HTTPException(400, "aucune tuile fournie")
if len(tiles) > MAX_CELLS: if len(tiles) > MAX_CELLS:
raise HTTPException(400, f"trop de tuiles ({len(tiles)}) — max {MAX_CELLS}") raise HTTPException(400, f"trop de tuiles ({len(tiles)}) — max {MAX_CELLS}")
if req.ground_class not in GROUND_CLASS_METHODS:
raise HTTPException(
400, f"méthode de classification invalide : {req.ground_class!r} "
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
# Génération du nord au sud (row décroissante, puis col croissante) : les # Génération du nord au sud (row décroissante, puis col croissante) : les
# workers prennent les tuiles dans l'ordre de soumission et la carte se # workers prennent les tuiles dans l'ordre de soumission et la carte se
# remplit de haut en bas pendant le run. # remplit de haut en bas pendant le run.
@ -1437,11 +1484,7 @@ def _resolve_request(req):
def _launch_job(tiles, viz, req, qid=None): def _launch_job(tiles, viz, req, qid=None):
"""Démarre un run du pipeline. À appeler sous _job_lock, serveur libre.""" """Démarre un run du pipeline. À appeler sous _job_lock, serveur libre."""
cmd = _build_command(tiles, regenerate=req.regenerate, cmd = _build_command(tiles, regenerate=req.regenerate, viz=viz)
ground_class=req.ground_class,
ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify,
edge_buffer=req.edge_buffer)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True) LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8") log_fh = open(LOG_FILE, "w", encoding="utf-8")
# Nouveau run : journal d'événements remis à zéro et identifiant de run # Nouveau run : journal d'événements remis à zéro et identifiant de run

View File

@ -93,6 +93,11 @@ _MAP_HTML = """<!DOCTYPE html>
<div class="card-body" id="tileBody"></div> <div class="card-body" id="tileBody"></div>
</div> </div>
<div id="rose" class="card" hidden title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
<canvas id="roseCanvas" width="132" height="132"></canvas>
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div>
<div id="usecard" class="card" hidden> <div id="usecard" class="card" hidden>
<div class="card-head"> <div class="card-head">
<span class="card-title">Fond d'imagerie XYZ</span> <span class="card-title">Fond d'imagerie XYZ</span>
@ -112,19 +117,8 @@ _MAP_HTML = """<!DOCTYPE html>
<div id="genOpts"> <div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span> <div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div> <select id="genViz" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<div class="gen-row"><span class="gen-lbl">Classification</span>
<select id="genClass" title="Méthode de classification du sol">
<option value="ign">IGN (pré-classée)</option>
<option value="auto">Auto</option>
<option value="smrf">SMRF</option>
<option value="csf">CSF</option>
</select></div>
<div class="gen-row"><span class="gen-lbl">Classes IGN</span>
<input id="genIgnClasses" class="gen-input" value="sol"
title="Classes LAS du MNT IGN : sol(2), unclassified(1), eau(9), virtuel(66), pont(17), sursol(64) — séparées par des virgules"></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label> <label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<label class="chk"><input type="checkbox" id="genReclass"> Reclasser le sol (même méthode)</label> <div class="meta">Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)</div>
<label class="chk"><input type="checkbox" id="genEdge"> Raccord des bords (bande 100 m)</label>
</div> </div>
<div id="genClassInfo" class="meta"></div> <div id="genClassInfo" class="meta"></div>
<div class="card-foot" id="genActions"> <div class="card-foot" id="genActions">
@ -186,7 +180,22 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); } #layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
#tilecard { left: 12px; top: 12px; width: 320px; max-width: calc(100vw - 24px); } #tilecard { left: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px); overflow-y: auto; }
.info-sec { margin-top: 10px; }
.info-sec h4 { margin: 0 0 4px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--muted); }
.info-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; font-size: 12.5px; }
.info-kv dt { color: var(--muted); }
.info-kv dd { margin: 0; overflow-wrap: anywhere; }
.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: 6px;
background: rgba(90, 160, 255, 0.18); color: var(--text); text-decoration: none; font-weight: 600; }
.info-dl:hover { background: rgba(90, 160, 255, 0.3); }
#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
align-items: center; gap: 2px; z-index: 800; }
#rose canvas { width: 132px; height: 132px; }
.rose-cap { font-size: 11px; text-align: center; color: var(--text); line-height: 1.3; }
.rose-cap span { color: var(--muted); font-size: 10.5px; }
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; } #usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: 910; }
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); } .card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } .card-title { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
@ -592,6 +601,7 @@ function applyLayers() {
base.setOpacity(STATE.base.opacity); base.setOpacity(STATE.base.opacity);
const container = base.getContainer && base.getContainer(); const container = base.getContainer && base.getContainer();
if (container) container.classList.toggle('base-dark', STATE.base.dark); if (container) container.classList.toggle('base-dark', STATE.base.dark);
updateRose();
} }
function moveLayer(key, delta) { function moveLayer(key, delta) {
@ -753,34 +763,180 @@ function renderBaseRow() {
} }
// --- fiche dalle ---------------------------------------------------------- // --- fiche dalle ----------------------------------------------------------
function showTileInfo(data, latlng) { let tileSel = null; // cadre de la dalle sélectionnée
lastTileQuery = latlng || lastTileQuery;
const regenBtn = (GEN.available && GEN.allowed) function esc(v) {
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ Générer cette dalle</button></div>' return String(v == null ? '' : v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
: '';
const card = el('tilecard');
if (!data || !data.found) {
el('tileTitle').textContent = 'Aucune dalle ici';
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>' + regenBtn;
const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false;
return;
} }
function fmtDate(v, withTime) {
if (!v) return '—';
const d = new Date(v);
if (isNaN(d)) return esc(v);
const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
: { dateStyle: 'long', timeZone: 'Europe/Paris' };
return d.toLocaleString('fr-FR', opt);
}
function kv(rows) {
return '<dl class="info-kv">' + rows.filter(r => r[1] != null && r[1] !== '')
.map(r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('') + '</dl>';
}
function selectTileOutline(data) {
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
if (!data || !data.corners) return;
tileSel = L.polygon(data.corners, { color: '#ffd24a', weight: 2, fill: false,
dashArray: '6 4', interactive: false }).addTo(map);
}
function ignSection(ign) {
if (!ign) return '<div class="meta">Chargement des informations IGN…</div>';
if (ign.error) return '<div class="meta">Catalogue IGN injoignable pour le moment.</div>';
if (!ign.found) return '<div class="meta">Dalle non publiée par l\'IGN (hors couverture LiDAR HD ou pas encore diffusée).</div>';
const start = ign.acquisition_start, end = ign.acquisition_end;
let when = fmtDate(start, true);
if (start && end && String(start).slice(0, 10) === String(end).slice(0, 10) && String(end).length > 10) {
when = fmtDate(start, true) + ' → ' + new Date(end).toLocaleTimeString('fr-FR',
{ hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
} else if (end && String(end).slice(0, 10) !== String(start || '').slice(0, 10)) {
when = fmtDate(start) + ' → ' + fmtDate(end);
}
const pts = ign.points ? Number(ign.points).toLocaleString('fr-FR') + ' points' : null;
return kv([
['Scan LiDAR', when],
['Capteur', (ign.sensors || []).map(esc).join('<br>')],
['Mission', esc(ign.mission)],
['Acquisition', [ign.acquisition_operator, ign.acquisition_owner && ('pour ' + ign.acquisition_owner)]
.filter(Boolean).map(esc).join(' ')],
['Édition IGN', ign.edition_date ? fmtDate(ign.edition_date) : null],
['Classement', esc(ign.classification_process)],
['Nuage', pts],
]) + (ign.download_url
? '<a class="info-dl" href="' + esc(ign.download_url) + '" target="_blank" rel="noopener">⤓ Télécharger le nuage LiDAR IGN (.copc.laz)</a>'
: '') + (ign.stac_url
? '<div class="meta"><a href="' + esc(ign.stac_url) + '" target="_blank" rel="noopener">Fiche du catalogue</a></div>' : '');
}
function calageSection(c) {
if (!c || !c.beams || !c.beams.length) return '';
const offs = Object.entries(c.offsets_cm || {}).map(([k, v]) => k + ' : ' + (v > 0 ? '+' : '') + String(v).replace('.', ',') + ' cm');
const lines = Object.values(c.lines_rms_cm || {});
return '<div class="info-sec"><h4>Recalage vertical des passes</h4>' + kv([
['Faisceaux', c.beams.length + ' (PSID ' + c.beams.map(esc).join(', ') + ')'],
['Décalages', offs.length ? offs.join('<br>') : 'aucun ≥ 0,5 cm'],
['Lignes', lines.length ? 'corrigées, ' + String(Math.max(...lines)).replace('.', ',') + ' cm rms au plus' : null],
]) + '</div>';
}
function renderTileCard(data, ign) {
const regenBtn = (GEN.available && GEN.allowed)
? '<div class="card-foot"><button id="tileRegen" class="link-btn" title="Ajouter cette dalle à la sélection de génération (téléchargement IGN + traitement)">↻ ' + (data.found ? 'Régénérer' : 'Générer') + ' cette dalle</button></div>'
: '';
el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row; el('tileTitle').textContent = 'Dalle ' + data.col + ' · ' + data.row;
const bb = data.l93_bbox || [];
let html = '<div class="meta"><b>' + esc(data.name) + '</b></div>' +
'<div class="info-sec"><h4>Emprise</h4>' + kv([
['Lambert 93', bb.length ? 'X ' + bb[0].toLocaleString('fr-FR') + ' – ' + bb[2].toLocaleString('fr-FR') +
'<br>Y ' + bb[1].toLocaleString('fr-FR') + ' – ' + bb[3].toLocaleString('fr-FR') : null],
['Surface', '1 km × 1 km'],
]) + '</div>' +
'<div class="info-sec"><h4>Acquisition LiDAR (IGN)</h4>' + ignSection(ign) + '</div>';
if (data.found) {
const viz = (data.layers || []).map(k => { const viz = (data.layers || []).map(k => {
const info = META.layers.find(l => l.key === k); const info = META.layers.find(l => l.key === k);
return info ? info.label : k; return info ? info.label : k;
}).join(', '); }).join(', ');
el('tileBody').innerHTML = html += '<div class="info-sec"><h4>Rendu</h4>' + kv([
'<div class="meta"><b>' + data.name + '</b></div>' + ['Résolution', String(data.resolution).replace('.', ',') + ' m/px'],
'<div class="meta">Résolution : <b>' + String(data.resolution).replace('.', ',') + ' m/px</b>' + ['Classification', esc(data.method || 'inconnue')],
' · Classification : <b>' + (data.method || 'inconnue') + '</b></div>' + ['Généré le', esc(data.generated || '—')],
(data.generated ? '<div class="meta">Généré le <b>' + data.generated + '</b></div>' : '') + ['Couches', esc(viz || '—')],
'<div class="meta">Couches : ' + (viz || '—') + '</div>' + regenBtn; ]) + '</div>' + calageSection(data.calage);
} else {
html += '<div class="info-sec"><div class="meta">Pas encore de rendu sur cette dalle.</div></div>';
}
el('tileBody').innerHTML = html + regenBtn;
const btn = el('tileRegen'); const btn = el('tileRegen');
if (btn) btn.addEventListener('click', genAddCellFromQuery); if (btn) btn.addEventListener('click', genAddCellFromQuery);
card.hidden = false; el('tilecard').hidden = false;
}
function showTileInfo(data, latlng) {
lastTileQuery = latlng || lastTileQuery;
if (!data) {
el('tileTitle').textContent = 'Dalle';
el('tileBody').innerHTML = '<div class="meta">Serveur injoignable : réessayez dans un instant.</div>';
el('tilecard').hidden = false;
return;
}
selectTileOutline(data);
renderTileCard(data, null);
// Fiche IGN à part : la sélection s'affiche sans attendre le catalogue.
const key = data.col + '_' + data.row;
showTileInfo.current = key;
fetch('api/map/ign?col=' + data.col + '&row=' + data.row)
.then(r => r.ok ? r.json() : { error: 'HTTP ' + r.status })
.catch(() => ({ error: 'réseau' }))
.then(ign => { if (showTileInfo.current === key) renderTileCard(data, ign); });
}
// --- rose des vents du relief orienté --------------------------------------
// Mêmes couleurs que visualizations._relief_lut : teinte CIELAB = orientation
// (aspect = orientation boussole + 90°, repère arctan2(dy, dx) du rendu),
// chroma 60 · L*(100−L*)/2500, à clarté fixe.
function labToRgb(L, a, b) {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const finv = t => (t > 6 / 29 ? t * t * t : 3 * (6 / 29) * (6 / 29) * (t - 4 / 29));
const X = 0.95047 * finv(fx), Y = finv(fy), Z = 1.08883 * finv(fz);
const lin = [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z];
return lin.map(c => {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, v)) * 255);
});
}
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = 132;
cv.width = size * dpr; cv.height = size * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const c = size / 2, rOut = 50, rIn = 22, L = 64;
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
const img = ctx.createImageData(size * dpr, size * dpr);
for (let py = 0; py < size * dpr; py++) {
for (let px = 0; px < size * dpr; px++) {
const x = px / dpr - c, y = py / dpr - c, r = Math.hypot(x, y);
if (r > rOut || r < rIn) continue;
const compass = (Math.atan2(x, -y) * 180 / Math.PI + 360) % 360; // 0 = N, sens horaire
const h = ((compass + 90) % 360) * Math.PI / 180;
const [R, G, B] = labToRgb(L, chroma * Math.cos(h), chroma * Math.sin(h));
const i = (py * size * dpr + px) * 4;
img.data[i] = R; img.data[i + 1] = G; img.data[i + 2] = B; img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
for (const [t, deg] of labels) {
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
ctx.fillText(t, x, y);
}
}
function updateRose() {
const on = !!(STATE && STATE.on && STATE.on.relief_oriente && LAYERS.get('relief_oriente'));
el('rose').hidden = !on;
if (on) drawRose();
} }
// --- fond d'imagerie externe (JOSM, QGIS, iD…) ---------------------------- // --- fond d'imagerie externe (JOSM, QGIS, iD…) ----------------------------
@ -984,10 +1140,6 @@ function genLaunch() {
genApi('POST', 'api/generate', { genApi('POST', 'api/generate', {
tiles: tiles, tiles: tiles,
regenerate: el('genRegen').checked, regenerate: el('genRegen').checked,
reclassify: el('genReclass').checked,
ground_class: el('genClass').value,
ign_classes: el('genIgnClasses').value.trim() || 'sol',
edge_buffer: el('genEdge').checked,
viz: genSelectedViz(), viz: genSelectedViz(),
}).then(d => { }).then(d => {
genSel.clear(); genSel.clear();
@ -1161,12 +1313,16 @@ map.on('click', (e) => {
if (genDrawing || Date.now() < genIgnoreClickUntil) return; if (genDrawing || Date.now() < genIgnoreClickUntil) return;
fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng) fetch('api/map/tile?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(d => showTileInfo(d, e.latlng)).catch(() => {}); .catch(() => null)
.then(d => showTileInfo(d, e.latlng));
}); });
el('cardToggle').addEventListener('click', () => el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed')); el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => { el('tilecard').hidden = true; }); el('tileClose').addEventListener('click', () => {
el('tilecard').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; }); el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard); el('btnUse').addEventListener('click', renderUseCard);
el('btnShare').addEventListener('click', () => { el('btnShare').addEventListener('click', () => {

View File

@ -697,7 +697,6 @@ def test_generate_validates_request(tmp_path, monkeypatch):
mapserve = _gen_setup(tmp_path, monkeypatch) mapserve = _gen_setup(tmp_path, monkeypatch)
for kwargs in ({"tiles": []}, for kwargs in ({"tiles": []},
{"tiles": [[1054]]}, {"tiles": [[1054]]},
{"tiles": [[1054, 6882]], "ground_class": "inconnue"},
{"tiles": [[c, 6882] for c in range(500)]}): {"tiles": [[c, 6882] for c in range(500)]}):
req = mapserve.GenerateRequest(**kwargs) req = mapserve.GenerateRequest(**kwargs)
with pytest.raises(HTTPException) as e: with pytest.raises(HTTPException) as e:
@ -819,13 +818,29 @@ def test_tiles_inventory_endpoint(tmp_path, monkeypatch):
assert light["tiles"] is None assert light["tiles"] is None
def test_generation_always_ign_with_edge_buffer(tmp_path, monkeypatch):
"""Classification IGN (sol) et raccord des bords de 100 m imposés, quel
que soit ce que le client envoie."""
mapserve = _gen_setup(tmp_path, monkeypatch)
cmd = mapserve._build_command([(1054, 6882)])
i = cmd.index("--edge-buffer")
assert cmd[i + 1] == "100"
assert cmd[cmd.index("--ground-classification") + 1] == "ign"
assert cmd[cmd.index("--ign-classes") + 1] == "sol"
req = mapserve.GenerateRequest(tiles=[[1054, 6882]], ground_class="csf", edge_buffer=False)
assert not hasattr(req, "ground_class") or getattr(req, "ground_class", None) != "csf"
def test_ui_offers_generation_controls(): def test_ui_offers_generation_controls():
"""L'interface embarque les contrôles de génération (+ Zone, options, run).""" """L'interface embarque les contrôles de génération (+ Zone, options, run)."""
from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS from lidar_pipeline.mapui import _MAP_CSS, _MAP_HTML, _MAP_JS
assert 'id="btnZone"' in _MAP_HTML and 'id="btnComplete"' in _MAP_HTML assert 'id="btnZone"' in _MAP_HTML and 'id="btnComplete"' in _MAP_HTML
assert 'id="gencard"' in _MAP_HTML and 'id="genGo"' in _MAP_HTML assert 'id="gencard"' in _MAP_HTML and 'id="genGo"' in _MAP_HTML
assert 'id="genStop"' in _MAP_HTML and 'id="genQueueClear"' in _MAP_HTML assert 'id="genStop"' in _MAP_HTML and 'id="genQueueClear"' in _MAP_HTML
assert 'id="genViz"' in _MAP_HTML and 'id="genEdge"' in _MAP_HTML assert 'id="genViz"' in _MAP_HTML
# Classification et raccord des bords imposés : plus de réglage
for gone in ('id="genClass"', 'id="genIgnClasses"', 'id="genReclass"', 'id="genEdge"'):
assert gone not in _MAP_HTML
# Boutons masqués par défaut : révélés seulement si /api/status autorise # Boutons masqués par défaut : révélés seulement si /api/status autorise
assert "GEN.available && GEN.allowed" in _MAP_JS assert "GEN.available && GEN.allowed" in _MAP_JS
# Dessin de zone + cadres de run + sondage d'état # Dessin de zone + cadres de run + sondage d'état
@ -869,3 +884,41 @@ def test_cache_only_fetches_missing_tile_upstream(tmp_path, monkeypatch):
assert tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "webp").is_file() assert tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "webp").is_file()
finally: finally:
monkeypatch.delenv("LIDAR_TILE_CACHE_ONLY") monkeypatch.delenv("LIDAR_TILE_CACHE_ONLY")
def test_tile_selection_always_answers(tmp_path, monkeypatch):
"""La sélection d'une dalle répond toujours, sans appel réseau : dalle
absente comprise, avec nom, emprise L93 et cadre de sélection."""
mapserve = _setup(tmp_path, monkeypatch, layers=("relief_oriente",))
def boom(*a, **k):
raise AssertionError("aucun appel IGN dans /api/map/tile")
monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", boom)
d = mapserve.map_tile_info(lat=48.0, lng=2.0) # loin de toute dalle
assert d["found"] is False and d["name"].startswith("LHD_FXX_")
assert len(d["corners"]) == 4 and d["l93_bbox"][2] - d["l93_bbox"][0] == 1000
def test_ign_metadata_cached_and_resilient(tmp_path, monkeypatch):
"""Fiche IGN : un seul appel au catalogue (cache disque) ; catalogue
injoignable = message, jamais d'exception."""
mapserve = _setup(tmp_path, monkeypatch)
calls = []
def fake(col, row, timeout=10):
calls.append((col, row))
return {"found": True, "download_url": "https://data.geopf.fr/x.copc.laz",
"acquisition_start": "2022-03-05T16:20:51Z"}
monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", fake)
a = mapserve.map_tile_ign(col=1004, row=6832)
b = mapserve.map_tile_ign(col=1004, row=6832)
assert a["download_url"].endswith(".copc.laz") and b == a and len(calls) == 1
def down(col, row, timeout=10):
raise OSError("réseau coupé")
monkeypatch.setattr("lidar_pipeline.fetch_ign.tile_ign_metadata", down)
assert mapserve.map_tile_ign(col=1, row=2)["found"] is False
def test_ui_tile_card_and_wind_rose():
from lidar_pipeline.mapui import _MAP_HTML, _MAP_JS
assert 'id="rose"' in _MAP_HTML and 'id="roseCanvas"' in _MAP_HTML
assert "api/map/ign?col=" in _MAP_JS and "Télécharger le nuage LiDAR" in _MAP_JS
assert "compass + 90" in _MAP_JS # même repère que le rendu (aspect = boussole + 90°)