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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -60,3 +60,4 @@ notebooks/
|
|||||||
# Éventuels fichiers de cache matplotlib
|
# Éventuels fichiers de cache matplotlib
|
||||||
matplotlibrc
|
matplotlibrc
|
||||||
docker-compose.webapp.override.yml
|
docker-compose.webapp.override.yml
|
||||||
|
output-test/
|
||||||
|
|||||||
@ -28,6 +28,8 @@
|
|||||||
- **Logger is always `logging.getLogger("lidar")`**, never `__name__`. All modules route through this single logger so worker processes can configure it.
|
- **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`.
|
- **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.
|
- **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`.
|
||||||
|
- **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.
|
||||||
- **Tests use lazy imports inside each test function**, never at module top, to avoid importing CuPy/GDAL at import time.
|
- **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.
|
- **`_`-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.
|
- **`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.
|
||||||
|
|||||||
@ -150,6 +150,23 @@ def main():
|
|||||||
"Requalifie le point le plus bas de chaque colonne en terrain — utile sous "
|
"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."
|
"végétation dense ou en relief raide où la classification du sol sous-couvre le terrain."
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--openness-downsample",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
metavar="FACTEUR",
|
||||||
|
help="Facteur de sous-échantillonnage du calcul d'openness (lancé de rayons) : "
|
||||||
|
"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(
|
||||||
|
"--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)"
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--keep-tif",
|
"--keep-tif",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@ -344,6 +361,8 @@ def main():
|
|||||||
force_classify=args.force_classification,
|
force_classify=args.force_classification,
|
||||||
keep_tif=args.keep_tif,
|
keep_tif=args.keep_tif,
|
||||||
bare_earth=args.bare_earth,
|
bare_earth=args.bare_earth,
|
||||||
|
strip_align=not args.no_strip_align,
|
||||||
|
openness_downsample=args.openness_downsample,
|
||||||
quality=quality,
|
quality=quality,
|
||||||
only_viz=only_viz,
|
only_viz=only_viz,
|
||||||
skip_viz=skip_viz,
|
skip_viz=skip_viz,
|
||||||
|
|||||||
@ -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):
|
def _strip_lidar_ext(path):
|
||||||
"""Extract base name from a LAZ/LAS file (mirrors pipeline._file_basename)."""
|
"""Extract base name from a LAZ/LAS file (mirrors pipeline._file_basename)."""
|
||||||
name = Path(path).name
|
name = Path(path).name
|
||||||
@ -675,7 +813,8 @@ def _interpolate_holes(dtm, downsample=8):
|
|||||||
return filled, int(holes.sum())
|
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.
|
"""Rasterize the per-cell minimum z (lowest return) of the full point cloud.
|
||||||
|
|
||||||
In complex/forested terrain the ground is under-classified, leaving DTM
|
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).
|
width, height: Output grid dimensions (pixels).
|
||||||
bounds: (min_x, min_y, max_x, max_y) the grid covers.
|
bounds: (min_x, min_y, max_x, max_y) the grid covers.
|
||||||
chunk_size: Points per streaming chunk.
|
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:
|
Returns:
|
||||||
(height, width) float32 array of per-cell min z (NaN where no point).
|
(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)
|
grid = np.full((height, width), np.nan, dtype=np.float32)
|
||||||
rng = [[min_x, max_x], [min_y, max_y]]
|
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):
|
def process(points):
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return
|
return
|
||||||
x = np.asarray(points.x, dtype=np.float64)
|
x = np.asarray(points.x, dtype=np.float64)
|
||||||
y = np.asarray(points.y, dtype=np.float64)
|
y = np.asarray(points.y, dtype=np.float64)
|
||||||
z = np.asarray(points.z, 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',
|
st = binned_statistic_2d(x, y, z, statistic='min',
|
||||||
bins=[width, height], range=rng)
|
bins=[width, height], range=rng)
|
||||||
# Match the DTM convention: .T then flip Y (north at top).
|
# 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,
|
def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
|
||||||
output_suffix="", source_laz=None, bare_earth=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.
|
"""Create DTM using fast binning method with gap filling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -745,6 +898,10 @@ def create_dtm_fast(las_file, basename, dtm_dir, resolution, force=False,
|
|||||||
pure: Sans effet (conservé pour compatibilité). Fonctionnement
|
pure: Sans effet (conservé pour compatibilité). Fonctionnement
|
||||||
historique rétabli : petits trous comblés par fillnodata, grands
|
historique rétabli : petits trous comblés par fillnodata, grands
|
||||||
trous laissés en nodata (rendus en noir dans les rendus).
|
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:
|
Returns:
|
||||||
Path to output DTM GeoTIFF, or None on failure.
|
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}")
|
logger.error(f" ✗ Fichier vide (0 points): {las_file.name}")
|
||||||
return None
|
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:
|
try:
|
||||||
|
|
||||||
min_x, max_x = float(las.header.min[0]), float(las.header.max[0])
|
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.debug(f" Grid: {width}x{height} pixels ({len(las.points):,} points)")
|
||||||
logger.info(f" Rasterisation {width}x{height} ({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(
|
stat = binned_statistic_2d(
|
||||||
las.x, las.y, las.z,
|
xs, ys, zs,
|
||||||
statistic='mean',
|
statistic='mean',
|
||||||
bins=[width, height],
|
bins=[width, height],
|
||||||
range=[[min_x, max_x], [min_y, max_y]]
|
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).
|
# explicite (--bare-earth).
|
||||||
if bare_earth and source_laz is not None:
|
if bare_earth and source_laz is not None:
|
||||||
min_grid = _min_return_grid(source_laz, width, height,
|
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
|
# Cellules sans sol mesuré (NaN) : le retour le plus bas devient
|
||||||
# la mesure — sinon la comparaison NaN est fausse et la cellule
|
# la mesure — sinon la comparaison NaN est fausse et la cellule
|
||||||
# retombe sur l'interpolation en fin de passe.
|
# 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:
|
) as dst:
|
||||||
dst.write(dtm.astype('float32'), 1)
|
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}")
|
logger.info(f" ✓ DTM créé: {output_tif.name}")
|
||||||
return output_tif
|
return output_tif
|
||||||
|
|
||||||
|
|||||||
@ -3063,9 +3063,11 @@ function clearGhosts() { ghostCells.forEach(g => g.remove()); ghostCells.length
|
|||||||
// Visualisations choisies dans la barre de génération (noms d'étapes --only).
|
// Visualisations choisies dans la barre de génération (noms d'étapes --only).
|
||||||
// Sert au preview (détection des tuiles incomplètes) et au lancement : une
|
// Sert au preview (détection des tuiles incomplètes) et au lancement : une
|
||||||
// tuile existante mais privée d'une de ces visualisations est incluse.
|
// tuile existante mais privée d'une de ces visualisations est incluse.
|
||||||
|
// Rien de sélectionné → toutes les couches du sélecteur (= panneau affiché).
|
||||||
function selectedGenViz() {
|
function selectedGenViz() {
|
||||||
|
const all = genViz ? Array.from(genViz.options).map(o => o.value) : [];
|
||||||
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
const sel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||||||
return sel.length ? sel : ['aspect'];
|
return sel.length ? sel : (all.length ? all : ['aspect']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitGenMode() {
|
function exitGenMode() {
|
||||||
|
|||||||
@ -55,7 +55,7 @@ class FilePrefixFilter(logging.Filter):
|
|||||||
_file_filter = FilePrefixFilter()
|
_file_filter = FilePrefixFilter()
|
||||||
|
|
||||||
from .progress import report_event
|
from .progress import report_event
|
||||||
from .dtm import classify_ground, create_dtm_fast
|
from .dtm import classify_ground, create_dtm_fast, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD
|
||||||
from .visualizations import (
|
from .visualizations import (
|
||||||
SharedDEM,
|
SharedDEM,
|
||||||
generate_hillshade, generate_slope, generate_aspect,
|
generate_hillshade, generate_slope, generate_aspect,
|
||||||
@ -109,7 +109,7 @@ VIZ_STEPS = [
|
|||||||
class LidarArchaeoPipeline:
|
class LidarArchaeoPipeline:
|
||||||
"""Orchestrates the LiDAR archaeological analysis pipeline."""
|
"""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):
|
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):
|
||||||
self.input_dir = Path(input_dir)
|
self.input_dir = Path(input_dir)
|
||||||
self.output_dir = Path(output_dir)
|
self.output_dir = Path(output_dir)
|
||||||
# Accept single float or comma-separated string for multi-resolution
|
# Accept single float or comma-separated string for multi-resolution
|
||||||
@ -134,6 +134,8 @@ class LidarArchaeoPipeline:
|
|||||||
self.gpu_ids = gpu_ids
|
self.gpu_ids = gpu_ids
|
||||||
self.no_index = no_index
|
self.no_index = no_index
|
||||||
self.incremental_index = incremental_index
|
self.incremental_index = incremental_index
|
||||||
|
self.strip_align = strip_align
|
||||||
|
self.openness_downsample = openness_downsample
|
||||||
self._last_index_rebuild = 0.0
|
self._last_index_rebuild = 0.0
|
||||||
self.temp_dir = self.output_dir / "temp"
|
self.temp_dir = self.output_dir / "temp"
|
||||||
|
|
||||||
@ -368,6 +370,27 @@ class LidarArchaeoPipeline:
|
|||||||
recorded = self._dtm_method_name(basename, res_suffix)
|
recorded = self._dtm_method_name(basename, res_suffix)
|
||||||
return recorded is None or recorded == self._effective_ground_method()
|
return recorded is None or recorded == self._effective_ground_method()
|
||||||
|
|
||||||
|
def _strip_align_matches(self, basename, res_suffix):
|
||||||
|
"""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é.
|
||||||
|
"""
|
||||||
|
sidecar = self.dtm_dir / f"{basename}_dtm{res_suffix}_stripalign.json"
|
||||||
|
if not self.strip_align:
|
||||||
|
return not sidecar.exists()
|
||||||
|
if not sidecar.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _write_dtm_method(self, basename, res_suffix):
|
def _write_dtm_method(self, basename, res_suffix):
|
||||||
"""Record the ground classification method used to build a DTM."""
|
"""Record the ground classification method used to build a DTM."""
|
||||||
try:
|
try:
|
||||||
@ -416,7 +439,10 @@ class LidarArchaeoPipeline:
|
|||||||
res_suffix = self._res_suffix(res)
|
res_suffix = self._res_suffix(res)
|
||||||
dtm_path = self.dtm_dir / f"{basename}_dtm{res_suffix}.tif"
|
dtm_path = self.dtm_dir / f"{basename}_dtm{res_suffix}.tif"
|
||||||
if dtm_path.exists() and not self.force_classify:
|
if dtm_path.exists() and not self.force_classify:
|
||||||
if method_matches:
|
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 method_matches:
|
||||||
import rasterio
|
import rasterio
|
||||||
try:
|
try:
|
||||||
with rasterio.open(dtm_path) as src:
|
with rasterio.open(dtm_path) as src:
|
||||||
@ -468,7 +494,8 @@ class LidarArchaeoPipeline:
|
|||||||
output_suffix=res_suffix,
|
output_suffix=res_suffix,
|
||||||
source_laz=laz_file,
|
source_laz=laz_file,
|
||||||
bare_earth=self.bare_earth,
|
bare_earth=self.bare_earth,
|
||||||
pure=pure_ign)
|
pure=pure_ign,
|
||||||
|
strip_align=self.strip_align)
|
||||||
t_dtm = time.time() - t2
|
t_dtm = time.time() - t2
|
||||||
if not dtm_file:
|
if not dtm_file:
|
||||||
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
|
logger.error(f" ✗ Échec DTM {res}m/px ({t_dtm:.1f}s)")
|
||||||
@ -483,6 +510,12 @@ class LidarArchaeoPipeline:
|
|||||||
self._write_dtm_method(basename, res_suffix)
|
self._write_dtm_method(basename, res_suffix)
|
||||||
|
|
||||||
# Process each resolution: visualizations + PDF
|
# Process each resolution: visualizations + PDF
|
||||||
|
# Option de calcul (surcharge le défaut du module) appliquée ICI car les
|
||||||
|
# workers (spawn) réimportent les modules à froid : c'est le seul endroit
|
||||||
|
# qui s'exécute dans le processus qui fait le calcul.
|
||||||
|
if self.openness_downsample is not None:
|
||||||
|
from . import visualizations as _viz_mod
|
||||||
|
_viz_mod.OPENNESS_DOWNSAMPLE = max(1, int(self.openness_downsample))
|
||||||
all_vis_results = {}
|
all_vis_results = {}
|
||||||
for res in self.resolutions:
|
for res in self.resolutions:
|
||||||
res_suffix = self._res_suffix(res)
|
res_suffix = self._res_suffix(res)
|
||||||
@ -580,7 +613,7 @@ class LidarArchaeoPipeline:
|
|||||||
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
|
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
|
||||||
resolutions_str = ','.join(str(r) for r in self.resolutions)
|
resolutions_str = ','.join(str(r) for r in self.resolutions)
|
||||||
future_to_file = {
|
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): 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.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
|
||||||
for file_idx, laz_file in enumerate(files)
|
for file_idx, laz_file in enumerate(files)
|
||||||
}
|
}
|
||||||
done = 0
|
done = 0
|
||||||
@ -682,7 +715,7 @@ class LidarArchaeoPipeline:
|
|||||||
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
|
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):
|
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):
|
||||||
"""Standalone function for multiprocessing — creates its own pipeline instance.
|
"""Standalone function for multiprocessing — creates its own pipeline instance.
|
||||||
|
|
||||||
Each worker gets its own temp directory to avoid file conflicts.
|
Each worker gets its own temp directory to avoid file conflicts.
|
||||||
@ -709,7 +742,7 @@ def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, fo
|
|||||||
worker_logger.addHandler(handler)
|
worker_logger.addHandler(handler)
|
||||||
worker_logger.addFilter(_file_filter)
|
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)
|
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)
|
||||||
basename = _file_basename(laz_file_str)
|
basename = _file_basename(laz_file_str)
|
||||||
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
|
pipeline.temp_dir = pipeline.output_dir / "temp" / basename
|
||||||
pipeline.temp_dir.mkdir(exist_ok=True)
|
pipeline.temp_dir.mkdir(exist_ok=True)
|
||||||
|
|||||||
@ -567,3 +567,89 @@ class TestStripLidarExt:
|
|||||||
from lidar_pipeline.dtm import _strip_lidar_ext
|
from lidar_pipeline.dtm import _strip_lidar_ext
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
assert _strip_lidar_ext(Path("/data/input/file.copc.laz")) == "file"
|
assert _strip_lidar_ext(Path("/data/input/file.copc.laz")) == "file"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripVerticalOffsets:
|
||||||
|
"""Vertical de-bias of flight-line point sources (strip alignment)."""
|
||||||
|
|
||||||
|
def _synthetic(self, offsets, n=1_500_000, extent=300.0, seed=0):
|
||||||
|
"""Interleaved sources over one tile; each carries a known Z bias."""
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
x = rng.uniform(0, extent, n)
|
||||||
|
y = rng.uniform(0, extent, n)
|
||||||
|
z = 100 + 0.02 * x - 0.01 * y + rng.normal(0, 0.01, n)
|
||||||
|
psid = rng.integers(0, len(offsets), n)
|
||||||
|
return x, y, z + np.asarray(offsets)[psid], psid.astype(np.uint16)
|
||||||
|
|
||||||
|
def test_recovers_known_offsets(self):
|
||||||
|
"""±5 cm biases are recovered; the aligned source stays untouched."""
|
||||||
|
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||||
|
x, y, z, psid = self._synthetic((0.0, 0.05, -0.05))
|
||||||
|
offs = _strip_vertical_offsets(x, y, z, psid)
|
||||||
|
assert abs(offs.get(1, 0.0) - 0.05) < 0.01
|
||||||
|
assert abs(offs.get(2, 0.0) + 0.05) < 0.01
|
||||||
|
assert 0 not in offs # biais ~0 < seuil : pas de correction
|
||||||
|
|
||||||
|
def test_small_bias_below_threshold_ignored(self):
|
||||||
|
"""Biases under the 0.5 cm threshold trigger no correction."""
|
||||||
|
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||||
|
x, y, z, psid = self._synthetic((0.0, 0.003, -0.003))
|
||||||
|
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||||
|
|
||||||
|
def test_single_source_returns_empty(self):
|
||||||
|
"""A single point source cannot be compared: no offsets."""
|
||||||
|
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||||
|
x, y, z, psid = self._synthetic((0.05,))
|
||||||
|
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||||
|
|
||||||
|
def test_no_shared_cells_returns_empty(self):
|
||||||
|
"""Sources covering disjoint areas (no overlap) are not corrected."""
|
||||||
|
from lidar_pipeline.dtm import _strip_vertical_offsets
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
n = 200_000
|
||||||
|
x = np.concatenate([rng.uniform(0, 100, n), rng.uniform(200, 300, n)])
|
||||||
|
y = rng.uniform(0, 300, 2 * n)
|
||||||
|
z = 100 + rng.normal(0, 0.01, 2 * n)
|
||||||
|
psid = np.concatenate([np.zeros(n, np.uint16), np.ones(n, np.uint16)])
|
||||||
|
z[psid == 1] += 0.10
|
||||||
|
assert _strip_vertical_offsets(x, y, z, psid) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripAlignSidecar:
|
||||||
|
def test_sidecar_roundtrip_and_threshold(self, tmp_path):
|
||||||
|
"""Sidecar records version/threshold/offsets and matches config."""
|
||||||
|
from lidar_pipeline.dtm import (
|
||||||
|
_write_strip_align_sidecar, STRIP_ALIGN_VERSION, STRIP_ALIGN_THRESHOLD)
|
||||||
|
import json
|
||||||
|
offsets = {1049: 0.026, 1147: -0.026}
|
||||||
|
_write_strip_align_sidecar(tmp_path, "TILE", "_r0p2", offsets)
|
||||||
|
data = json.loads((tmp_path / "TILE_dtm_r0p2_stripalign.json").read_text())
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_pipeline_match_logic(self, tmp_path):
|
||||||
|
"""_strip_align_matches invalidates legacy DTMs and config changes."""
|
||||||
|
from lidar_pipeline.pipeline import LidarArchaeoPipeline
|
||||||
|
from lidar_pipeline.dtm import _write_strip_align_sidecar
|
||||||
|
import json
|
||||||
|
|
||||||
|
class P(LidarArchaeoPipeline):
|
||||||
|
def __init__(self, out, strip_align):
|
||||||
|
self.output_dir = out
|
||||||
|
self.dtm_dir = out / "DTM"
|
||||||
|
self.dtm_dir.mkdir(exist_ok=True)
|
||||||
|
self.strip_align = strip_align
|
||||||
|
|
||||||
|
p = P(tmp_path, strip_align=True)
|
||||||
|
# DTM hérité sans sidecar : à régénérer
|
||||||
|
assert not p._strip_align_matches("TILE", "_r0p2")
|
||||||
|
# Sidecar conforme : valide
|
||||||
|
_write_strip_align_sidecar(p.dtm_dir, "TILE", "_r0p2", {})
|
||||||
|
assert p._strip_align_matches("TILE", "_r0p2")
|
||||||
|
# Seuil différent : à régénérer
|
||||||
|
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")
|
||||||
|
# Calage désactivé + DTM calé : à régénérer
|
||||||
|
assert not P(tmp_path, strip_align=False)._strip_align_matches("TILE", "_r0p2")
|
||||||
|
|||||||
@ -80,6 +80,52 @@ class TestOpenness:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.exists()
|
assert result.exists()
|
||||||
|
|
||||||
|
def test_downsampled_matches_full_resolution(self, tmp_path, tmp_output_dir):
|
||||||
|
"""Factor 2: same output shape, highly correlated with factor 1.
|
||||||
|
|
||||||
|
MNT dédié à 1 m/px (structures de plusieurs dizaines de pixels, régime
|
||||||
|
de production 0,2 m/px) : la fixture synthétique partagée à 5 m/px a un
|
||||||
|
mur large de 2 px, hors régime pour valider une décimation ×2.
|
||||||
|
"""
|
||||||
|
import rasterio
|
||||||
|
from rasterio.transform import from_bounds
|
||||||
|
from lidar_pipeline import visualizations
|
||||||
|
from lidar_pipeline.visualizations import generate_openness
|
||||||
|
|
||||||
|
size = 240
|
||||||
|
x = np.linspace(0, size, size)
|
||||||
|
X, Y = np.meshgrid(x, x)
|
||||||
|
dem = 100.0 + 0.01 * X + 0.005 * Y
|
||||||
|
dem += 5.0 * np.exp(-((X - 120)**2 + (Y - 120)**2) / (2 * 40**2))
|
||||||
|
dist_wall = np.abs((X - 40) * 0.707 + (Y - 60) * 0.707) / np.sqrt(2)
|
||||||
|
dem += 1.5 * np.exp(-dist_wall**2 / (2 * 8**2))
|
||||||
|
dem -= 2.0 * np.exp(-np.abs(X - 200)**2 / (2 * 12**2))
|
||||||
|
# Sans bruit blanc : à 1 m/px un bruit σ=5 cm domine l'angle
|
||||||
|
# d'horizon à courte distance (max le long du rayon) et masque la
|
||||||
|
# géométrie que ce test cherche à valider (décimation + zoom).
|
||||||
|
dem_file = tmp_path / "dem_1m.tif"
|
||||||
|
with rasterio.open(dem_file, 'w', driver='GTiff', height=size, width=size,
|
||||||
|
count=1, dtype='float32', crs='EPSG:2154',
|
||||||
|
transform=from_bounds(660000, 6700000, 660240, 6700240, size, size)) as dst:
|
||||||
|
dst.write(dem.astype('float32'), 1)
|
||||||
|
|
||||||
|
saved = visualizations.OPENNESS_DOWNSAMPLE
|
||||||
|
try:
|
||||||
|
visualizations.OPENNESS_DOWNSAMPLE = 1
|
||||||
|
r1 = generate_openness(dem_file, "full", tmp_output_dir, 1.0, positive=True)
|
||||||
|
visualizations.OPENNESS_DOWNSAMPLE = 2
|
||||||
|
r2 = generate_openness(dem_file, "dec", tmp_output_dir, 1.0, positive=True)
|
||||||
|
finally:
|
||||||
|
visualizations.OPENNESS_DOWNSAMPLE = saved
|
||||||
|
|
||||||
|
with rasterio.open(r1) as s1, rasterio.open(r2) as s2:
|
||||||
|
a, b = s1.read(1), s2.read(1)
|
||||||
|
assert a.shape == b.shape
|
||||||
|
m = ~np.isnan(a) & ~np.isnan(b)
|
||||||
|
assert m.sum() > 0
|
||||||
|
corr = np.corrcoef(a[m], b[m])[0, 1]
|
||||||
|
assert corr > 0.97, f"corrélation openness décimée/native trop faible : {corr:.3f}"
|
||||||
|
|
||||||
|
|
||||||
class TestMSLRM:
|
class TestMSLRM:
|
||||||
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
def test_generates_tif(self, synthetic_dem, tmp_output_dir):
|
||||||
|
|||||||
@ -558,12 +558,15 @@ def test_start_next_queued_launches_after_run(tmp_path, monkeypatch):
|
|||||||
webapp._queue[:] = saved_queue
|
webapp._queue[:] = saved_queue
|
||||||
|
|
||||||
|
|
||||||
def test_build_command_default_viz_aspect():
|
def test_build_command_default_viz_panel():
|
||||||
"""Sans choix de visualisation, la commande génère uniquement aspect."""
|
"""Sans choix de visualisation, la commande génère les couches affichées."""
|
||||||
from lidar_pipeline.webapp import _build_command
|
from lidar_pipeline.webapp import _build_command, _panel_viz_steps
|
||||||
cmd = _build_command([(1054, 6882)])
|
cmd = _build_command([(1054, 6882)])
|
||||||
i = cmd.index("--only")
|
i = cmd.index("--only")
|
||||||
assert cmd[i + 1] == "aspect"
|
expected = _panel_viz_steps()
|
||||||
|
n = len(expected)
|
||||||
|
assert cmd[i + 1:i + 1 + n] == expected
|
||||||
|
assert "pos_open" in expected # panneau : slope, aspect, positive_openness
|
||||||
|
|
||||||
|
|
||||||
def test_build_command_custom_viz():
|
def test_build_command_custom_viz():
|
||||||
|
|||||||
@ -628,12 +628,24 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Sous-échantillonnage du calcul d'openness : le lancé de rayons est l'étape
|
||||||
|
# la plus coûteuse du pipeline (rayons de 100 m = 500 pas à 0,2 m/px). L'openness
|
||||||
|
# étant un champ angulaire lisse (moyenne d'horizons jusqu'à 100 m), on la
|
||||||
|
# calcule sur une grille décimée par blocs — max local pour l'openness
|
||||||
|
# positive, min pour la négative, afin de préserver les reliefs qui bornent
|
||||||
|
# l'angle d'horizon (talus, murs) — puis on la rééchantillonne à la
|
||||||
|
# résolution demandée. Coût ÷ facteur³ (cellules ÷ facteur², rayons ÷ facteur)
|
||||||
|
# ; facteur 2 ≈ ×8 plus rapide, rendu quasi identique. 1 = pleine résolution.
|
||||||
|
OPENNESS_DOWNSAMPLE = 2
|
||||||
|
|
||||||
|
|
||||||
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, shared=None):
|
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, shared=None):
|
||||||
"""Positive/Negative Openness - multi-radius ray-tracing with std normalization.
|
"""Positive/Negative Openness - multi-radius ray-tracing with std normalization.
|
||||||
|
|
||||||
Traces rays in 8 directions at 3 radii (25, 50, 100m).
|
Traces rays in 8 directions at 3 radii (25, 50, 100m) on a block-decimated
|
||||||
Results are combined with equal weight across radii, then normalized
|
grid (cf. OPENNESS_DOWNSAMPLE), then bilinearly resamples the result back
|
||||||
by standard deviation for cross-tile comparability.
|
to the requested resolution. Results are combined with equal weight across
|
||||||
|
radii, then normalized by standard deviation for cross-tile comparability.
|
||||||
"""
|
"""
|
||||||
name = "positive_openness" if positive else "negative_openness"
|
name = "positive_openness" if positive else "negative_openness"
|
||||||
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
|
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
|
||||||
@ -646,8 +658,21 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
|
|||||||
_prepare_dem_for_raycast(dem_file, shared, resolution)
|
_prepare_dem_for_raycast(dem_file, shared, resolution)
|
||||||
|
|
||||||
radii_m = [25, 50, 100]
|
radii_m = [25, 50, 100]
|
||||||
max_dist = int(max(radii_m) / res) # rayon réel en pixels, non tronqué
|
|
||||||
n_dirs = 8
|
n_dirs = 8
|
||||||
|
full_rows, full_cols = rows, cols
|
||||||
|
|
||||||
|
# Grille décimée par blocs (cf. OPENNESS_DOWNSAMPLE)
|
||||||
|
factor = max(1, int(OPENNESS_DOWNSAMPLE))
|
||||||
|
if factor > 1 and rows >= 2 * factor and cols >= 2 * factor:
|
||||||
|
r2 = (rows // factor) * factor
|
||||||
|
c2 = (cols // factor) * factor
|
||||||
|
blocks = dem[:r2, :c2].reshape(r2 // factor, factor, c2 // factor, factor)
|
||||||
|
dem = blocks.max(axis=(1, 3)) if positive else blocks.min(axis=(1, 3))
|
||||||
|
rows, cols = dem.shape
|
||||||
|
res = res * factor
|
||||||
|
logger.info(f" Grille décimée ×{factor} ({rows}×{cols}) — rééchantillonnage final")
|
||||||
|
|
||||||
|
max_dist = int(max(radii_m) / res) # rayon réel en pixels, non tronqué
|
||||||
|
|
||||||
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
|
pos_angles, neg_angles = _ray_trace_horizons(dem, rows, cols, res, n_dirs, max_dist, radii_m)
|
||||||
|
|
||||||
@ -660,6 +685,18 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
|
|||||||
# Mean across directions and radii (equal weight) — on CPU now
|
# Mean across directions and radii (equal weight) — on CPU now
|
||||||
openness = np.mean(angles, axis=(0, 1))
|
openness = np.mean(angles, axis=(0, 1))
|
||||||
openness_result = np.degrees(openness).astype(np.float32)
|
openness_result = np.degrees(openness).astype(np.float32)
|
||||||
|
|
||||||
|
# Retour à la grille pleine résolution (bilinéaire ; bord ajusté au plus
|
||||||
|
# proche voisin si les dimensions n'étaient pas divisibles par le facteur)
|
||||||
|
if (rows, cols) != (full_rows, full_cols):
|
||||||
|
from scipy.ndimage import zoom
|
||||||
|
zoomed = zoom(openness_result, factor, order=1)
|
||||||
|
if zoomed.shape != (full_rows, full_cols):
|
||||||
|
zoomed = np.pad(zoomed,
|
||||||
|
((0, full_rows - zoomed.shape[0]),
|
||||||
|
(0, full_cols - zoomed.shape[1])),
|
||||||
|
mode='edge')
|
||||||
|
openness_result = zoomed.astype(np.float32)
|
||||||
openness_result[nan_mask] = np.nan
|
openness_result[nan_mask] = np.nan
|
||||||
|
|
||||||
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
|
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
|
||||||
|
|||||||
@ -463,6 +463,20 @@ def _fallback_viz_step_names():
|
|||||||
return [KEYWORD_TO_STEP.get(k, k) for k in VIZ_LABELS]
|
return [KEYWORD_TO_STEP.get(k, k) for k in VIZ_LABELS]
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_viz_steps():
|
||||||
|
"""Couches réellement affichées par la webapp, en noms d'étapes --only.
|
||||||
|
|
||||||
|
Source unique : PANEL_VIZ (index.py) — le panneau de couches et le
|
||||||
|
sélecteur de génération sont pilotés par ce registre, les valeurs par
|
||||||
|
défaut de /api/preview et /api/generate doivent donc l'être aussi, sans
|
||||||
|
quoi une tuile générée « par défaut » serait incomplète pour le panneau.
|
||||||
|
"""
|
||||||
|
from .index import PANEL_VIZ, KEYWORD_TO_STEP
|
||||||
|
if not PANEL_VIZ:
|
||||||
|
return ["aspect"]
|
||||||
|
return [KEYWORD_TO_STEP.get(v, v) for v in PANEL_VIZ]
|
||||||
|
|
||||||
|
|
||||||
def _viz_step_labels():
|
def _viz_step_labels():
|
||||||
"""Libellés français des étapes de visualisation (clé = nom d'étape).
|
"""Libellés français des étapes de visualisation (clé = nom d'étape).
|
||||||
|
|
||||||
@ -480,7 +494,8 @@ class PreviewRequest(BaseModel):
|
|||||||
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
|
regenerate: bool = Field(False, description="Inclure les tuiles déjà générées")
|
||||||
viz: Optional[list] = Field(None,
|
viz: Optional[list] = Field(None,
|
||||||
description="Visualisations demandées, noms d'étapes du "
|
description="Visualisations demandées, noms d'étapes du "
|
||||||
"pipeline (ex: aspect, wavelet) ; défaut : aspect. "
|
"pipeline (ex: aspect, wavelet) ; défaut : les "
|
||||||
|
"couches affichées dans le panneau. "
|
||||||
"Une tuile existante est considérée faite "
|
"Une tuile existante est considérée faite "
|
||||||
"seulement si elle les possède toutes")
|
"seulement si elle les possède toutes")
|
||||||
all_missing: bool = Field(False,
|
all_missing: bool = Field(False,
|
||||||
@ -517,7 +532,7 @@ class GenerateRequest(BaseModel):
|
|||||||
viz: list = Field(None,
|
viz: list = Field(None,
|
||||||
description="Visualisations à générer, noms d'étapes du "
|
description="Visualisations à générer, noms d'étapes du "
|
||||||
"pipeline (ex: aspect, wavelet, slope) ; "
|
"pipeline (ex: aspect, wavelet, slope) ; "
|
||||||
"défaut : aspect")
|
"défaut : les couches affichées dans le panneau")
|
||||||
all_missing: bool = Field(False,
|
all_missing: bool = Field(False,
|
||||||
description="Ignorer la liste de tuiles : traiter toutes "
|
description="Ignorer la liste de tuiles : traiter toutes "
|
||||||
"les dalles LHD présentes dans input/ qui "
|
"les dalles LHD présentes dans input/ qui "
|
||||||
@ -1152,7 +1167,7 @@ def preview(req: PreviewRequest):
|
|||||||
# Webapp légère : le distant connaît input/ et output/ complets
|
# Webapp légère : le distant connaît input/ et output/ complets
|
||||||
return _proxy_api("POST", "/api/preview", json.loads(req.json()))
|
return _proxy_api("POST", "/api/preview", json.loads(req.json()))
|
||||||
names = _viz_step_names()
|
names = _viz_step_names()
|
||||||
viz = [v for v in (req.viz or []) if v] or ["aspect"]
|
viz = [v for v in (req.viz or []) if v] or _panel_viz_steps()
|
||||||
invalid = [v for v in viz if v not in names]
|
invalid = [v for v in viz if v not in names]
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -1217,7 +1232,7 @@ def _build_command(tiles, regenerate=False, ground_class="ign", bare_earth=False
|
|||||||
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
|
cmd = [sys.executable, "-u", "-m", "lidar_pipeline", str(INPUT_DIR),
|
||||||
"-o", str(OUTPUT_DIR),
|
"-o", str(OUTPUT_DIR),
|
||||||
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
|
"-r", ",".join(str(r) for r in GENERATE_RESOLUTIONS),
|
||||||
"--only", *(viz or ["aspect"]),
|
"--only", *(viz or _panel_viz_steps()),
|
||||||
"--ground-classification", ground_class,
|
"--ground-classification", ground_class,
|
||||||
"--ign-classes", ign_classes]
|
"--ign-classes", ign_classes]
|
||||||
if bare_earth:
|
if bare_earth:
|
||||||
@ -1246,7 +1261,7 @@ def _resolve_request(req):
|
|||||||
input/ peuvent avoir changé entre la mise en file et le départ du run.
|
input/ peuvent avoir changé entre la mise en file et le départ du run.
|
||||||
"""
|
"""
|
||||||
names = _viz_step_names()
|
names = _viz_step_names()
|
||||||
viz = [v for v in (req.viz or []) if v] or ["aspect"]
|
viz = [v for v in (req.viz or []) if v] or _panel_viz_steps()
|
||||||
invalid = [v for v in viz if v not in names]
|
invalid = [v for v in viz if v not in names]
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
Reference in New Issue
Block a user