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:
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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})]
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user