Split webapp for Raspberry Pi deployment, remote generation API and sync

La webapp (carte + vignettes) et la génération de tuiles se déploient sur
deux machines : image légère Dockerfile.webapp (FastAPI + Pillow AVIF
natif + pyproj) sur Raspberry Pi, pipeline complet sur la machine de
traitement. LIDAR_GENERATION_URL délègue /api/generate, /api/preview et
/api/status ; /api/sync ramène les tuiles par rsync puis régénère
vignettes et index localement. Token partagé optionnel
(LIDAR_API_TOKEN/LIDAR_REMOTE_TOKEN). Retire du dépôt les journaux
internes (.swival, audit-findings) et les données (data/, notebooks/).
Doc : docs/DEPLOY_WEBAPP.md.
This commit is contained in:
Antoine Jacquin
2026-09-02 19:44:39 +02:00
parent ed3e90ea89
commit 422f58d772
24 changed files with 2390 additions and 329 deletions

View File

@ -238,6 +238,12 @@ def main():
action="store_true",
help="Ne pas générer la carte globale à la fin du traitement"
)
parser.add_argument(
"--incremental-index",
action="store_true",
help="Régénérer la carte après chaque tuile terminée (affichage en direct "
"dans la webapp, sans attendre la fin du run)"
)
args = parser.parse_args()
@ -286,20 +292,11 @@ def main():
logger.warning("Aucune tuile traitée trouvée — carte globale non générée")
return
# Téléchargement des dalles IGN manquantes avant le traitement
if args.fetch_tiles:
from .fetch_ign import fetch_tiles, parse_tile_specs
try:
specs = parse_tile_specs(args.fetch_tiles)
except ValueError as e:
logger.error(str(e))
return
logger.info(f"Téléchargement de {len(specs)} dalle(s) LiDAR HD depuis l'IGN...")
fetched = fetch_tiles(args.input, specs, args.output)
if fetched:
logger.info(f"{len(fetched)} dalle(s) téléchargée(s) — traitement...")
else:
logger.warning("Aucune dalle téléchargée (déjà présentes ou introuvables)")
# Nouveau run : remise à zéro du journal d'événements (file de
# génération). Ici, avant le téléchargement — les événements download
# survivent au démarrage du traitement (process_all ne tronque plus).
from .progress import reset_events
reset_events(args.output)
quality = 100 if args.lossless else args.quality
# Parse --only and --skip: accept comma-separated values
@ -309,6 +306,27 @@ def main():
skip_viz = None
if args.skip:
skip_viz = [v.strip() for item in args.skip for v in item.split(',')]
resolutions = [float(r.strip()) for r in args.resolution.split(',') if r.strip()]
# Téléchargement des dalles IGN manquantes avant le traitement.
# only_viz/resolutions : une tuile n'est ignorée que si elle possède
# déjà toutes les visualisations demandées — une tuile incomplète est
# (re)téléchargée pour générer ses visualisations manquantes.
if args.fetch_tiles:
from .fetch_ign import fetch_tiles, parse_tile_specs
try:
specs = parse_tile_specs(args.fetch_tiles)
except ValueError as e:
logger.error(str(e))
return
logger.info(f"Téléchargement de {len(specs)} dalle(s) LiDAR HD depuis l'IGN...")
fetched = fetch_tiles(args.input, specs, args.output,
only_viz=only_viz, resolutions=resolutions,
force=args.force or args.force_classification)
if fetched:
logger.info(f"{len(fetched)} dalle(s) téléchargée(s) — traitement...")
else:
logger.warning("Aucune dalle téléchargée (déjà présentes ou introuvables)")
pipeline = LidarArchaeoPipeline(
input_dir=args.input,
output_dir=args.output,
@ -326,6 +344,7 @@ def main():
output_format=args.format,
gpu_ids=gpu_ids,
no_index=args.no_index,
incremental_index=args.incremental_index,
)
# If --file is specified, process only matching files

View File

@ -54,6 +54,14 @@ def tile_filename(col, row):
return f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69.copc.laz"
def _report_download(output_dir, tile, state, detail=None):
"""Émet un événement de téléchargement pour la file de génération (best-effort)."""
if output_dir is None:
return
from .progress import report_event
report_event(output_dir, tile, "download", state, detail=detail)
def _bbox_wgs84(col, row):
"""Bbox WGS84 de la dalle (col,row) pour la requête STAC (peut être élargie)."""
try:
@ -117,46 +125,76 @@ def download_file(url, dest_path, timeout=120, chunk=1024 * 1024):
return done
def fetch_tiles(input_dir, specs, output_dir=None):
"""Télécharge les dalles IGN spécifiées, sauf celles déjà présentes/générées.
def fetch_tiles(input_dir, specs, output_dir=None, only_viz=None, resolutions=(0.5,), force=False):
"""Télécharge les dalles IGN spécifiées, sauf celles inutiles au traitement.
Args:
input_dir: dossier des fichiers LAZ (écriture autorisée requise).
specs: liste de tuples (col, row).
output_dir: dossier de sortie (optionnel) — permet d'ignorer les
tuiles dont les visualisations existent déjà.
tuiles déjà complètes.
only_viz: noms d'étapes des visualisations demandées (ex: ['aspect']).
Si fourni, une tuile n'est ignorée que si elle possède déjà
toutes ces visualisations aux résolutions demandées —
sinon son LAZ est (re)téléchargé pour compléter les
visualisations manquantes.
resolutions: résolutions attendues (m/px) pour considérer une tuile
complète quand only_viz est fourni.
force: True pour télécharger même les tuiles complètes (régénération :
leur LAZ est requis pour retraiter avec --force).
Returns:
Liste des chemins téléchargés.
"""
input_dir = Path(input_dir)
complete = None
if output_dir is not None and only_viz and not force:
from .index import cells_with_all_viz, step_to_keyword
keys = [step_to_keyword(v) for v in only_viz]
complete = cells_with_all_viz(Path(output_dir) / "visualisations",
keys, resolutions)
downloaded = []
for col, row in specs:
name = tile_filename(col, row)
tile = name[:-len(".copc.laz")] if name.endswith(".copc.laz") else name
dest = input_dir / name
if dest.exists():
logger.info(f" {name} : déjà présent dans input/ — aucun téléchargement")
_report_download(output_dir, tile, "skip", "déjà dans input/")
continue
if output_dir is not None:
vis_dir = Path(output_dir) / "visualisations"
if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")):
logger.info(f" {name} : visualisations déjà générées — ignorée")
continue
if output_dir is not None and not force:
if complete is not None:
if (col, row) in complete:
logger.info(f" {name} : visualisations déjà complètes — ignorée")
_report_download(output_dir, tile, "skip", "visualisations déjà complètes")
continue
else:
vis_dir = Path(output_dir) / "visualisations"
if list(vis_dir.glob(f"LHD_FXX_{col:04d}_{row:04d}_PTS*")):
logger.info(f" {name} : visualisations déjà générées — ignorée")
_report_download(output_dir, tile, "skip", "visualisations déjà générées")
continue
logger.info(f" {name} : recherche dans le catalogue IGN...")
_report_download(output_dir, tile, "start", "catalogue IGN")
try:
url = find_tile_url(col, row)
except Exception as e:
logger.warning(f" ✗ {name} : erreur catalogue ({e})")
_report_download(output_dir, tile, "fail", f"erreur catalogue : {e}")
continue
if not url:
logger.warning(f" ✗ {name} : introuvable dans le catalogue IGN (zone non publiée ?)")
_report_download(output_dir, tile, "fail", "introuvable dans le catalogue IGN")
continue
logger.info(f" {name} : téléchargement depuis la géoplateforme...")
_report_download(output_dir, tile, "start", "géoplateforme")
try:
download_file(url, dest)
logger.info(f" ✓ {name} téléchargée")
_report_download(output_dir, tile, "ok")
downloaded.append(dest)
except Exception as e:
dest.unlink(missing_ok=True)
logger.warning(f" ✗ {name} : échec du téléchargement ({e})")
_report_download(output_dir, tile, "fail", f"échec du téléchargement : {e}")
return downloaded

View File

@ -27,6 +27,12 @@ _gpu_reason = None
# GPU restriction from -g flag (host-level indices)
_restricted_gpu_ids: list[int] | None = None
# Vrai si CUDA_VISIBLE_DEVICES a été écrit par _init_gpu lui-même (choix du
# meilleur GPU). Cette valeur NE DOIT PAS être traitée comme une restriction
# externe : sinon available_gpu_ids() ne retourne plus que le GPU choisi et
# tous les workers reçoivent le même gpu_id (GPU 1 jamais utilisé).
_env_set_by_init = False
# Discovered GPU candidates (populated by _pick_gpu)
_gpu_candidates: list = []
@ -38,7 +44,7 @@ def _pick_gpu() -> list:
result = subprocess.run(
['nvidia-smi', '--query-gpu=index,name,compute_cap,memory.total',
'--format=csv,noheader,nounits'],
capture_output=True, text=True, timeout=5,
capture_output=True, text=True, timeout=15,
)
if result.returncode != 0:
return []
@ -86,10 +92,12 @@ def _filter_candidates(gpus: list) -> list:
nvidia-smi lists ALL GPUs even when CUDA_VISIBLE_DEVICES is set
(driver 580.x behavior), so we must filter manually.
La variable écrite par _init_gpu (choix auto du meilleur GPU) est ignorée :
seules les restrictions externes (run.sh -g, compose) comptent.
"""
# Filter by CUDA_VISIBLE_DEVICES if set
# Filter by CUDA_VISIBLE_DEVICES if set externally
cuda_visible = os.environ.get('CUDA_VISIBLE_DEVICES')
if cuda_visible is not None:
if cuda_visible is not None and not _env_set_by_init:
try:
visible = {int(i.strip()) for i in cuda_visible.split(',')}
gpus = [g for g in gpus if g[0] in visible]
@ -104,6 +112,40 @@ def _filter_candidates(gpus: list) -> list:
return gpus
def _runtime_candidates():
"""Détection GPU de repli via le runtime CuPy (sans nvidia-smi).
nvidia-smi peut dépasser son timeout quand le système est chargé :
_pick_gpu() retourne alors [] et les workers passent à tort en CPU.
Les indices CuPy sont renumérotés selon CUDA_VISIBLE_DEVICES — on les
remappe en indices hôtes pour rester compatible avec _filter_candidates.
"""
try:
import cupy as _cp_runtime
n = _cp_runtime.cuda.runtime.getDeviceCount()
cuda_visible = os.environ.get('CUDA_VISIBLE_DEVICES')
try:
host_ids = [int(v.strip()) for v in cuda_visible.split(',')]
except (ValueError, AttributeError):
host_ids = list(range(n))
gpus = []
for i in range(min(n, len(host_ids))):
props = _cp_runtime.cuda.runtime.getDeviceProperties(i)
name = props.get('name', b'?')
if isinstance(name, bytes):
name = name.decode()
major = int(props.get('major', 0))
minor = int(props.get('minor', 0))
mem_mi = int(props.get('totalGlobalMem', 0)) // (1024 * 1024)
cap = f"{major}.{minor}"
score = major * 1000 + minor * 100 + mem_mi
gpus.append((host_ids[i], name, cap, mem_mi, score, major))
gpus.sort(key=lambda g: g[4], reverse=True)
return gpus
except Exception:
return []
def _init_gpu():
"""Lazily initialize CuPy on first GPU use.
@ -118,6 +160,9 @@ def _init_gpu():
_gpu_initialized = True
candidates = _filter_candidates(_gpu_candidates)
if not candidates:
# Repli runtime : nvidia-smi a échoué (timeout système chargé)
candidates = _filter_candidates(_runtime_candidates())
if not candidates:
logger.info("Pas de GPU utilisable — mode CPU uniquement")
_xp = np
@ -192,6 +237,8 @@ def _init_gpu():
return
idx, name, mem_mi = _working_gpu
global _env_set_by_init
_env_set_by_init = True
os.environ['CUDA_VISIBLE_DEVICES'] = str(idx)
import cupy as _real_cupy
@ -238,9 +285,19 @@ def restrict_gpus(gpu_ids: list[int], set_env_var: bool = False):
def set_active_gpu(gpu_id):
"""Restrict to a single GPU by host-level index."""
"""Restrict to a single GPU by host-level index.
Appelé par les workers spawnés (_process_file_standalone) : CuPy n'y est
pas encore initialisé (le worker n'exécute pas log_gpu_status), il faut
donc déclencher _init_gpu() ici, sinon le worker retombe silencieusement
en CPU. On réduit aussi CUDA_VISIBLE_DEVICES à ce seul GPU avant l'init
pour que le device 0 du worker soit le bon : sans cela, tous les workers
avec plusieurs GPU visibles partagent le premier d'entre eux.
"""
global _restricted_gpu_ids
_restricted_gpu_ids = [gpu_id]
os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id)
_init_gpu()
def _gpu_available():

View File

@ -41,7 +41,6 @@ VIZ_LABELS = {
'positive_openness': 'Openness positive',
'negative_openness': 'Openness négative',
'svf': 'Sky-View Factor',
'aniso_open': 'Openness anisotropique',
'roughness': 'Rugosité',
'wavelet': 'Ondelette',
'flow_acc': 'Accumulation d\'écoulement',
@ -54,26 +53,53 @@ VIZ_LABELS = {
# Couche activée par défaut à l'ouverture de la carte.
DEFAULT_VIZ = 'hillshade_multi'
# Couches proposées dans le panneau de couches et le sélecteur de génération
# (liste restreinte : base aspect + visualisations demandées). Les autres
# visualisations restent calculées et stockées mais ne sont pas proposées.
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
# Visualisations proposées dans le panneau de couches et le sélecteur de
# génération/régénération : les mêmes, toujours — le sélecteur est piloté par
# les couches réellement proposées à l'affichage (voir _render_html).
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'wavelet')
# Visualisations proposées dans la barre de génération de zones (noms
# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger).
GEN_VIZ_CHOICES = [
('aspect', 'Aspect'),
('slope', 'Pente'),
('pos_open', 'Openness positive'),
('aniso_open', 'Openness anisotropique'),
('wavelet', 'Ondelette'),
]
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
# cf. _expected_output_path dans pipeline.py).
KEYWORD_TO_STEP = {
'hillshade_multi': 'hillshade',
'positive_openness': 'pos_open',
'negative_openness': 'neg_open',
}
# Correspondance inverse : nom d'étape --only → mot-clé de fichier de sortie.
STEP_TO_KEYWORD = {step: kw for kw, step in KEYWORD_TO_STEP.items()}
def step_to_keyword(step):
"""Nom d'étape du pipeline (ex: 'pos_open') → mot-clé de fichier ('positive_openness')."""
return STEP_TO_KEYWORD.get(step, step)
def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
"""Cellules (col, row) disposant de TOUTES les visualisations demandées.
Une cellule est complète si, pour chaque résolution de `resolutions`, un
dossier de visualisations lui correspond et contient chaque mot-clé de
`viz_keys` (ex: 'aspect', 'hillshade_multi'). Sert à distinguer les tuiles
réellement terminées de celles à compléter : une tuile existante mais
incomplète (visualisation ou résolution manquante) reste à traiter.
Returns:
Ensemble des (col, row) complets.
"""
by_cell = {}
for t in scan_tiles(vis_dir):
per_res = by_cell.setdefault((t['col'], t['row']), {})
per_res.setdefault(t['resolution'], set()).update(t['viz'].keys())
return {cell for cell, per_res in by_cell.items()
if all(kw in per_res.get(res, ()) for res in resolutions for kw in viz_keys)}
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
# carte — les autres viz restent disponibles en repli dalle entière.
# Liste vide = découper toutes les visualisations disponibles.
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet')
'wavelet')
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
@ -85,7 +111,7 @@ _SUBTILE_AVIF_SPEED = 8
# Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli.
_VIZ_FALLBACK_ORDER = [
'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness',
'negative_openness', 'aspect', 'sailore', 'aniso_open', 'roughness',
'negative_openness', 'aspect', 'sailore', 'roughness',
'wavelet', 'flow_acc', 'solar', 'anomaly', 'ortho', 'topo',
]
@ -295,6 +321,18 @@ def _approx_l93_to_wgs84(x_m, y_m):
return lon, lat
def _approx_wgs84_to_l93(lon, lat):
"""Approximation affine WGS84 → Lambert 93 (inverse exacte du précédent).
Précision de l'ordre du km — repli sans rasterio ni pyproj (webapp
légère, cf. bbox_to_cells dans webapp.py).
"""
import math
y = (lat - 46.5) * 111320.0 + 6600000.0
x = (lon - 3.0) * (111320.0 * math.cos(math.radians(47.0))) + 700000.0
return x, y
def attach_gps_bounds(tiles):
"""Attache à chaque tuile ses coins GPS pour l'affichage Leaflet.
@ -302,25 +340,38 @@ def attach_gps_bounds(tiles):
(col, row) → X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
(Vérifié sur les bounds des DTM : X_min = col×1000, Y_max = row×1000.)
Utilise rasterio.warp (conversion PROJ exacte) si disponible,
sinon l'approximation affine _approx_l93_to_wgs84.
Utilise rasterio.warp (conversion PROJ exacte) si disponible, sinon
pyproj, sinon l'approximation affine _approx_l93_to_wgs84 (webapp
légère sans GDAL — précision ~km).
Ajoute à chaque tuile:
corners: [[lat, lon] × 4] dans l'ordre SW, SE, NE, NW
bounds : [[lat_sud, lon_ouest], [lat_nord, lon_est]]
"""
# Coins SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
xs = []
ys = []
for t in tiles:
xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000,
(t['col'] + 1) * 1000, t['col'] * 1000])
ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000,
t['row'] * 1000, t['row'] * 1000])
try:
from rasterio.warp import transform as warp_transform
xs = []
ys = []
for t in tiles:
# SW, SE, NE, NW — Y du bord sud = (row-1)×1000, bord nord = row×1000
xs.extend([t['col'] * 1000, (t['col'] + 1) * 1000,
(t['col'] + 1) * 1000, t['col'] * 1000])
ys.extend([(t['row'] - 1) * 1000, (t['row'] - 1) * 1000,
t['row'] * 1000, t['row'] * 1000])
lons, lats = warp_transform('EPSG:2154', 'EPSG:4326', xs, ys)
ok = True
except ImportError:
try:
from pyproj import Transformer
transformer = Transformer.from_crs('EPSG:2154', 'EPSG:4326',
always_xy=True)
lons, lats = transformer.transform(xs, ys)
ok = True
except ImportError:
logger.debug("Coins GPS approximatifs (rasterio et pyproj indisponibles)")
lons = None
lats = None
ok = False
except Exception as e:
logger.debug(f"Coins GPS approximatifs (rasterio indisponible: {e})")
lons = None
@ -555,6 +606,19 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
return usable
def _viz_src_dir(tile, info):
"""Dossier réel du fichier d'une visualisation.
Les couches fusionnées d'une autre résolution (run interrompu entre les
deux passes) vivent dans leur dossier d'origine — info['dir_name'] — et
non dans dir_path de la dalle affichée.
"""
d = info.get('dir_name') or tile.get('dir_name')
if d and d != Path(tile['dir_path']).name:
return Path(tile['dir_path']).parent / d
return Path(tile['dir_path'])
def _collect_tile_metadata(tile, dtm_dir):
"""Rassemble les métadonnées de génération d'une tuile.
@ -587,7 +651,8 @@ def _collect_tile_metadata(tile, dtm_dir):
for viz_key, info in tile['viz'].items():
try:
st = (Path(tile['dir_path']) / info['filename']).stat()
viz_dir = _viz_src_dir(tile, info)
st = (viz_dir / info['filename']).stat()
meta['viz'][viz_key] = {
'date': datetime.fromtimestamp(st.st_mtime).strftime('%Y-%m-%d %H:%M'),
'size': st.st_size,
@ -643,12 +708,15 @@ def build_index(output_dir, output_format='avif'):
# visualisations produites uniquement à l'autre résolution : sans cela,
# une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore)
# reste invisible sur la carte et absente du panneau de couches.
# dir_name mémorise le dossier d'origine : vignettes, URLs et métadonnées
# doivent lire le fichier là où il existe réellement.
for key, best in best_by_pos.items():
for other in tiles_by_pos[key]:
if other is best:
continue
for viz_key, viz_info in other['viz'].items():
best['viz'].setdefault(viz_key, viz_info)
if viz_key not in best['viz']:
best['viz'][viz_key] = dict(viz_info, dir_name=other['dir_name'])
tiles = sorted(best_by_pos.values(),
key=lambda t: (t['resolution'], -t['row'], t['col']))
@ -686,7 +754,9 @@ def build_index(output_dir, output_format='avif'):
viz_thumbs = {}
regen = 0
for viz_key, info in t['viz'].items():
src = Path(t['dir_path']) / info['filename']
# Les couches fusionnées d'une autre résolution vivent dans
# leur dossier d'origine (info['dir_name']), pas dir_path.
src = _viz_src_dir(t, info) / info['filename']
thumb_name = f"{t['dir_name']}_{viz_key}.jpg"
thumb_path = thumb_dir / thumb_name
# Régénère si manquante ou périmée (tuile recalculée depuis)
@ -699,9 +769,10 @@ def build_index(output_dir, output_format='avif'):
continue
else:
thumbs_generated += 1
viz_dir_name = _viz_src_dir(t, info).name
viz_thumbs[viz_key] = {
'thumb': f"index_thumbs/{thumb_name}",
'full': f"visualisations/{t['dir_name']}/{info['filename']}",
'full': f"visualisations/{viz_dir_name}/{info['filename']}",
}
if regen:
@ -759,6 +830,16 @@ def build_index(output_dir, output_format='avif'):
if n_sous:
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)")
# Export des données de carte (index_tiles.json) : la webapp les sert via
# /api/tiles pour afficher les tuiles terminées en direct, sans recharger
# la page — le pipeline le réécrit après chaque tuile en mode incrémental.
ordered_viz, default_viz = _panel_selection(all_viz_keys)
(output_dir / 'index_tiles.json').write_text(json.dumps({
'tiles': display_tiles,
'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz},
'stats': {'default_viz': default_viz, 'n_tiles': len(display_tiles)},
}, ensure_ascii=False), encoding='utf-8')
_write_assets(output_dir)
html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format)
html_path = output_dir / 'index.html'
@ -781,8 +862,12 @@ def _write_assets(output_dir):
(assets_dir / 'app.js').write_text(_APP_JS, encoding='utf-8')
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
def _panel_selection(all_viz_keys):
"""Couches proposées dans le panneau + couche activée par défaut.
Partagé par la coquille HTML et l'export index_tiles.json (la webapp doit
décrire le panneau exactement comme la page au moment du rebuild).
"""
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
# les autres visualisations existent sur disque mais ne sont pas proposées.
panel_keys = set(all_viz_keys)
@ -793,24 +878,35 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
for v in sorted(panel_keys):
if v not in ordered_viz:
ordered_viz.append(v)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
return ordered_viz, default_viz
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
ordered_viz, default_viz = _panel_selection(all_viz_keys)
tiles_json = json.dumps(tiles, ensure_ascii=False)
# Options du sélecteur de visualisations de la barre de génération.
# Options du sélecteur de génération/régénération : uniquement les couches
# réellement proposées dans le panneau de visualisation (mêmes couches,
# mêmes libellés ; valeurs = noms d'étapes --only du pipeline). Repli sur
# aspect si aucune couche n'est encore disponible.
gen_keys = ordered_viz if ordered_viz else ['aspect']
gen_viz_options = "\n".join(
f' <option value="{name}"{" selected" if name == "aspect" else ""}>'
f'{label}</option>'
for name, label in GEN_VIZ_CHOICES)
f' <option value="{KEYWORD_TO_STEP.get(v, v)}"'
f'{" selected" if v == "aspect" else ""}>'
f'{VIZ_LABELS.get(v, v)}</option>'
for v in gen_keys)
viz_meta_json = json.dumps({
k: {'label': VIZ_LABELS.get(k, k)}
for k in ordered_viz
}, ensure_ascii=False)
# Couche activée par défaut : préférence globale, sinon aspect (base
# complète), sinon la 1re proposée.
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
ensure_ascii=False)
@ -850,6 +946,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<nav id="mapTools" class="panel">
<button id="btnGenZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnComplete" title="Compléter : générer les visualisations manquantes sur toutes les dalles présentes dans input/ (sans téléchargement)" hidden><span class="ok-ic">✓</span> Compléter</button>
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
@ -955,6 +1052,8 @@ _APP_CSS = """/* Interface de la carte LiDAR — panneaux flottants type SIG. */
--accent: #5aa9e6;
--accent-dim: #2e7cc4;
--danger: #e94560;
--success: #4cc98a;
--success-dim: #2f8f63;
--radius: 10px;
}
@ -1020,6 +1119,13 @@ select, input[type="checkbox"] { accent-color: var(--accent); }
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
#mapTools #btnGenZone { width: auto; padding: 0 12px; font-size: 12.5px; background: var(--accent-dim); border-color: var(--accent-dim); }
#mapTools #btnGenZone:hover { background: var(--accent); }
#mapTools #btnComplete {
width: auto; padding: 0 14px; font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em;
background: var(--success-dim); border-color: var(--success-dim); color: #eafff4;
box-shadow: 0 2px 10px rgba(76, 201, 138, 0.25);
}
#mapTools #btnComplete:hover { background: var(--success); border-color: var(--success); box-shadow: 0 2px 14px rgba(76, 201, 138, 0.45); }
#mapTools #btnComplete .ok-ic { margin-right: 6px; font-weight: 700; }
/* --- Panneau de couches (gauche) --- */
#layerPanel { top: 74px; left: 12px; width: 300px; }
@ -1150,10 +1256,15 @@ input[type="range"]::-moz-range-thumb {
font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
}
/* File de génération : panneau latéral, le journal replié par défaut */
#genQueue { bottom: 48px; right: 12px; width: 780px; max-width: 94vw; }
/* File de génération : panneau latéral — peut occuper toute la hauteur
de l'écran si besoin (plafonné au viewport, scroll interne au-delà) */
#genQueue {
bottom: 48px; right: 12px; width: 780px; max-width: 94vw;
max-height: calc(100vh - 60px);
display: flex; flex-direction: column;
}
#genQueueBody {
padding: 8px 10px; max-height: 42vh; overflow-y: auto;
padding: 8px 10px; flex: 1; min-height: 0; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px;
}
.gen-queue-actions { display: flex; align-items: center; gap: 8px; }
@ -1189,6 +1300,40 @@ input[type="range"]::-moz-range-thumb {
color: #9fb3d9; white-space: pre-wrap;
}
/* Tuiles une par une : nom court + puces d'étapes (types de visualisation).
Sans plafond : c'est le corps du panneau qui scrolle si trop long. */
.gen-task-tiles {
margin: 0 8px 8px;
display: flex; flex-direction: column; gap: 4px;
}
.gen-tile {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--panel-border); border-radius: 6px;
padding: 4px 8px; font-size: 11.5px;
}
.gen-tile-name {
min-width: 78px; flex: none; font-variant-numeric: tabular-nums;
color: var(--text); cursor: default;
}
.gen-tile-steps { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
.gen-step {
display: inline-flex; align-items: center; gap: 4px;
padding: 1px 7px; border-radius: 999px;
background: rgba(255, 255, 255, 0.05); color: var(--muted);
font-size: 10.5px; white-space: nowrap;
}
.gen-step::before { content: '·'; font-weight: bold; }
.gen-step.running { color: var(--accent); background: rgba(90, 169, 230, 0.12); }
.gen-step.running::before { content: '⟳'; animation: gen-pulse 1.1s ease-in-out infinite; }
.gen-step.ok { color: #39d98a; background: rgba(57, 217, 138, 0.10); }
.gen-step.ok::before { content: '✓'; }
.gen-step.fail { color: var(--danger); background: rgba(233, 69, 96, 0.12); }
.gen-step.fail::before { content: '✗'; }
.gen-step.skip::before { content: '–'; }
.gen-tile-state { flex: none; font-size: 11px; }
.gen-tile.done .gen-tile-state { color: #39d98a; }
.gen-tile.failed .gen-tile-state { color: var(--danger); }
body.drawing, body.drawing #viewport { cursor: crosshair !important; }
/* --- Responsive --- */
@ -1289,8 +1434,10 @@ let gridBounds = null;
const markers = new Map();
const tileKey = (t) => t.col + ',' + t.row + ',' + t.resolution + ',' + (t.sub_i || 0) + ',' + (t.sub_j || 0);
for (const t of TILES) {
if (!t.corners) continue;
// Crée le marqueur Leaflet d'une tuile (utilisé à l'init et en direct,
// quand une tuile terminée apparaît en cours de génération).
function registerTile(t) {
if (!t.corners) return;
const center = [(t.corners[0][0] + t.corners[2][0]) / 2,
(t.corners[0][1] + t.corners[2][1]) / 2];
const icon = L.divIcon({
@ -1305,10 +1452,20 @@ for (const t of TILES) {
}).addTo(map);
mk.on('click', () => { if (!genMode) showTileInfo(t); });
markers.set(tileKey(t), mk);
const b = L.latLngBounds(t.corners);
gridBounds = gridBounds ? gridBounds.extend(b) : b;
}
function recomputeGridBounds() {
gridBounds = null;
for (const t of TILES) {
if (!t.corners) continue;
const b = L.latLngBounds(t.corners);
gridBounds = gridBounds ? gridBounds.extend(b) : b;
}
}
for (const t of TILES) registerTile(t);
recomputeGridBounds();
function tileDiv(t) {
const mk = markers.get(tileKey(t));
const el = mk && mk.getElement();
@ -1666,6 +1823,7 @@ function regenerateCurrentTile() {
hideTileInfo();
pendingTiles = [[t.col, t.row]];
pendingRegen = true;
pendingCompleteAll = false;
document.getElementById('genRegen').checked = true;
genMsg.textContent = '1 tuile à régénérer — choisissez les options puis lancez';
genGo.style.display = '';
@ -1677,6 +1835,7 @@ function regenerateCurrentTile() {
// ============================================================
let apiAvailable = false;
const genBtn = document.getElementById('btnGenZone');
const btnComplete = document.getElementById('btnComplete');
const genBar = document.getElementById('genBar');
const genMsg = document.getElementById('genMsg');
const genGo = document.getElementById('genGo');
@ -1691,16 +1850,24 @@ const genQueueBody = document.getElementById('genQueueBody');
const genQueueClear = document.getElementById('genQueueClear');
const genQueueCount = document.getElementById('genQueueCount');
let genMode = false, drawStart = null, drawRect = null;
let pendingTiles = [], pendingRegen = false;
let pendingTiles = [], pendingRegen = false, pendingCompleteAll = false;
const ghostCells = [];
fetch('/api/status').then(r => {
apiAvailable = r.ok;
if (r.ok) genBtn.hidden = false;
if (r.ok) { genBtn.hidden = false; btnComplete.hidden = false; }
}).catch(() => {});
function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length = 0; }
// Visualisations choisies dans la barre de génération (noms d'étapes --only).
// Sert au preview (détection des tuiles incomplètes) et au lancement : une
// tuile existante mais privée d'une de ces visualisations est incluse.
function selectedGenViz() {
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
return sel.length ? sel : ['aspect'];
}
function exitGenMode() {
genMode = false; drawStart = null;
document.body.classList.remove('drawing');
@ -1743,50 +1910,87 @@ map.on('mouseup', e => {
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()],
regenerate: genRegen.checked })
regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
if (drawRect) { drawRect.remove(); drawRect = null; }
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = false; // zone dessinée : le scope global est levé
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Aucune tuile manquante dans cette zone' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour traiter les tuiles existantes)');
genMsg.textContent = 'Zone déjà complète pour les visualisations choisies' +
(genRegen.checked ? '' : ' (cochez « Régénérer » pour forcer le retraitement)');
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à ' +
(genRegen.checked ? 'régénérer' : 'télécharger et générer') +
(genRegen.checked ? 'régénérer' : 'générer ou compléter (visualisations manquantes)') +
(d.capped ? ' (zone tronquée à 400)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
// « Compléter » : toutes les dalles LHD présentes dans input/ qui manquent au
// moins une des visualisations choisies (pas de téléchargement) — la barre de
// confirmation reste utilisable (choix des couches, classification, Lancer).
btnComplete.addEventListener('click', () => {
if (!apiAvailable) return;
clearGhosts();
genBar.classList.add('show');
genMsg.textContent = 'Analyse des tuiles présentes dans input/...';
genGo.style.display = 'none';
fetch('/api/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all_missing: true, regenerate: genRegen.checked,
viz: selectedGenViz() })
}).then(r => r.json()).then(d => {
pendingTiles = (d.cells || []).map(c => [c.col, c.row]);
pendingCompleteAll = true;
(d.cells || []).forEach(c => ghostCells.push(
L.polygon(c.corners, { color: '#e94560', weight: 1, dashArray: '4 4', fillOpacity: 0.08 }).addTo(map)
));
if (!pendingTiles.length) {
genMsg.textContent = 'Aucune visualisation manquante pour les couches choisies';
genGo.style.display = 'none';
return;
}
genMsg.textContent = pendingTiles.length + ' tuile(s) à compléter (dalles présentes dans input/)' +
(d.capped ? ' (liste tronquée)' : '');
genGo.style.display = '';
}).catch(() => { genMsg.textContent = 'API indisponible'; });
});
genGo.addEventListener('click', () => {
if (!pendingTiles.length) return;
genBar.classList.remove('show');
clearGhosts();
const nTiles = pendingTiles.length;
const isRegen = genRegen.checked || pendingRegen;
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
fetch('/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tiles: pendingTiles,
regenerate: isRegen,
viz: vizSel.length ? vizSel : ['aspect'],
viz: selectedGenViz(),
all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(() => startGenTask(nTiles + ' tuile(s) — ' + (isRegen ? 'régénération' : 'génération')))
.then(() => {
const verb = isRegen ? 'régénération'
: pendingCompleteAll ? 'complétion' : 'génération';
pendingCompleteAll = false;
return startGenTask(nTiles + ' tuile(s) — ' + verb);
})
.catch(msg => { genMsg.textContent = 'Erreur : ' + msg; genGo.style.display = 'none'; genBar.classList.add('show'); });
});
genCancel.addEventListener('click', () => {
genBar.classList.remove('show');
clearGhosts();
pendingTiles = []; pendingRegen = false;
pendingTiles = []; pendingRegen = false; pendingCompleteAll = false;
if (genMode) exitGenMode();
});
@ -1794,16 +1998,45 @@ genCancel.addEventListener('click', () => {
// File de génération : panneau latéral persistant (sessionStorage).
// Les tâches survivent au rechargement de la carte : une tâche finie
// reste affichée « Terminé » jusqu'au vidage manuel de la liste.
// Chaque tâche montre ses tuiles une par une (nom court + types
// d'étapes) ; le journal brut reste accessible via le bouton ⌄.
// ============================================================
const GEN_QKEY = 'lidarGenQueue';
let genTasks = [];
try { genTasks = JSON.parse(sessionStorage.getItem(GEN_QKEY) || '[]'); } catch (e) { genTasks = []; }
// Migration des tâches créées avant l'affichage par tuiles : sans champ
// tiles, le journal brut pouvait rester ouvert d'office — on le replie.
genTasks.forEach(t => { if (!Array.isArray(t.tiles)) { t.tiles = []; t.logOpen = false; } });
let genPollTimer = null;
function saveGenTasks() {
try { sessionStorage.setItem(GEN_QKEY, JSON.stringify(genTasks)); } catch (e) { /* privé */ }
}
function genStepEl(s) {
const el = document.createElement('span');
el.className = 'gen-step ' + s.state;
el.textContent = s.label;
if (s.state === 'skip') el.title = 'Déjà existant — ignoré';
return el;
}
function genTileEl(t) {
const el = document.createElement('div');
el.className = 'gen-tile ' + t.state;
const name = document.createElement('span');
name.className = 'gen-tile-name';
name.textContent = t.short; name.title = t.name;
const steps = document.createElement('span');
steps.className = 'gen-tile-steps';
(t.steps || []).forEach(s => steps.appendChild(genStepEl(s)));
const state = document.createElement('span');
state.className = 'gen-tile-state';
state.textContent = t.state === 'done' ? '✓' : t.state === 'failed' ? '✗' : '';
el.append(name, steps, state);
return el;
}
function genTaskEl(t) {
const el = document.createElement('div');
el.className = 'gen-task ' + t.state;
@ -1814,17 +2047,35 @@ function genTaskEl(t) {
label.className = 'gen-task-label';
label.title = t.label; label.textContent = t.label;
const state = document.createElement('span'); state.className = 'gen-task-state';
state.textContent = t.state === 'running' ? 'En cours…'
: t.state === 'done' ? 'Terminé'
: 'Échec (code ' + t.returncode + ')';
const tiles = t.tiles || [];
if (t.state === 'running') {
const finished = tiles.filter(x => x.state === 'done' || x.state === 'failed').length;
state.textContent = tiles.length
? 'En cours — ' + finished + '/' + tiles.length + ' tuile(s)'
: 'En cours…';
} else if (t.state === 'done') {
state.textContent = 'Terminé';
} else {
state.textContent = 'Échec (code ' + t.returncode + ')';
}
const time = document.createElement('span');
time.className = 'gen-task-time'; time.textContent = t.time;
const toggle = document.createElement('button');
toggle.className = 'icon-btn gen-task-toggle';
toggle.textContent = t.logOpen ? '⌃' : '⌄';
toggle.title = 'Afficher / masquer le journal';
toggle.title = 'Afficher / masquer le journal brut';
head.append(dot, label, state, time, toggle);
el.appendChild(head);
// Corps : tuiles une par une (nom + types d'étapes), en cours et échecs
// en tête, terminées en dessous — le journal brut reste replié sous ⌄.
if (tiles.length) {
const list = document.createElement('div');
list.className = 'gen-task-tiles';
const rank = s => s === 'running' ? 0 : s === 'failed' ? 1 : s === 'done' ? 3 : 2;
[...tiles].sort((a, b) => rank(a.state) - rank(b.state))
.forEach(x => list.appendChild(genTileEl(x)));
el.appendChild(list);
}
const log = document.createElement('pre');
log.className = 'gen-task-log';
log.hidden = !t.logOpen;
@ -1853,7 +2104,7 @@ function startGenTask(labelText) {
genTasks.push({
id: Date.now(), label: labelText,
time: now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false,
state: 'running', returncode: null, log: [], logOpen: false, tiles: [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
}
@ -1870,17 +2121,98 @@ function finishRunningTasks(d) {
renderGenQueue();
}
// ============================================================
// Tuiles en direct : chaque tuile terminée apparaît sans recharger
// ============================================================
// Pendant un run, le pipeline régénère l'index après chaque tuile terminée
// (--incremental-index) et la webapp sert ces données via /api/tiles. On
// fusionne les nouveautés dans la carte : la tuile est visible dès la fin
// de son rendu, sans attendre la fin de la file.
let liveTilesStamp = null;
function mergeTilesData(data) {
if (!data || !Array.isArray(data.tiles)) return;
const byKey = new Map();
for (const t of data.tiles) {
if (t.corners) byKey.set(tileKey(t), t);
}
let changed = false;
// Retire les tuiles disparues (ex. dalle 0,5 m remplacée par ses
// sous-tuiles 0,2 m plus fines)
for (let i = TILES.length - 1; i >= 0; i--) {
const key = tileKey(TILES[i]);
if (byKey.has(key)) continue;
const mk = markers.get(key);
if (mk) { mk.remove(); markers.delete(key); }
TILES.splice(i, 1);
changed = true;
}
byKey.forEach((t, key) => {
const cur = TILES.find(x => tileKey(x) === key);
if (!cur) {
TILES.push(t);
registerTile(t);
changed = true;
} else if (cur !== t) {
Object.assign(cur, t); // tuile enrichie (nouvelles couches en cours de run)
changed = true;
}
});
// Couches nouvellement proposées : ajoutées au panneau, éteintes par défaut
let metaChanged = false;
if (data.viz_meta) {
for (const k of Object.keys(data.viz_meta)) {
if (VIZ_META[k]) continue;
VIZ_META[k] = data.viz_meta[k];
if (!state.layers.order.includes(k)) {
state.layers.order.push(k);
state.layers.on[k] = false;
state.layers.opacity[k] = 1;
}
metaChanged = true;
}
}
if (changed) {
recomputeGridBounds();
const statTiles = document.getElementById('statTiles');
if (statTiles) {
const cols = [...new Set(TILES.map(t => t.col))].sort((a, b) => a - b);
const rows = [...new Set(TILES.map(t => t.row))].sort((a, b) => a - b);
const gw = cols.length ? cols[cols.length - 1] - cols[0] + 1 : 0;
const gh = rows.length ? rows[rows.length - 1] - rows[0] + 1 : 0;
statTiles.textContent = TILES.length + ' tuile(s) · ' + gw + '×' + gh + ' km · ' +
statTiles.textContent.split('·').pop().trim();
}
}
if (changed || metaChanged) renderLayerPanel();
if (changed) { syncTileLayers(); layoutTiles(); }
}
function pollLiveTiles() {
const q = liveTilesStamp != null ? '?stamp=' + liveTilesStamp : '';
fetch('/api/tiles' + q).then(r => r.ok ? r.json() : null).then(d => {
if (!d) return;
liveTilesStamp = d.stamp;
if (d.tiles) mergeTilesData(d);
}).catch(() => {});
}
function pollGenStatus() {
if (genPollTimer) return; // un seul sondage global pour toute la file
genPollTimer = setInterval(() => {
fetch('/api/status').then(r => r.json()).then(d => {
// Réponse d'erreur (ex: machine de traitement injoignable) : ignorer,
// le sondage reprendra — ne pas marquer les tâches en échec.
if (typeof d.running !== 'boolean') return;
const running = genTasks.filter(t => t.state === 'running');
if (!running.length) {
clearInterval(genPollTimer); genPollTimer = null;
return;
}
const lines = d.log || [];
for (const t of running) t.log = lines.slice(-60);
for (const t of running) {
t.log = lines.slice(-60);
// Tuiles une par une (nom court + types d'étapes) depuis /api/status
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
}
if (!d.running) {
clearInterval(genPollTimer); genPollTimer = null;
finishRunningTasks(d);
@ -1890,14 +2222,33 @@ function pollGenStatus() {
const c = map.getCenter();
sessionStorage.setItem('lidarMap', JSON.stringify({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }));
} catch (e) { /* sessionStorage indisponible : pas grave */ }
setTimeout(() => location.reload(), 2500);
syncThenReload();
} else {
saveGenTasks(); renderGenQueue();
pollLiveTiles();
}
}).catch(() => {});
}, 2000);
}
// Après un run : ramener les tuiles produites par la machine de traitement
// (rsync via /api/sync — mode webapp sur Raspberry Pi), reconstruire vignettes
// et carte, puis recharger. Sans backend distant, /api/sync se réduit à un
// rebuild local : même flux partout. Rechargement de secours après 30 min.
function syncThenReload() {
fetch('/api/sync', { method: 'POST' }).then(r => r.ok ? r.json() : null).then(() => {
const t0 = Date.now();
const check = setInterval(() => {
fetch('/api/sync').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running || Date.now() - t0 > 30 * 60 * 1000) {
clearInterval(check);
location.reload();
}
}).catch(() => { clearInterval(check); location.reload(); });
}, 2000);
}).catch(() => location.reload());
}
genQueueClear.addEventListener('click', () => {
genTasks = genTasks.filter(t => t.state === 'running');
saveGenTasks(); renderGenQueue();
@ -1905,17 +2256,30 @@ genQueueClear.addEventListener('click', () => {
renderGenQueue();
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
// ou adopte une génération lancée hors interface (API directe) pour
// l'afficher dans la file avec son journal.
// adopte une génération lancée hors interface (API directe), ou réaffiche le
// dernier run terminé après un redémarrage du serveur (état rechargé du
// fichier .generation.job.json). La clé `started` évite les doublons au
// rechargement : un même run n'est adopté qu'une fois.
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
if (!s || !s.running) return;
genTasks.push({
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
state: 'running', returncode: null, log: [], logOpen: false,
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
if (!s || genTasks.some(t => t.started === s.started)) return;
const now = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
if (s.running) {
genTasks.push({
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
time: now, started: s.started,
state: 'running', returncode: null, log: [], logOpen: false, tiles: s.tiles || [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
} else if (s.returncode !== null && s.tiles && s.tiles.length) {
genTasks.push({
id: Date.now(), label: 'Dernière génération',
time: now, started: s.started,
state: s.returncode === 0 ? 'done' : 'failed', returncode: s.returncode,
log: [], logOpen: false, tiles: s.tiles,
});
saveGenTasks(); renderGenQueue();
}
}).catch(() => {});
// ============================================================
@ -1932,8 +2296,8 @@ zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
// Détection de nouvelles couches (run de génération en cours)
// ============================================================
// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
// /api/layers liste ce qui existe sur disque, et le bouton ↻ reconstruit
// la carte (vignettes + sous-tuiles) puis recharge la page.
// /api/layers liste ce qui existe sur disque, et le bouton ↻ resynchronise
// puis reconstruit la carte (vignettes + sous-tuiles) et recharge la page.
const layerRefreshBtn = document.getElementById('layerRefresh');
function checkNewLayers() {
fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
@ -1951,11 +2315,11 @@ setInterval(checkNewLayers, 60000);
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
layerRefreshBtn.textContent = '…';
layerRefreshBtn.disabled = true;
fetch('/api/rebuild', { method: 'POST' })
fetch('/api/sync', { method: 'POST' })
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
.then(() => new Promise(resolve => {
const poll = setInterval(() => {
fetch('/api/rebuild').then(r => r.json()).then(s => {
fetch('/api/sync').then(r => r.json()).then(s => {
if (!s.running) { clearInterval(poll); resolve(s.error); }
}).catch(() => { clearInterval(poll); resolve(null); });
}, 3000);
@ -1964,7 +2328,7 @@ if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
if (err) {
layerRefreshBtn.textContent = '↻';
layerRefreshBtn.disabled = false;
layerRefreshBtn.title = 'Erreur rebuild : ' + err;
layerRefreshBtn.title = 'Erreur synchronisation/rebuild : ' + err;
} else {
location.reload();
}

View File

@ -54,6 +54,7 @@ class FilePrefixFilter(logging.Filter):
# Module-level filter instance so process_file can set it
_file_filter = FilePrefixFilter()
from .progress import report_event
from .dtm import classify_ground, create_dtm_fast
from .visualizations import (
SharedDEM,
@ -62,7 +63,7 @@ from .visualizations import (
generate_mslrm, generate_sailore,
generate_roughness, generate_wavelet,
generate_solar,
generate_svf, generate_aniso_open,
generate_svf,
generate_flow_accumulation,
generate_anomaly_mask,
)
@ -83,7 +84,6 @@ VIZ_STEPS = [
('pos_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=True, shared=shared)),
('neg_open', lambda d, b, v, r, shared=None: generate_openness(d, b, v, r, positive=False, shared=shared)),
('svf', generate_svf),
('aniso_open', generate_aniso_open),
('roughness', generate_roughness),
('wavelet', generate_wavelet),
('flow_acc', generate_flow_accumulation),
@ -109,7 +109,7 @@ VIZ_STEPS = [
class LidarArchaeoPipeline:
"""Orchestrates the LiDAR archaeological analysis pipeline."""
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False):
def __init__(self, input_dir, output_dir, resolution=0.5, workers=1, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, bare_earth=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution
@ -133,6 +133,8 @@ class LidarArchaeoPipeline:
self.output_format = output_format
self.gpu_ids = gpu_ids
self.no_index = no_index
self.incremental_index = incremental_index
self._last_index_rebuild = 0.0
self.temp_dir = self.output_dir / "temp"
if not self.input_dir.exists():
@ -265,6 +267,7 @@ class LidarArchaeoPipeline:
for idx, (name, func) in enumerate(self.viz_steps, 1):
if not needs_generation[name]:
logger.info(f" [{idx}/{total}] {name}: déjà existant, ignoré")
self._report(basename, "viz", "skip", name, res=resolution)
vis_results[name] = self._expected_output_path(name, basename, file_vis_dir, self.output_format)
continue
@ -283,6 +286,7 @@ class LidarArchaeoPipeline:
tif.unlink(missing_ok=True)
logger.info(f" [{idx}/{total}] {name}...")
self._report(basename, "viz", "start", name, res=resolution)
t0 = time.time()
try:
# IGN overlays don't use SharedDEM (they download external data)
@ -295,11 +299,14 @@ class LidarArchaeoPipeline:
elapsed = time.time() - t0
if result:
logger.info(f" [{idx}/{total}] ✓ {name} ({elapsed:.1f}s)")
self._report(basename, "viz", "ok", name, res=resolution)
else:
logger.warning(f" [{idx}/{total}] ✗ {name} — no output ({elapsed:.1f}s)")
self._report(basename, "viz", "fail", name, res=resolution)
except Exception as e:
vis_results[name] = None
logger.error(f" [{idx}/{total}] ✗ {name}: {e}", exc_info=True)
self._report(basename, "viz", "fail", name, res=resolution)
# Free GPU memory between visualizations to prevent OOM
gpu_cleanup()
@ -370,6 +377,10 @@ class LidarArchaeoPipeline:
except Exception:
pass
def _report(self, basename, phase, state, detail=None, res=None):
"""Émet un événement de progression (file de génération, best-effort)."""
report_event(self.output_dir, basename, phase, state, detail=detail, res=res)
def process_file(self, laz_file):
"""Process a single LAZ file through the full pipeline.
@ -389,6 +400,7 @@ class LidarArchaeoPipeline:
# Validate file integrity before any processing
from .dtm import validate_laz
if not validate_laz(laz_file):
self._report(basename, "tile", "fail", "fichier LAZ invalide")
return False
# Step 1: Ground classification (shared across all resolutions)
@ -417,8 +429,10 @@ class LidarArchaeoPipeline:
if i == 0:
logger.info(f"[1/5] Classification du sol — sautée (DTM existant)")
logger.info(f"[2/5] Génération DTM {res}m/px — sautée (DTM existant)")
self._report(basename, "classif", "skip", "DTM existant")
else:
logger.info(f" DTM {res}m/px déjà existant — ignoré")
self._report(basename, "dtm", "skip", res=res)
continue
except Exception:
logger.warning(f"Impossible de lire le DTM existant — régénération")
@ -431,16 +445,21 @@ class LidarArchaeoPipeline:
if las_file is None:
# First time: do ground classification
logger.info("[1/5] Classification du sol...")
self._report(basename, "classif", "start")
t1 = time.time()
las_file = classify_ground(laz_file, self.temp_dir, method=self.ground_method, force=self.force_classify, ign_classes=self.ign_classes)
t_classif = time.time() - t1
if not las_file:
logger.error(f" ✗ Échec classification ({t_classif:.1f}s)")
self._report(basename, "classif", "fail")
self._report(basename, "tile", "fail", "échec classification")
return False
logger.info(f" ✓ Classification terminée ({t_classif:.1f}s)")
self._report(basename, "classif", "ok")
# Generate DTM at this resolution
logger.info(f"{'[2/5]' if i == 0 else ' '} Génération DTM {res}m/px...")
self._report(basename, "dtm", "start", res=res)
t2 = time.time()
# Classification IGN → mode pur : DTM = rasterisation brute des
# classes choisies, sans plancher ni comblement (sauf --bare-earth)
@ -454,10 +473,13 @@ class LidarArchaeoPipeline:
t_dtm = time.time() - t2
if not dtm_file:
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
self._report(basename, "dtm", "fail", res=res)
if i == 0:
self._report(basename, "tile", "fail", "échec DTM")
return False # Primary resolution failure is fatal
continue # Additional resolution failure is non-fatal
logger.info(f" ✓ DTM {res}m/px terminé ({t_dtm:.1f}s)")
self._report(basename, "dtm", "ok", res=res)
dtm_rebuilt = True
self._write_dtm_method(basename, res_suffix)
@ -492,9 +514,36 @@ class LidarArchaeoPipeline:
t_total = time.time() - t_start
logger.info(f"✓ {basename} terminé en {t_total:.1f}s")
self._report(basename, "tile", "ok", f"{t_total:.0f}s")
_file_filter.basename = None
return True
def _rebuild_index_incremental(self):
"""Régénère la carte juste après une tuile terminée (mode incrémental).
Réécrit index.html + index_tiles.json pour que la webapp affiche la
tuile sans attendre la fin du run. Anti-rebond : 3 s minimum entre
deux passes — les tuiles terminées pendant l'intervalle sont couvertes
par la passe suivante ou par la passe finale. Les logs de build_index
sont masqués pour ne pas noyer le journal du run.
"""
if self.no_index:
return
now = time.time()
if now - self._last_index_rebuild < 3.0:
return
self._last_index_rebuild = now
try:
from .index import build_index
saved_level = logger.getEffectiveLevel()
logger.setLevel(logging.WARNING)
try:
build_index(self.output_dir, self.output_format)
finally:
logger.setLevel(saved_level)
except Exception as e:
logger.debug(f"Rebuild incrémental de l'index ignoré : {e}")
def process_all(self, files=None):
"""Process all LAZ files in input directory (or an explicit list)."""
files = files if files is not None else self.find_laz_files()
@ -547,9 +596,13 @@ class LidarArchaeoPipeline:
results[laz_file.name] = success
status = "✓" if success else "✗"
logger.info(f" [{done}/{len(files)}] {status} {laz_file.name}")
if success and self.incremental_index:
self._rebuild_index_incremental()
except Exception as e:
logger.error(f" [{done}/{len(files)}] ✗ {laz_file.name}: {e}")
logger.debug(f" Traceback:", exc_info=True)
report_event(self.output_dir, _file_basename(laz_file),
"tile", "fail", detail=str(e))
results[laz_file.name] = False
except KeyboardInterrupt:
logger.info("Interruption — annulation des travaux en cours...")
@ -564,12 +617,16 @@ class LidarArchaeoPipeline:
logger.info(f"--- Fichier {idx}/{total} ---")
try:
results[laz_file.name] = self.process_file(laz_file)
if results[laz_file.name] and self.incremental_index:
self._rebuild_index_incremental()
except KeyboardInterrupt:
logger.info("Interruption — arrêt immédiat.")
return
except Exception as e:
logger.error(f"✗ Erreur traitement {laz_file.name}: {e}")
logger.debug("Traceback:", exc_info=True)
report_event(self.output_dir, _file_basename(laz_file),
"tile", "fail", detail=str(e))
results[laz_file.name] = False
# Summary

154
lidar_pipeline/progress.py Normal file
View File

@ -0,0 +1,154 @@
"""Progression structurée d'un run de génération (contrat pipeline → interface).
Le pipeline et le téléchargement IGN écrivent des événements JSON ligne à
ligne dans <sortie>/.generation.events.jsonl ; la webapp les agrège via
/api/status pour afficher les tuiles une par une (nom, type, état) dans la
file de génération, à la place du journal brut.
Module volontairement découplé de la webapp : le rendu des tuiles pourra être
déporté hors du serveur web — le producteur d'événements ne dépend que du
dossier de sortie. Les workers parallèles écrivent chacun leur ligne en mode
ajout (une ligne < 4 Ko avec O_APPEND est atomique sur POSIX).
"""
import json
import re
import time
from pathlib import Path
EVENTS_FILENAME = ".generation.events.jsonl"
# Phases d'une tuile, dans l'ordre d'exécution.
PHASES = ("download", "classif", "dtm", "viz", "tile")
_TILE_RE = re.compile(r"LHD_FXX_(\d+)_(\d+)_")
def events_path(output_dir):
"""Chemin du journal d'événements d'un run."""
return Path(output_dir) / EVENTS_FILENAME
def tile_short_name(name):
"""Nom court d'une tuile : LHD_FXX_1054_6882_PTS_LAMB93_IGN69 → 1054-6882."""
m = _TILE_RE.search(name or "")
if not m:
return name
return f"{int(m.group(1))}-{int(m.group(2))}"
def reset_events(output_dir):
"""Prépare un journal vide pour un nouveau run (tronque l'ancien)."""
try:
path = events_path(output_dir)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("", encoding="utf-8")
except OSError:
pass # progression best-effort : ne jamais bloquer le traitement
def report_event(output_dir, tile, phase, state, detail=None, res=None):
"""Ajoute un événement (une ligne JSON). Ne lève jamais.
Ouverture en mode ajout à chaque appel : les workers parallélisés
(ProcessPoolExecutor, 'spawn') ne partagent aucun descripteur de fichier.
"""
event = {"ts": round(time.time(), 3), "tile": tile, "phase": phase, "state": state}
if detail is not None:
event["detail"] = detail
if res is not None:
event["res"] = res
try:
with open(events_path(output_dir), "a", encoding="utf-8") as fh:
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
except OSError:
pass
def read_events(output_dir, limit=5000):
"""Lit les événements du journal (dernières `limit` lignes, invalides ignorées)."""
try:
lines = events_path(output_dir).read_text(
encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
events = []
for line in lines[-limit:]:
try:
events.append(json.loads(line))
except ValueError:
continue # ligne partielle en cours d'écriture par le run
return events
def _step_label(phase, detail, res, viz_labels):
"""Libellé français d'une étape, affiché tel quel dans la file."""
if phase == "download":
return "Téléchargement"
if phase == "classif":
return "Classification"
if phase == "dtm":
return f"DTM {str(res).replace('.', ',')} m"
if phase == "viz":
label = (viz_labels or {}).get(detail, detail)
if res is not None and float(res) != 0.5:
label += f" {str(res).replace('.', ',')} m"
return label
return detail or phase
def aggregate_tiles(events, viz_labels=None):
"""Regroupe les événements en tuiles : une entrée par tuile, étapes ordonnées.
Retourne [{short, name, state, steps: [{key, label, state, detail}]}] dans
l'ordre de première apparition. État d'une étape : running | ok | fail |
skip. État d'une tuile : pending | running | done | failed.
"""
tiles = {}
for ev in events:
if not isinstance(ev, dict) or ev.get("phase") not in PHASES:
continue
name = ev.get("tile") or ""
short = tile_short_name(name)
tile = tiles.setdefault(
short, {"short": short, "name": name, "state": "pending", "steps": {}})
if name:
tile["name"] = name
state = ev.get("state")
if state == "start":
state = "running" # état d'affichage normalisé
detail = ev.get("detail")
res = ev.get("res")
if ev.get("phase") == "tile":
if state == "ok":
tile["state"] = "done"
elif state == "fail":
tile["state"] = "failed"
continue
# Clé d'étape : le détail n'identifie une étape que pour les viz
# (leur nom) — les sous-étapes du téléchargement fusionnent en une.
phase = ev.get("phase")
key = f"viz:{detail}:{res}" if phase == "viz" else f"{phase}:{res}"
step = tile["steps"].setdefault(
key, {"key": key, "detail": detail, "res": res, "state": state,
"label": _step_label(phase, detail, res, viz_labels)})
# ok/fail sont terminaux — on ne régresse pas vers running/skip
if step["state"] not in ("ok", "fail") or state == "fail":
step["state"] = state
result = []
for tile in tiles.values():
steps = list(tile["steps"].values())
if tile["state"] == "pending":
if any(s["state"] == "fail" for s in steps):
tile["state"] = "failed"
elif steps:
tile["state"] = "running" # pas encore d'événement tuile final
result.append({"short": tile["short"], "name": tile["name"],
"state": tile["state"], "steps": steps})
return result
def progress_snapshot(output_dir, viz_labels=None):
"""État consolidé des tuiles du dernier run (prêt pour /api/status)."""
return aggregate_tiles(read_events(output_dir), viz_labels=viz_labels)

View File

@ -97,14 +97,6 @@ COLORMAPS = {
'vmin_mode': 'fixed', 'vmin_val': -3,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
'aniso_open': {
'cmap': 'seismic',
'title': 'Openness Anisotropique (pondération directionnelle)',
'legend': 'Openness positive − négative pondérée (z-score local)\nRouge = Surélévation dominante (mur, levée)\nBleu = Dépression dominante (fossé, doline)\nÉchelle fixe ±3σ — couleurs homogènes entre tuiles\nPondère les directions NW-SE et NE-SW davantage',
'description': 'Openness avec pondération anisotropique — détecte mieux les structures alignées NW-SE et NE-SW',
'vmin_mode': 'fixed', 'vmin_val': -3,
'vmax_mode': 'fixed', 'vmax_val': 3,
},
# === Famille OUVERTURE : séquentiel, valeurs normalisées ===
'positive_openness': {
'cmap': 'YlOrBr',
@ -164,12 +156,26 @@ COLORMAPS = {
'vmax_mode': 'percentile', 'vmax_pct': 98,
},
'wavelet': {
'cmap': 'cividis',
'cmap': 'inferno',
'title': 'Ondelette Mexican Hat (CWT multi-échelle)',
'legend': 'Réponse RMS multi-échelles (σ locales)\nÉchelles adaptées à la résolution\n\nClair = Structure détectée à cette échelle\nSombre = Pas de structure\nÉchelle fixe 0–3σ — couleurs homogènes entre tuiles\n\nOptimisé pour formes circulaires:\ntumulus, enclos, fossés annulaires',
'description': 'Transformée en ondelette 2D — excellente pour détecter structures circulaires',
'vmin_mode': 'fixed', 'vmin_val': 0,
'vmax_mode': 'fixed', 'vmax_val': 3,
'legend': 'Indice RMS multi-échelles, centré sur la médiane de la\ntuile (1 = niveau moyen, plus haut = structure)\n\nGrands volumes retirés (moyenne locale 35 m) :\nun fossé en sommet ou en pente ne ressort\npas plus qu\'un fossé à plat\n\nÉtirement quantile global figé (calibré sur un\néchantillon de tuiles) : même valeur = même couleur\nsur toutes les tuiles et résolutions\n\nOptimisé petites structures :\nchemins, fossés, ramparts',
'description': 'Transformée en ondelette 2D : détection des petites structures (chemins, fossés, ramparts)',
# Nœuds (percentile → valeur) mesurés sur 20 tuiles réelles par
# résolution avec l'algorithme actuel (détendage 35 m, échelles
# 1-50 m) : médiane inter-tuiles des percentiles par tuile, robuste
# aux tuiles très structurées. Distribution plus large qu'avant
# détendage : le fond macro-relief supprimé, les petites structures
# percent bien plus au-dessus du bruit (p98 ≈ 9 vs 1,65 avant).
'knots': {
0.5: ([0.089, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.274,
1.661, 2.32, 3.842, 5.661, 8.936, 11.93, 15.06],
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
0.2: ([0.088, 0.213, 0.296, 0.445, 0.602, 0.783, 1.0, 1.287,
1.694, 2.358, 3.846, 5.687, 8.971, 11.928, 14.971],
[0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60,
0.70, 0.80, 0.90, 0.95, 0.98, 0.99, 0.995]),
},
},
'flow_acc': {
'cmap': 'YlGn',
@ -212,7 +218,7 @@ RGB_LEGENDS = {
}
def _apply_colormap(data, tif_file):
def _apply_colormap(data, tif_file, resolution=None):
"""Apply the registered colormap normalization to data based on filename.
Returns (data, cmap, title, legend_label, description, is_rgb).
@ -238,31 +244,45 @@ def _apply_colormap(data, tif_file):
vmin = vmax = None
# Compute vmin/vmax based on mode
if info['vmin_mode'] == 'fixed':
vmin = info['vmin_val']
elif info['vmin_mode'] == 'percentile':
vmin = np.percentile(valid_data, info['vmin_pct'])
elif info['vmin_mode'] == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmin = -vmax_abs
vmax = vmax_abs # symmetric mode sets both vmin and vmax
if vmax is None:
# Only compute vmax if not already set by symmetric mode
if info.get('vmax_mode') == 'fixed':
vmax = info['vmax_val']
elif info.get('vmax_mode') == 'percentile':
vmax = np.percentile(valid_data, info['vmax_pct'])
elif info.get('vmax_mode') == 'symmetric':
knots = info.get('knots')
if knots is not None:
# Étalonnage quantile figé (appariement d'histogramme global,
# cf. normalisation radiométrique des mosaïques) : fonction de
# transfert en nœuds mesurés une fois sur un échantillon de
# tuiles — même valeur → même couleur sur toutes les tuiles,
# toute la palette utilisée, insensible aux queues locales.
if isinstance(knots, dict):
key = min(knots, key=lambda r: abs(float(r) - float(resolution or 0.5)))
knots = knots[key]
kv, kt = knots
data = np.interp(np.asarray(data, dtype=float), kv, kt, left=0.0, right=1.0)
vmin, vmax = kv[0], kv[-1]
else:
# Compute vmin/vmax based on mode
if info['vmin_mode'] == 'fixed':
vmin = info['vmin_val']
elif info['vmin_mode'] == 'percentile':
vmin = np.percentile(valid_data, info['vmin_pct'])
elif info['vmin_mode'] == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmax = vmax_abs
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmin = -vmax_abs
vmax = vmax_abs # symmetric mode sets both vmin and vmax
# Apply normalization
if vmin is not None and vmax is not None:
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
if vmax is None:
# Only compute vmax if not already set by symmetric mode
if info.get('vmax_mode') == 'fixed':
vmax = info['vmax_val']
elif info.get('vmax_mode') == 'percentile':
vmax = np.percentile(valid_data, info['vmax_pct'])
elif info.get('vmax_mode') == 'symmetric':
vmax_abs = max(abs(np.percentile(valid_data, info['sym_pct'][0])),
abs(np.percentile(valid_data, info['sym_pct'][1])), 0.001)
vmax = vmax_abs
# Apply normalization
if vmin is not None and vmax is not None:
data = np.clip((data - vmin) / max(vmax - vmin, 0.001), 0, 1)
legend = info['legend'].format(vmin=vmin or 0, vmax=vmax or 0)
return data, info['cmap'], info['title'], legend, info['description'], False
@ -463,7 +483,7 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
nan_mask = nan_mask # keep for later
# Apply colormap
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
data, cmap, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
# Apply NaN mask: make zones without data transparent
has_nan_mask = nan_mask is not None and not is_rgb_result
@ -818,7 +838,7 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=98, outpu
data = src.read(1)
# Apply colormap normalization
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file)
data, cmap_name, title, legend_label, description, is_rgb_result = _apply_colormap(data, tif_file, resolution=resolution)
# Convert to RGB using colormap
if is_rgb_result:
@ -907,7 +927,7 @@ def generate_pdf_report(basename, vis_dir, pdf_dir, resolution):
# Sort analysis files by archaeological priority
order = ['mslrm', 'svf', 'negative_openness',
'positive_openness', 'aniso_open', 'sailore', 'hillshade_multi',
'positive_openness', 'sailore', 'hillshade_multi',
'flow_acc', 'solar', 'slope', 'roughness', 'wavelet']
def sort_key(f):

View File

@ -96,3 +96,82 @@ def test_fetch_tiles_skips_existing_and_generated(tmp_path, monkeypatch):
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
output_dir=output_dir)
assert result == []
def _make_viz_dirs(output_dir, col, row, viz_keys, resolutions=(0.5, 0.2)):
"""Crée les dossiers de visualisations d'une tuile (une entrée par résolution).
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
le basename nu (cf. _expected_output_path dans le pipeline).
"""
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
for res in resolutions:
suffix = "" if res == 0.5 else "_r" + str(res).replace('.', 'p')
vis = output_dir / "visualisations" / (base + suffix)
vis.mkdir(parents=True, exist_ok=True)
for key in viz_keys:
(vis / f"{base}_{key}.avif").write_bytes(b"x")
def test_fetch_tiles_downloads_incomplete_tile(tmp_path, monkeypatch):
"""Une tuile incomplète (visualisation manquante) est téléchargée, une complète non."""
from pathlib import Path
from lidar_pipeline import fetch_ign
input_dir = tmp_path / "input"
input_dir.mkdir()
output_dir = tmp_path / "output"
_make_viz_dirs(output_dir, 1054, 6882, ["aspect"]) # slope manquant
_make_viz_dirs(output_dir, 1055, 6882, ["aspect", "slope"]) # complète
calls = []
def fake_find_tile_url(col, row, timeout=20):
calls.append((col, row))
return "https://example.org/dalle.copc.laz"
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
Path(dest).write_bytes(b"laz")
return 3
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882), (1055, 6882)],
output_dir=output_dir,
only_viz=["aspect", "slope"],
resolutions=(0.5, 0.2))
# Seule la tuile incomplète est retéléchargée
assert calls == [(1054, 6882)]
assert [p.name for p in result] == [fetch_ign.tile_filename(1054, 6882)]
def test_fetch_tiles_force_downloads_complete(tmp_path, monkeypatch):
"""force=True télécharge même une tuile complète (régénération avec --force)."""
from pathlib import Path
from lidar_pipeline import fetch_ign
input_dir = tmp_path / "input"
input_dir.mkdir()
output_dir = tmp_path / "output"
_make_viz_dirs(output_dir, 1054, 6882, ["aspect", "slope"])
calls = []
def fake_find_tile_url(col, row, timeout=20):
calls.append((col, row))
return "https://example.org/dalle.copc.laz"
def fake_download(url, dest, timeout=120, chunk=1024 * 1024):
Path(dest).write_bytes(b"laz")
return 3
monkeypatch.setattr(fetch_ign, "find_tile_url", fake_find_tile_url)
monkeypatch.setattr(fetch_ign, "download_file", fake_download)
result = fetch_ign.fetch_tiles(input_dir, [(1054, 6882)],
output_dir=output_dir,
only_viz=["aspect", "slope"],
resolutions=(0.5, 0.2), force=True)
assert calls == [(1054, 6882)]
assert len(result) == 1

View File

@ -214,7 +214,9 @@ def test_build_index_generates_html(tmp_path):
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
_make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "a", 1000, 6881,
('aspect', 'slope', 'positive_openness',
'wavelet', 'hillshade_multi', 'svf'))
_make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',))
result = build_index(output_dir)
@ -235,19 +237,46 @@ def test_build_index_generates_html(tmp_path):
assert (assets / "app.css").read_text(encoding='utf-8').startswith('/*')
app_js = (assets / "app.js").read_text(encoding='utf-8')
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur multi-visualisations (wavelet, aniso_open,
# openness positive, pente...) et envoi du champ viz à /api/generate
# Barre de génération : sélecteur limité aux couches proposées dans le
# panneau de visualisation (aspect + slope + openness + ondelette,
# valeurs = noms d'étapes --only). La sélection est envoyée à /api/preview
# (détection des tuiles incomplètes) comme à /api/generate.
# Les couches hors panneau (svf, hillshade) n'y figurent pas.
assert 'id="genViz"' in content
assert 'value="wavelet"' in content
assert 'value="aniso_open"' in content
assert 'value="aniso_open"' not in content
assert 'value="pos_open"' in content
assert 'value="slope"' in content
assert "genViz" in app_js and "viz: vizSel.length" in app_js
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content
assert "genViz" in app_js and "viz: selectedGenViz()" in app_js
assert app_js.count("selectedGenViz()") >= 3 # définition + preview + generate
# Adoption du dernier run au chargement (redémarrage du serveur) :
# un run terminé réapparaît dans la file avec ses tuiles, sans doublon.
assert "Dernière génération" in app_js
assert "t.started === s.started" in app_js
# Bouton « Compléter » : visualisations manquantes des dalles présentes
# dans input/ (scope all_missing envoyé au preview et au generate).
assert 'id="btnComplete"' in content
assert "all_missing" in app_js and app_js.count("all_missing") >= 2
assert 'assets/app.css' in content
# Bouton d'actualisation des couches (détection des nouveautés sur disque)
# Bouton d'actualisation des couches (détection des nouveautés sur disque) :
# le ↻ resynchronise puis reconstruit la carte (mode webapp distante).
assert 'id="layerRefresh"' in content
assert "/api/layers" in app_js and "/api/rebuild" in app_js
assert "/api/layers" in app_js and "/api/sync" in app_js
assert 'assets/app.js' in content
# Fin d'un run de génération : synchronisation puis rechargement (le
# rechargement direct reste en secours si /api/sync est absent).
assert "syncThenReload" in app_js
# Tuiles en direct : la carte sonde /api/tiles pendant un run et fusionne
# les tuiles terminées sans recharger (mode incrémental du pipeline).
assert "/api/tiles" in app_js and "pollLiveTiles" in app_js
assert "registerTile" in app_js and "mergeTilesData" in app_js
# Export des données de carte (servi par /api/tiles)
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
assert data['tiles'] and data['tiles'][0]['corners']
assert 'aspect' in data['viz_meta']
assert data['stats']['n_tiles'] == len(data['tiles'])
# Vérifie les vignettes générées
thumb_dir = output_dir / "index_thumbs"
assert thumb_dir.is_dir()
@ -259,16 +288,53 @@ def test_subtiled_viz_covers_generation_choices():
"""Les viz générables depuis la carte sont sous-tuilées (affichage fluide)."""
from lidar_pipeline.index import _CARTO_SUBTILED_VIZ
for key in ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
'aniso_open', 'wavelet'):
'wavelet'):
assert key in _CARTO_SUBTILED_VIZ
def test_panel_restricted_to_requested_layers():
"""Le panneau et le sélecteur ne proposent que la base aspect + les 4 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, GEN_VIZ_CHOICES
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
assert {name for name, _ in GEN_VIZ_CHOICES} == {
'aspect', 'slope', 'pos_open', 'aniso_open', 'wavelet'}
"""Le panneau et le sélecteur ne proposent que la base aspect + les 3 couches demandées."""
from lidar_pipeline.index import PANEL_VIZ, KEYWORD_TO_STEP
from lidar_pipeline.pipeline import VIZ_STEPS
assert PANEL_VIZ == ('aspect', 'slope', 'positive_openness', 'wavelet')
# Chaque couche du panneau correspond à une étape --only valide du pipeline
steps = {name for name, _ in VIZ_STEPS}
for key in PANEL_VIZ:
assert KEYWORD_TO_STEP.get(key, key) in steps
def test_build_index_merges_cross_resolution_viz(tmp_path):
"""Une couche produite seulement à 0,5 m reste visible sur la dalle 0,2 m.
Le dernier run peut être interrompu entre les deux passes de résolution :
la fusion inter-résolutions doit pointer vignettes et URLs vers le dossier
d'origine de chaque fichier (dir_name), pas vers dir_path de la dalle
affichée — sinon les vignettes échouent et la couche disparaît.
"""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
base = "LHD_FXX_1000_6881_PTS_LAMB93_IGN69"
# Passe 0,5 m : aspect + slope ; passe 0,2 m : aspect seulement
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect', 'slope'))
_make_fake_viz_dir(vis_dir, base, 1000, 6881, ('aspect',), res_suffix='_r0p2')
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
# Une seule position affichée (résolution la plus fine), deux couches
tiles = [t for t in data['tiles'] if (t['col'], t['row']) == (1000, 6881)]
keys = set()
for t in tiles:
keys.update(t['viz'].keys())
assert {'aspect', 'slope'} <= keys
# Vignette slope générée + chaque thumb/full existe réellement sur disque
assert (output_dir / "index_thumbs" / f"{base}_r0p2_slope.jpg").exists()
for t in tiles:
for v in t['viz'].values():
assert (output_dir / v['thumb']).exists(), v['thumb']
assert (output_dir / v['full']).exists(), v['full']
def test_build_index_regenerates_stale_thumbnails(tmp_path):
@ -449,3 +515,12 @@ def test_subtile_corners_grid():
se_quad = _subtile_corners(corners, 1, 0, k)
assert sw_quad[1] == se_quad[0]
assert sw_quad[2] == se_quad[3]
def test_approx_wgs84_to_l93_roundtrip():
"""L'approximation affine WGS84→L93 est l'inverse exacte de L93→WGS84."""
from lidar_pipeline.index import _approx_l93_to_wgs84, _approx_wgs84_to_l93
for x, y in ((1054000.0, 6882000.0), (700000.0, 6600000.0), (950123.0, 6410456.0)):
lon, lat = _approx_l93_to_wgs84(x, y)
x2, y2 = _approx_wgs84_to_l93(lon, lat)
assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6

View File

@ -20,9 +20,9 @@ class TestVizSteps:
assert len(names) == len(set(names)), "VIZ_STEPS has duplicate names"
def test_expected_visualization_count(self):
"""Should have 16 visualizations (14 terrain + ortho + topo)."""
"""Should have 15 visualizations (13 terrain + ortho + topo)."""
from lidar_pipeline.pipeline import VIZ_STEPS
assert len(VIZ_STEPS) == 16
assert len(VIZ_STEPS) == 15
def test_ortho_and_topo_present(self):
from lidar_pipeline.pipeline import VIZ_STEPS
@ -49,6 +49,29 @@ class TestLidarArchaeoPipeline:
with pytest.raises(ValueError, match="introuvable"):
LidarArchaeoPipeline("/nonexistent/path", str(tmp_path / "output"))
def test_incremental_index_rebuild(self, tmp_path, monkeypatch):
"""Mode incrémental : l'index est régénéré après une tuile, avec anti-rebond."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
import lidar_pipeline.index as index_mod
input_dir = tmp_path / "input"
input_dir.mkdir()
calls = []
monkeypatch.setattr(index_mod, "build_index",
lambda *a, **k: calls.append(a) or None)
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"),
incremental_index=True)
pipeline._rebuild_index_incremental()
pipeline._rebuild_index_incremental() # < 3 s : anti-rebond, ignoré
assert len(calls) == 1
# --no-index : jamais de rebuild incrémental
pipeline._last_index_rebuild = 0.0
pipeline.no_index = True
pipeline._rebuild_index_incremental()
assert len(calls) == 1
def test_find_laz_files_empty(self, tmp_path):
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"

View File

@ -0,0 +1,127 @@
"""Tests du journal d'événements de progression (file de génération)."""
def test_report_and_read_events(tmp_path):
"""Les événements s'écrivent en JSONL et se relisent dans l'ordre."""
from lidar_pipeline.progress import read_events, report_event, reset_events
reset_events(tmp_path)
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "download", "start")
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "download", "ok")
events = read_events(tmp_path)
assert len(events) == 2
assert events[0]["phase"] == "download" and events[0]["state"] == "start"
assert events[1]["state"] == "ok"
assert events[1]["tile"] == "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
def test_reset_clears_previous_run(tmp_path):
"""reset_events tronque le journal : un nouveau run repart de zéro."""
from lidar_pipeline.progress import read_events, report_event, reset_events
reset_events(tmp_path)
report_event(tmp_path, "X", "tile", "ok")
reset_events(tmp_path)
assert read_events(tmp_path) == []
def test_read_events_missing_or_partial(tmp_path):
"""Dossier sans journal → liste vide ; ligne partielle → ignorée."""
from lidar_pipeline.progress import events_path, read_events
assert read_events(tmp_path) == []
events_path(tmp_path).write_text(
'{"tile": "X", "phase": "tile", "state": "ok"}\n'
'{"tile": "X", "phase": "vi', encoding="utf-8")
events = read_events(tmp_path)
assert len(events) == 1 and events[0]["state"] == "ok"
def test_short_name():
"""Le nom court extrait col-row du nom de dalle IGN."""
from lidar_pipeline.progress import tile_short_name
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69") == "1054-6882"
assert tile_short_name("LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz") == "1054-6882"
assert tile_short_name("autre.tif") == "autre.tif"
assert tile_short_name("") == ""
def test_aggregate_full_run(tmp_path):
"""Run complet : étapes ordonnées avec libellés français, tuile terminée."""
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
reset_events(tmp_path)
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
report_event(tmp_path, b, "download", "skip", "déjà dans input/")
report_event(tmp_path, b, "classif", "start")
report_event(tmp_path, b, "classif", "ok")
report_event(tmp_path, b, "dtm", "ok", res=0.5)
report_event(tmp_path, b, "viz", "start", "aspect", res=0.5)
report_event(tmp_path, b, "viz", "ok", "aspect", res=0.5)
report_event(tmp_path, b, "tile", "ok", "123s")
tiles = aggregate_tiles(read_events(tmp_path), viz_labels={"aspect": "Exposition"})
assert len(tiles) == 1
t = tiles[0]
assert t["short"] == "1054-6882" and t["state"] == "done"
assert [s["label"] for s in t["steps"]] == [
"Téléchargement", "Classification", "DTM 0,5 m", "Exposition"]
assert [s["state"] for s in t["steps"]] == ["skip", "ok", "ok", "ok"]
def test_aggregate_running_step(tmp_path):
"""Une étape démarrée non terminée laisse la tuile « running »."""
from lidar_pipeline.progress import aggregate_tiles, read_events, report_event, reset_events
reset_events(tmp_path)
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
report_event(tmp_path, b, "download", "ok")
report_event(tmp_path, b, "classif", "ok")
report_event(tmp_path, b, "dtm", "ok", res=0.5)
report_event(tmp_path, b, "viz", "start", "aspect", res=0.5)
tiles = aggregate_tiles(read_events(tmp_path))
assert tiles[0]["state"] == "running"
assert tiles[0]["steps"][-1]["state"] == "running"
def test_aggregate_failure_marks_tile_failed():
"""Une viz en échec marque la tuile failed même sans événement tuile."""
from lidar_pipeline.progress import aggregate_tiles
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
{"tile": b, "phase": "classif", "state": "ok"},
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.5},
{"tile": b, "phase": "viz", "state": "start", "detail": "aspect", "res": 0.5},
{"tile": b, "phase": "viz", "state": "fail", "detail": "aspect", "res": 0.5},
])
assert tiles[0]["state"] == "failed"
aspect = [s for s in tiles[0]["steps"] if s["detail"] == "aspect"][0]
assert aspect["state"] == "fail"
def test_aggregate_multi_resolution_and_bad_input():
"""Deux résolutions → deux étapes DTM/viz distinctes ; entrées invalides ignorées."""
from lidar_pipeline.progress import aggregate_tiles
b = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
"pas du tout du json",
None,
{"tile": b, "phase": "inconnu", "state": "ok"},
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.5},
{"tile": b, "phase": "dtm", "state": "ok", "res": 0.2},
{"tile": b, "phase": "viz", "state": "ok", "detail": "aspect", "res": 0.2},
{"tile": b, "phase": "tile", "state": "ok"},
], viz_labels={"aspect": "Exposition"})
assert len(tiles) == 1
labels = [s["label"] for s in tiles[0]["steps"]]
assert labels == ["DTM 0,5 m", "DTM 0,2 m", "Exposition 0,2 m"]
# La résolution secondaire (0,2 m) est suffixée, la primaire (0,5 m) non
def test_aggregate_multiple_tiles_ordered():
"""Une entrée par tuile, dans l'ordre de première apparition."""
from lidar_pipeline.progress import aggregate_tiles
a = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
b = "LHD_FXX_1055_6882_PTS_LAMB93_IGN69"
tiles = aggregate_tiles([
{"tile": a, "phase": "download", "state": "ok"},
{"tile": b, "phase": "download", "state": "start", "detail": "catalogue IGN"},
{"tile": a, "phase": "tile", "state": "ok"},
])
assert [t["short"] for t in tiles] == ["1054-6882", "1055-6882"]
assert tiles[0]["state"] == "done"
assert tiles[1]["state"] == "running"

View File

@ -97,6 +97,30 @@ class TestApplyColormap:
assert result is not None
assert result.exists()
def test_knots_mode_fixed_transfer(self):
"""L'étalonnage quantile figé (ondelette) : même valeur → même couleur.
Les nœuds sont constants (pas de percentile local) : clamp aux
extrêmes, médiane (1.0) → 0.5, transfert monotone.
"""
from lidar_pipeline.rendering import COLORMAPS, _apply_colormap
info = COLORMAPS['wavelet']
kv, kt = info['knots'][0.5]
assert kv[6] == 1.0 and kt[6] == 0.5 # nœud médian
vals = np.array([0.05, kv[0], 1.0, kv[-1], 50.0], dtype=float)
out, cmap, *_ = _apply_colormap(vals, 'x_wavelet.tif', resolution=0.5)
assert cmap == 'inferno'
assert out[0] == 0.0 and out[1] == 0.01 # clamp bas
assert abs(out[2] - 0.5) < 1e-9 # médiane → 0.5
assert out[3] == 0.995 and out[4] == 1.0 # nœud haut, puis clamp au-delà
# Monotonie (pas d'inversion de teinte)
s = np.sort(np.random.default_rng(1).uniform(0.2, 3.0, 500))
o, *_ = _apply_colormap(s, 'x_wavelet.tif', resolution=0.5)
assert np.all(np.diff(o) >= -1e-12)
# Les nœuds 0.2 m diffèrent de ceux 0.5 m (calibrations distinctes)
kv02, _ = info['knots'][0.2]
assert kv02 != kv
class TestTifToCrop:
"""Conversion TIF → dalle cartographique (tif_to_crop)."""

View File

@ -121,6 +121,77 @@ class TestWavelet:
assert result is not None
assert result.exists()
def test_output_median_centered(self, synthetic_dem, tmp_output_dir):
"""Recentrage robuste : médiane ≈ 1 quel que soit le terrain.
C'est la condition pour qu'un étirement couleur global fixe donne
des couleurs homogènes entre tuiles (cf. knots dans rendering.py).
"""
import rasterio
from lidar_pipeline.visualizations import generate_wavelet
result = generate_wavelet(synthetic_dem, "test", tmp_output_dir, 5.0)
with rasterio.open(result) as src:
data = src.read(1)
valid = data[np.isfinite(data)]
assert abs(np.median(valid) - 1.0) < 0.05
def test_ditch_on_hilltop_not_amplified(self, tmp_path, tmp_output_dir):
"""Un fossé en sommet de colline ne ressort pas plus qu'à plat.
Le détendage (moyenne locale gaussienne ~35 m) doit neutraliser la
position topographique :
- le pic d'indice sur le fossé en sommet reste comparable au même
fossé sur terrain plat ;
- le fond du sommet (sans structure) reste comparable au fond plat.
MNT synthétique bruité (σ=8 cm) : sans détendage, le fond sommet
ressort ~1,7× le fond plat (sommets jaunes sur la carte).
"""
import rasterio
from rasterio.transform import from_bounds
from lidar_pipeline.visualizations import generate_wavelet
size = 600
res = 1.0
x = np.arange(size) * res
y = np.arange(size) * res
X, Y = np.meshgrid(x, y)
dem = 100.0 + 0.01 * X
# Colline réaliste (sigma 80 m, 25 m de haut) à gauche + bruit capteur
dem += 25.0 * np.exp(-((X - 150)**2 + (Y - 300)**2) / (2 * 80**2))
rng = np.random.default_rng(42)
dem += rng.normal(0, 0.08, dem.shape)
# Deux fossés identiques : l'un au sommet de la colline, l'autre à plat
for xc in (150, 480):
dem -= 1.5 * np.exp(-((X - xc)**2) / (2 * 1.2**2))
dem_file = tmp_path / "ditch_dem.tif"
transform = from_bounds(660000, 6700000, 660600, 6700600, size, size)
with rasterio.open(
dem_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
) as dst:
dst.write(dem.astype('float32'), 1)
result = generate_wavelet(dem_file, "ditch", tmp_output_dir, res)
assert result is not None and result.exists()
with rasterio.open(result) as src:
data = src.read(1)
# Pics sur une bande verticale autour de chaque fossé
peak_hilltop = np.nanmax(data[:, 145:156])
peak_flat = np.nanmax(data[:, 475:486])
assert peak_flat > 5.0 # le fossé ressort nettement au-dessus du fond
# Pas d'amplification du fossé par la position topographique
assert peak_hilltop < 1.5 * peak_flat
# Fond du sommet (35 m à l'est du fossé sommital) vs fond plat
bg_hilltop = np.nanmedian(data[250:350, 185:226])
bg_flat = np.nanmedian(data[250:350, 500:561])
assert bg_hilltop < 1.4 * bg_flat # sans détendage : ~1,7×
class TestFlowAccumulation:
def test_generates_tif(self, synthetic_dem, tmp_output_dir):

View File

@ -26,7 +26,7 @@ def test_processed_cells(tmp_path):
from lidar_pipeline.webapp import processed_cells
vis = tmp_path / "visualisations" / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2"
vis.mkdir(parents=True)
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_r0p2_aspect.avif").write_bytes(b"x")
(vis / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69_aspect.avif").write_bytes(b"x")
assert processed_cells(tmp_path) == {(1054, 6882)}
@ -53,6 +53,174 @@ def test_missing_cells_include_done(tmp_path):
assert {(t['col'], t['row']) for t in todo} == {(1054, 6882), (1055, 6882)}
def _make_tile(tmp_path, col, row, viz_keys, resolutions=(0.5, 0.2)):
"""Crée les dossiers de visualisations d'une tuile (une entrée par résolution).
Le suffixe de résolution n'est que sur le DOSSIER : les fichiers gardent
le basename nu (cf. _expected_output_path dans le pipeline).
"""
base = f"LHD_FXX_{col:04d}_{row:04d}_PTS_LAMB93_IGN69"
for res in resolutions:
suffix = "" if res == 0.5 else "_r" + str(res).replace('.', 'p')
vis = tmp_path / "visualisations" / (base + suffix)
vis.mkdir(parents=True, exist_ok=True)
for key in viz_keys:
(vis / f"{base}_{key}.avif").write_bytes(b"x")
def test_missing_cells_includes_incomplete_tile(tmp_path):
"""Une tuile existante mais incomplète reste incluse (visualisation manquante)."""
from lidar_pipeline.webapp import missing_cells_with_corners
_make_tile(tmp_path, 1054, 6882, ["aspect"])
# aspect aux deux résolutions → complète pour ['aspect']
assert missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"]) == []
# slope manquante → la tuile est incluse pour être complétée
todo = missing_cells_with_corners([(1054, 6882)], tmp_path,
viz=["aspect", "slope"])
assert [(t['col'], t['row']) for t in todo] == [(1054, 6882)]
def test_missing_cells_requires_both_resolutions(tmp_path):
"""Une tuile complète à 0.5 mais sans 0.2 reste à traiter."""
from lidar_pipeline.webapp import missing_cells_with_corners
_make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.5,))
assert len(missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"])) == 1
_make_tile(tmp_path, 1054, 6882, ["aspect"], resolutions=(0.2,))
assert missing_cells_with_corners([(1054, 6882)], tmp_path, viz=["aspect"]) == []
def test_complete_cells_maps_step_names(tmp_path):
"""Les noms d'étapes sont convertis en mots-clés de fichiers (hillshade → hillshade_multi)."""
from lidar_pipeline.webapp import complete_cells
_make_tile(tmp_path, 1054, 6882, ["hillshade_multi"])
assert complete_cells(tmp_path, ["hillshade"]) == {(1054, 6882)}
# 'pos_open' → 'positive_openness' : absent → tuile non complète
assert complete_cells(tmp_path, ["pos_open"]) == set()
def test_preview_rejects_unknown_viz():
"""/api/preview refuse un nom de visualisation inconnu (HTTPException 400)."""
from fastapi import HTTPException
from lidar_pipeline.webapp import preview, PreviewRequest
req = PreviewRequest(bbox=[7.85, 48.93, 7.86, 48.94], viz=["inconnu"])
try:
preview(req)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 400
assert "inconnu" in e.detail
def test_build_command_resolutions_match_completeness():
"""La commande -r et la détection de complétude utilisent les mêmes résolutions."""
from lidar_pipeline.webapp import GENERATE_RESOLUTIONS, _build_command
cmd = _build_command([(1054, 6882)])
i = cmd.index("-r")
assert cmd[i + 1] == ",".join(str(r) for r in GENERATE_RESOLUTIONS)
def test_job_state_persisted_across_reload(tmp_path, monkeypatch):
"""L'état du job survit au redémarrage du serveur (file .generation.job.json)."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "JOB_FILE", tmp_path / "job.json")
saved = {k: webapp._job.get(k) for k in ("proc", "started", "returncode", "cmd", "finished")}
try:
webapp._job.update({"started": 123.0, "returncode": 0,
"cmd": ["python", "-m", "lidar_pipeline"], "finished": 456.0})
webapp._save_job_state()
assert (tmp_path / "job.json").exists()
# Simulation du redémarrage : état mémoire vide, rechargé depuis disque
webapp._job.update({"proc": None, "started": None, "returncode": None,
"cmd": None, "finished": None})
webapp._load_job_state()
assert webapp._job["started"] == 123.0
assert webapp._job["returncode"] == 0
assert webapp._job["finished"] == 456.0
finally:
webapp._job.update(saved)
def test_laz_cells_parses_input(tmp_path):
"""laz_cells repère les dalles LHD présentes dans input/ (les autres ignorés)."""
from lidar_pipeline.webapp import laz_cells
(tmp_path / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x")
(tmp_path / "LHD_FXX_1055_6881_PTS_LAMB93_IGN69.laz").write_bytes(b"x")
(tmp_path / "LHD_FXX_1056_6880_PTS_LAMB93_IGN69.copc.las").write_bytes(b"x")
(tmp_path / "autre_fichier.laz").write_bytes(b"x")
assert laz_cells(tmp_path) == [(1054, 6882), (1055, 6881), (1056, 6880)]
def test_preview_all_missing(tmp_path, monkeypatch):
"""all_missing: seulement les dalles présentes dans input/ et incomplètes."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
(tmp_path / "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x")
(tmp_path / "LHD_FXX_1055_6881_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x")
# 1054,6882 : aspect aux deux résolutions → complète pour ['aspect']
base = "LHD_FXX_1054_6882_PTS_LAMB93_IGN69"
for suffix in ("", "_r0p2"):
vis = tmp_path / "visualisations" / (base + suffix)
vis.mkdir(parents=True)
(vis / f"{base}_aspect.avif").write_bytes(b"x")
d = webapp.preview(webapp.PreviewRequest(all_missing=True, viz=["aspect"]))
assert d["count"] == 1
assert d["cells"][0]["col"] == 1055 and d["cells"][0]["row"] == 6881
def test_generate_all_missing(tmp_path, monkeypatch):
"""all_missing: traite toutes les dalles présentes (au-delà de la limite 400)."""
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
for i in range(1054, 1455): # 401 dalles → au-delà de MAX_CELLS
(tmp_path / f"LHD_FXX_{i:04d}_6882_PTS_LAMB93_IGN69.copc.laz").write_bytes(b"x")
captured = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
class _P:
def wait(self):
return 0
def poll(self):
return 0
return _P()
# Stub du module subprocess : patcher le vrai module global casserait ses
# autres usages (matplotlib l'appelle à l'import, ex. fc-list) et la
# restauration serait un no-op (valeur déjà falsifiée).
import subprocess as _real_subprocess
class _SubprocessStub:
Popen = staticmethod(fake_popen)
run = staticmethod(_real_subprocess.run)
STDOUT = _real_subprocess.STDOUT
monkeypatch.setattr(webapp, "subprocess", _SubprocessStub)
req = webapp.GenerateRequest(tiles=[], all_missing=True, viz=["aspect"])
result = webapp.generate(req)
assert result["demarré"] is True and result["tuiles"] == 401
i = captured["cmd"].index("--file")
assert len(captured["cmd"][i + 1:]) == 401
assert "LHD_FXX_1054_6882_PTS_LAMB93_IGN69.copc.laz" in captured["cmd"]
def test_generate_all_missing_empty(tmp_path, monkeypatch):
"""all_missing sans dalle présente → 400."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "INPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
try:
webapp.generate(webapp.GenerateRequest(tiles=[], all_missing=True))
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 400
def test_build_command_regenerate():
"""regenerate=True ajoute --force --force-classification à la commande."""
from lidar_pipeline.webapp import _build_command
@ -92,9 +260,37 @@ def test_build_command_default_viz_aspect():
def test_build_command_custom_viz():
"""Les visualisations demandées sont passées à --only dans l'ordre."""
from lidar_pipeline.webapp import _build_command
cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "aniso_open"])
cmd = _build_command([(1054, 6882)], viz=["aspect", "wavelet", "slope"])
i = cmd.index("--only")
assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "aniso_open"]
assert cmd[i + 1:i + 4] == ["aspect", "wavelet", "slope"]
def test_build_command_incremental_index():
"""La génération web reconstruit l'index après chaque tuile (affichage en direct)."""
from lidar_pipeline.webapp import _build_command
cmd = _build_command([(1054, 6882)])
assert "--incremental-index" in cmd
def test_tiles_endpoint_stamp_diffing(tmp_path, monkeypatch):
"""/api/tiles sert index_tiles.json, allégé quand le stamp correspond."""
import json
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
# Fichier absent (aucune tuile encore indexée) → réponse vide
assert webapp.tiles_data() == {"stamp": None, "tiles": None}
# Fichier présent → données complètes puis réponse allégée au stamp
(tmp_path / "index_tiles.json").write_text(
json.dumps({"tiles": [{"col": 1054, "corners": []}],
"viz_meta": {}, "stats": {}}), encoding="utf-8")
full = webapp.tiles_data()
assert full["tiles"][0]["col"] == 1054
light = webapp.tiles_data(stamp=full["stamp"])
assert light["tiles"] is None and light["stamp"] == full["stamp"]
# Un stamp périmé redonne les données complètes
assert webapp.tiles_data(stamp=full["stamp"] - 10)["tiles"] is not None
def test_viz_step_names_match_pipeline():
@ -117,9 +313,8 @@ def test_generate_rejects_unknown_viz():
assert "inconnu" in e.detail
def test_generate_accepts_wavelet_and_aniso():
"""L'API accepte wavelet + aniso_open et les transmet à --only."""
import subprocess
def test_generate_accepts_wavelet_and_slope(monkeypatch):
"""L'API accepte wavelet + slope et les transmet à --only."""
import lidar_pipeline.webapp as webapp
captured = {}
@ -134,16 +329,22 @@ def test_generate_accepts_wavelet_and_aniso():
return 0
return _P()
webapp.subprocess.Popen = fake_popen
try:
req = webapp.GenerateRequest(tiles=[[1054, 6882]],
viz=["wavelet", "aniso_open"])
result = webapp.generate(req)
assert result["demarré"] is True
i = captured["cmd"].index("--only")
assert captured["cmd"][i + 1:i + 3] == ["wavelet", "aniso_open"]
finally:
webapp.subprocess.Popen = subprocess.Popen
# Stub du module subprocess (voir test_generate_all_missing) : ne pas
# patcher le vrai module global.
import subprocess as _real_subprocess
class _SubprocessStub:
Popen = staticmethod(fake_popen)
run = staticmethod(_real_subprocess.run)
STDOUT = _real_subprocess.STDOUT
monkeypatch.setattr(webapp, "subprocess", _SubprocessStub)
req = webapp.GenerateRequest(tiles=[[1054, 6882]],
viz=["wavelet", "slope"])
result = webapp.generate(req)
assert result["demarré"] is True
i = captured["cmd"].index("--only")
assert captured["cmd"][i + 1:i + 3] == ["wavelet", "slope"]
def test_available_layers_scans_disk(tmp_path, monkeypatch):
@ -173,3 +374,212 @@ def test_rebuild_index_background(tmp_path, monkeypatch):
assert calls # build_index a bien été appelé
assert webapp.rebuild_status()["running"] is False
assert webapp.rebuild_status()["done"] is not None
def test_status_exposes_tiles_from_events(tmp_path, monkeypatch):
"""/api/status agrège les événements en tuiles (nom court + étapes)."""
import lidar_pipeline.webapp as webapp
from lidar_pipeline.progress import report_event, reset_events
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
reset_events(tmp_path)
report_event(tmp_path, "LHD_FXX_1054_6882_PTS_LAMB93_IGN69", "viz",
"start", "aspect", res=0.5)
s = webapp.status()
match = [t for t in s["tiles"] if t["short"] == "1054-6882"]
assert len(match) == 1
assert match[0]["state"] == "running"
assert any(step["label"] == "Aspect" for step in match[0]["steps"])
def test_generate_resets_progress_events(tmp_path, monkeypatch):
"""Un nouveau run repart d'un journal d'événements vide."""
import lidar_pipeline.webapp as webapp
from lidar_pipeline.progress import read_events, report_event
def fake_popen(cmd, **kwargs):
class _P:
def wait(self):
return 0
def poll(self):
return 0
return _P()
import subprocess as _real_subprocess
class _SubprocessStub:
Popen = staticmethod(fake_popen)
run = staticmethod(_real_subprocess.run)
STDOUT = _real_subprocess.STDOUT
monkeypatch.setattr(webapp, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(webapp, "subprocess", _SubprocessStub)
report_event(tmp_path, "ancienne_tuile", "tile", "ok")
req = webapp.GenerateRequest(tiles=[[1054, 6882]])
assert webapp.generate(req)["demarré"] is True
assert read_events(tmp_path) == []
# ============================================================
# Mode webapp légère (Raspberry Pi) : génération déléguée à une
# machine de traitement distante via LIDAR_GENERATION_URL.
# ============================================================
def test_generate_forwards_to_remote(monkeypatch):
"""/api/generate transmet la demande à la machine de traitement."""
import lidar_pipeline.webapp as webapp
calls = []
def fake_proxy(method, path, payload=None, timeout=30):
calls.append((method, path, payload))
return {"demarré": True, "tuiles": 1}
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
monkeypatch.setattr(webapp, "_proxy_api", fake_proxy)
req = webapp.GenerateRequest(tiles=[[1054, 6882]], viz=["aspect"])
result = webapp.generate(req)
assert result["demarré"] is True and result["distant"] is True
assert calls == [("POST", "/api/generate",
{"tiles": [[1054, 6882]], "regenerate": False,
"ground_class": "ign", "ign_classes": "sol",
"bare_earth": False, "viz": ["aspect"],
"all_missing": False})]
def test_status_proxies_remote(monkeypatch):
"""/api/status rapporte l'état du job distant (file de génération)."""
import lidar_pipeline.webapp as webapp
calls = []
def fake_proxy(method, path, payload=None, timeout=30):
calls.append((method, path, timeout))
return {"running": True, "tiles": []}
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
monkeypatch.setattr(webapp, "_proxy_api", fake_proxy)
data = webapp.status()
assert data["running"] is True and data["distant"] is True
assert calls == [("GET", "/api/status", 10)] # timeout court : sondage UI
def test_preview_forwards_to_remote(monkeypatch):
"""/api/preview interroge le distant (il seul connaît input/ complet)."""
import lidar_pipeline.webapp as webapp
calls = []
def fake_proxy(method, path, payload=None, timeout=30):
calls.append((method, path, payload))
return {"count": 0, "cells": []}
monkeypatch.setattr(webapp, "GENERATION_URL", "http://distant:8973")
monkeypatch.setattr(webapp, "_proxy_api", fake_proxy)
req = webapp.PreviewRequest(bbox=[7.85, 48.93, 7.86, 48.94], viz=["aspect"])
assert webapp.preview(req)["count"] == 0
assert calls[0][0] == "POST" and calls[0][1] == "/api/preview"
assert calls[0][2]["bbox"] == [7.85, 48.93, 7.86, 48.94]
def test_proxy_translates_unreachable_host(monkeypatch):
"""Une machine de traitement injoignable devient un 503 explicite."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
monkeypatch.setattr(webapp, "GENERATION_URL", "http://localhost:1")
try:
webapp._proxy_api("GET", "/api/status", timeout=1)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 503
assert "injoignable" in e.detail
def test_fallback_viz_step_names_match_pipeline():
"""Le repli léger (sans pipeline) liste les mêmes étapes, même ordre."""
from lidar_pipeline.pipeline import VIZ_STEPS
from lidar_pipeline.webapp import _fallback_viz_step_names
assert _fallback_viz_step_names() == [name for name, _ in VIZ_STEPS]
def test_viz_step_labels_without_pipeline():
"""Les libellés d'étapes restent corrects même sans importer le pipeline."""
import lidar_pipeline.webapp as webapp
labels = webapp._viz_step_labels()
assert labels["hillshade"] == "Hillshade multidirectionnel"
assert labels["pos_open"] == "Openness positive"
def test_sync_runs_command_then_rebuild(tmp_path, monkeypatch):
"""POST /api/sync exécute LIDAR_SYNC_CMD puis build_index en arrière-plan."""
import time as _time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0")
webapp._rebuild["done"] = None # ignore l'état d'un test précédent
assert webapp.sync_and_rebuild()["sync"] is True
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls # l'index a été reconstruit après la synchronisation
assert webapp.rebuild_status()["error"] is None
assert webapp.rebuild_status()["done"] is not None
def test_sync_failure_reported_but_index_rebuilt(tmp_path, monkeypatch):
"""Un rsync en échec est remonté dans l'état sans bloquer le rebuild."""
import time as _time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
monkeypatch.setattr(webapp, "SYNC_CMD", "exit 3")
webapp._rebuild["done"] = None
webapp.sync_and_rebuild()
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls # rebuild quand même (données locales éventuelles)
assert "rc=3" in webapp.rebuild_status()["error"]
def test_sync_without_cmd_is_rebuild_only(monkeypatch):
"""Sans LIDAR_SYNC_CMD (machine locale), /api/sync = simple rebuild."""
import time as _time
import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod
calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out))
monkeypatch.setattr(webapp, "SYNC_CMD", None)
webapp._rebuild["done"] = None
assert webapp.sync_and_rebuild()["sync"] is False
for _ in range(200):
s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]:
break
_time.sleep(0.05)
assert calls and webapp.rebuild_status()["error"] is None
def test_require_token():
"""Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header."""
from fastapi import HTTPException
import lidar_pipeline.webapp as webapp
old = webapp.API_TOKEN
webapp.API_TOKEN = "secret"
try:
for bad in (None, "faux"):
try:
webapp._require_token(bad)
assert False, "une HTTPException était attendue"
except HTTPException as e:
assert e.status_code == 401
assert webapp._require_token("secret") is None
finally:
webapp.API_TOKEN = old
# Sans jeton configuré : tout passe (LAN de confiance)
webapp.API_TOKEN = None
assert webapp._require_token(None) is None

View File

@ -960,12 +960,21 @@ def generate_solar(dem_file, basename, vis_dir, resolution, shared=None):
def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
"""Mexican Hat wavelet multi-scale analysis (GPU if available).
CWT 2D at multiple scales adapted to resolution.
- At 0.5m/px: [1, 2, 5, 10, 20, 50, 100]m
- At 0.2m/px: [0.5, 1, 2, 5, 10, 20, 50, 100]m
Focused on small archaeological structures (paths, ditches, ramparts).
CWT 2D at scales [1, 2, 5, 10, 20, 50]m (0.5m added below 0.25m/px).
The 100m scale was dropped: it mostly responds to landforms (hills,
valleys), not to structures.
Uses std normalization per scale and weighted RMS combination
with emphasis on archaeologically relevant scales (2-50m).
Large landforms are removed first by subtracting a Gaussian local-mean
trend (~35m). The residual is analyzed relative to its ~35m neighborhood,
so a ditch on a hilltop or slope does not stand out more than the same
ditch on flat ground (topographic-position independence). A Gaussian
smoothing preserves locally planar slopes, so slopes are removed too.
Uses robust per-scale normalization (MAD) and median-centered weighted RMS
combination with emphasis on small scales (1-10m).
The output is an index relative to the tile's own median level (≈ 1):
comparable from tile to tile, so a single fixed color stretch works.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Ondelette Mexican Hat multi-échelle{gpu_tag}...")
@ -985,18 +994,44 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
filled, _ = _fill_nans(dem_np.astype(np.float64))
min_scale = max(resolution * 2, 1.0)
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50, 100]
# 100m retirée : elle répond surtout aux grandes formes du terrain,
# pas aux structures. Accent sur 1-10m (petites structures).
candidate_scales = [0.5, 1, 2, 5, 10, 20, 50]
scales = [s for s in candidate_scales if s >= min_scale]
scale_weights = {
0.5: 0.6, 1.0: 0.8, 2.0: 1.5, 5.0: 2.0,
10.0: 1.8, 20.0: 1.5, 50.0: 1.0, 100.0: 0.6,
0.5: 0.7, 1.0: 1.2, 2.0: 1.8, 5.0: 2.2,
10.0: 2.0, 20.0: 1.5, 50.0: 0.8,
}
weights = np.array([scale_weights.get(s, 1.0) for s in scales])
logger.info(f" Échelles CWT: {scales}m (résolution {resolution}m/px)")
from scipy.ndimage import gaussian_laplace
from scipy.ndimage import gaussian_laplace, gaussian_filter
# Retrait des grands volumes (collines, vallées) : on soustrait une
# moyenne locale gaussienne avant la CWT. Un lissage gaussien préserve
# les pentes planes, donc le résidu est analysé par rapport à son
# voisinage ~35m : un fossé en sommet ou en flanc de colline ne
# ressort pas plus que le même fossé à plat.
# Fraction conservée pour une structure gaussienne de largeur σ_f :
# σ_t²/(σ_f²+σ_t²) → 10m : 92%, 20m : 75%, colline 150m : 5%.
# Mesuré sur MNT synthétique bruité : le contraste des petites
# structures est insensible à σ_t ; seul le fond sommet/plat varie
# (1.71 sans détendage → 1.21 à 35m).
detrend_sigma_m = 35.0
detrend_sigma_px = detrend_sigma_m / resolution
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_filter as gpu_gaussian_filter
trend = to_cpu(gpu_gaussian_filter(to_gpu(filled), sigma=detrend_sigma_px))
except Exception:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
else:
trend = gaussian_filter(filled, sigma=detrend_sigma_px)
residual = filled - trend
del trend
logger.info(f" Retrait des grands volumes (tendance gaussienne {detrend_sigma_m:.0f}m)")
wavelet_stack = []
@ -1005,16 +1040,20 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
if _gpu_mod.HAS_GPU:
try:
from cupyx.scipy.ndimage import gaussian_laplace as gpu_gaussian_laplace
response = -gpu_gaussian_laplace(to_gpu(filled), sigma=sigma_px)
response = -gpu_gaussian_laplace(to_gpu(residual), sigma=sigma_px)
response = to_cpu(response)
except Exception:
response = -gaussian_laplace(filled, sigma=sigma_px)
response = -gaussian_laplace(residual, sigma=sigma_px)
else:
response = -gaussian_laplace(filled, sigma=sigma_px)
response = -gaussian_laplace(residual, sigma=sigma_px)
response[nan_mask] = np.nan
valid = response[~nan_mask]
std_val = max(np.nanstd(valid), 0.01) if len(valid) > 0 else 0.01
response = response / std_val
# σ robuste (MAD) : le std classique est gonflé par les queues
# (structures marquées, bords de tuile) et varie fortement d'une
# tuile à l'autre — cause première des dominantes de couleur
# par tuile sur la carte.
mad = np.nanmedian(np.abs(valid - np.nanmedian(valid))) if len(valid) > 0 else 0.0
response = response / max(1.4826 * mad, 0.01)
wavelet_stack.append(response)
stack = np.array(wavelet_stack)
@ -1025,6 +1064,15 @@ def generate_wavelet(dem_file, basename, vis_dir, resolution, shared=None):
combined = np.sqrt(np.nansum((stack ** 2) * weights_3d, axis=0) / np.sum(weights))
combined[nan_mask] = np.nan
# Recentrage par la médiane de la tuile : la RMS devient un indice
# relatif au niveau moyen de la tuile (médiane = 1). La distribution
# est alors comparable d'une tuile à l'autre — condition pour un
# étirement couleur global fixe et homogène entre tuiles.
finite = combined[np.isfinite(combined)]
if finite.size:
combined = combined / max(float(np.median(finite)), 0.01)
combined[nan_mask] = np.nan
_save_tif(output, combined.astype(np.float32), transform, crs)
logger.info(f" ✓ Ondelette terminée ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
@ -1227,69 +1275,6 @@ def generate_flow_accumulation(dem_file, basename, vis_dir, resolution, shared=N
return None
# ============================================================
# Anisotropic Openness
# ============================================================
def generate_aniso_open(dem_file, basename, vis_dir, resolution, shared=None):
"""Anisotropic Openness - weighted directional openness, multi-radius (GPU if available).
Computes positive and negative openness with anisotropic weighting:
NW/SE directions weighted more heavily to enhance detection of structures
aligned NE-SW (common in French archaeological sites: villas, enclosures).
Multi-radius (25, 50, 100m) with equal weight, results std-normalized
for cross-tile comparability.
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Openness Anisotropique (multi-rayon){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_aniso_open.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
n_dirs = 8
# Anisotropic weights: emphasize NW-SE and NE-SW directions
weights = np.array([1.0, 1.5, 1.0, 1.5, 1.0, 1.5, 1.0, 1.5])
radii_m = [25, 50, 100]
max_dist = min(int(100 / res), 300)
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
# Weighted combination across directions and radii
weight_total = np.sum(weights)
n_radii = len(radii_m)
pos_combined = np.zeros((rows, cols), dtype=np.float64)
neg_combined = np.zeros((rows, cols), dtype=np.float64)
for r_idx in range(n_radii):
for d_idx in range(n_dirs):
w = weights[d_idx]
pos_combined += pos_angles[d_idx, r_idx] * w / (n_radii * weight_total)
neg_combined += neg_angles[d_idx, r_idx] * w / (n_radii * weight_total)
aniso_result = np.degrees(pos_combined - neg_combined).astype(np.float32)
aniso_result[nan_mask] = np.nan
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
# plage de rendu fixe → mosaïque de couleur homogène
valid = aniso_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
aniso_result = (aniso_result - np.nanmean(valid)) / std_val
_save_tif(output, aniso_result, transform, crs)
logger.info(f" ✓ Openness anisotropique terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
return output
except Exception as e:
logger.error(f" ✗ Erreur openness anisotropique: {e}", exc_info=True)
return None
# ============================================================
# Anomaly Mask — automatic threshold detection
# ============================================================
@ -1342,7 +1327,6 @@ def generate_anomaly_mask(dem_file, basename, vis_dir, resolution, shared=None,
("wavelet", 1.5), # Circular + linear structures
("svf", 1.3), # Sky-view depressions
("flow_acc", 1.2), # Drainage channels / ditches
("aniso_open", 1.0), # Anisotropic structures
("positive_openness", 0.8), # Surélevations
]

View File

@ -5,18 +5,42 @@ le téléchargement IGN). Endpoints :
GET / → carte interactive (output/index.html)
GET /api/status → état de la génération en cours (ou dernière terminée)
POST /api/preview → cellules 1 km intersectant une bbox WGS84 (option
regenerate=true pour inclure celles déjà générées)
GET /api/tiles → données de carte (index_tiles.json) pour l'affichage
en direct ; ?stamp=X → réponse allégée si inchangé
POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync
depuis la machine de traitement) puis régénérer la
carte en arrière-plan
POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option
all_missing=true) toutes les dalles LHD présentes dans
input/ qui manquent au moins une visualisation demandée.
Sont incluses les tuiles absentes ET les tuiles
existantes incomplètes ; option viz = noms d'étapes
demandées, option regenerate=true pour inclure les
tuiles complètes
POST /api/generate → télécharge (géoplateforme IGN) puis traite des cellules
(option regenerate=true ajoute --force --force-classification ;
option ground_class choisit la méthode de classification du sol)
option ground_class choisit la méthode de classification du sol ;
option all_missing=true traite toutes les dalles présentes
dans input/ qui manquent les visualisations demandées, sans
téléchargement ; sans --force, le pipeline ne génère que
les visualisations manquantes des tuiles existantes)
Fichiers statiques : /assets (interface), /index_thumbs, /index_subtiles,
/visualisations, /DTM.
Un seul job à la fois : la génération lance `python -m lidar_pipeline` en
sous-processus avec --fetch-tiles + --file, journalisé dans .generation.log.
Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère
(sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de
traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et
/api/status sont transmis tels quels au service distant, qui exécute la même
webapp avec le pipeline complet. Les résultats reviennent par la commande de
synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et
carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN
(webapp légère) protègent optionnellement les appels distants.
"""
import json
import math
import os
import subprocess
@ -25,7 +49,9 @@ import threading
import time
from pathlib import Path
from fastapi import FastAPI, HTTPException
from typing import Optional
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
@ -33,7 +59,25 @@ INPUT_DIR = Path(os.environ.get("LIDAR_INPUT_DIR", "/data/input"))
OUTPUT_DIR = Path(os.environ.get("LIDAR_OUTPUT_DIR", "/data/output"))
PORT = int(os.environ.get("LIDAR_PORT", "8973"))
LOG_FILE = OUTPUT_DIR / ".generation.log"
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande
JOB_FILE = OUTPUT_DIR / ".generation.job.json"
MAX_CELLS = 400 # garde-fou : ~400 km² max par demande (zones dessinées)
MAX_CELLS_ALL = 10000 # garde-fou : passe globale « tout compléter » (dalles déjà présentes)
# Machine de traitement distante (webapp légère type Raspberry Pi) : si
# définie, la génération (téléchargement IGN + traitement) y est déléguée.
GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/")
# Jeton partagé : la webapp légère le présente à la machine de traitement
# (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None
# Commande shell qui ramène les tuiles traitées dans output/ local (rsync
# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild.
SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None
# Résolutions générées par /api/generate (cf. _build_command). La détection des
# tuiles à compléter exige ces mêmes résolutions : une tuile n'est complète
# que si elle possède les visualisations demandées aux deux résolutions.
GENERATE_RESOLUTIONS = (0.5, 0.2)
app = FastAPI(title="Carte LiDAR — génération de zones")
@ -62,15 +106,90 @@ def assets(file_path: str):
GROUND_CLASS_METHODS = ("auto", "ign", "smrf", "csf")
def _require_token(x_lidar_token: str = Header(None)):
"""Protège les routes mutantes si LIDAR_API_TOKEN est défini (sinon no-op).
Utilisé par la machine de traitement pour n'accepter que la webapp légère
autorisée (qui présente LIDAR_REMOTE_TOKEN) sur le réseau local.
"""
if API_TOKEN and x_lidar_token != API_TOKEN:
raise HTTPException(401, "token d'API manquant ou invalide")
def _proxy_api(method, path, payload=None, timeout=30):
"""Transmet un appel d'API à la machine de traitement (mode webapp légère).
Retourne la réponse JSON distante. Les erreurs HTTP distantes (ex: 409 une
génération est déjà en cours) sont relayées telles quelles ; une machine
injoignable devient un 503 explicite pour l'interface.
"""
import urllib.error
import urllib.request
req = urllib.request.Request(
GENERATION_URL + path,
data=json.dumps(payload).encode("utf-8") if payload is not None else None,
method=method)
req.add_header("Content-Type", "application/json")
if REMOTE_TOKEN:
req.add_header("X-Lidar-Token", REMOTE_TOKEN)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode("utf-8")).get("detail", str(e))
except (ValueError, OSError):
detail = str(e)
raise HTTPException(e.code, detail)
except (urllib.error.URLError, OSError) as e:
raise HTTPException(503, f"machine de traitement injoignable "
f"({GENERATION_URL}) : {e}")
def _viz_step_names():
"""Noms des visualisations acceptées (étapes VIZ_STEPS du pipeline)."""
from .pipeline import VIZ_STEPS
return [name for name, _ in VIZ_STEPS]
"""Noms des visualisations acceptées (étapes VIZ_STEPS du pipeline).
Sur la webapp légère (sans dépendances de traitement), repli sur le
registre de l'index — le même ordre que VIZ_STEPS.
"""
try:
from .pipeline import VIZ_STEPS
return [name for name, _ in VIZ_STEPS]
except ImportError:
return _fallback_viz_step_names()
def _fallback_viz_step_names():
"""Noms d'étapes dérivés du registre léger de l'index (sans pipeline)."""
from .index import KEYWORD_TO_STEP, VIZ_LABELS
return [KEYWORD_TO_STEP.get(k, k) for k in VIZ_LABELS]
def _viz_step_labels():
"""Libellés français des étapes de visualisation (clé = nom d'étape).
VIZ_LABELS est indexée par mot-clé de nom de fichier ; trois étapes ont un
nom de sortie différent (cf. _expected_output_path dans le pipeline).
"""
from .index import VIZ_LABELS, step_to_keyword
return {name: VIZ_LABELS.get(step_to_keyword(name), name)
for name in _viz_step_names()}
class PreviewRequest(BaseModel):
bbox: list = Field(..., description="[ouest, sud, est, nord] en WGS84")
bbox: list = Field(None, description="[ouest, sud, est, nord] en WGS84 "
"(requis sauf all_missing=true)")
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
viz: list = Field(None,
description="Visualisations demandées, noms d'étapes du "
"pipeline (ex: aspect, wavelet) ; défaut : aspect. "
"Une tuile existante est considérée faite "
"seulement si elle les possède toutes")
all_missing: bool = Field(False,
description="Ignorer la bbox : toutes les dalles LHD "
"présentes dans input/ qui manquent au moins "
"une des visualisations demandées (sans "
"téléchargement)")
class GenerateRequest(BaseModel):
@ -91,25 +210,74 @@ class GenerateRequest(BaseModel):
"bas en terrain)")
viz: list = Field(None,
description="Visualisations à générer, noms d'étapes du "
"pipeline (ex: aspect, wavelet, aniso_open) ; "
"pipeline (ex: aspect, wavelet, slope) ; "
"défaut : aspect")
all_missing: bool = Field(False,
description="Ignorer la liste de tuiles : traiter toutes "
"les dalles LHD présentes dans input/ qui "
"manquent au moins une des visualisations "
"demandées (sans téléchargement)")
# --- État du job de génération -------------------------------------------
_job = {"proc": None, "started": None, "returncode": None, "cmd": None}
_job = {"proc": None, "started": None, "returncode": None, "cmd": None, "finished": None}
_job_lock = threading.Lock()
def _save_job_state():
"""Écrit l'état du job sur disque — survit au redémarrage du serveur.
Sans cela, un restart du conteneur perd tout : /api/status ne rapporte
plus le dernier run et la file de génération reste vide au rechargement.
Best-effort : n'acquiert pas le verrou (appelé aussi sous verrou).
"""
state = {k: _job.get(k) for k in ("started", "returncode", "cmd", "finished")}
try:
JOB_FILE.write_text(json.dumps(state), encoding="utf-8")
except OSError:
pass
def _load_job_state():
"""Recharge l'état du dernier job au démarrage du serveur."""
try:
state = json.loads(JOB_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
with _job_lock:
_job["started"] = state.get("started")
_job["returncode"] = state.get("returncode")
_job["cmd"] = state.get("cmd")
_job["finished"] = state.get("finished")
_load_job_state()
def bbox_to_cells(w, s, e, n):
"""Cellules L93 de 1 km (col, row) intersectant une bbox WGS84.
Une cellule (col, row) couvre X ∈ [col, col+1] km, Y ∈ [row-1, row] km.
Conversion via rasterio/PROJ si disponible, sinon pyproj, sinon
l'approximation affine de l'index (webapp légère sans GDAL).
"""
from rasterio.warp import transform as warp_transform
lons, lats = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n])
# warp_transform renvoie (xs, ys) dans la CRS cible
min_x, max_x = min(lons) + 0.5, max(lons) - 0.5 # rétrécit d'1 m : bords exclus
min_y, max_y = min(lats) + 0.5, max(lats) - 0.5
from .index import _approx_wgs84_to_l93
try:
from rasterio.warp import transform as warp_transform
xs, ys = warp_transform("EPSG:4326", "EPSG:2154", [w, e, w, e], [s, s, n, n])
except ImportError:
try:
from pyproj import Transformer
transformer = Transformer.from_crs("EPSG:4326", "EPSG:2154", always_xy=True)
xs, ys = transformer.transform([w, e, w, e], [s, s, n, n])
except ImportError:
pts = [_approx_wgs84_to_l93(lon, lat)
for lon, lat in ((w, s), (e, s), (w, n), (e, n))]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
# transform renvoie (xs, ys) dans la CRS cible
min_x, max_x = min(xs) + 0.5, max(xs) - 0.5 # rétrécit d'1 m : bords exclus
min_y, max_y = min(ys) + 0.5, max(ys) - 0.5
if max_x <= min_x or max_y <= min_y:
return []
cols = range(int(math.floor(min_x / 1000)), int(math.floor(max_x / 1000)) + 1)
@ -124,13 +292,54 @@ def processed_cells(output_dir):
return {(t["col"], t["row"]) for t in tiles}
def missing_cells_with_corners(cells, output_dir, include_done=False):
"""Filtre les cellules déjà traitées et calcule leurs coins WGS84.
def laz_cells(input_dir):
"""Coordonnées (col, row) des dalles LHD présentes dans input/.
Repère les fichiers dont le nom est LHD_FXX_{col}_{row}_... (extensions
.copc.laz/.copc.las/.laz/.las) ; les autres fichiers sont ignorés.
"""
from .index import parse_basename_coords
cells = set()
for f in list(Path(input_dir).glob("*.laz")) + list(Path(input_dir).glob("*.las")):
name = f.name
for ext in ('.copc.laz', '.copc.las', '.laz', '.las'):
if name.lower().endswith(ext):
name = name[:-len(ext)]
break
coords = parse_basename_coords(name)
if coords:
cells.add(coords)
return sorted(cells)
def complete_cells(output_dir, viz_steps):
"""Cellules ayant TOUTES les visualisations demandées, aux deux résolutions.
viz_steps : noms d'étapes du pipeline (ex: 'aspect', 'pos_open'), convertis
en mots-clés de fichiers de sortie avant comparaison avec le disque.
"""
from .index import cells_with_all_viz, step_to_keyword
keys = [step_to_keyword(v) for v in viz_steps]
return cells_with_all_viz(Path(output_dir) / "visualisations", keys,
GENERATE_RESOLUTIONS)
def missing_cells_with_corners(cells, output_dir, include_done=False, viz=None):
"""Filtre les cellules à traiter et calcule leurs coins WGS84.
Sans viz : une cellule ayant des visualisations est considérée faite.
Avec viz (noms d'étapes) : seules les cellules disposant de TOUTES les
visualisations demandées (aux résolutions de la génération) sont faites —
les tuiles existantes mais incomplètes restent incluses, afin de générer
les visualisations manquantes sans --force (le pipeline ignore l'existant).
Retourne [{col, row, corners: [[lat, lon] × 4 SW,SE,NE,NW}].
"""
from .index import attach_gps_bounds
done = processed_cells(output_dir)
if viz:
done = complete_cells(output_dir, viz)
else:
done = processed_cells(output_dir)
todo = [{"col": c, "row": r} for (c, r) in cells
if include_done or (c, r) not in done]
if todo:
@ -157,6 +366,12 @@ def root():
@app.get("/api/status")
def status():
if GENERATION_URL:
# Webapp légère : l'état vient de la machine de traitement
data = _proxy_api("GET", "/api/status", timeout=10)
data["distant"] = True
return data
from .progress import progress_snapshot
with _job_lock:
proc = _job["proc"]
running = proc is not None and proc.poll() is None
@ -166,6 +381,9 @@ def status():
"returncode": _job["returncode"],
"cmd": _job["cmd"],
"log": _tail_log(40),
# Tuiles une par une (nom court, type, état) — agrégées depuis les
# événements écrits par le pipeline (.generation.events.jsonl)
"tiles": progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels()),
}
@ -189,36 +407,99 @@ def available_layers():
return found
# --- Rebuild de l'index en arrière-plan -------------------------------------
_rebuild = {"running": False, "error": None, "done": None}
@app.get("/api/tiles")
def tiles_data(stamp: Optional[float] = None):
"""Données de carte (index_tiles.json) — tuiles visibles sans recharger.
Le pipeline lancé par /api/generate tourne avec --incremental-index : il
réécrit ce fichier après chaque tuile terminée. La carte sonde cet
endpoint pendant un run et fusionne les nouveautés. Avec ?stamp=X : la
réponse est allégée ({tiles: null}) tant que le fichier n'a pas changé.
"""
path = OUTPUT_DIR / "index_tiles.json"
try:
current = path.stat().st_mtime
if stamp is not None and abs(current - stamp) < 1e-4:
return {"stamp": current, "tiles": None}
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"stamp": None, "tiles": None}
data["stamp"] = current
return data
@app.post("/api/rebuild")
def rebuild_index():
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
# --- Synchronisation + rebuild de l'index en arrière-plan -------------------
_rebuild = {"running": False, "error": None, "done": None, "phase": None}
def _start_rebuild(with_sync):
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index.
Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées
par la machine distante (rsync) ; l'échec éventuel de la synchronisation
est remonté dans l'état sans empêcher le rebuild des données locales.
"""
if _rebuild["running"]:
raise HTTPException(409, "un rebuild est déjà en cours")
raise HTTPException(409, "une synchronisation/rebuild est déjà en cours")
def _run():
_rebuild["running"] = True
_rebuild["error"] = None
_rebuild["done"] = None
sync_error = None
if with_sync and SYNC_CMD:
_rebuild["phase"] = "sync"
try:
r = subprocess.run(SYNC_CMD, shell=True, capture_output=True,
text=True, timeout=4 * 3600)
if r.returncode != 0:
tail = (r.stderr or r.stdout or "").strip()[-500:]
sync_error = (f"synchronisation échouée (rc={r.returncode})"
+ (f" : {tail}" if tail else ""))
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
sync_error = f"synchronisation échouée : {e}"
_rebuild["phase"] = "index"
try:
from .index import build_index
build_index(OUTPUT_DIR)
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'API
_rebuild["error"] = str(e)
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e)
else:
_rebuild["error"] = sync_error
finally:
_rebuild["running"] = False
_rebuild["phase"] = None
_rebuild["done"] = time.time()
threading.Thread(target=_run, daemon=True).start()
return {"demarré": True}
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)}
@app.post("/api/sync", dependencies=[Depends(_require_token)])
def sync_and_rebuild():
"""Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan.
Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple
rebuild de l'index — même comportement que /api/rebuild.
"""
return _start_rebuild(with_sync=True)
@app.get("/api/sync")
def sync_status():
return rebuild_status()
@app.post("/api/rebuild", dependencies=[Depends(_require_token)])
def rebuild_index():
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan."""
return _start_rebuild(with_sync=False)
@app.get("/api/rebuild")
def rebuild_status():
return {"running": _rebuild["running"], "error": _rebuild["error"],
"done": _rebuild["done"]}
"done": _rebuild["done"], "phase": _rebuild["phase"]}
def _tail_log(n_lines):
@ -229,15 +510,32 @@ def _tail_log(n_lines):
return []
@app.post("/api/preview")
@app.post("/api/preview", dependencies=[Depends(_require_token)])
def preview(req: PreviewRequest):
if len(req.bbox) != 4:
raise HTTPException(400, "bbox attendue : [ouest, sud, est, nord]")
w, s, e, n = (float(v) for v in req.bbox)
cells = bbox_to_cells(w, s, e, n)
capped = len(cells) > MAX_CELLS
todo = missing_cells_with_corners(cells[:MAX_CELLS], OUTPUT_DIR,
include_done=req.regenerate)
if GENERATION_URL:
# Webapp légère : le distant connaît input/ et output/ complets
return _proxy_api("POST", "/api/preview", json.loads(req.json()))
names = _viz_step_names()
viz = [v for v in (req.viz or []) if v] or ["aspect"]
invalid = [v for v in viz if v not in names]
if invalid:
raise HTTPException(
400, f"visualisation invalide : {', '.join(invalid)} "
f"(attendues : {', '.join(names)})")
if req.all_missing:
# Passe globale : dalles LHD déjà présentes dans input/ (aucun téléchargement)
cells = laz_cells(INPUT_DIR)
capped = len(cells) > MAX_CELLS_ALL
todo = missing_cells_with_corners(cells[:MAX_CELLS_ALL], OUTPUT_DIR,
include_done=req.regenerate, viz=viz)
else:
if not req.bbox or len(req.bbox) != 4:
raise HTTPException(400, "bbox attendue : [ouest, sud, est, nord]")
w, s, e, n = (float(v) for v in req.bbox)
cells = bbox_to_cells(w, s, e, n)
capped = len(cells) > MAX_CELLS
todo = missing_cells_with_corners(cells[:MAX_CELLS], OUTPUT_DIR,
include_done=req.regenerate, viz=viz)
return {"count": len(todo), "capped": capped, "cells": todo}
@ -252,14 +550,15 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
régénération des visualisations des tuiles déjà présentes. Avec
bare_earth=True, le DTM est ramené au retour le plus bas de chaque
cellule (sol nu). Avec viz, on choisit les visualisations générées
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "aniso_open"] ;
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
défaut : aspect).
"""
from .fetch_ign import tile_filename
# -u : sortie non bufferisée — le journal .generation.log doit être
# lu en temps réel par /api/status (progression affichée dans l'UI).
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
"-o", str(OUTPUT_DIR), "-r", "0.5,0.2",
"-o", str(OUTPUT_DIR),
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
"--only", *(viz or ["aspect"]),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
@ -269,6 +568,9 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
cmd += ["--force", "--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
# Carte régénérée après chaque tuile terminée : la webapp l'affiche en
# direct via /api/tiles pendant le run.
cmd += ["--incremental-index"]
cmd += ["--fetch-tiles"]
cmd += [f"{c},{r}" for (c, r) in tiles]
cmd += ["--file"]
@ -276,21 +578,14 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
return cmd
@app.post("/api/generate")
@app.post("/api/generate", dependencies=[Depends(_require_token)])
def generate(req: GenerateRequest):
tiles = []
for pair in req.tiles:
if not (isinstance(pair, list) and len(pair) == 2):
raise HTTPException(400, f"tuile invalide : {pair!r} (attendu [col, row])")
tiles.append((int(pair[0]), int(pair[1])))
if not tiles:
raise HTTPException(400, "aucune tuile fournie")
if len(tiles) > 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)})")
if GENERATION_URL:
# Webapp légère : téléchargement + traitement sur la machine distante,
# qui valide les paramètres et renvoie son propre état de job.
data = _proxy_api("POST", "/api/generate", json.loads(req.json()))
data["distant"] = True
return data
names = _viz_step_names()
viz = [v for v in (req.viz or []) if v] or ["aspect"]
invalid = [v for v in viz if v not in names]
@ -298,6 +593,28 @@ def generate(req: GenerateRequest):
raise HTTPException(
400, f"visualisation invalide : {', '.join(invalid)} "
f"(attendues : {', '.join(names)})")
if req.all_missing:
# Passe globale : les dalles présentes dans input/ qui manquent les
# visualisations demandées (les complètes incluses seulement si regenerate)
done = set() if req.regenerate else complete_cells(OUTPUT_DIR, viz)
tiles = [c for c in laz_cells(INPUT_DIR)
if c not in done or req.regenerate]
if not tiles:
raise HTTPException(400, "aucune tuile à compléter dans input/")
else:
tiles = []
for pair in req.tiles:
if not (isinstance(pair, list) and len(pair) == 2):
raise HTTPException(400, f"tuile invalide : {pair!r} (attendu [col, row])")
tiles.append((int(pair[0]), int(pair[1])))
if not tiles:
raise HTTPException(400, "aucune tuile fournie")
if len(tiles) > 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)})")
with _job_lock:
proc = _job["proc"]
@ -306,7 +623,12 @@ def generate(req: GenerateRequest):
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class, bare_earth=req.bare_earth, ign_classes=req.ign_classes, viz=viz)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8")
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd})
# Nouveau run : journal d'événements remis à zéro (les tuiles affichées
# dans la file correspondent au run qui démarre, pas au précédent)
from .progress import reset_events
reset_events(OUTPUT_DIR)
_job.update({"proc": None, "started": time.time(), "returncode": None, "cmd": cmd, "finished": None})
_save_job_state()
p = subprocess.Popen(cmd, stdout=log_fh, stderr=subprocess.STDOUT,
cwd="/app" if Path("/app").exists() else None)
@ -315,6 +637,8 @@ def generate(req: GenerateRequest):
log_fh.close()
with _job_lock:
_job["returncode"] = rc
_job["finished"] = time.time()
_save_job_state()
threading.Thread(target=_watch, daemon=True).start()
_job["proc"] = p