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:
@ -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 |
|
||||||
|
|||||||
25
docs/MAPS.md
25
docs/MAPS.md
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
: '';
|
}
|
||||||
const card = el('tilecard');
|
|
||||||
if (!data || !data.found) {
|
function fmtDate(v, withTime) {
|
||||||
el('tileTitle').textContent = 'Aucune dalle ici';
|
if (!v) return '—';
|
||||||
el('tileBody').innerHTML = '<div class="meta">Pas de rendu disponible à cet endroit.</div>' + regenBtn;
|
const d = new Date(v);
|
||||||
const btn = el('tileRegen');
|
if (isNaN(d)) return esc(v);
|
||||||
if (btn) btn.addEventListener('click', genAddCellFromQuery);
|
const opt = withTime ? { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Paris' }
|
||||||
card.hidden = false;
|
: { dateStyle: 'long', timeZone: 'Europe/Paris' };
|
||||||
return;
|
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', () => {
|
||||||
|
|||||||
@ -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°)
|
||||||
|
|||||||
Reference in New Issue
Block a user