Recaler les lignes de balayage des passes et accélérer le rendu

Troisième passe du calage vertical : chaque ligne de balayage (décalage et
inclinaison due au roulis) est recalée contre le consensus des autres
faisceaux, à toutes les échelles, avec un profil d'étalonnage par faisceau
et par degré d'angle qui retire les écarts non linéaires en travers de la
fauchée. Les lignes sans recouvrement sont corrigées contre leur propre
faisceau. Efface les lignes en creux et la marche au bord de fauchée
mesurées sur LHD_FXX_0999_6882 (validé sur des blocs jamais vus). Calcul
vectorisé, CuPy si GPU ; la gigue par fenêtres de temps devient inutile
quand scan_angle existe.

Rendu plus rapide : encodage AVIF speed 9 (0,6 s au lieu de 4 s par dalle),
classification IGN par extraction directe laspy au lieu de PDAL (4,9 s au
lieu de 13,5 s), comblement des trous et gradients sur GPU, cache numba
persistant dans l'image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 01:57:04 +02:00
parent 31645a42e8
commit d32502b74e
9 changed files with 712 additions and 13 deletions

View File

@ -55,7 +55,30 @@ IGN_CLASS_NAMES = {
# 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
#
# 3ᵉ passe — décalage ligne à ligne : les fenêtres de 0,1 s (lissées sur
# 0,5 s) regroupent ~15 lignes de balayage (~150 lignes/s) et ne travaillent
# qu'en recouvrement. Or deux lignes SUCCESSIVES d'une même passe peuvent
# différer de 1 à 2 cm (motif alterné, mesuré sur LHD_FXX_0999_6882) : stries
# fines perpendiculaires au vol sur tout le MNT. Chaque faisceau est découpé
# en lignes (sauts de scan_angle en dents de scie), le décalage robuste de
# chaque ligne (décalage ET inclinaison le long de la ligne : le roulis
# bascule les lignes) est mesuré contre la surface de SON faisceau (maille 0,5 m,
# boîte 1,5 m, plan local ajusté à la position réelle du point, 3 itérations
# pour que la ligne ne fausse pas sa propre référence), et seule la composante
# ligne à ligne est retirée (série − lissage gaussien σ 3 lignes ; une médiane
# glissante suivrait un motif alterné au lieu de l'effacer) : les variations
# lentes restent aux passes précédentes. Fonctionne sans recouvrement ; requiert
# gps_time et scan_angle.
#
# Ajustement conjoint (prioritaire) : la surface d'un faisceau absorbe toute
# erreur plus large que sa boîte de référence ; là où plusieurs faisceaux se
# recouvrent, chaque ligne (décalage + inclinaison) est donc recalée contre le
# consensus des AUTRES faisceaux, à toutes les échelles (roulis lent d'une
# passe entière, lignes isolées très décalées), par pas amortis et itérés.
# La correction sur son propre faisceau ne sert plus qu'aux lignes sans
# recouvrement.
STRIP_ALIGN_VERSION = 3
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
@ -63,11 +86,31 @@ 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)
STRIP_LINE_CELL = 0.5 # m : maille de la surface de référence d'un faisceau
STRIP_LINE_BOX = 3 # mailles : lissage de la référence (boîte 1,5 m)
STRIP_LINE_WINDOW = 3 # lignes : σ du lissage gaussien retiré (garde le ligne-à-ligne)
STRIP_LINE_ITERS = 3 # itérations (atténuation par la ligne elle-même < 1 %)
STRIP_LINE_MAX = 0.05 # m : correction maxi d'une ligne (garde-fou)
STRIP_LINE_MIN_POINTS = 100 # points sol mini pour mesurer une ligne
STRIP_LINE_GAP = 0.05 # s : trou de temps qui coupe une ligne (fin de passe)
STRIP_LINE_MIN_RMS = 0.002 # m : faisceau laissé tel quel sous ce niveau
STRIP_LINE_MODEL = "conjoint+decalage+inclinaison+profil-angle" # modèle (consigné, invalide le cache)
_SCAN_ANGLE_UNIT = 0.006 # ° par unité de scan_angle (LAS 1.4)
STRIP_ANGLE_BIN = 1.0 # ° : pas du profil de correction par faisceau selon l'angle
STRIP_ANGLE_MIN_POINTS = 500 # points en recouvrement mini par classe d'angle (sinon valeur voisine)
STRIP_LINE_SUBSAMPLE = 3 # estimation sur 1 point sur 3 (correction appliquée à tous)
STRIP_JOINT_CELL = 1.0 # m : maille du consensus des autres faisceaux
STRIP_JOINT_ITERS = 8 # itérations maxi de l'ajustement conjoint
STRIP_JOINT_DAMPING = 0.5 # pas amorti : deux faisceaux se rapprochent sans se croiser
STRIP_JOINT_TOL = 0.001 # m : arrêt quand le pas moyen passe sous 1 mm
STRIP_JOINT_MIN_POINTS = 30 # points en recouvrement mini pour recaler une ligne
STRIP_JOINT_MAX = 0.15 # m : correction maxi d'un point (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 = {}
_STRIP_LINES_CACHE = {}
def _strip_surface_grid(x, y, z, inv, n_sources, cell=STRIP_ALIGN_CELL):
@ -278,6 +321,381 @@ def _strip_jitter_offsets(x, y, z, psid, t, cell=STRIP_ALIGN_CELL,
return result
def _module_of(arr):
"""Module (numpy ou cupy) d'un tableau."""
mod = type(arr).__module__
if mod.startswith("cupy"):
import cupy
return cupy
return np
def _array_module(use_gpu):
"""cupy si le GPU est actif et demandé, sinon numpy."""
if use_gpu:
from . import gpu as _gpu
if _gpu.is_gpu_active() and _gpu._cp is not None:
return _gpu._cp
return np
def _scan_line_ids(t, angle, gap=STRIP_LINE_GAP):
"""Identifiant de ligne de balayage de points d'UN faisceau triés par temps.
Nouvelle ligne à chaque saut de scan_angle de plus de la moitié de
l'amplitude dans le sens opposé au balayage (retour de la dent de scie)
ou à chaque trou de temps > gap (fin de passe). Les trous laissés par la
végétation retirée dans une ligne ne la coupent pas.
"""
m = _module_of(angle)
angle = m.asarray(angle, dtype=m.float64)
if len(angle) == 0:
return m.zeros(0, dtype=m.int64)
amp = float(m.percentile(angle, 99) - m.percentile(angle, 1))
d = m.diff(angle)
moving = d[d != 0]
sweep = float(m.sign(m.median(moving))) if len(moving) else 1.0
# Retour de la dent de scie : grand saut de sens OPPOSÉ au balayage. Un
# trou de végétation fait aussi sauter l'angle, mais dans le sens du
# balayage : il ne coupe pas la ligne.
new = m.concatenate([m.ones(1, dtype=bool), (d * sweep < -0.5 * amp) | (m.diff(t) > gap)])
return m.cumsum(new) - 1
def _group_median(values, groups, n_groups, min_count):
"""Médiane des valeurs finies par groupe (vectorisée), NaN sous min_count."""
finite = np.isfinite(values)
order = np.lexsort((np.where(finite, values, np.inf), groups))
g = groups[order]
v = values[order]
nf = np.bincount(groups[finite], minlength=n_groups)
start = np.searchsorted(g, np.arange(n_groups))
med = np.full(n_groups, np.nan)
ok = nf >= max(1, min_count)
lo = start[ok] + (nf[ok] - 1) // 2
hi = start[ok] + nf[ok] // 2
med[ok] = 0.5 * (v[lo] + v[hi])
return med
def _line_fit(r, w, line, u, n_lines, min_points):
"""Moindres carrés pondérés par ligne : r ≈ a + b·u (sommes par bincount).
Returns:
(a, b, ok) ; b vaut 0 là où l'étendue de u ne permet pas d'estimer
une inclinaison (a seul, moyenne pondérée).
"""
m = _module_of(r)
rw = m.where(w, r, 0.0)
wf = w.astype(m.float64)
s0 = m.bincount(line, weights=wf, minlength=n_lines)
s1 = m.bincount(line, weights=wf * u, minlength=n_lines)
s2 = m.bincount(line, weights=wf * u * u, minlength=n_lines)
sr = m.bincount(line, weights=rw, minlength=n_lines)
sur = m.bincount(line, weights=rw * u, minlength=n_lines)
det = s0 * s2 - s1 * s1
ok = s0 >= min_points
tilt = ok & (det > 1e-2 * m.maximum(s0, 1) ** 2)
a = m.where(ok, sr / m.maximum(s0, 1), 0.0)
b = m.zeros(n_lines)
safe = m.where(tilt, det, 1.0)
a = m.where(tilt, (s2 * sr - s1 * sur) / safe, a)
b = m.where(tilt, (s0 * sur - s1 * sr) / safe, b)
return a, b, ok
def _robust_mask(r, floor=0.03, k=5.0):
"""Résidus finis sous k MAD (plancher floor) : écarte végétation basse,
points mal classés et bords de trous sans trier chaque ligne."""
m = _module_of(r)
f = m.isfinite(r)
if not bool(f.any()):
return f
mad = 1.4826 * float(m.median(m.abs(r[f])))
return f & (m.abs(r) < max(floor, k * mad))
def _scan_line_corrections_beam(x, y, z, t, angle, cell=STRIP_LINE_CELL,
box=STRIP_LINE_BOX, window=STRIP_LINE_WINDOW,
iters=STRIP_LINE_ITERS, max_corr=STRIP_LINE_MAX,
min_points=STRIP_LINE_MIN_POINTS,
subsample=STRIP_LINE_SUBSAMPLE):
"""Corrections ligne à ligne d'un faisceau contre SA surface (à SOUSTRAIRE).
Chaque ligne est modélisée par un décalage ET une inclinaison le long de
la ligne (a + b·u, u = scan_angle normalisé) : une erreur de roulis
bascule la ligne, un bout plus haut que l'autre. Seule la composante
ligne à ligne est retirée (série − gaussienne σ window lignes). Estimation
sur 1 point sur subsample, moindres carrés tronqués (5 MAD) vectorisés.
Returns:
(corr par point dans l'ordre d'entrée, décalages par ligne,
inclinaisons par ligne au bord de fauchée).
"""
from scipy.ndimage import gaussian_filter1d, uniform_filter
n = len(z)
o = np.argsort(t, kind="stable")
line_all = np.empty(n, dtype=np.int64)
line_all[o] = _scan_line_ids(t[o], angle[o])
nl = int(line_all.max()) + 1 if n else 0
tot_a, tot_b = np.zeros(nl), np.zeros(nl)
if nl < 10 * window:
return np.zeros(n), tot_a, tot_b
u_all = np.asarray(angle, dtype=np.float64) / max(np.percentile(np.abs(angle), 99), 1e-9)
sel = np.arange(n) % max(1, int(subsample)) == 0
xs, ys, zs = x[sel], y[sel], np.asarray(z, dtype=np.float64)[sel]
line, u = line_all[sel], u_all[sel]
# Référence = plan local : moyennes (x, y, z) par boîte de mailles, pente
# de la surface lissée ; évalué à la position réelle du point (la moyenne
# d'une maille n'est pas en son centre : sur une pente, une interpolation
# au centre crée un biais qui dépend de la position de la ligne).
x0, y0 = xs.min(), ys.min()
ix = np.floor((xs - x0) / cell).astype(np.int64)
iy = np.floor((ys - y0) / cell).astype(np.int64)
W, H = int(ix.max()) + 1, int(iy.max()) + 1
flat = iy * W + ix
count_b = uniform_filter(np.bincount(flat, minlength=W * H).reshape(H, W).astype(np.float64), box)
valid = count_b > 0
inv_c = np.where(valid, 1.0 / np.maximum(count_b, 1e-12), np.nan)
mean_x = uniform_filter(np.bincount(flat, weights=xs - x0, minlength=W * H).reshape(H, W), box) * inv_c
mean_y = uniform_filter(np.bincount(flat, weights=ys - y0, minlength=W * H).reshape(H, W), box) * inv_c
dxp = (xs - x0) - mean_x.ravel()[flat]
dyp = (ys - y0) - mean_y.ravel()[flat]
min_pts = max(10, min_points // max(1, int(subsample)))
z_work = zs.copy()
for _ in range(iters):
mean_z = uniform_filter(np.bincount(flat, weights=z_work, minlength=W * H).reshape(H, W), box) * inv_c
gz_y, gz_x = np.gradient(np.where(valid, mean_z, np.nanmean(mean_z)), cell)
r = z_work - (mean_z.ravel()[flat] + gz_x.ravel()[flat] * dxp + gz_y.ravel()[flat] * dyp)
a, b, ok = _line_fit(r, _robust_mask(r), line, u, nl, min_pts)
if ok.sum() < 10 * window:
break
idx = np.flatnonzero(ok)
a = np.interp(np.arange(nl), idx, a[ok])
b = np.interp(np.arange(nl), idx, b[ok])
# Seule la composante ligne à ligne est retirée (une médiane glissante
# suivrait un motif alterné au lieu de l'effacer)
da = a - gaussian_filter1d(a, window, mode="nearest")
db = b - gaussian_filter1d(b, window, mode="nearest")
da[~ok] = 0.0
db[~ok] = 0.0
tot_a += da
tot_b += db
z_work = zs - np.clip(tot_a[line] + tot_b[line] * u, -max_corr, max_corr)
corr = np.clip(tot_a[line_all] + tot_b[line_all] * u_all, -max_corr, max_corr)
return corr, tot_a, tot_b
def _joint_line_corrections(x, y, z, psid, t, angle, cell=STRIP_JOINT_CELL,
iters=STRIP_JOINT_ITERS, damping=STRIP_JOINT_DAMPING,
tol=STRIP_JOINT_TOL, min_points=STRIP_JOINT_MIN_POINTS,
subsample=STRIP_LINE_SUBSAMPLE, max_corr=STRIP_JOINT_MAX,
use_gpu=True):
"""Ajustement conjoint des lignes de tous les faisceaux contre le
consensus des AUTRES faisceaux (décalage + inclinaison par ligne).
À chaque itération, le résidu d'un point est mesuré contre la moyenne des
autres faisceaux de sa maille (ramenée à sa position par la pente de la
surface), chaque ligne est ajustée par moindres carrés tronqués, et une
fraction damping du pas est appliquée à toutes les lignes à la fois ;
l'altitude moyenne est recentrée (pas de dérive d'ensemble). Sur GPU
(CuPy) si disponible, repli numpy sur toute erreur.
Returns:
(corr à SOUSTRAIRE par point, id de ligne global par point,
lignes recalées (bool par ligne), nombre d'itérations) — numpy.
"""
m = _array_module(use_gpu)
if m is not np:
try:
return _joint_line_corrections_impl(m, x, y, z, psid, t, angle, cell, iters,
damping, tol, min_points, subsample, max_corr)
except Exception as e:
logger.warning(f" Ajustement conjoint GPU impossible ({e}) — repli CPU")
return _joint_line_corrections_impl(np, x, y, z, psid, t, angle, cell, iters,
damping, tol, min_points, subsample, max_corr)
def _joint_line_corrections_impl(m, x, y, z, psid, t, angle, cell, iters, damping,
tol, min_points, subsample, max_corr):
def host(a):
return a.get() if m is not np else a
n = len(z)
psid_h = np.asarray(psid)
beams, bidx_h = np.unique(psid_h, return_inverse=True)
# Classe d'angle par faisceau : profil d'étalonnage commun à toutes les
# lignes d'un faisceau (écart non linéaire en travers de la fauchée).
abin_h = np.round(np.asarray(angle, dtype=np.float64) * _SCAN_ANGLE_UNIT / STRIP_ANGLE_BIN).astype(np.int64)
amin = int(abin_h.min()) if n else 0
n_ab = int(abin_h.max()) - amin + 1 if n else 1
abin_h = bidx_h * n_ab + (abin_h - amin)
x, y = m.asarray(x, dtype=m.float64), m.asarray(y, dtype=m.float64)
z, t = m.asarray(z, dtype=m.float64), m.asarray(t, dtype=m.float64)
angle = m.asarray(angle, dtype=m.float64)
bidx = m.asarray(bidx_h)
gl = m.zeros(n, dtype=m.int64)
u = m.zeros(n)
base = 0
for i in range(len(beams)):
idx = m.flatnonzero(bidx == i)
o = m.argsort(t[idx])
gl[idx[o]] = _scan_line_ids(t[idx][o], angle[idx][o]) + base
base = int(gl[idx].max()) + 1
u[idx] = angle[idx] / max(float(m.percentile(m.abs(angle[idx]), 99)), 1e-9)
n_lines = base
touched = m.zeros(n_lines, dtype=bool)
if len(beams) < 2 or n == 0:
return np.zeros(n), host(gl), host(touched), 0
abin = m.asarray(abin_h)
n_bins = len(beams) * n_ab
sel = m.arange(0, n, max(1, int(subsample)))
xs, ys, zs = x[sel], y[sel], z[sel]
gs, us, bs, cs = gl[sel], u[sel], bidx[sel], abin[sel]
x0, y0 = float(xs.min()), float(ys.min())
ix = m.floor((xs - x0) / cell).astype(m.int64)
iy = m.floor((ys - y0) / cell).astype(m.int64)
W, H = int(ix.max()) + 1, int(iy.max()) + 1
nk = W * H
key = iy * W + ix
bkey = bs * nk + key
dxc = (xs - x0) - (ix + 0.5) * cell
dyc = (ys - y0) - (iy + 0.5) * cell
n_all = m.bincount(key, minlength=nk)
n_own = m.bincount(bkey, minlength=len(beams) * nk)
n_other = n_all[key] - n_own[bkey]
overlap = n_other >= 2
min_pts = max(10, min_points // max(1, int(subsample)))
A = m.zeros(n_lines)
B = m.zeros(n_lines)
C = m.zeros(n_bins)
min_bin = max(30, STRIP_ANGLE_MIN_POINTS // max(1, int(subsample)))
it = 0
for it in range(1, iters + 1):
zc = zs - m.clip(A[gs] + B[gs] * us + C[cs], -max_corr, max_corr)
s_all = m.bincount(key, weights=zc, minlength=nk)
s_own = m.bincount(bkey, weights=zc, minlength=len(beams) * nk)
mean = m.where(n_all > 0, s_all / m.maximum(n_all, 1), m.nan).reshape(H, W)
gy, gx = m.gradient(m.where(m.isfinite(mean), mean, m.nanmean(mean)), cell)
ref = ((s_all[key] - s_own[bkey]) / m.maximum(n_other, 1)
+ gx.ravel()[key] * dxc + gy.ravel()[key] * dyc)
r = m.where(overlap, zc - ref, m.nan)
w = m.zeros(len(r), dtype=bool)
for i in range(len(beams)):
mb = bs == i
w[mb] = _robust_mask(r[mb])
a, b, ok = _line_fit(r, w, gs, us, n_lines, min_pts)
touched |= ok
# Profil par faisceau et classe d'angle : moyenne tronquée du résidu
# restant après le pas de ligne ; sa moyenne (portée par les lignes)
# est retirée faisceau par faisceau. Sa pente est conservée : quand la
# fauchée ne traverse la dalle qu'en partie, l'inclinaison des lignes
# est indéterminée et seul le profil peut la porter.
r2 = m.where(w, r - (a[gs] + b[gs] * us), 0.0)
wf = w.astype(m.float64)
cnt = m.bincount(cs, weights=wf, minlength=n_bins)
prof = m.where(cnt >= min_bin, m.bincount(cs, weights=r2, minlength=n_bins) / m.maximum(cnt, 1), 0.0)
su = m.bincount(cs, weights=wf * us, minlength=n_bins)
ub = m.where(cnt > 0, su / m.maximum(cnt, 1), 0.0)
for i in range(len(beams)):
sl = slice(i * n_ab, (i + 1) * n_ab)
k = cnt[sl] >= min_bin
if int(k.sum()) >= 3:
wk = cnt[sl][k]
uk, pk = ub[sl][k], prof[sl][k]
um, pm = float((wk * uk).sum() / wk.sum()), float((wk * pk).sum() / wk.sum())
fitted = prof[sl] - pm
# Classe trop pauvre (bord de fauchée, classe incomplète) :
# valeur de la classe valide la plus proche, pas zéro — c'est
# précisément au bord que l'écart est le plus fort.
idx = m.arange(n_ab, dtype=m.float64)
prof[sl] = m.interp(idx, idx[k], fitted[k])
else:
prof[sl] = 0.0
A += damping * a
B += damping * b
C += damping * prof
A -= float(m.mean(A[gs] + B[gs] * us + C[cs])) # pas de dérive d'ensemble
step_lines = float(m.mean(m.abs(a[ok]))) * damping if bool(ok.any()) else 0.0
step_prof = float(m.max(m.abs(prof))) * damping
if max(step_lines, step_prof) < tol:
break
corr = m.clip(A[gl] + B[gl] * u + C[abin], -max_corr, max_corr)
return host(corr), host(gl), host(touched), it
def _rolling_median_fast(values, window):
"""Médiane glissante centrée (bords répétés), vectorisée."""
from numpy.lib.stride_tricks import sliding_window_view
half = window // 2
return np.median(sliding_window_view(np.pad(values, half, mode="edge"), window), axis=1)
def _scan_line_corrections(x, y, z, psid, t, angle, min_rms=STRIP_LINE_MIN_RMS):
"""Corrections ligne à ligne de tous les faisceaux d'une tuile.
1. Ajustement conjoint contre les autres faisceaux (lignes en recouvrement).
2. Lignes sans recouvrement : composante ligne à ligne contre la surface
de leur propre faisceau.
z doit être déjà calé (offsets constants et gigue temporelle).
Returns:
(corrections à SOUSTRAIRE par point, {psid: (lignes, rms, max)} des
faisceaux corrigés).
"""
psid = np.asarray(psid)
z = np.asarray(z, dtype=np.float64)
corr, gl, touched, _ = _joint_line_corrections(x, y, z, psid, t, angle)
stats = {}
for p in np.unique(psid):
m = psid == p
if int(m.sum()) < 50 * STRIP_LINE_MIN_POINTS:
continue
alone = ~touched[gl[m]]
if alone.mean() > 0.05:
c_self, per_line, _ = _scan_line_corrections_beam(
x[m], y[m], z[m] - corr[m], t[m], angle[m])
c_beam = corr[m] + np.where(alone, c_self, 0.0)
else:
c_beam = corr[m]
rms = float(np.sqrt(np.mean(c_beam ** 2))) if m.any() else 0.0
if rms < min_rms:
corr[m] = 0.0
continue
corr[m] = c_beam
n_lines = int(len(np.unique(gl[m])))
stats[int(p)] = (n_lines, rms, float(np.max(np.abs(c_beam))))
return corr, stats
def _scan_angle(las):
"""scan_angle (LAS 1.4) ou scan_angle_rank (LAS ≤ 1.3), None si absent."""
for name in ("scan_angle", "scan_angle_rank"):
try:
return np.asarray(getattr(las, name), dtype=np.float64)
except AttributeError:
continue
return None
def _scan_lines_for_file(las_file, las, z_aligned, t):
"""Corrections ligne à ligne d'un LAS sol, mémoïsées par (chemin, mtime)."""
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_LINES_CACHE:
return _STRIP_LINES_CACHE[cache_key]
angle = _scan_angle(las)
result = (np.zeros(len(z_aligned)), {})
if angle is not None and len(angle) == len(z_aligned):
result = _scan_line_corrections(
np.asarray(las.x, dtype=np.float64), np.asarray(las.y, dtype=np.float64),
z_aligned, np.asarray(las.point_source_id), t, angle)
_STRIP_LINES_CACHE[cache_key] = result
return result
def _apply_strip_jitter(psid, t, jitter):
"""Corrections de gigue interpolées au temps GPS de chaque point.
@ -357,7 +775,7 @@ def _strip_offsets_for_file(las_file, las):
def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets,
jitter=None):
jitter=None, lines=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
@ -381,6 +799,11 @@ def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets,
"jitter_bin": STRIP_JITTER_BIN,
"jitter_smooth": STRIP_JITTER_SMOOTH,
"jitter": jitter_payload,
"line_window": STRIP_LINE_WINDOW,
"line_cell": STRIP_LINE_CELL,
"line_model": STRIP_LINE_MODEL,
"lines": {str(p): {"lines": n, "rms_m": round(r, 4), "max_m": round(m, 4)}
for p, (n, r, m) in (lines or {}).items()},
}
try:
sidecar = Path(dtm_dir) / f"{basename}_dtm{output_suffix}_stripalign.json"
@ -742,6 +1165,8 @@ def detect_ground_method(laz_file):
if las is None:
logger.info(f" → Méthode: SMRF (défaut — lecture impossible)")
return 'smrf'
_LAST_READ.clear()
_LAST_READ[str(laz_file)] = las # réutilisé par l'extraction IGN
total_points = len(las.points)
if total_points == 0:
@ -801,6 +1226,39 @@ def detect_ground_method(laz_file):
return method
_LAST_READ = {}
def _extract_ign_ground(laz_file, output_las, codes):
"""Extraction directe (laspy) des classes IGN choisies vers un LAS.
Même résultat que le pipeline PDAL (filtres ReturnNumber ≥ 1,
NumberOfReturns ≥ 1, classes) mais sans relecture ni conversion :
~4 s au lieu de ~9 s par dalle, et la lecture de la détection automatique
est réutilisée. Format de points et échelles du fichier source conservés.
Returns:
True si le fichier a été écrit avec au moins un point.
"""
import laspy
las = _LAST_READ.pop(str(laz_file), None)
if las is None:
las = laspy.read(str(laz_file))
keep = ((np.asarray(las.return_number) >= 1)
& (np.asarray(las.number_of_returns) >= 1)
& np.isin(np.asarray(las.classification), np.asarray(sorted(codes))))
if not keep.any():
return False
header = laspy.LasHeader(point_format=las.header.point_format,
version=las.header.version)
header.scales = las.header.scales
header.offsets = las.header.offsets
out = laspy.LasData(header)
out.points = las.points[keep]
out.write(str(output_las))
return True
def classify_ground(laz_file, temp_dir, method='auto', force=False, ign_classes="sol"):
"""Classify ground points using PDAL ground classification filter.
@ -843,6 +1301,20 @@ def classify_ground(laz_file, temp_dir, method='auto', force=False, ign_classes=
logger.info(f" Reclassification forcée — suppression de {output_las.name}")
output_las.unlink()
# Pré-classification IGN : extraction directe, PDAL en secours.
if method == 'ign':
try:
if _extract_ign_ground(laz_file, output_las, ign_codes or [2]):
logger.info(f" ✓ Classification sol IGN terminée (extraction directe)")
return output_las
logger.warning(" Aucun point des classes IGN demandées — repli SMRF")
output_las.unlink(missing_ok=True)
return _fallback_to_smrf(laz_file, temp_dir, laz_base, force, source=method_label)
except Exception as e:
logger.warning(f" Extraction IGN directe impossible ({e}) — pipeline PDAL")
output_las.unlink(missing_ok=True)
_LAST_READ.clear()
pipeline_json = _create_ground_pipeline(laz_file, output_las, method, ign_codes=ign_codes)
pipeline_file = temp_dir / f"pipeline_{method_label}.json"
@ -1265,7 +1737,11 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
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):
# L'ajustement conjoint ligne à ligne (3ᵉ passe) recale déjà chaque
# ligne contre les autres faisceaux, à toutes les échelles : la gigue
# par fenêtres de temps n'est calculée que si scan_angle manque.
lines_possible = _scan_angle(las) is not None
if gps_time is not None and len(gps_time) == len(las.points) and not lines_possible:
try:
strip_jitter = _strip_jitter_for_file(las_file, las, strip_offsets)
except Exception as e:
@ -1328,6 +1804,20 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
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)
# 3ᵉ passe : décalage ligne à ligne (après les deux premières).
strip_lines = {}
if strip_align and gps_time is not None and len(gps_time) == len(zs):
t_lines = time.perf_counter()
try:
line_corr, strip_lines = _scan_lines_for_file(las_file, las, zs, gps_time)
zs = zs - line_corr
except Exception as e:
logger.warning(f" Mesure du décalage ligne à ligne impossible ({e}) — non corrigé")
strip_lines = {}
for p_, (nl_, rms_, max_) in sorted(strip_lines.items()):
logger.info(f" Lignes PSID {p_} : rms {rms_ * 100:.1f} cm, max {max_ * 100:.1f} cm "
f"({nl_} lignes)")
logger.info(f" Décalage ligne à ligne : {time.perf_counter() - t_lines:.1f}s")
# Points sol des tuiles voisines dans la bande de raccord (best-effort,
# non calés par faisceau : bande de contexte, l'image finale est
@ -1412,7 +1902,7 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
if strip_align:
_write_strip_align_sidecar(dtm_dir, basename, output_suffix,
strip_offsets, strip_jitter)
strip_offsets, strip_jitter, strip_lines)
logger.info(f" ✓ DTM créé: {output_tif.name}")
return output_tif