Caler les faisceaux de vol, décimer l'openness et cibler les couches affichées

Trois chantiers liés à la qualité et au coût des rendus :

- Calage vertical des faisceaux : les passes d'une tuile peuvent être biaisées
  de quelques cm (±2,5 cm mesurés sur 1000_6882), créant des marches aux
  recouvrements. Les offsets par PointSourceId sont mesurés sur les points
  sol de la tuile (réf. médiane itérée) et retranchés ≥ 0,5 cm avant
  rastérisation, avec sidecar de cache et application au plancher bare-earth.
- Openness décimée ×2 : lancé de rayons sur grille par blocs (max/min) puis
  rééchantillonnage bilinéaire — 532 s → 40 s par tuile à 0,2 m sur CPU,
  signal archéologique préservé. Réglable --openness-downsample.
- Génération webapp concentrée sur les couches affichées : les défauts
  /api/generate, /api/preview et le sélecteur génèrent le panneau complet
  (slope, aspect, pos_open) au lieu d'aspect seul.
This commit is contained in:
Antoine Jacquin
2026-09-19 00:53:22 +02:00
parent 796e68c870
commit 41206f65de
11 changed files with 455 additions and 25 deletions

View File

@ -31,6 +31,144 @@ IGN_CLASS_NAMES = {
}
# Calage vertical des faisceaux de vol (strip alignment). Une tuile LiDAR HD
# est couverte par plusieurs passes d'acquisition, chacune portée par un ou
# deux PointSourceId (faisceaux). Les passes sont bien alignées horizontalement
# mais certains faisceaux portent un biais vertical de quelques cm (mesuré
# jusqu'à ~5 cm sur LHD_FXX_1000_6882 : les deux faisceaux d'une passe écartés
# de ±2,5 cm). À 0,2 m/px ces écarts créent des marches et du bruit aux
# coutures des zones de recouvrement. On mesure l'offset robuste de chaque
# faisceau sur les points sol de la tuile elle-même (les PointSourceId changent
# 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
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
# 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 = {}
def _strip_vertical_offsets(x, y, z, psid, cell=STRIP_ALIGN_CELL,
threshold=STRIP_ALIGN_THRESHOLD,
min_shared=STRIP_ALIGN_MIN_SHARED):
"""Mesure les offsets verticaux relatifs entre faisceaux d'une tuile.
Méthode : surface sol par faisceau (moyenne des points à moins de 0,5 m du
minimum de chaque maille de 1 m, robuste à la végétation résiduelle), puis
offset de chaque faisceau = médiane de l'écart à la surface médiane de
référence (itéré 3 fois), sur les seules cellules couvertes par au moins
deux faisceaux. Ne corrige rien d'autre que le vertical : le calage
horizontal mesuré sur les données est excellent (≤ 1 cm).
Args:
x, y, z, psid: coordonnées et PointSourceId des points sol.
cell: taille de maille de comparaison (m).
threshold: seuil (m) en dessous duquel un offset est ignoré.
min_shared: cellules communes minimales pour valider un faisceau.
Returns:
dict {psid: offset} des offsets à SOUSTRAIRE (z - offset), ne
contenant que les |offset| >= threshold ; vide si rien à corriger
(faisceau unique, pas de recouvrement, tuile déjà alignée).
"""
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:
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
comparable = np.sum(~np.isnan(grid), axis=0) >= 2
offsets = np.zeros(len(us))
for _ in range(3):
ref = np.nanmedian(grid + offsets[:, None], axis=0)
for k in range(len(us)):
m = ~np.isnan(grid[k]) & comparable
if int(m.sum()) >= min_shared:
offsets[k] = np.median(grid[k][m] - ref[m])
return {int(p): round(float(offsets[k]), 3)
for k, p in enumerate(us) if abs(offsets[k]) >= threshold}
def _strip_offsets_for_file(las_file, las):
"""Offsets de calage d'un LAS sol, mémoïsés 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_OFFSETS_CACHE:
return _STRIP_OFFSETS_CACHE[cache_key]
try:
psid = np.asarray(las.point_source_id)
except AttributeError:
psid = None # dimension absente (producteur tiers) : pas de calage
if psid is not None and len(psid) == len(las.points):
offsets = _strip_vertical_offsets(
np.asarray(las.x, dtype=np.float64),
np.asarray(las.y, dtype=np.float64),
np.asarray(las.z, dtype=np.float64),
psid)
else:
offsets = {}
_STRIP_OFFSETS_CACHE[cache_key] = offsets
return offsets
def _write_strip_align_sidecar(dtm_dir, basename, output_suffix, offsets):
"""Consigne les offsets de calage appliqués (version et seuil inclus).
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.
"""
payload = {
"version": STRIP_ALIGN_VERSION,
"threshold": STRIP_ALIGN_THRESHOLD,
"offsets": offsets,
}
try:
sidecar = Path(dtm_dir) / f"{basename}_dtm{output_suffix}_stripalign.json"
sidecar.write_text(json.dumps(payload, ensure_ascii=False),
encoding="utf-8")
except Exception as e:
logger.warning(f" Écriture sidecar calage faisceaux impossible: {e}")
def _strip_lidar_ext(path):
"""Extract base name from a LAZ/LAS file (mirrors pipeline._file_basename)."""
name = Path(path).name
@ -675,7 +813,8 @@ 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):
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.
In complex/forested terrain the ground is under-classified, leaving DTM
@ -689,6 +828,9 @@ def _min_return_grid(laz_file, width, height, bounds, chunk_size=2_000_000):
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é.
Returns:
(height, width) float32 array of per-cell min z (NaN where no point).
@ -698,12 +840,23 @@ def _min_return_grid(laz_file, width, height, bounds, chunk_size=2_000_000):
grid = np.full((height, width), np.nan, dtype=np.float32)
rng = [[min_x, max_x], [min_y, max_y]]
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:
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).
@ -726,7 +879,7 @@ def _min_return_grid(laz_file, width, height, bounds, chunk_size=2_000_000):
def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
output_suffix="", source_laz=None, bare_earth=False,
pure=False):
pure=False, strip_align=True):
"""Create DTM using fast binning method with gap filling.
Args:
@ -745,6 +898,10 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
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.
Returns:
Path to output DTM GeoTIFF, or None on failure.
@ -774,6 +931,22 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
logger.error(f" ✗ Fichier vide (0 points): {las_file.name}")
return None
# 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 = {}
if strip_align:
try:
strip_offsets = _strip_offsets_for_file(las_file, las)
except Exception as e:
logger.warning(f" Mesure du calage faisceaux impossible ({e}) — MNT non calé")
strip_offsets = {}
if strip_offsets:
logger.info(" Calage faisceaux : " + ", ".join(
f"PSID {p} {off:+.3f} m" for p, off in sorted(strip_offsets.items())))
else:
logger.debug(" Calage faisceaux : aucun écart >= "
f"{STRIP_ALIGN_THRESHOLD * 100:.1f} cm, rien à corriger")
try:
min_x, max_x = float(las.header.min[0]), float(las.header.max[0])
@ -786,8 +959,17 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
logger.debug(f" Grid: {width}x{height} pixels ({len(las.points):,} points)")
logger.info(f" Rasterisation {width}x{height} ({len(las.points):,} points)...")
xs = np.asarray(las.x, dtype=np.float64)
ys = np.asarray(las.y, dtype=np.float64)
zs = np.asarray(las.z, dtype=np.float64)
if strip_offsets:
lut = np.zeros(65536)
for p, off in strip_offsets.items():
lut[int(p) & 0xFFFF] = off
zs = zs - lut[np.asarray(las.point_source_id, dtype=np.int64)]
stat = binned_statistic_2d(
las.x, las.y, las.z,
xs, ys, zs,
statistic='mean',
bins=[width, height],
range=[[min_x, max_x], [min_y, max_y]]
@ -803,7 +985,8 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
# 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))
(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.
@ -844,6 +1027,9 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
) as dst:
dst.write(dtm.astype('float32'), 1)
if strip_align:
_write_strip_align_sidecar(dtm_dir, basename, output_suffix,
strip_offsets)
logger.info(f" ✓ DTM créé: {output_tif.name}")
return output_tif