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:
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user