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

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

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

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

View File

@ -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
xs, ys, zs = [], [], []
found = 0
for neighbor in neighbors:
tmp_path = None
try:
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.debug(f" Voisine {Path(neighbor).name} ignorée : {e}")
finally:
if tmp_path:
try:
Path(tmp_path).unlink(missing_ok=True)
except Exception:
pass
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:
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)
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 laspy.open(str(laz_file)) as las:
for chunk in las.chunk_iterator(chunk_size):
process(chunk)
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
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