Raccorder les bords de tuiles voisines et encadrer les rendus en cours

Le MNT de chaque dalle s'étend d'une bande de 100 m remplie avec les
points sol des 8 tuiles voisines (option « Raccord des bords ») : les
rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une
dalle à l'autre, les images restent recadrées sur le kilomètre exact.

Pendant une génération, la carte encadre les dalles du run : orange
pulsant en cours de rendu, rouge en échec — les coins WGS84 sont portés
par /api/status pour toutes les dalles non terminées.

La file de génération trie les dalles du nord au sud et passe à 10
workers GPU.
This commit is contained in:
Antoine Jacquin
2026-09-19 20:44:44 +02:00
parent 204b2ad111
commit 5f018fe2b6
15 changed files with 1075 additions and 247 deletions

View File

@ -20,6 +20,7 @@
## Conventions
- **Generation is 0.2 m only** (policy): `/api/generate` (`GENERATE_RESOLUTIONS` in `webapp.py`), the compose `process` command and the CLI `-r` default all produce 0.2 m exclusively; 0.5 m stays available via explicit `-r 0.5`. Completeness detection (`complete_cells`) requires the viz at 0.2 m only.
- **Génération du nord au sud** : les tuiles sont traitées par ligne décroissante (row = nord en km), colonnes croissantes — `find_laz_files` (pipeline.py) pour les passes batch et `_resolve_request` (webapp.py) pour les runs lancés depuis la carte. Les workers prennent les fichiers dans l'ordre de soumission : la carte se remplit de haut en bas pendant un run (`--file` explicite au CLI = ordre utilisateur préservé). Parallélisme de génération : `LIDAR_WORKERS` (10 dans les compose ; fallback 10 dans `webapp.py`).
- **Sub-tuilage intégral** : `_CARTO_SUBTILED_VIZ` (vide dans `index.py`) découpe TOUTES les couches en quadrants 500 m à 0,2 m ; ortho/topo sont encodées en AVIF q75 (`_SUBTILE_DETAIL_VIZ`) contre q55 pour les rampes de couleur. Une couche qui échoue à la découpe retombe en dalle entière (`_fallback_full_dalle`) sans pénaliser les autres.
- **Bilingual naming**: all code identifiers are English; every user-facing string, log message, argparse help, and comment is French.
- **Adding a visualization requires 4 edits**: (1) `generate_X()` in `visualizations.py`, (2) entry in `VIZ_STEPS` in `pipeline.py`, (3) entry in `COLORMAPS` in `rendering.py`, (4) entry in `VIZ_LEGENDS` in `index.py` (title/legend/description + sampled cmap gradient — single text source merged into `COLORMAPS` at import, also used by the export mosaic legend in `export.py`). Missing any one breaks the pipeline.
@ -28,8 +29,10 @@
- **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it.
- **Filename special-cases** in `_expected_output_path()`: `pos_open` → `positive_openness`, `neg_open` → `negative_openness`, `hillshade` → `hillshade_multi`.
- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 98.
- **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil différents ⇒ régénération du DTM), appliqués aussi au plancher `--bare-earth`. Désactivable : `--no-strip-align`.
- **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`.
- **Gigue intra-faisceau (2ᵉ passe du calage)** : les lignes de balayage successives d'une MÊME passe peuvent être décalées verticalement de façon aléatoire (vibration capteur / bruit haute fréquence de trajectoire) — un offset constant par faisceau n'y suffit pas. `_strip_jitter_offsets` découpe chaque faisceau en fenêtres de temps GPS (`STRIP_JITTER_BIN` = 0,1 s, origine de temps propre à chaque faisceau), mesure l'offset robuste de chaque fenêtre contre la surface médiane des AUTRES faisceaux (maille 1 m partagée, ≥ `STRIP_JITTER_MIN_CELLS` = 40 cellules), lisse la série (médiane glissante `STRIP_JITTER_SMOOTH` = 5 fenêtres), la borne à ± `STRIP_JITTER_MAX` (10 cm) puis l'interpole au temps GPS de chaque point (`_apply_strip_jitter`) ; en recouvrement à deux faisceaux, chacun reçoit une série (chacun absorbe sa part). Requiert la dimension `gps_time` (silencieusement ignorée sinon). Sidecar version 2 (séries dans `jitter`), couverte par `--no-strip-align`.
- **Openness sous-échantillonnée** : `generate_openness` calcule le lancé de rayons (l'étape la plus coûteuse : 532 s/tuile à 0,2 m sur CPU) sur une grille décimée par blocs (`OPENNESS_DOWNSAMPLE = 2` : max par bloc en positive, min en négative — préserve les reliefs qui bornent l'horizon) puis rééchantillonne en bilinéaire. Coût ÷ facteur³ : 532 s → 40 s (×13). Signal archéologique préservé (corr. 0,93 après lissage) ; la texture de bruit sub-métrique disparaît. `--openness-downsample 1` = pleine résolution. SVF et openness anisotrope ne sont PAS concernés.
- **Raccord des bords entre tuiles** : les rendus à grand noyau (openness/SVF : rayons 100 m ; LRM : 15 m) tronquent leur fenêtre au bord de dalle — bandes d'artefacts à chaque changement de tuile. `--edge-buffer N` (défaut 0 = off ; case « Raccord des bords » de la webapp, `EDGE_BUFFER_METERS` = 100 m dans `webapp.py`) fait rastériser le DTM sur la **dalle nominale 1 km alignée sur la grille** plus une bande de N m remplie avec les points sol des 8 LAZ voisines (`_neighbor_ground_points` dans `dtm.py` : PDAL en flux, découpe + filtre de classes IGN ; voisine absente de input/ = téléchargement automatique depuis le catalogue IGN avant le run (dédupliqué sur tout le lot, `_fetch_edge_neighbors` dans `pipeline.py`) ; introuvable ou échec = bande vide). Les visualisations calculent sur l'emprise étendue puis `rendering.py` (`_core_tile_window`, via `tif_to_crop`/`tif_to_png`) recadre les sorties sur la dalle 1 km exacte lue dans le nom LHD — les AVIF restent des carrés 1 km alignés dans la mosaïque. Tampon consigné dans le tag GeoTIFF `LIDAR_EDGE_BUFFER` du DTM : changer `--edge-buffer` invalide le cache DTM automatiquement (tag absent = 0). Bandes voisines non calées par faisceaux (contexte seul, recadrée hors image finale). Coût : ~7 s de lecture par voisine + ~44 % de pixels en plus à 100 m/0,2 m. Nom hors pattern LHD : option ignorée (bornes d'en-tête, pas de recadrage).
- **Tests use lazy imports inside each test function**, never at module top, to avoid importing CuPy/GDAL at import time.
- **`_`-prefixed names are critical private**: `_create_ground_pipeline`, `_fallback_to_smrf`, `_fill_nans`, `_init_gpu`, `_process_file_standalone` — do not call from outside their module.
- **`build_index()` writes 3 files**: `output/index.html` (data shell, `const TILES` embedded), `output/assets/app.css` and `output/assets/app.js` (source: `_APP_CSS`/`_APP_JS` constants in `index.py`). `webapp.py` serves `/assets` with no-cache headers. Each tile carries `meta` — ground method read from `DTM/*_dtm{_rXpY}_method.txt` (falls back to the primary-resolution sidecar) + per-viz dates/sizes.

View File

@ -35,7 +35,7 @@ services:
- LIDAR_INPUT_DIR=/data/input
- LIDAR_OUTPUT_DIR=/data/output
- LIDAR_GPU=1
- LIDAR_WORKERS=2
- LIDAR_WORKERS=10
command: python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port 8973
restart: unless-stopped

View File

@ -33,7 +33,7 @@ services:
- LIDAR_OUTPUT_DIR=/data/output
# Les générations lancées depuis une webapp distante utilisent le GPU
- LIDAR_GPU=1
- LIDAR_WORKERS=5
- LIDAR_WORKERS=10
# Protéger l'API si le réseau n'est pas de confiance : même valeur que
# LIDAR_REMOTE_TOKEN sur chaque webapp distante (sinon, laisser commenté)
# - LIDAR_API_TOKEN=change-moi

View File

@ -30,7 +30,7 @@ services:
- LIDAR_OUTPUT_DIR=/data/output
# Les générations lancées depuis la carte utilisent le GPU
- LIDAR_GPU=1
- LIDAR_WORKERS=2
- LIDAR_WORKERS=10
# Machine de traitement pour une webapp distante (Raspberry Pi) :
# décommenter et définir la même valeur en LIDAR_REMOTE_TOKEN là-bas
# (protège /api/generate, /api/preview, /api/rebuild, /api/sync).

View File

@ -113,12 +113,11 @@ interpolation. Le KPConv/RandLA-Net est plus précis en 3D pur mais plus lourd
- Base = pré-classification IGN (rapide, ~10 s). `auto` la préfère dès que
≥ 20 % des points sont classés sol (seuil abaissé de 30 % à 20 %, car le
MNT est ensuite complété — voir ci-dessous).
- Le MNT est **toujours** complété dans `create_dtm_fast` : comblement par le
**retour le plus bas par cellule** (`_min_return_grid`, Wack & Wimmer 2002)
pour les trous, puis **interpolation terrain-aware** (`_interpolate_holes`).
Résultat : MNT continu (0 % de trous) pour n'importe quelle base.
- Vérifié sur le tile 0999_6778 : base CSF + comblement → 1,9 M trous
comblés par retour le plus bas, 5,3 % interpolés, MNT 0 % de trous.
- Le MNT n'est complété que pour les petits trous (< 1 m, `fillnodata`) :
les grands trous (forêt dense, relief raide où le sol est sous-classé)
restent en nodata (noir dans les rendus). Volontairement pas de plancher
au retour le plus bas : sous canopée dense ce retour est la végétation,
qui imprimerait les arbres dans le MNT.
- Qualité max dans les cas durs (raide + dense), ~1-2 min/tile + GPU +
entraînement acceptés → **U-Net rasterisé (C)**. (non implémenté)
- Meilleur filtre géométrique disponible dans PDAL → **SMRF (A)** (meilleure

View File

@ -143,13 +143,6 @@ def main():
"et les images). Sans ce flag, changer --ground-classification suffit : la "
"méthode enregistrée est comparée et un changement déclenche la reclassification."
)
parser.add_argument(
"--bare-earth",
action="store_true",
help="Sol nu : ramener le DTM au retour le plus bas de chaque cellule. "
"Requalifie le point le plus bas de chaque colonne en terrain — utile sous "
"végétation dense ou en relief raide où la classification du sol sous-couvre le terrain."
)
parser.add_argument(
"--openness-downsample",
type=int,
@ -159,13 +152,27 @@ def main():
"grille décimée par blocs puis rééchantillonnage. Défaut : 2 (~8× plus "
"rapide, rendu quasi identique) ; 1 = pleine résolution"
)
parser.add_argument(
"--edge-buffer",
type=float,
default=0.0,
metavar="METRES",
help="Raccord des bords : étendre le MNT d'une bande de N mètres remplie avec "
"les points sol des 8 tuiles LAZ voisines, pour que les rendus à grand "
"noyau (openness, SVF, LRM) soient continus d'une tuile à l'autre. "
"100 m couvre tous les rayons ; les images finales sont recadrées sur "
"la dalle 1 km exacte. 0 = désactivé (défaut). Un changement de valeur "
"régénère les DTM concernés."
)
parser.add_argument(
"--no-strip-align",
action="store_true",
help="Désactiver le calage vertical des faisceaux de vol. Par défaut, les écarts "
"verticaux ≥ 0,5 cm entre lignes de vol (PointSourceId) d'une tuile sont mesurés "
"sur les points sol et corrigés avant rastérisation du MNT (offsets consignés "
"dans DTM/*_stripalign.json)"
"sur les points sol et corrigés avant rastérisation du MNT, puis la gigue "
"intra-faisceau (décalages aléatoires des lignes de balayage successives d'une "
"même passe, type vibration capteur) est corrigée par fenêtres de temps GPS "
"(offsets et séries consignés dans DTM/*_stripalign.json)"
)
parser.add_argument(
"--keep-tif",
@ -360,9 +367,9 @@ def main():
ign_classes=args.ign_classes,
force_classify=args.force_classification,
keep_tif=args.keep_tif,
bare_earth=args.bare_earth,
strip_align=not args.no_strip_align,
openness_downsample=args.openness_downsample,
edge_buffer=args.edge_buffer,
quality=quality,
only_viz=only_viz,
skip_viz=skip_viz,

View File

@ -42,14 +42,74 @@ IGN_CLASS_NAMES = {
# selon la campagne, rien n'est codé en dur) et on le retranche avant
# rastérisation. Les offsets dérivent le long d'une ligne de vol (signe inversé
# entre tuiles voisines mesuré) : le calcul est donc par tuile, jamais global.
STRIP_ALIGN_VERSION = 1
#
# 2ᵉ passe — gigue intra-faisceau : le décalage vertical peut aussi varier au
# fil d'une MÊME passe (vibration capteur / bruit haute fréquence de la
# trajectoire) : les lignes de balayage successives d'un faisceau apparaissent
# alors décalées verticalement de façon aléatoire. Chaque faisceau est découpé
# en fenêtres de temps GPS, l'offset robuste de chaque fenêtre est mesuré contre
# la surface médiane des autres faisceaux (même maille 1 m), la série est lissée
# (médiane glissante) puis interpolée au temps GPS de chaque point. Requiert la
# dimension gps_time (ignorée silencieusement sinon).
STRIP_ALIGN_VERSION = 2
STRIP_ALIGN_THRESHOLD = 0.005 # m : écart mini pour corriger un faisceau (0,5 cm)
STRIP_ALIGN_CELL = 1.0 # m : maille de comparaison des faisceaux
STRIP_ALIGN_MIN_SHARED = 500 # cellules sol communes mini pour valider un offset
STRIP_JITTER_BIN = 0.1 # s : durée d'une fenêtre de temps GPS (gigue)
STRIP_JITTER_SMOOTH = 5 # fenêtres : largeur de la médiane glissante
STRIP_JITTER_MIN_CELLS = 40 # cellules sol partagées mini pour valider une fenêtre
STRIP_JITTER_MAX = 0.10 # m : amplitude maxi d'une correction de gigue (garde-fou)
# Mémo des offsets par fichier : la classification est partagée entre
# résolutions, le même LAS sol est rasterisé à 0,5 m puis 0,2 m.
_STRIP_OFFSETS_CACHE = {}
_STRIP_JITTER_CACHE = {}
def _strip_surface_grid(x, y, z, inv, n_sources, cell=STRIP_ALIGN_CELL):
"""Surfaces sol par faisceau sur maille 1 m (moyenne des points bas).
Pour chaque faisceau et chaque maille : moyenne des points situés à moins
de 0,5 m du minimum du faisceau dans la maille (robuste à la végétation
résiduelle), mailles à ≥ 2 points seulement.
Returns:
(allcells, grid, key) : cellules triées, grid[faisceau, cellule] = Z
moyen (NaN si absent) et clé de maille de CHAQUE point ; None si
aucune surface n'est peuplée.
"""
x0 = np.floor(np.min(x) / cell) * cell
y0 = np.floor(np.min(y) / cell) * cell
xi = ((x - x0) / cell).astype(np.int64)
yi = ((y - y0) / cell).astype(np.int64)
ny = int(yi.max()) + 1
key = xi * ny + yi
def _surface(k):
m = inv == k
kk, zz = key[m], z[m]
order = np.argsort(kk, kind='stable')
k_s, z_s = kk[order], zz[order]
starts = np.flatnonzero(np.r_[True, k_s[1:] != k_s[:-1]])
mins = np.minimum.reduceat(z_s, starts)
ukey = k_s[starts]
thr = mins[np.searchsorted(ukey, kk)]
sel = np.flatnonzero(zz <= thr + 0.5)
k2, z2 = kk[sel], zz[sel]
cnt = np.bincount(k2)
sums = np.bincount(k2, weights=z2)
v = np.flatnonzero(cnt >= 2)
return v, sums[v] / cnt[v]
surfaces = [_surface(k) for k in range(n_sources)]
populated = [c for c, _ in surfaces if len(c)]
if not populated:
return None
allcells = np.unique(np.concatenate(populated))
grid = np.full((n_sources, len(allcells)), np.nan)
for k, (c, zs) in enumerate(surfaces):
grid[k, np.searchsorted(allcells, c)] = zs
return allcells, grid, key
def _strip_vertical_offsets(x, y, z, psid, cell=STRIP_ALIGN_CELL,
@ -78,37 +138,10 @@ def _strip_vertical_offsets(x, y, z, psid, cell=STRIP_ALIGN_CELL,
us, inv = np.unique(psid, return_inverse=True)
if len(us) < 2:
return {}
x0 = np.floor(np.min(x) / cell) * cell
y0 = np.floor(np.min(y) / cell) * cell
xi = ((x - x0) / cell).astype(np.int64)
yi = ((y - y0) / cell).astype(np.int64)
ny = int(yi.max()) + 1
key = xi * ny + yi
def _surface(k):
m = inv == k
kk, zz = key[m], z[m]
order = np.argsort(kk, kind='stable')
k_s, z_s = kk[order], zz[order]
starts = np.flatnonzero(np.r_[True, k_s[1:] != k_s[:-1]])
mins = np.minimum.reduceat(z_s, starts)
ukey = k_s[starts]
thr = mins[np.searchsorted(ukey, kk)]
sel = np.flatnonzero(zz <= thr + 0.5)
k2, z2 = kk[sel], zz[sel]
cnt = np.bincount(k2)
sums = np.bincount(k2, weights=z2)
v = np.flatnonzero(cnt >= 2)
return v, sums[v] / cnt[v]
surfaces = [_surface(k) for k in range(len(us))]
populated = [c for c, _ in surfaces if len(c)]
if not populated:
built = _strip_surface_grid(x, y, z, inv, len(us), cell)
if built is None:
return {}
allcells = np.unique(np.concatenate(populated))
grid = np.full((len(us), len(allcells)), np.nan)
for k, (c, zs) in enumerate(surfaces):
grid[k, np.searchsorted(allcells, c)] = zs
allcells, grid, _key = built
comparable = np.sum(~np.isnan(grid), axis=0) >= 2
offsets = np.zeros(len(us))
@ -123,6 +156,177 @@ def _strip_vertical_offsets(x, y, z, psid, cell=STRIP_ALIGN_CELL,
for k, p in enumerate(us) if abs(offsets[k]) >= threshold}
def _rolling_median(values, window):
"""Médiane glissante (fenêtre tronquée aux bords) sur un petit vecteur."""
n = len(values)
if window <= 1 or n == 0:
return np.array(values, dtype=np.float64)
half = window // 2
return np.array([np.median(values[max(0, i - half):i + half + 1])
for i in range(n)])
def _strip_jitter_offsets(x, y, z, psid, t, cell=STRIP_ALIGN_CELL,
bin_seconds=STRIP_JITTER_BIN,
smooth=STRIP_JITTER_SMOOTH,
min_cells=STRIP_JITTER_MIN_CELLS,
max_corr=STRIP_JITTER_MAX,
threshold=STRIP_ALIGN_THRESHOLD):
"""Mesure la gigue verticale intra-faisceau par fenêtres de temps GPS.
Le calage constant retire un offset par faisceau, mais le décalage
vertical peut aussi varier au fil d'une même passe (vibration capteur,
bruit haute fréquence de la trajectoire). Chaque faisceau est découpé en
fenêtres de temps GPS ; l'offset robuste de chaque fenêtre est mesuré
contre la surface médiane des AUTRES faisceaux (maille 1 m, même
sélection de points bas que le calage constant — en recouvrement à deux,
une référence incluant le faisceau testé ne révélerait que la moitié du
décalage), puis la série est lissée (médiane glissante) pour ne pas
suivre le bruit de mesure. z doit déjà être corrigé des offsets constants.
Args:
x, y, z, psid: coordonnées, Z (constant-calé) et PointSourceId des
points sol.
t: temps GPS (s) de chaque point.
cell: taille de maille de comparaison (m).
bin_seconds: durée d'une fenêtre de temps (s).
smooth: largeur de la médiane glissante (fenêtres).
min_cells: cellules partagées minimales pour valider une fenêtre.
max_corr: amplitude maximale d'une correction (garde-fou, m).
threshold: amplitude de série sous laquelle un faisceau est
considéré comme stable (pas de correction).
Returns:
dict {psid: (times, corrections)} des corrections à SOUSTRAIRE,
interpolables linéairement au temps GPS de chaque point ; vide si
rien de mesurable (faisceau unique, temps absent, pas de
recouvrement).
"""
us, inv = np.unique(psid, return_inverse=True)
if len(us) < 2:
return {}
t = np.asarray(t, dtype=np.float64)
if t.size != z.size or not np.isfinite(t).all():
return {}
built = _strip_surface_grid(x, y, z, inv, len(us), cell)
if built is None:
return {}
allcells, grid, key = built
covered = np.sum(~np.isnan(grid), axis=0) >= 2
# Référence d'un faisceau = médiane des autres faisceaux.
ref = np.full(grid.shape, np.nan)
if len(us) == 2:
ref[0], ref[1] = grid[1], grid[0]
else:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning) # tranches tout-NaN
for k in range(len(us)):
ref[k] = np.nanmedian(np.delete(grid, k, axis=0), axis=0)
# Résidu vertical de chaque point contre la référence de son faisceau.
idx = np.minimum(np.searchsorted(allcells, key), len(allcells) - 1)
ref_point = ref[inv, idx]
hit = (allcells[idx] == key) & covered[idx] & np.isfinite(ref_point)
residual = np.full(np.asarray(z).shape, np.nan)
residual[hit] = np.asarray(z)[hit] - ref_point[hit]
# Origine de temps propre à chaque faisceau : des passes d'une même tuile
# peuvent être espacées de plusieurs heures, les fenêtres restent ainsi
# dense autour du vol réel (et un saut de semaine GPS ne crée pas de
# géantes plages vides).
origin = np.full(len(us), np.inf)
np.minimum.at(origin, inv, t)
tb = np.floor((t - origin[inv]) / bin_seconds).astype(np.int64)
n_bins = int(tb.max()) + 1
group = inv * n_bins + tb
# Médiane robuste par groupe (faisceau, fenêtre) : les résidus finis sont
# triés en tête de segment, les NaN (sans référence) sont ignorés.
order = np.lexsort((np.where(np.isfinite(residual), residual, np.inf), group))
g_s, r_s = group[order], residual[order]
starts = np.flatnonzero(np.r_[True, g_s[1:] != g_s[:-1]])
ends = np.r_[starts[1:], len(g_s)]
gids, times_c, med = [], [], []
for s, e in zip(starts, ends):
finite = np.isfinite(r_s[s:e])
if int(finite.sum()) < min_cells:
continue
gids.append(int(g_s[s]))
med.append(float(np.median(r_s[s:e][finite])))
if not gids:
return {}
gids = np.asarray(gids, dtype=np.int64)
med = np.asarray(med, dtype=np.float64)
result = {}
for k in range(len(us)):
selk = gids // n_bins == k
if int(selk.sum()) < 3: # série trop courte : correction non fiable
continue
b = gids[selk] % n_bins
cs = np.clip(_rolling_median(med[selk], smooth), -max_corr, max_corr)
if float(np.max(np.abs(cs))) < threshold:
continue
result[int(us[k])] = (origin[k] + (b + 0.5) * bin_seconds, cs)
return result
def _apply_strip_jitter(psid, t, jitter):
"""Corrections de gigue interpolées au temps GPS de chaque point.
Args:
psid, t: PointSourceId et temps GPS (s) de chaque point.
jitter: dict {psid: (times, corrections)} issu de _strip_jitter_offsets.
Returns:
array des corrections à SOUSTRAIRE (0 pour les points sans série).
"""
corr = np.zeros(len(t))
if not jitter:
return corr
psid = np.asarray(psid)
t = np.asarray(t, dtype=np.float64)
for p, (times, cs) in jitter.items():
m = psid == p
if m.any():
corr[m] = np.interp(t[m], times, cs)
return corr
def _strip_jitter_for_file(las_file, las, offsets):
"""Gigue temporelle d'un LAS sol (offsets constants mesurés), mémoïsée."""
try:
p = Path(las_file)
cache_key = (str(p), p.stat().st_mtime_ns)
except OSError:
cache_key = (str(las_file), 0)
if cache_key in _STRIP_JITTER_CACHE:
return _STRIP_JITTER_CACHE[cache_key]
jitter = {}
try:
t = np.asarray(las.gps_time, dtype=np.float64)
psid = np.asarray(las.point_source_id)
except AttributeError:
t, psid = None, None # dimensions absentes : pas de gigue mesurable
if (t is not None and psid is not None
and len(t) == len(las.points) == len(psid)):
z = np.asarray(las.z, dtype=np.float64)
if offsets:
lut = np.zeros(65536)
for p_, off in offsets.items():
lut[int(p_) & 0xFFFF] = off
z = z - lut[np.asarray(las.point_source_id, dtype=np.int64)]
jitter = _strip_jitter_offsets(
np.asarray(las.x, dtype=np.float64),
np.asarray(las.y, dtype=np.float64),
z, psid, t)
_STRIP_JITTER_CACHE[cache_key] = jitter
return jitter
def _strip_offsets_for_file(las_file, las):
"""Offsets de calage d'un LAS sol, mémoïsés par (chemin, mtime)."""
try:
@ -148,18 +352,31 @@ def _strip_offsets_for_file(las_file, las):
return offsets
def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets):
"""Consigne les offsets de calage appliqués (version et seuil inclus).
def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets,
jitter=None):
"""Consigne les calages appliqués (version, seuil, offsets et gigue).
Le sidecar sert de suivi de cache : un DTM sans sidecar, ou produit avec
une version/un seuil différents, est régénéré. Il est écrit même quand
aucun offset n'a été appliqué, pour ne pas re-mesurer une tuile déjà
connue comme bien alignée.
une version/un seuil/des paramètres de gigue différents, est régénéré. Il
est écrit même quand aucune correction n'a été appliquée, pour ne pas
re-mesurer une tuile déjà connue comme bien alignée.
"""
jitter_payload = {}
for p, (times, cs) in (jitter or {}).items():
cs = np.asarray(cs, dtype=np.float64)
jitter_payload[str(p)] = {
"bins": int(len(times)),
"rms_m": round(float(np.sqrt(np.mean(cs ** 2))), 4),
"max_m": round(float(np.max(np.abs(cs))), 4),
"series_m": [round(float(c), 4) for c in cs],
}
payload = {
"version": STRIP_ALIGN_VERSION,
"threshold": STRIP_ALIGN_THRESHOLD,
"offsets": offsets,
"jitter_bin": STRIP_JITTER_BIN,
"jitter_smooth": STRIP_JITTER_SMOOTH,
"jitter": jitter_payload,
}
try:
sidecar = Path(dtm_dir) / f"{basename}_dtm{output_suffix}_stripalign.json"
@ -813,73 +1030,139 @@ def _interpolate_holes(dtm, downsample=8):
return filled, int(holes.sum())
def _min_return_grid(laz_file, width, height, bounds, chunk_size=2_000_000,
strip_offsets=None):
"""Rasterize the per-cell minimum z (lowest return) of the full point cloud.
# ------------------------------------------------------------
# Raccord des bords (edge buffer avec les tuiles adjacentes)
# ------------------------------------------------------------
# Les visualisations à grand noyau (openness/SVF : rayons jusqu'à 100 m,
# LRM : 15 m) tronquent leur fenêtre au bord de la dalle : les rendus
# présentent alors une bande d'artefacts à chaque changement de tuile. Avec
# edge_buffer > 0, le MNT est rastérisé sur la dalle nominale 1 km ÉTENDUE
# d'une bande de `edge_buffer` mètres remplie avec les points sol des 8
# tuiles LAZ voisines (lecture PDAL en flux : découpe + filtre de classes).
# Les visualisations voient ainsi le terrain réel au-delà du bord, et les
# images finales sont recadrées sur la dalle exacte (cf. rendering.py).
EDGE_BUFFER_TAG = "LIDAR_EDGE_BUFFER" # tag GeoTIFF : tampon utilisé (m)
In complex/forested terrain the ground is under-classified, leaving DTM
holes. Filling them with the *lowest measured return* of the cell (Wack &
Wimmer 2002) recovers a real ground surface (forest floor, rock, clearing)
instead of a pure interpolation. The read is streamed in chunks so memory
stays bounded to the output grid regardless of the point count.
# Décalages des 8 voisines d'une dalle (col, row) en km
_NEIGHBOR_OFFSETS = [(-1, -1), (0, -1), (1, -1), (-1, 0),
(1, 0), (-1, 1), (0, 1), (1, 1)]
def _tile_coords(name):
"""Coordonnées (col, row) en km d'un nom de fichier LHD, ou None."""
from .index import parse_basename_coords
return parse_basename_coords(Path(name).name)
def _neighbor_laz_files(source_laz):
"""Liste les 8 fichiers LAZ/LAS adjacents à `source_laz` dans son dossier."""
coords = _tile_coords(source_laz)
if coords is None:
return []
col, row = coords
directory = Path(source_laz).parent
neighbors = []
for dcol, drow in _NEIGHBOR_OFFSETS:
nc, nr = col + dcol, row + drow
# Noms LHD : col/row en km sur 4 chiffres complétés (ex. 0638_6628) ;
# on accepte aussi la variante sans remplissage pour les dalles exotiques.
names = {f"LHD_FXX_{nc:04d}_{nr:04d}", f"LHD_FXX_{nc}_{nr}"}
matches = []
for name in names:
matches += list(directory.glob(f"{name}_*.las"))
matches += list(directory.glob(f"{name}_*.laz"))
if matches:
neighbors.append(sorted(matches)[0])
else:
logger.debug(f" Voisine absente : LHD_FXX_{nc:04d}_{nr:04d} (bande de bord vide)")
return neighbors
def _neighbor_ground_points(source_laz, bounds, classes):
"""Points sol des tuiles voisines dans `bounds` (raccord des bords).
Lecture PDAL en flux par voisine : découpe sur l'emprise étendue puis
filtre de classes (mêmes codes que le MNT). Best-effort : une voisine
illisible ou absente est ignorée — la bande correspondante reste vide.
Args:
laz_file: Path to the full (unclassified) LAZ/LAS file.
width, height: Output grid dimensions (pixels).
bounds: (min_x, min_y, max_x, max_y) the grid covers.
chunk_size: Points per streaming chunk.
strip_offsets: Optionnel : dict {point_source_id: offset} issu du
calage des faisceaux, retranché aux Z du nuage complet pour
rester cohérent avec le MNT calé.
source_laz: LAZ de la tuile traitée (repère pour trouver les voisines).
bounds: (min_x, min_y, max_x, max_y) de l'emprise étendue.
classes: codes LAS à extraire (ex. [2] = sol).
Returns:
(height, width) float32 array of per-cell min z (NaN where no point).
(xs, ys, zs) concaténés (tableaux vides si aucune voisine).
"""
import laspy
import tempfile
neighbors = _neighbor_laz_files(source_laz)
if not neighbors:
return (np.empty(0),) * 3
min_x, min_y, max_x, max_y = bounds
grid = np.full((height, width), np.nan, dtype=np.float32)
rng = [[min_x, max_x], [min_y, max_y]]
codes = sorted(set(int(c) for c in classes)) or [2]
limits = ",".join(f"Classification[{c}:{c}]" for c in codes)
lut = None
if strip_offsets:
lut = np.zeros(65536)
for p, off in strip_offsets.items():
lut[int(p) & 0xFFFF] = off
def process(points):
if len(points) == 0:
return
x = np.asarray(points.x, dtype=np.float64)
y = np.asarray(points.y, dtype=np.float64)
z = np.asarray(points.z, dtype=np.float64)
if lut is not None:
xs, ys, zs = [], [], []
found = 0
for neighbor in neighbors:
tmp_path = None
try:
z = z - lut[np.asarray(points.point_source_id, dtype=np.int64)]
except AttributeError:
pass
st = binned_statistic_2d(x, y, z, statistic='min',
bins=[width, height], range=rng)
# Match the DTM convention: .T then flip Y (north at top).
cell_min = st.statistic.T[::-1, :].astype(np.float32)
# fmin ignores NaN so cells without a point in this chunk stay NaN.
np.fmin(grid, cell_min, out=grid)
try:
with laspy.open(str(laz_file)) as las:
for chunk in las.chunk_iterator(chunk_size):
process(chunk)
with tempfile.NamedTemporaryFile(suffix='.las', delete=False) as tmp:
tmp_path = tmp.name
pipeline = json.dumps({
"pipeline": [
{"type": "readers.las", "filename": str(neighbor)},
{"type": "filters.crop",
"bounds": f"([{min_x},{max_x}],[{min_y},{max_y}])"},
{"type": "filters.range", "limits": limits},
{"type": "writers.las", "filename": tmp_path},
]
})
result = subprocess.run(
["pdal", "pipeline", "--stdin"],
input=pipeline, capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
raise RuntimeError(result.stderr[:200])
import laspy
las = laspy.read(tmp_path)
if len(las.points) > 0:
xs.append(np.asarray(las.x, dtype=np.float64))
ys.append(np.asarray(las.y, dtype=np.float64))
zs.append(np.asarray(las.z, dtype=np.float64))
found += 1
except Exception as e:
logger.warning(f" Lecture streaming impossible ({e}) — lecture complète")
las = _read_with_pdal(laz_file)
if las is None:
return grid
process(las)
return grid
logger.debug(f" Voisine {Path(neighbor).name} ignorée : {e}")
finally:
if tmp_path:
try:
Path(tmp_path).unlink(missing_ok=True)
except Exception:
pass
if not found:
logger.warning(" Aucune voisine lisible — bande de bord vide")
return (np.empty(0),) * 3
logger.info(f" Raccord bords : {found} voisine(s), "
f"{sum(len(a) for a in xs):,} pts sol")
return np.concatenate(xs), np.concatenate(ys), np.concatenate(zs)
def read_dtm_edge_buffer(dtm_path):
"""Tampon de raccord enregistré dans un DTM (m ; 0 si absent/illisible)."""
try:
with rasterio.open(dtm_path) as src:
tags = src.tags()
return float(tags.get(EDGE_BUFFER_TAG, 0.0) or 0.0)
except Exception:
return 0.0
def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
output_suffix="", source_laz=None, bare_earth=False,
pure=False, strip_align=True):
output_suffix="", source_laz=None,
pure=False, strip_align=True, edge_buffer=0.0,
neighbor_classes=None):
"""Create DTM using fast binning method with gap filling.
Args:
@ -889,19 +1172,26 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
resolution: Grid resolution in meters per pixel.
force: If True, regenerate even if DTM already exists.
output_suffix: Suffix for output filename (e.g. '_r0p2' for additional resolutions).
source_laz: Optionnel : chemin du LAZ complet (non classé). Utilisé
uniquement avec bare_earth (plancher au retour le plus bas).
bare_earth: If True, pull the DTM down to the lowest measured return of
each cell (bare-earth floor). This requalifies the lowest point of
every column as terrain, recovering the ground under dense
vegetation / steep relief that the ground classifier rejected.
source_laz: Optionnel : LAZ complet de la tuile traitée (repère pour
lire les 8 tuiles voisines du raccord de bords, cf. edge_buffer).
pure: Sans effet (conservé pour compatibilité). Fonctionnement
historique rétabli : petits trous comblés par fillnodata, grands
trous laissés en nodata (rendus en noir dans les rendus).
strip_align: Si True (défaut), mesure et corrige les écarts verticaux
entre faisceaux de vol (PointSourceId) avant rastérisation ; les
offsets ≥ STRIP_ALIGN_THRESHOLD (0,5 cm) sont consignés dans un
sidecar *_dtm*_stripalign.json.
entre faisceaux de vol (PointSourceId) avant rastérisation : offsets
constants ≥ STRIP_ALIGN_THRESHOLD (0,5 cm) puis gigue intra-faisceau
par fenêtres de temps GPS ; tout est consigné dans un sidecar
*_dtm*_stripalign.json.
edge_buffer: Raccord des bords en mètres (0 = désactivé). Le MNT couvre
alors la dalle nominale 1 km étendue de cette bande, remplie avec
les points sol des 8 tuiles LAZ voisines (source_laz requis) ; les
visualisations calculent sur l'emprise étendue puis les images sont
recadrées sur la dalle exacte (rendering.py). Le tampon est inscrit
dans le tag GeoTIFF LIDAR_EDGE_BUFFER pour l'invalidation du cache.
neighbor_classes: codes LAS extraits chez les voisines (défaut : les
classes IGN du MNT, ex. [2]). Les voisines sont lues dans leur
pré-classification fournisseur, même si la tuile centrale est
classée SMRF/CSF (bande de contexte, quelques cm d'écart au pire).
Returns:
Path to output DTM GeoTIFF, or None on failure.
@ -934,6 +1224,8 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
# Calage vertical des faisceaux de vol avant rastérisation : best-effort,
# en cas d'échec de la mesure on continue non calé (jamais d'abort).
strip_offsets = {}
strip_jitter = {}
gps_time = None
if strip_align:
try:
strip_offsets = _strip_offsets_for_file(las_file, las)
@ -946,6 +1238,24 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
else:
logger.debug(" Calage faisceaux : aucun écart >= "
f"{STRIP_ALIGN_THRESHOLD * 100:.1f} cm, rien à corriger")
# 2ᵉ passe : gigue intra-faisceau (temps GPS requis, ignorée sinon).
try:
gps_time = np.asarray(las.gps_time, dtype=np.float64)
except AttributeError:
gps_time = None
if gps_time is not None and len(gps_time) == len(las.points):
try:
strip_jitter = _strip_jitter_for_file(las_file, las, strip_offsets)
except Exception as e:
logger.warning(f" Mesure de la gigue intra-faisceau impossible ({e}) — gigue non corrigée")
strip_jitter = {}
if strip_jitter:
for p_, (jt, jc) in sorted(strip_jitter.items()):
logger.info(f" Gigue PSID {p_} : ±{np.max(np.abs(jc)) * 100:.1f} cm "
f"(rms {np.sqrt(np.mean(np.asarray(jc) ** 2)) * 100:.1f} cm, "
f"{len(jt)} fenêtres de {STRIP_JITTER_BIN:g} s)")
else:
logger.debug(" Gigue intra-faisceau : rien à corriger")
try:
@ -955,6 +1265,32 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
width = int(np.ceil((max_x - min_x) / resolution))
height = int(np.ceil((max_y - min_y) / resolution))
# Raccord des bords : emprise = dalle nominale 1 km (alignée sur la
# grille multi-tuiles) + bande de edge_buffer mètres remplie par les
# points sol des voisines. Sinon : bornes de l'en-tête (historique).
used_edge_buffer = 0.0
if edge_buffer > 0:
coords = _tile_coords(source_laz or las_file)
if coords is not None:
buffer_px = max(1, int(round(edge_buffer / resolution)))
buffer_m = buffer_px * resolution
col_km, row_km = coords
# Grille LHD : (col, row) = coin nord-ouest en km →
# X ∈ [col, col+1] km, Y ∈ [row-1, row] km (bord nord = row).
min_x = float(col_km) * 1000.0
max_x = min_x + 1000.0
max_y = float(row_km) * 1000.0
min_y = max_y - 1000.0
ext_bounds = (min_x - buffer_m, min_y - buffer_m,
max_x + buffer_m, max_y + buffer_m)
width = int(round(1000.0 / resolution)) + 2 * buffer_px
height = width
min_x, min_y, max_x, max_y = ext_bounds
used_edge_buffer = float(edge_buffer)
else:
logger.warning(" Raccord des bords impossible : nom de "
f"fichier non LHD ({basename}) — tuile seule")
logger.debug(f" Bounds: X[{min_x:.1f}, {max_x:.1f}] Y[{min_y:.1f}, {max_y:.1f}]")
logger.debug(f" Grid: {width}x{height} pixels ({len(las.points):,} points)")
logger.info(f" Rasterisation {width}x{height} ({len(las.points):,} points)...")
@ -967,6 +1303,20 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
for p, off in strip_offsets.items():
lut[int(p) & 0xFFFF] = off
zs = zs - lut[np.asarray(las.point_source_id, dtype=np.int64)]
if strip_jitter and gps_time is not None:
zs = zs - _apply_strip_jitter(las.point_source_id, gps_time, strip_jitter)
# Points sol des tuiles voisines dans la bande de raccord (best-effort,
# non calés par faisceau : bande de contexte, l'image finale est
# recadrée sur la dalle avant livraison).
if used_edge_buffer > 0 and source_laz is not None:
nx, ny, nz = _neighbor_ground_points(
source_laz, (min_x, min_y, max_x, max_y),
neighbor_classes if neighbor_classes is not None else [2])
if len(nx):
xs = np.concatenate([xs, nx])
ys = np.concatenate([ys, ny])
zs = np.concatenate([zs, nz])
stat = binned_statistic_2d(
xs, ys, zs,
@ -981,20 +1331,9 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
# Comblement « historique » (fonctionnement d'origine, rétabli) :
# seuls les petits trous proches des données sont remplis ; les grands
# trous restent en nodata et apparaissent en noir dans les rendus.
# Le plancher au retour le plus bas n'est appliqué qu'à la demande
# explicite (--bare-earth).
if bare_earth and source_laz is not None:
min_grid = _min_return_grid(source_laz, width, height,
(min_x, min_y, max_x, max_y),
strip_offsets=strip_offsets)
# Cellules sans sol mesuré (NaN) : le retour le plus bas devient
# la mesure — sinon la comparaison NaN est fausse et la cellule
# retombe sur l'interpolation en fin de passe.
has_min = ~np.isnan(min_grid)
lower = has_min & (min_grid < dtm)
lower |= has_min & np.isnan(dtm)
dtm = np.where(lower, min_grid, dtm)
logger.info(f" Sol nu : {int(lower.sum()):,} cellules raménées au retour le plus bas")
# Volontairement PAS de plancher au retour le plus bas : sous canopée
# dense ce retour est la végétation, qui imprimerait les arbres dans
# le MNT.
# Fill small gaps (< 1 m from data) precisely — comme avant
nan_count = np.count_nonzero(np.isnan(dtm))
@ -1026,10 +1365,14 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
compress='lzw'
) as dst:
dst.write(dtm.astype('float32'), 1)
if used_edge_buffer > 0:
# Tampon de raccord inscrit dans le fichier : changement de
# --edge-buffer ⇒ invalidation automatique du cache DTM.
dst.update_tags(**{EDGE_BUFFER_TAG: f"{used_edge_buffer:g}"})
if strip_align:
_write_strip_align_sidecar(dtm_dir, basename, output_suffix,
strip_offsets)
strip_offsets, strip_jitter)
logger.info(f" ✓ DTM créé: {output_tif.name}")
return output_tif

View File

@ -1352,8 +1352,8 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<option value="sol,unclassified,eau,virtuel">Sol+non-classé+eau+virtuel (2+1+9+66)</option>
</select>
</label>
<label class="gen-opt" title="Ramène le MNT au retour le plus bas de chaque cellule (sol nu)">
<input type="checkbox" id="genBareEarth"> Sol nu
<label class="gen-opt" title="Complète les bords du MNT avec les points sol des 8 tuiles voisines (bande de 100 m) : les rendus à grand noyau (openness, SVF, LRM) deviennent continus d'une tuile à l'autre. Les images restent recadrées sur la dalle 1 km exacte. Régénère le MNT des tuiles concernées (~1 min de lecture des voisines par tuile en plus).">
<input type="checkbox" id="genEdgeBuffer"> Raccord des bords (tuiles voisines)
</label>
<div class="gen-run">
<button id="genGo" class="primary">Lancer</button>
@ -1903,6 +1903,12 @@ body.exporting, body.exporting #viewport { cursor: pointer; }
}
body.genSel, body.genSel #viewport { cursor: pointer; }
/* Cadres des dalles du run de génération (pane genDraw) : seule la dalle
active pulse ; attente (bleu pointillé) et échec (rouge) restent statiques,
posés en JS par updateRenderFrames(). */
.render-frame.render-running { animation: frame-pulse 1.4s ease-in-out infinite; }
@keyframes frame-pulse { 0%, 100% { stroke-opacity: 1; } 50% { stroke-opacity: 0.3; } }
/* Barres de défilement fines et discrètes */
.tab-page::-webkit-scrollbar,
.gen-task-log::-webkit-scrollbar { width: 8px; }
@ -3019,7 +3025,7 @@ const genClassInfo = document.getElementById('genClassInfo');
const genClass = document.getElementById('genClass');
const genIgnClasses = document.getElementById('genIgnClasses');
const genViz = document.getElementById('genViz');
const genBareEarth = document.getElementById('genBareEarth');
const genEdgeBuffer = document.getElementById('genEdgeBuffer');
const genQueue = document.getElementById('genQueue');
const genBadge = document.getElementById('genBadge');
const genStop = document.getElementById('genStop');
@ -3034,6 +3040,7 @@ let genSelMode = false;
const genSel = new Set(); // clés "col,row" des dalles retenues (clics + zones cumulés)
const genSelCorners = new Map(); // clé "col,row" → coins GPS (dalles sans données)
const genSelPolys = new Map(); // clé "col,row" → polygone Leaflet de surlignage
const renderFrames = new Map(); // clé "col,row" → cadre coloré de la dalle en cours de rendu
const GEN_MAX_CELLS = 400; // garde-fou serveur : ~400 km² max par demande
// Affiche la barre de confirmation de génération, dans l'onglet Génération
@ -3193,6 +3200,46 @@ function genSelCells() {
return [...genSel].map(k => k.split(',').map(Number));
}
// Cadres colorés sur la carte autour des dalles du run en cours : orange
// pulsant pour la dalle en cours de rendu, bleu pointillé pour celles qui
// attendent, rouge pour les échecs. Les coins viennent de /api/status
// (attachés aux dalles non terminées, y compris sans données existantes) ;
// un sondage sans coins n'affiche rien — la file de l'onglet Génération
// reste la référence.
const RENDER_FRAME_STYLES = {
running: { color: '#ffb454', weight: 3, fillOpacity: 0.08,
className: 'render-frame render-running' },
pending: { color: '#5aa9e6', weight: 1.5, dashArray: '6 5', fill: false,
className: 'render-frame render-pending' },
failed: { color: '#e94560', weight: 2, dashArray: '2 4', fillOpacity: 0.08,
className: 'render-frame render-failed' }
};
function updateRenderFrames(tiles) {
const wanted = new Map();
for (const t of (tiles || [])) {
if (t.state === 'done' || !t.corners || t.col == null || t.row == null) continue;
if (!RENDER_FRAME_STYLES[t.state]) continue;
wanted.set(genSelKey(t.col, t.row), t);
}
for (const [k, frame] of renderFrames) {
if (wanted.has(k) && wanted.get(k).state === frame.state) continue;
frame.rect.remove();
renderFrames.delete(k);
}
for (const [k, t] of wanted) {
if (renderFrames.has(k)) continue;
const rect = L.polygon(t.corners, Object.assign(
{ pane: 'genDraw', interactive: false }, RENDER_FRAME_STYLES[t.state])).addTo(map);
renderFrames.set(k, { rect: rect, state: t.state });
}
}
function clearRenderFrames() {
renderFrames.forEach(f => f.rect.remove());
renderFrames.clear();
}
function updateGenSelBar() {
showGenBar(!genSel.size
? 'Mode sélection : clic sur la carte = ajouter / retirer une tuile'
@ -3318,7 +3365,7 @@ genGo.addEventListener('click', () => {
all_missing: pendingCompleteAll,
ground_class: genClass ? genClass.value : 'ign',
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
bare_earth: genBareEarth ? genBareEarth.checked : false,
edge_buffer: genEdgeBuffer ? genEdgeBuffer.checked : false,
reclassify: genReclass ? genReclass.checked : false })
}).then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || ('HTTP ' + r.status))))
.then(d => {
@ -3850,6 +3897,7 @@ function pollGenStatus() {
if (d.tiles && d.tiles.length) t.tiles = d.tiles;
}
if (!d.running) {
clearRenderFrames();
finishRunningTasks(d);
if (genTasks.some(t => t.state === 'queued')) {
// Des demandes attendent encore leur tour : poursuivre la veille
@ -3867,6 +3915,7 @@ function pollGenStatus() {
syncThenReload();
} else {
saveGenTasks(); renderGenQueue();
updateRenderFrames(d.tiles);
pollLiveTiles();
}
}).catch(() => {});
@ -3965,6 +4014,7 @@ else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
state: 'running', returncode: null, log: [], logOpen: false, tiles: s.tiles || [],
});
saveGenTasks(); renderGenQueue(); pollGenStatus();
updateRenderFrames(s.tiles);
} else if (s.returncode !== null && s.tiles && s.tiles.length) {
genTasks.push({
id: Date.now(), label: 'Dernière génération',

View File

@ -55,7 +55,8 @@ class FilePrefixFilter(logging.Filter):
_file_filter = FilePrefixFilter()
from .progress import report_event
from .dtm import classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD
from .dtm import (classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION,
STRIP_ALIGN_THRESHOLD, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
from .visualizations import (
SharedDEM,
generate_hillshade, generate_slope, generate_aspect,
@ -109,7 +110,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, incremental_index=False, strip_align=True, openness_downsample=None):
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, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_ids=None, no_index=False, incremental_index=False, strip_align=True, openness_downsample=None, edge_buffer=0.0):
self.input_dir = Path(input_dir)
self.output_dir = Path(output_dir)
# Accept single float or comma-separated string for multi-resolution
@ -125,7 +126,6 @@ class LidarArchaeoPipeline:
self.ground_method = ground_method
self.ign_classes = ign_classes
self.force_classify = force_classify
self.bare_earth = bare_earth
self.keep_tif = keep_tif
self.quality = quality
self.only_viz = only_viz
@ -136,6 +136,7 @@ class LidarArchaeoPipeline:
self.incremental_index = incremental_index
self.strip_align = strip_align
self.openness_downsample = openness_downsample
self.edge_buffer = float(edge_buffer)
self._last_index_rebuild = 0.0
self.temp_dir = self.output_dir / "temp"
@ -178,6 +179,8 @@ class LidarArchaeoPipeline:
logger.info(f" Classification sol : {self.ground_method}")
logger.info(f" Force classif.: {'OUI' if self.force_classify else 'non'}")
logger.info(f" Keep TIFF : {'OUI' if self.keep_tif else 'non'}")
if self.edge_buffer > 0:
logger.info(f" Raccord bords: {self.edge_buffer:g} m (points sol des tuiles voisines)")
logger.info(f" Qualité {self.output_format.upper()}: {self.quality if self.quality < 100 else 'lossless'}")
if only_viz:
logger.info(f" Visualisations: uniquement {', '.join(only_viz)}")
@ -186,12 +189,29 @@ class LidarArchaeoPipeline:
logger.info(f" Visualisations: {len(self.viz_steps)}/{len(VIZ_STEPS)}")
def find_laz_files(self):
"""Find all LAZ/LAS files in input directory."""
"""Find all LAZ/LAS files in input directory, triés du nord au sud.
Les tuiles LHD_FXX_{col}_{row} sont ordonnées par ligne décroissante
(row = nord en km) puis colonne croissante : les workers prennent les
fichiers dans l'ordre de soumission, la carte se remplit ainsi du nord
vers le sud lors des passes globales. Les fichiers hors pattern LHD
restent triés par nom, en fin de liste.
"""
from .index import parse_basename_coords
files = list(self.input_dir.glob("*.laz")) + list(self.input_dir.glob("*.las"))
logger.info(f"{len(files)} fichier(s) LiDAR trouvé(s)")
for f in sorted(files):
def _north_key(f):
coords = parse_basename_coords(_file_basename(f))
if coords:
col, row = coords
return (0, -row, col, f.name)
return (1, 0, 0, f.name)
files.sort(key=_north_key)
logger.info(f"{len(files)} fichier(s) LiDAR trouvé(s) — triés du nord au sud")
for f in files:
logger.debug(f" {f.name}")
return sorted(files)
return files
def check_tools(self):
"""Check that required external tools are available."""
@ -374,9 +394,9 @@ class LidarArchaeoPipeline:
"""True si le sidecar de calage des faisceaux correspond à la config.
Un DTM sans sidecar (antérieur au calage) est régénéré pour mesurer
et consigner ses offsets ; un sidecar de version ou de seuil
différents aussi. Calage désactivé : tout DTM porteur d'un sidecar
(donc calé) est régénéré non calé.
et consigner ses offsets ; un sidecar de version, de seuil ou de
paramètres de gigue intra-faisceau différents aussi. Calage désactivé :
tout DTM porteur d'un sidecar (donc calé) est régénéré non calé.
"""
sidecar = self.dtm_dir / f"{basename}_dtm{res_suffix}_stripalign.json"
if not self.strip_align:
@ -387,7 +407,9 @@ class LidarArchaeoPipeline:
import json
data = json.loads(sidecar.read_text(encoding="utf-8"))
return (data.get("version") == STRIP_ALIGN_VERSION
and abs(float(data.get("threshold", -1)) - STRIP_ALIGN_THRESHOLD) < 1e-9)
and abs(float(data.get("threshold", -1)) - STRIP_ALIGN_THRESHOLD) < 1e-9
and abs(float(data.get("jitter_bin", -1)) - STRIP_JITTER_BIN) < 1e-9
and int(data.get("jitter_smooth", -1)) == STRIP_JITTER_SMOOTH)
except Exception:
return False
@ -399,6 +421,56 @@ class LidarArchaeoPipeline:
except Exception:
pass
def _edge_buffer_matches(self, dtm_path):
"""True si le tampon de raccord du DTM correspond à la config.
Le tampon est inscrit dans le tag GeoTIFF LIDAR_EDGE_BUFFER (dtm.py).
Un DTM sans tag (antérieur au raccord) compte comme tampon 0 :
activer --edge-buffer régénère donc les DTM en cache, le désactiver
régénère les DTM raccordés.
"""
from .dtm import read_dtm_edge_buffer
return abs(read_dtm_edge_buffer(dtm_path) - self.edge_buffer) < 1e-6
def _fetch_edge_neighbors(self, files):
"""Télécharge les dalles LAZ voisines manquantes (raccord des bords).
La bande de raccord lit les 8 LAZ adjacentes de chaque tuile ; celles
absentes de input/ sont téléchargées depuis le catalogue IGN avant de
lancer les workers, pour que la bande soit remplie jusqu'au bord de la
zone même pour des tuiles jamais rendues. La liste est dédupliquée sur
tout le lot : la couronne d'un bloc contigu ne coûte qu'un passage.
Une dalle introuvable (zone non publiée) ou en échec laisse simplement
la bande vide — le rendu continue (best-effort).
"""
if self.edge_buffer <= 0:
return
from .dtm import _tile_coords, _NEIGHBOR_OFFSETS
from .fetch_ign import fetch_tiles, tile_filename
wanted = set()
for laz_file in files:
coords = _tile_coords(Path(laz_file).name)
if coords is None:
continue
col, row = coords
for dcol, drow in _NEIGHBOR_OFFSETS:
wanted.add((col + dcol, row + drow))
missing = sorted(
cr for cr in wanted
if not (self.input_dir / tile_filename(cr[0], cr[1])).exists())
if not missing:
return
logger.info(f"Raccord des bords : {len(missing)} dalle(s) voisine(s) "
f"absente(s) de input/ — téléchargement depuis le catalogue IGN")
t0 = time.time()
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
fetched = [path for batch in pool.map(
lambda spec: fetch_tiles(self.input_dir, [spec]), missing)
for path in batch]
logger.info(f"Raccord des bords : {len(fetched)}/{len(missing)} dalle(s) "
f"voisine(s) téléchargée(s) ({time.time() - t0:.0f} s)")
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)
@ -442,6 +514,12 @@ class LidarArchaeoPipeline:
if not self._strip_align_matches(basename, res_suffix):
logger.info(f" DTM{res_suffix} sans calage de faisceaux conforme — régénération (offsets verticaux mesurés et appliqués)")
dtm_path.unlink()
elif not self._edge_buffer_matches(dtm_path):
from .dtm import read_dtm_edge_buffer
recorded = read_dtm_edge_buffer(dtm_path)
logger.info(f" DTM{res_suffix} avec raccord de {recorded:g} m ≠ {self.edge_buffer:g} m "
f"demandé — régénération (bande de bord des tuiles voisines)")
dtm_path.unlink()
elif method_matches:
import rasterio
try:
@ -487,15 +565,21 @@ class LidarArchaeoPipeline:
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)
# classes choisies, sans plancher ni comblement
pure_ign = "_ground_ign" in Path(las_file).name
# Classes des voisines pour la bande de raccord : mêmes classes IGN
# que le MNT (les voisines sont lues dans leur pré-classification
# fournisseur, quelle que soit la méthode de la tuile centrale).
from .dtm import parse_ign_classes
neighbor_codes = parse_ign_classes(self.ign_classes)
dtm_file = create_dtm_fast(las_file, basename, self.dtm_dir, res,
force=self.force or self.force_classify,
output_suffix=res_suffix,
source_laz=laz_file,
bare_earth=self.bare_earth,
pure=pure_ign,
strip_align=self.strip_align)
strip_align=self.strip_align,
edge_buffer=self.edge_buffer,
neighbor_classes=neighbor_codes)
t_dtm = time.time() - t2
if not dtm_file:
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
@ -600,6 +684,10 @@ class LidarArchaeoPipeline:
if self.gpu_ids is not None:
restrict_gpus(self.gpu_ids)
# Raccord des bords : pré-télécharger les voisines manquantes avant
# les workers (no-op si le raccord est désactivé).
self._fetch_edge_neighbors(files)
if self.workers > 1 and len(files) > 1:
n_gpus = num_gpus() or 1
if n_gpus > 1:
@ -613,7 +701,7 @@ class LidarArchaeoPipeline:
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
resolutions_str = ','.join(str(r) for r in self.resolutions)
future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.bare_earth, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None, self.openness_downsample): laz_file
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None, self.openness_downsample, self.edge_buffer): laz_file
for file_idx, laz_file in enumerate(files)
}
done = 0
@ -715,7 +803,7 @@ class LidarArchaeoPipeline:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, 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_id=None, openness_downsample=None):
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=98, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0):
"""Standalone function for multiprocessing — creates its own pipeline instance.
Each worker gets its own temp directory to avoid file conflicts.
@ -742,7 +830,7 @@ def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, fo
worker_logger.addHandler(handler)
worker_logger.addFilter(_file_filter)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, ign_classes=ign_classes, force_classify=force_classify, keep_tif=keep_tif, bare_earth=bare_earth, quality=quality, only_viz=only_viz, skip_viz=skip_viz, output_format=output_format, openness_downsample=openness_downsample)
pipeline = LidarArchaeoPipeline(input_dir, output_dir, resolution=resolution, workers=1, force=force, ground_method=ground_method, ign_classes=ign_classes, force_classify=force_classify, keep_tif=keep_tif, quality=quality, only_viz=only_viz, skip_viz=skip_viz, output_format=output_format, openness_downsample=openness_downsample, edge_buffer=edge_buffer)
basename = _file_basename(laz_file_str)
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
pipeline.temp_dir.mkdir(exist_ok=True)

View File

@ -170,7 +170,7 @@ RGB_LEGENDS = {
# Fusion des textes de légende (titre / lecture du rendu / méthode de calcul)
# depuis la source unique VIZ_LEGENDS (index.py, sans dépendance lourde).
from .index import VIZ_LEGENDS
from .index import VIZ_LEGENDS, parse_basename_coords
for _key, _info in COLORMAPS.items():
_info.update(VIZ_LEGENDS[_key])
@ -178,6 +178,37 @@ for _key, _info in RGB_LEGENDS.items():
_info.update(VIZ_LEGENDS[_key])
def _core_tile_window(tif_file, src):
"""Fenêtre raster de la dalle nominale 1 km (raccord des bords).
Un TIF produit sur une emprise étendue (bande de raccord remplie avec les
tuiles voisines, cf. --edge-buffer dans dtm.py) est recadré sur la dalle
LHD exacte : les images finales restent des carrés de 1 km alignés sur la
grille multi-tuiles, sans artefact au passage d'une tuile à l'autre.
Retourne None si le TIF ne déborde pas (rien à recadrer) ou si le nom ne
porte pas de coordonnées LHD.
"""
coords = parse_basename_coords(Path(tif_file).stem)
if coords is None:
return None
col_km, row_km = coords
# Grille LHD : (col, row) = coin nord-ouest en km → X ∈ [col, col+1] km,
# Y ∈ [row-1, row] km (bord nord = row).
west = float(col_km) * 1000.0
north = float(row_km) * 1000.0
south = north - 1000.0
try:
from rasterio.windows import Window, from_bounds
win = from_bounds(west, south, west + 1000.0, north, src.transform)
win = win.round_offsets().round_lengths().intersection(
Window(0, 0, src.width, src.height))
except Exception:
return None
if win.width < src.width or win.height < src.height:
return win
return None
def _apply_colormap(data, tif_file, resolution=None):
"""Apply the registered colormap normalization to data based on filename.
@ -387,6 +418,15 @@ def tif_to_png(tif_file, vis_dir, resolution, keep_tif=False, source_info=None,
transform = src.transform
crs = src.crs
# Raccord des bords : recadrage sur la dalle nominale 1 km (les
# coordonnées GPS, l'échelle et la légende suivent le recadrage).
core_win = _core_tile_window(tif_file, src)
if core_win is not None:
rows = slice(core_win.row_off, core_win.row_off + core_win.height)
cols = slice(core_win.col_off, core_win.col_off + core_win.width)
data = data[rows, cols, :] if is_rgb else data[rows, cols]
transform = src.window_transform(core_win)
if is_rgb:
height, width, _ = data.shape
else:
@ -813,6 +853,14 @@ def tif_to_crop(tif_file, vis_dir, resolution, keep_tif=False, quality=98, outpu
data = data.astype(np.float32, copy=False)
data[data == src.nodata] = np.nan
# Raccord des bords : recadrage sur la dalle nominale 1 km
core_win = _core_tile_window(tif_file, src)
if core_win is not None:
rows = slice(core_win.row_off, core_win.row_off + core_win.height)
cols = slice(core_win.col_off, core_win.col_off + core_win.width)
data = data[rows, cols, :] if data.ndim == 3 else data[rows, cols]
# Apply colormap normalization
data, cmap_name, title, legend_label, description, is_rgb_result, _cvmin, _cvmax = _apply_colormap(data, tif_file, resolution=resolution)

View File

@ -140,81 +140,6 @@ class TestInterpolateHoles:
assert np.isnan(filled).all()
class TestMinReturnGrid:
def test_takes_lowest_return_per_cell(self, tmp_output_dir):
"""_min_return_grid rasterise le point le plus bas par cellule (pas la moyenne)."""
import laspy
from lidar_pipeline.dtm import _min_return_grid
out = tmp_output_dir / "pts.las"
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
# Grille 2x2 sur [0,2]x[0,2]. La cellule (0,0) porte deux points z=5 et
# z=2 (min=2, moyenne=3.5) ; (1,0) z=3 ; (0,1) z=4 ; (1,1) vide.
las.x = [0.2, 0.5, 1.2, 0.3]
las.y = [0.2, 0.3, 0.4, 1.5]
las.z = [5.0, 2.0, 3.0, 4.0]
las.write(str(out))
grid = _min_return_grid(out, 2, 2, (0.0, 0.0, 2.0, 2.0))
assert grid.shape == (2, 2)
assert int(np.isnan(grid).sum()) == 1
# Une seule valeur par cellule, et la cellule (0,0) vaut le MIN (2.0).
vals = sorted(float(v) for v in grid[~np.isnan(grid)])
assert vals == [2.0, 3.0, 4.0]
assert 3.5 not in vals
class TestBareEarth:
"""Le plancher « sol nu » ramène le DTM au retour le plus bas de chaque cellule."""
def _write_las(self, path, points):
"""points: list of (x, y, z). Écrit un LAS 1.2 format 0 aux bornes [0,2]x[0,2]."""
import laspy
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x = [p[0] for p in points]
las.y = [p[1] for p in points]
las.z = [p[2] for p in points]
las.write(str(path))
return path
def _make_clouds(self, tmp_output_dir):
"""Grille 2x2 (res=1.0). Les points « coin » à 0.05/1.95 imposent l'étendue
[0.05,1.95] (laspy re-déduit les bornes de l'en-tête depuis les points).
Le sol (las_file) vaut z=10 partout. Le nuage complet (source_laz) a un
retour plus bas dans les cellules (0,0) -> 2 et (1,0) -> 5 ; les deux autres
cellules n'ont que z=10."""
ground = [(0.5, 0.5, 10.0), (1.5, 0.5, 10.0), (0.5, 1.5, 10.0), (1.5, 1.5, 10.0),
(0.05, 0.05, 10.0), (1.95, 1.95, 10.0)]
source = list(ground) + [(0.3, 0.3, 2.0), (1.3, 0.3, 5.0)]
las_file = self._write_las(tmp_output_dir / "ground.las", ground)
source_laz = self._write_las(tmp_output_dir / "source.las", source)
return las_file, source_laz
def _dtm_values(self, tmp_output_dir, bare_earth):
from lidar_pipeline.dtm import create_dtm_fast
import rasterio
las_file, source_laz = self._make_clouds(tmp_output_dir)
out = create_dtm_fast(las_file, "tile", tmp_output_dir, 1.0,
force=True, source_laz=source_laz, bare_earth=bare_earth)
assert out is not None
with rasterio.open(str(out)) as src:
arr = src.read(1).astype("float64")
return arr[~np.isnan(arr)]
def test_bare_earth_pulls_dtm_to_lowest_return(self, tmp_output_dir):
"""Avec bare_earth, le DTM descend aux retours les plus bas (2 et 5)."""
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=True))
# Les cellules sans retour plus bas restent à 10 ; les deux autres descendent.
assert vals == [2.0, 5.0, 10.0, 10.0]
assert vals[0] == 2.0 and vals[1] == 5.0
def test_no_bare_earth_keeps_mean(self, tmp_output_dir):
"""Sans bare_earth, le DTM garde la moyenne des points sol (10 partout)."""
vals = sorted(float(v) for v in self._dtm_values(tmp_output_dir, bare_earth=False))
assert all(v == 10.0 for v in vals)
class TestDetectGroundMethod:
def _make_mock_las(self, num_returns, z_values):
"""Create a mock laspy object with specified NumberOfReturns and z."""
@ -615,6 +540,69 @@ class TestStripVerticalOffsets:
assert _strip_vertical_offsets(x, y, z, psid) == {}
class TestStripJitterOffsets:
"""Gigue verticale intra-faisceau par fenêtres de temps GPS."""
def _synthetic(self, bias_fn, n=800_000, extent=300.0, duration=20.0, seed=0):
"""Deux faisceaux entrelacés ; le n° 1 porte un biais dépendant du temps."""
rng = np.random.default_rng(seed)
x = rng.uniform(0, extent, n)
y = rng.uniform(0, extent, n)
clean = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
t = rng.uniform(0, duration, n)
psid = rng.integers(0, 2, n).astype(np.uint16)
return x, y, clean + bias_fn(t, psid), psid, t, clean
def test_recovers_time_varying_offset(self):
"""Une oscillation lente ±4 cm du faisceau 1 est retirée du terrain vrai.
En recouvrement à deux, la référence hors-faisceau attribue une série
à chaque faisceau (chacun absorbe sa part) : on vérifie le résidu
contre le terrain synthétique propre, fenêtre par fenêtre.
"""
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
w = 2 * np.pi / 8.0
x, y, z, psid, t, clean = self._synthetic(
lambda tt, p: 0.04 * np.sin(w * tt) * (p == 1))
jitter = _strip_jitter_offsets(x, y, z, psid, t)
assert set(jitter) == {0, 1}
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
assert np.sqrt(np.mean(resid ** 2)) < 0.012
for lo in np.arange(0, 20.0, 2.0):
m = (t >= lo) & (t < lo + 2.0)
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
def test_tracks_step_offset(self):
"""Un échelon −3 cm sur la seconde moitié du vol est suivi."""
from lidar_pipeline.dtm import _strip_jitter_offsets, _apply_strip_jitter
x, y, z, psid, t, clean = self._synthetic(
lambda tt, p: np.where(tt >= 10.0, -0.03, 0.0) * (p == 1))
jitter = _strip_jitter_offsets(x, y, z, psid, t)
resid = z - _apply_strip_jitter(psid, t, jitter) - clean
assert np.sqrt(np.mean(resid ** 2)) < 0.012
for lo in (3.0, 6.0, 13.0, 16.0): # loin de la transition lissée
m = (t >= lo) & (t < lo + 2.0)
assert abs(resid[m].mean()) < 0.012, f"fenêtre {lo:.0f}-{lo + 2:.0f} s"
def test_apply_interpolates_linearly(self):
"""Interpolation entre centres de fenêtres ; 0 hors faisceau connu."""
from lidar_pipeline.dtm import _apply_strip_jitter
jitter = {7: (np.array([10.0, 11.0]), np.array([0.0, 0.1]))}
psid = np.array([7, 7, 7, 3], dtype=np.uint16)
t = np.array([10.0, 10.5, 15.0, 10.5])
np.testing.assert_allclose(
_apply_strip_jitter(psid, t, jitter), [0.0, 0.05, 0.1, 0.0])
def test_requires_two_sources_and_time(self):
"""Faisceau unique ou temps non fini : rien à corriger."""
from lidar_pipeline.dtm import _strip_jitter_offsets
x, y, z, psid, t, _clean = self._synthetic(lambda tt, p: 0.04 * np.sin(tt) * (p == 1))
assert _strip_jitter_offsets(x, y, z, np.zeros_like(psid), t) == {}
t_nan = t.copy()
t_nan[0] = np.nan
assert _strip_jitter_offsets(x, y, z, psid, t_nan) == {}
class TestStripAlignSidecar:
def test_sidecar_roundtrip_and_threshold(self, tmp_path):
"""Sidecar records version/threshold/offsets and matches config."""
@ -627,6 +615,22 @@ class TestStripAlignSidecar:
assert data["version"] == STRIP_ALIGN_VERSION
assert data["threshold"] == STRIP_ALIGN_THRESHOLD
assert data["offsets"] == {"1049": 0.026, "1147": -0.026} # clés JSON en chaînes
assert data["jitter"] == {}
def test_sidecar_records_jitter_series(self, tmp_path):
"""Le sidecar consigne les séries de gigue et leurs paramètres."""
from lidar_pipeline.dtm import (
_write_strip_align_sidecar, STRIP_JITTER_BIN, STRIP_JITTER_SMOOTH)
import json
jitter = {11: (np.array([0.05, 0.15]), np.array([0.012, -0.008]))}
_write_strip_align_sidecar(tmp_path, "T", "", {}, jitter)
data = json.loads((tmp_path / "T_dtm_stripalign.json").read_text())
assert data["jitter_bin"] == STRIP_JITTER_BIN
assert data["jitter_smooth"] == STRIP_JITTER_SMOOTH
entry = data["jitter"]["11"]
assert entry["bins"] == 2
assert entry["series_m"] == [0.012, -0.008]
assert entry["max_m"] == 0.012
def test_pipeline_match_logic(self, tmp_path):
"""_strip_align_matches invalidates legacy DTMs and config changes."""
@ -651,5 +655,114 @@ class TestStripAlignSidecar:
bad = p.dtm_dir / "TILE_dtm_r0p2_stripalign.json"
bad.write_text(json.dumps({"version": 1, "threshold": 0.02, "offsets": {}}))
assert not p._strip_align_matches("TILE", "_r0p2")
# Paramètres de gigue différents : à régénérer
bad.write_text(json.dumps({"version": 2, "threshold": 0.005, "offsets": {},
"jitter_bin": 0.5, "jitter_smooth": 5}))
assert not p._strip_align_matches("TILE", "_r0p2")
# Calage désactivé + DTM calé : à régénérer
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
class TestEdgeBuffer:
"""Raccord des bords : MNT étendu par les points sol des tuiles voisines."""
BASENAME = "LHD_FXX_0638_6628_PTS_LAMB93_IGN69"
# Grille LHD : (col, row) = coin nord-ouest → 0638_6628 couvre
# X ∈ [638000, 639000], Y ∈ [6627000, 6628000] (bord nord = 6628 km).
NOMINAL = (638000.0, 6627000.0, 639000.0, 6628000.0) # dalle 1 km
def _write_las(self, path, points, classification=None):
import laspy
hdr = laspy.LasHeader(version='1.2', point_format=0)
las = laspy.LasData(hdr)
las.x = [p[0] for p in points]
las.y = [p[1] for p in points]
las.z = [p[2] for p in points]
if classification is not None:
las.classification = classification
las.write(str(path))
return path
def _write_clouds(self, root, res=50.0):
"""Tuile centrale à z=10 + voisine EST à z=20 (un point par maille res)."""
import numpy as np
input_dir = root / "input"
input_dir.mkdir(exist_ok=True)
min_x, min_y, max_x, max_y = self.NOMINAL
xs = np.arange(min_x + res / 2, max_x, res)
ys = np.arange(min_y + res / 2, max_y, res)
gx, gy = np.meshgrid(xs, ys)
main = list(zip(gx.ravel(), gy.ravel(), np.full(gx.size, 10.0)))
nxs = xs + 1000.0
ngx, ngy = np.meshgrid(nxs, ys)
east = list(zip(ngx.ravel(), ngy.ravel(), np.full(ngx.size, 20.0)))
ground = self._write_las(root / "ground.las", main)
source = self._write_las(input_dir / f"{self.BASENAME}.copc.laz", main)
self._write_las(input_dir / "LHD_FXX_0639_6628_PTS_LAMB93_IGN69.copc.laz",
east, classification=[2] * len(east))
return ground, source, input_dir
def test_neighbor_discovery(self, tmp_output_dir):
from lidar_pipeline.dtm import _neighbor_laz_files
(tmp_output_dir / f"{self.BASENAME}.copc.laz").touch()
present = [(637, 6627), (639, 6629), (638, 6629)]
for c, r in present:
(tmp_output_dir / f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz").touch()
# Bruit non voisin : jamais retenu
(tmp_output_dir / "LHD_FXX_0650_6700_PTS_LAMB93_IGN69.copc.laz").touch()
found = _neighbor_laz_files(tmp_output_dir / f"{self.BASENAME}.copc.laz")
assert {f.name for f in found} == {
f"LHD_FXX_{c}_{r}_PTS_LAMB93_IGN69.copc.laz" for c, r in present}
def test_neighbor_discovery_non_lhd(self, tmp_output_dir):
from lidar_pipeline.dtm import _neighbor_laz_files
src = tmp_output_dir / "nuage_arbitraire.laz"
src.touch()
assert _neighbor_laz_files(src) == []
def test_buffered_dtm_extends_into_neighbor(self, tmp_output_dir):
"""MNT 24x24 (dalle 20x20 + bande 100 m), bande EST remplie à z=20 par la voisine."""
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer, EDGE_BUFFER_TAG
import rasterio
ground, source, _ = self._write_clouds(tmp_output_dir)
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
force=True, source_laz=source, strip_align=False,
edge_buffer=100.0, neighbor_classes=[2])
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert (src.width, src.height) == (24, 24)
assert abs(src.bounds.left - 637900.0) < 1e-6
assert abs(src.bounds.top - 6628100.0) < 1e-6
assert src.tags().get(EDGE_BUFFER_TAG) == "100"
arr = src.read(1)
assert arr[12, 12] == 10.0 # cœur : tuile centrale
assert arr[12, 23] == 20.0 # bande EST : points de la voisine
assert np.isnan(arr[0, 0]) # bande OUEST sans voisine : vide
assert read_dtm_edge_buffer(dtm) == 100.0
def test_unbuffered_dtm_has_no_tag(self, tmp_output_dir):
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer, EDGE_BUFFER_TAG
import rasterio
ground, source, _ = self._write_clouds(tmp_output_dir)
dtm = create_dtm_fast(ground, self.BASENAME, tmp_output_dir, 50.0,
force=True, source_laz=source, strip_align=False)
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert EDGE_BUFFER_TAG not in src.tags()
assert read_dtm_edge_buffer(dtm) == 0.0
def test_buffered_dtm_non_lhd_falls_back(self, tmp_output_dir):
"""Nom hors pattern LHD : pas de tuile nominale, bornes d'en-tête conservées."""
from lidar_pipeline.dtm import create_dtm_fast, read_dtm_edge_buffer
import rasterio
ground = self._write_las(tmp_output_dir / "ground.las",
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
source = self._write_las(tmp_output_dir / "nuage.laz",
[(0.5, 0.5, 10.0), (0.05, 0.05, 10.0), (1.95, 1.95, 10.0)])
dtm = create_dtm_fast(ground, "nuage", tmp_output_dir, 1.0,
force=True, source_laz=source, strip_align=False,
edge_buffer=100.0)
assert dtm is not None
with rasterio.open(str(dtm)) as src:
assert abs(src.bounds.left - 0.05) < 1e-6 # bornes de l'en-tête
assert read_dtm_edge_buffer(dtm) == 0.0

View File

@ -31,6 +31,51 @@ class TestVizSteps:
assert "topo" in names
class TestFetchEdgeNeighbors:
"""Raccord des bords : pré-téléchargement des voisines manquantes."""
def test_downloads_missing_ring_dedup(self, tmp_path, monkeypatch):
"""Les 8 voisines manquantes sont demandées une seule fois, présente exclue."""
import lidar_pipeline.fetch_ign as fetch_ign
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
lazh = input_dir / "LHD_FXX_0999_6778_PTS_LAMB93_IGN69.copc.laz"
lazh.touch()
# Une voisine déjà présente ne doit pas être retéléchargée.
(input_dir / "LHD_FXX_1000_6779_PTS_LAMB93_IGN69.copc.laz").touch()
calls = []
def fake_fetch_tiles(input_dir_, specs, **kwargs):
calls.extend(specs)
return [input_dir / "fake" for _ in specs]
monkeypatch.setattr(fetch_ign, "fetch_tiles", fake_fetch_tiles)
pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output",
edge_buffer=100.0)
pipeline._fetch_edge_neighbors([lazh])
assert len(calls) == 7 # 8 voisines - 1 déjà présente
assert (1000, 6779) not in calls
assert sorted(set(calls)) == sorted(calls) # dédupliqué
def test_no_download_without_edge_buffer(self, tmp_path, monkeypatch):
"""Raccord désactivé : aucun téléchargement de voisines."""
import lidar_pipeline.fetch_ign as fetch_ign
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
lazh = input_dir / "LHD_FXX_0999_6778_PTS_LAMB93_IGN69.copc.laz"
lazh.touch()
def boom(*args, **kwargs):
raise AssertionError("fetch_tiles ne doit pas être appelé")
monkeypatch.setattr(fetch_ign, "fetch_tiles", boom)
pipeline = LidarArchaeoPipeline(input_dir, tmp_path / "output",
edge_buffer=0.0)
pipeline._fetch_edge_neighbors([lazh])
class TestLidarArchaeoPipeline:
def test_init_creates_dirs(self, tmp_path):
from lidar_pipeline.pipeline import LidarArchaeoPipeline
@ -95,6 +140,26 @@ class TestLidarArchaeoPipeline:
assert "other.las" in names
assert "readme.txt" not in names
def test_find_laz_files_sorted_north_to_south(self, tmp_path):
"""Lignes LHD triées du nord au sud (row décroissante, col croissante)."""
from lidar_pipeline.pipeline import LidarArchaeoPipeline
input_dir = tmp_path / "input"
input_dir.mkdir()
for name in ("LHD_FXX_1054_6880_PTS_LAMB93_IGN69.copc.laz",
"LHD_FXX_1056_6882_PTS_LAMB93_IGN69.copc.laz",
"LHD_FXX_1053_6882_PTS_LAMB93_IGN69.copc.laz",
"zz_autre.laz"):
(input_dir / name).touch()
pipeline = LidarArchaeoPipeline(str(input_dir), str(tmp_path / "output"))
names = [f.name for f in pipeline.find_laz_files()]
assert names == [
"LHD_FXX_1053_6882_PTS_LAMB93_IGN69.copc.laz", # ligne nord, col mini
"LHD_FXX_1056_6882_PTS_LAMB93_IGN69.copc.laz", # ligne nord, col maxi
"LHD_FXX_1054_6880_PTS_LAMB93_IGN69.copc.laz", # ligne sud
"zz_autre.laz", # hors pattern : en fin
]
class TestDtmMethodSidecar:
"""Méthode de classification enregistrée à côté du DTM (invalidation du cache)."""

View File

@ -175,3 +175,44 @@ class TestTifToCrop:
assert out is not None and out.exists()
img = PILImage.open(str(out))
assert img.size == (40, 40)
class TestCoreTileWindow:
"""Recadrage des TIF à bande de raccord sur la dalle nominale 1 km."""
def _write_tif(self, tmp_path, name, bounds, size):
transform = from_bounds(*bounds, size, size)
tif_file = tmp_path / name
with rasterio.open(
tif_file, 'w', driver='GTiff', height=size, width=size,
count=1, dtype='float32', crs='EPSG:2154', transform=transform,
) as dst:
dst.write(np.zeros((size, size), dtype=np.float32), 1)
return tif_file
def test_window_on_buffered_tif(self, tmp_path):
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "LHD_FXX_0638_6628_PTS_LAMB93_IGN69_slope.tif",
(637900, 6626900, 639100, 6628100), 1200)
with rasterio.open(tif) as src:
win = _core_tile_window(tif, src)
assert win is not None
assert (win.width, win.height) == (1000, 1000)
assert (win.col_off, win.row_off) == (100, 100)
wt = src.window_transform(win)
assert abs(wt.c - 638000.0) < 1e-6
assert abs(wt.f - 6628000.0) < 1e-6
def test_no_window_without_overflow(self, tmp_path):
"""TIF historique sur les bornes d'en-tête (~999,99 m) : rien à recadrer."""
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "LHD_FXX_0638_6628_PTS_LAMB93_IGN69_slope.tif",
(638000, 6627000, 638999.99, 6627999.99), 5000)
with rasterio.open(tif) as src:
assert _core_tile_window(tif, src) is None
def test_no_window_for_non_lhd_name(self, tmp_path):
from lidar_pipeline.rendering import _core_tile_window
tif = self._write_tif(tmp_path, "test_vis.tif",
(637900, 6626900, 639100, 6628100), 1200)
with rasterio.open(tif) as src:
assert _core_tile_window(tif, src) is None

View File

@ -158,6 +158,17 @@ def test_build_command_resolutions_match_completeness():
assert cmd[i + 1] == ",".join(str(r) for r in GENERATE_RESOLUTIONS)
def test_resolve_request_orders_tiles_north_to_south():
"""Les tuiles d'une demande partent du nord vers le sud (row décroissante)."""
from types import SimpleNamespace
from lidar_pipeline.webapp import _resolve_request
req = SimpleNamespace(viz=[], all_missing=False, regenerate=False,
tiles=[[1055, 6881], [1054, 6882], [1054, 6880], [1053, 6882]],
ground_class="ign")
tiles, _viz = _resolve_request(req)
assert tiles == [(1053, 6882), (1054, 6882), (1055, 6881), (1054, 6880)]
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
@ -189,6 +200,27 @@ def test_laz_cells_parses_input(tmp_path):
assert laz_cells(tmp_path) == [(1054, 6882), (1055, 6881), (1056, 6880)]
def test_attach_frame_bounds_only_unfinished():
"""Cadres de rendu : coins WGS84 sur les dalles non terminées seulement."""
from lidar_pipeline.webapp import _attach_frame_bounds
tiles = [
{"short": "1054-6882", "name": "LHD_FXX_1054_6882_PTS_LAMB93_IGN69",
"state": "running", "steps": []},
{"short": "1054-6881", "name": "LHD_FXX_1054_6881_PTS_LAMB93_IGN69",
"state": "done", "steps": []},
{"short": "inconnu", "name": "pas_une_dalle", "state": "failed", "steps": []},
]
_attach_frame_bounds(tiles)
assert tiles[0]["col"] == 1054 and tiles[0]["row"] == 6882
assert len(tiles[0]["corners"]) == 4 # SW, SE, NE, NW
lat = [c[0] for c in tiles[0]["corners"]]
lon = [c[1] for c in tiles[0]["corners"]]
assert abs(max(lat) - min(lat)) < 0.02 # ~1 km en degrés
assert abs(max(lon) - min(lon)) < 0.03
assert "corners" not in tiles[1] # terminée : pas de cadre
assert "corners" not in tiles[2] # nom hors grille LHD
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
@ -913,7 +945,8 @@ def test_generate_forwards_to_remote(monkeypatch):
{"tiles": [[1054, 6882]], "regenerate": False,
"reclassify": False,
"ground_class": "ign", "ign_classes": "sol",
"bare_earth": False, "viz": ["aspect"],
"edge_buffer": False,
"viz": ["aspect"],
"all_missing": False})]

View File

@ -525,10 +525,12 @@ class GenerateRequest(BaseModel):
"unclassified(1), eau(9), virtuel(66), "
"pont(17), sursol(64). Mode pur, "
"aucune retouche. (défaut: sol)")
bare_earth: bool = Field(False,
description="Sol nu : DTM au retour le plus bas de "
"chaque cellule (requalifie le point le plus "
"bas en terrain)")
edge_buffer: bool = Field(False,
description="Raccord des bords : étendre le MNT d'une "
"bande de 100 m remplie avec les points sol "
"des 8 tuiles voisines — rendus continus "
"d'une tuile à l'autre (recadrés sur la "
"dalle 1 km exacte)")
viz: list = Field(None,
description="Visualisations à générer, noms d'étapes du "
"pipeline (ex: aspect, wavelet, slope) ; "
@ -844,6 +846,29 @@ def missing_cells_with_corners(cells, output_dir, include_done=False, viz=None):
return todo
def _attach_frame_bounds(tiles):
"""Ajoute aux tuiles non terminées leurs coins WGS84 (cadre sur la carte).
L'interface dessine un cadre coloré autour des dalles du run en cours
(orange : rendu en cours, bleu : en attente, rouge : échec) ; les coins
viennent de la grille L93 (col, row) → bornes exactes via PROJ. Les
dalles terminées n'ont pas de cadre : coins omis pour alléger le sondage
/api/status (toutes les ~2 s pendant un run).
"""
from .index import attach_gps_bounds, parse_basename_coords
todo = []
for tile in tiles:
if tile.get("state") == "done":
continue
coords = parse_basename_coords(tile.get("name") or "")
if not coords:
continue
tile["col"], tile["row"] = coords
todo.append(tile)
if todo:
attach_gps_bounds(todo)
@app.get("/")
def root():
index = OUTPUT_DIR / "index.html"
@ -880,6 +905,9 @@ def status(request: Request = None):
data["regen_allowed"] = regen_allowed
return data
from .progress import progress_snapshot
tiles = progress_snapshot(OUTPUT_DIR, viz_labels=_viz_step_labels(),
run_id=_job.get("run_id"))
_attach_frame_bounds(tiles)
with _job_lock:
pending = bool(_queue)
if pending:
@ -900,9 +928,9 @@ def status(request: Request = None):
# L'interface masque les boutons de génération hors réseau local
"regen_allowed": regen_allowed,
# 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(),
run_id=_job.get("run_id")),
# événements écrits par le pipeline (.generation.events.jsonl) ;
# les non terminées portent leurs coins pour le cadre sur la carte
"tiles": tiles,
}
@ -1213,7 +1241,10 @@ def cell_at_point(lat: float, lng: float):
return {"col": col, "row": row, "corners": tile.get("corners") or []}
def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False, ign_classes="sol", viz=None, reclassify=False):
EDGE_BUFFER_METERS = 100.0 # raccord des bords : largeur de bande (m)
def _build_command(tiles, regenerate=False, ground_class="ign", ign_classes="sol", viz=None, reclassify=False, edge_buffer=False):
"""Commande de génération : téléchargement IGN + traitement des fichiers.
La classification du sol est choisie via `ground_class` (défaut : "ign",
@ -1226,8 +1257,10 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
Avec reclassify=True, --force-classification relance la classification
même à méthode inchangée (sinon, choisir une méthode différente via
ground_class/ign_classes reclassifie déjà les tuiles concernées).
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
Avec edge_buffer=True, le MNT est étendu d'une bande de
EDGE_BUFFER_METERS remplie avec les points sol des tuiles voisines
(raccord des bords : rendus continus entre tuiles, images recadrées sur
la dalle 1 km). Avec viz, on choisit les visualisations générées
(noms d'étapes du pipeline, ex. ["aspect", "wavelet", "slope"] ;
défaut : aspect).
"""
@ -1240,14 +1273,14 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
"--only", *(viz or _panel_viz_steps()),
"--ground-classification", ground_class,
"--ign-classes", ign_classes]
if bare_earth:
cmd += ["--bare-earth"]
if edge_buffer:
cmd += ["--edge-buffer", f"{EDGE_BUFFER_METERS:g}"]
if regenerate:
cmd += ["--force"]
if reclassify:
cmd += ["--force-classification"]
if os.environ.get("LIDAR_GPU", "") == "1":
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "2")]
cmd += ["-g", "all", "-w", os.environ.get("LIDAR_WORKERS", "10")]
# 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"]
@ -1301,6 +1334,10 @@ def _resolve_request(req):
raise HTTPException(
400, f"méthode de classification invalide : {req.ground_class!r} "
f"(attendu : {', '.join(GROUND_CLASS_METHODS)})")
# Génération du nord au sud (row décroissante, puis col croissante) : les
# workers prennent les tuiles dans l'ordre de soumission et la carte se
# remplit de haut en bas pendant le run.
tiles.sort(key=lambda cr: (-cr[1], cr[0]))
return tiles, viz
@ -1311,8 +1348,9 @@ def _launch_job(tiles, viz, req, qid=None):
la demande vient de la file (suivi côté interface).
"""
cmd = _build_command(tiles, regenerate=req.regenerate, ground_class=req.ground_class,
bare_earth=req.bare_earth, ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify)
ign_classes=req.ign_classes,
viz=viz, reclassify=req.reclassify,
edge_buffer=req.edge_buffer)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(LOG_FILE, "w", encoding="utf-8")
# Nouveau run : journal d'événements remis à zéro (les tuiles affichées