Ajouter la couche relief orienté et une échelle d'openness fixe

Nouvelle visualisation relief_oriente : une image RGB unique qui fusionne
l'openness positive locale (MNT détendancé σ 10 m, rayons 5/10/20 m,
16 directions) portée par la clarté CIELAB et l'orientation des pentes
portée par la teinte. Échelle log fixe et support de 40 m sous la bande
de raccord de 100 m : dalles jointives. Calcul sur grille décimée à 0,8 m,
noyau dédié (CuPy RawKernel, numba parallèle, repli numpy) et colorisation
par table L* × teinte : ~8 s par dalle sur CPU au lieu de ~50 s.

L'openness positive et négative est normalisée par des références figées
mesurées sur 15 dalles au lieu d'un z-score par dalle, qui rendait
l'échelle de couleur non jointive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 00:05:40 +02:00
parent 2a84b76d33
commit 0d3c3ba8bc
11 changed files with 587 additions and 25 deletions

View File

@ -638,6 +638,19 @@ def generate_svf(dem_file, basename, vis_dir, resolution, shared=None):
# ; facteur 2 ≈ ×8 plus rapide, rendu quasi identique. 1 = pleine résolution.
OPENNESS_DOWNSAMPLE = 2
# Références de normalisation de l'openness (degrés : moyenne, écart-type).
# Le z-score par tuile rendait l'échelle non jointive — même ouverture
# physique, couleur différente d'une dalle à l'autre selon le relief
# environnant. Médianes inter-tuiles mesurées à 0,2 m (décimation ×2) sur
# des dalles réparties sur le territoire (plaine, bocage, forêt, montagne,
# volcans, delta, urbain, littoral). Références FIGÉES : même ouverture =
# même couleur sur toutes les tuiles.
# Rayons du lancé de rayons (mètres), moyennés à poids égaux.
OPENNESS_RADII_M = (25, 50, 100)
OPENNESS_POS_REF = (5.571, 4.112)
OPENNESS_NEG_REF = (5.974, 4.070)
def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, shared=None):
"""Positive/Negative Openness - multi-radius ray-tracing with std normalization.
@ -645,7 +658,8 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
Traces rays in 8 directions at 3 radii (25, 50, 100m) on a block-decimated
grid (cf. OPENNESS_DOWNSAMPLE), then bilinearly resamples the result back
to the requested resolution. Results are combined with equal weight across
radii, then normalized by standard deviation for cross-tile comparability.
radii, then normalized by FIXED references (OPENNESS_POS_REF /
OPENNESS_NEG_REF, degrees) so that adjacent tiles share one colour scale.
"""
name = "positive_openness" if positive else "negative_openness"
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
@ -657,7 +671,7 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
radii_m = [25, 50, 100]
radii_m = list(OPENNESS_RADII_M)
n_dirs = 8
full_rows, full_cols = rows, cols
@ -699,12 +713,10 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
openness_result = zoomed.astype(np.float32)
openness_result[nan_mask] = np.nan
# Z-score (écarts locaux en sigmas) : unités comparables entre tuiles,
# plage de rendu fixe → mosaïque de couleur homogène
valid = openness_result[~nan_mask]
if len(valid) > 0:
std_val = max(np.nanstd(valid), 0.01)
openness_result = (openness_result - np.nanmean(valid)) / std_val
# Écart aux références figées, en sigmas : même ouverture physique =
# même valeur sur toutes les tuiles → mosaïque de couleur jointive
ref_mean, ref_std = OPENNESS_POS_REF if positive else OPENNESS_NEG_REF
openness_result = (openness_result - ref_mean) / ref_std
_save_tif(output, openness_result, transform, crs)
logger.info(f" ✓ {name} terminé ({time.time()-t0:.1f}s){' [GPU]' if _gpu_mod.HAS_GPU else ''}")
@ -714,6 +726,384 @@ def generate_openness(dem_file, basename, vis_dir, resolution, positive=True, sh
return None
# ============================================================
# Relief orienté : openness locale × aspect en une seule image RGB
# ============================================================
#
# Clarté (CIELAB L*) = micro-relief : openness positive calculée sur le MNT
# détendancé (MNT − gaussienne 10 m), rayons courts 5/10/20 m, plus un léger
# ombrage directionnel. Teinte = orientation de la pente (aspect) sur le
# cercle CIELAB, à clarté constante : aucune couleur ne crée de faux relief.
# Pas de statistique par dalle (échelle log FIXE) et un support total
# (rayon 20 m + lissage 4σ = 40 m) inférieur à la bande de raccord de 100 m :
# les dalles adjacentes se raccordent sans couture.
#
# Coût maîtrisé : détendance et lancé de rayons tournent sur une grille
# décimée à ~0,8 m (bloc max, cf. OPENNESS_DOWNSAMPLE) — seule l'openness
# finale est agrandie ; noyau dédié qui n'accumule que la moyenne des angles
# (CuPy RawKernel sur GPU, numba parallèle sur CPU, numpy en dernier
# recours) ; couleurs par table pré-calculée (L* × teinte) au lieu d'une
# conversion Lab → sRGB par pixel.
RELIEF_DETREND_M = 10.0 # σ de la gaussienne retirée au MNT
RELIEF_RADII_M = (5, 10, 20) # rayons du lancé de rayons, poids égaux
RELIEF_N_DIRS = 16 # 16 directions : pas de losanges à 8 branches
RELIEF_GRID_M = 0.8 # pas de la grille de calcul décimée
RELIEF_OPEN_RANGE = (0.5, 12.0) # degrés, échelle log fixe (jointive)
RELIEF_CHROMA = 60.0 # chroma CIELAB max (atteint vers L* = 50)
RELIEF_SHADE_WEIGHT = 0.35 # part de l'ombrage directionnel dans L*
# Azimut de l'ombrage, exprimé dans le repère de l'aspect ci-dessous
# (arctan2(dy, dx), dy vers le sud) — réglage validé sur la galerie de rendus.
RELIEF_SHADE_AZIMUTH = 315.0
RELIEF_SHADE_ALTITUDE = 45.0
RELIEF_NODATA_RGB = (38, 38, 41)
_RELIEF_LUT = None
def _lab_to_srgb(L, a, b):
"""CIELAB (D65) → sRGB [0, 1], écrêté."""
fy = (L + 16) / 116
fx = fy + a / 500
fz = fy - b / 200
finv = lambda t: np.where(t > 6 / 29, t ** 3, 3 * (6 / 29) ** 2 * (t - 4 / 29))
X, Y, Z = 0.95047 * finv(fx), finv(fy), 1.08883 * finv(fz)
rgb = np.stack([3.2406 * X - 1.5372 * Y - 0.4986 * Z,
-0.9689 * X + 1.8758 * Y + 0.0415 * Z,
0.0557 * X - 0.2040 * Y + 1.0570 * Z], -1)
rgb = np.where(rgb <= 0.0031308, 12.92 * rgb,
1.055 * np.clip(rgb, 0, None) ** (1 / 2.4) - 0.055)
return np.clip(rgb, 0, 1)
def _relief_lut():
"""Table (256 niveaux de L*, 360 teintes) → sRGB uint8, calculée une fois.
La chroma décroît vers le noir et le blanc (L*(100−L*)/2500) : les
couleurs restent dans la gamme sRGB au lieu d'être écrêtées.
"""
global _RELIEF_LUT
if _RELIEF_LUT is None:
L = np.linspace(0, 100, 256)[:, None]
h = np.radians(np.arange(360))[None, :]
C = RELIEF_CHROMA * np.clip(L * (100 - L) / 2500.0, 0, 1)
_RELIEF_LUT = (_lab_to_srgb(np.broadcast_to(L, (256, 360)), C * np.cos(h), C * np.sin(h))
* 255 + 0.5).astype(np.uint8)
return _RELIEF_LUT
def _horizon_rays(res, n_dirs, radii_m):
"""Décalages (col, ligne) de chaque pas de rayon, distances et checkpoints.
Même géométrie que _ray_trace_horizons_core : direction k à l'angle
2πk/n, pas arrondis au pixel, distance = pas × résolution.
"""
max_step = max(1, int(max(radii_m) / res))
angles = np.linspace(0, 2 * np.pi, n_dirs, endpoint=False)
steps = np.arange(1, max_step + 1)
offs = np.empty((n_dirs, max_step, 2), dtype=np.int32)
offs[:, :, 0] = np.rint(np.cos(angles)[:, None] * steps)
offs[:, :, 1] = np.rint(np.sin(angles)[:, None] * steps)
dist = (steps * res).astype(np.float32)
cps = np.array(sorted(max(1, min(int(r / res), max_step)) for r in radii_m), dtype=np.int32)
return offs, dist, cps
_HORIZON_CUDA_SRC = r'''
extern "C" __global__
void mean_horizon(const float* dem, const int* offs, const float* dist,
const int* cps, const int rows, const int cols,
const int nd, const int ns, const int nr, float* out) {
long idx = (long)blockDim.x * blockIdx.x + threadIdx.x;
if (idx >= (long)rows * cols) return;
int i = idx / cols, j = idx % cols;
float z0 = dem[idx], acc = 0.f;
for (int d = 0; d < nd; ++d) {
float run = 0.f;
int k = 0;
for (int s = 0; s < ns; ++s) {
int ii = i + offs[(d * ns + s) * 2 + 1];
int jj = j + offs[(d * ns + s) * 2];
if (ii >= 0 && ii < rows && jj >= 0 && jj < cols)
run = fmaxf(run, (dem[(long)ii * cols + jj] - z0) / dist[s]);
while (k < nr && s + 1 >= cps[k]) { acc += atanf(run); ++k; }
}
while (k < nr) { acc += atanf(run); ++k; }
}
out[idx] = acc / (float)(nd * nr);
}
'''
_horizon_cuda_kernel = None
_horizon_numba_kernel = None
def _mean_horizon_gpu(dem, offs, dist, cps):
"""Un thread CUDA par pixel ; tout reste en registres (aucun tableau
intermédiaire par direction ou par rayon)."""
global _horizon_cuda_kernel
cp = _gpu_mod._cp
if _horizon_cuda_kernel is None:
_horizon_cuda_kernel = cp.RawKernel(_HORIZON_CUDA_SRC, 'mean_horizon')
rows, cols = dem.shape
d_dem = cp.ascontiguousarray(cp.asarray(dem, dtype=cp.float32))
out = cp.empty((rows, cols), dtype=cp.float32)
n = rows * cols
threads = 256
_horizon_cuda_kernel(((n + threads - 1) // threads,), (threads,),
(d_dem, cp.asarray(offs), cp.asarray(dist), cp.asarray(cps),
np.int32(rows), np.int32(cols), np.int32(offs.shape[0]),
np.int32(offs.shape[1]), np.int32(len(cps)), out))
return out
def _mean_horizon_numba(dem, offs, dist, cps):
"""Même noyau en numba parallèle (une ligne de pixels par thread CPU)."""
global _horizon_numba_kernel
if _horizon_numba_kernel is None:
from numba import njit, prange
@njit(parallel=True, cache=True, fastmath=True)
def _kernel(dem, offs, dist, cps):
rows, cols = dem.shape
nd, ns, nr = offs.shape[0], offs.shape[1], cps.shape[0]
out = np.empty((rows, cols), dtype=np.float32)
for i in prange(rows):
for j in range(cols):
z0 = dem[i, j]
acc = 0.0
for d in range(nd):
run = 0.0
k = 0
for s in range(ns):
ii = i + offs[d, s, 1]
jj = j + offs[d, s, 0]
if ii >= 0 and ii < rows and jj >= 0 and jj < cols:
t = (dem[ii, jj] - z0) / dist[s]
if t > run:
run = t
while k < nr and s + 1 >= cps[k]:
acc += math.atan(run)
k += 1
while k < nr:
acc += math.atan(run)
k += 1
out[i, j] = acc / (nd * nr)
return out
_horizon_numba_kernel = _kernel
return _horizon_numba_kernel(np.ascontiguousarray(dem, dtype=np.float32), offs, dist, cps)
def _mean_horizon_numpy(dem, offs, dist, cps):
"""Repli vectorisé (sans numba ni GPU) : un décalage de tableau par pas."""
rows, cols = dem.shape
pad = int(np.abs(offs).max())
padded = np.pad(dem.astype(np.float32), pad, constant_values=np.nan)
acc = np.zeros((rows, cols), dtype=np.float64)
for d in range(offs.shape[0]):
run = np.zeros((rows, cols), dtype=np.float32)
k = 0
for s in range(offs.shape[1]):
px, py = offs[d, s]
view = padded[pad + py:pad + py + rows, pad + px:pad + px + cols]
run = np.fmax(run, (view - dem) / dist[s])
while k < len(cps) and s + 1 >= cps[k]:
acc += np.arctan(run)
k += 1
while k < len(cps):
acc += np.arctan(run)
k += 1
return (acc / (offs.shape[0] * len(cps))).astype(np.float32)
def _mean_horizon_angle(dem, res, n_dirs, radii_m):
"""Moyenne (directions × rayons) de l'angle d'horizon positif, en radians.
GPU (CuPy RawKernel) si disponible — le résultat reste alors sur le GPU —,
sinon numba, sinon numpy. Un échec GPU (compilation NVRTC, VRAM) bascule
sur le CPU pour ce calcul sans perdre la visualisation.
"""
offs, dist, cps = _horizon_rays(res, n_dirs, radii_m)
if _gpu_mod.is_gpu_active():
try:
return _mean_horizon_gpu(dem, offs, dist, cps), "GPU"
except Exception as e:
logger.warning(f" ⚠ Noyau GPU relief orienté indisponible ({e}) — repli CPU")
dem = to_cpu(dem)
try:
return _mean_horizon_numba(dem, offs, dist, cps), "numba"
except ImportError:
return _mean_horizon_numpy(dem, offs, dist, cps), "numpy"
def _pad_to(m, arr, rows, cols):
"""Complète par répétition du bord (dimensions non multiples du facteur)."""
if arr.shape == (rows, cols):
return arr
return m.pad(arr[:rows, :cols], ((0, max(0, rows - arr.shape[0])), (0, max(0, cols - arr.shape[1]))),
mode='edge')
def _relief_params():
"""Constantes scalaires du rendu (ombrage sans trigonométrie par pixel).
cos(pente) = 1/√(1+g²) et sin(pente)·cos(az − aspect) = (cos az·dx +
sin az·dy)/√(1+g²), avec aspect = arctan2(dy, dx) : l'ombrage ne coûte
qu'une racine par pixel.
"""
zen = math.radians(90.0 - RELIEF_SHADE_ALTITUDE)
az = math.radians(RELIEF_SHADE_AZIMUTH)
lo, hi = RELIEF_OPEN_RANGE
return (math.cos(zen), math.sin(zen) * math.cos(az), math.sin(zen) * math.sin(az),
lo, 1.0 / math.log(hi / lo), RELIEF_SHADE_WEIGHT)
def _relief_colorize_xp(m, openness, dx, dy, lut):
"""Colorisation vectorisée (CuPy sur GPU, numpy en repli)."""
cz, sa_x, sa_y, lo, inv_log, w = _relief_params()
inv = 1.0 / m.sqrt(1.0 + dx * dx + dy * dy)
shade = m.clip((cz + sa_x * dx + sa_y * dy) * inv / cz, 0, 1.25) / 1.25
t = m.clip(m.log(m.maximum(openness, 1e-3) / lo) * inv_log, 0, 1)
L = 12.0 + 84.0 * ((1 - w) * (1 - t) + w * shade)
del inv, shade, t
li = m.clip(m.rint(L * 2.55), 0, 255).astype(m.int32)
hue = m.mod(m.rint(m.degrees(m.arctan2(dy, dx))), 360).astype(m.int32)
return lut[li, hue]
_relief_color_numba_kernel = None
def _relief_colorize_numba(open_c, f, dx, dy, lut):
"""Colorisation CPU en une passe (numba parallèle) : l'openness est lue
sur la grille décimée par interpolation bilinéaire alignée sur les centres
de pixels (équivalent de xp_zoom), sans tableau pleine résolution
intermédiaire."""
global _relief_color_numba_kernel
if _relief_color_numba_kernel is None:
from numba import njit, prange
@njit(parallel=True, cache=True, fastmath=True)
def _kernel(open_c, f, dx, dy, lut, cz, sa_x, sa_y, lo, inv_log, w):
rows, cols = dx.shape
rc, cc = open_c.shape
out = np.empty((rows, cols, 3), dtype=np.uint8)
for i in prange(rows):
u = (i + 0.5) / f - 0.5
u = min(max(u, 0.0), rc - 1.0)
i0 = int(u)
i1 = min(i0 + 1, rc - 1)
fu = u - i0
for j in range(cols):
v = (j + 0.5) / f - 0.5
v = min(max(v, 0.0), cc - 1.0)
j0 = int(v)
j1 = min(j0 + 1, cc - 1)
fv = v - j0
o = ((1 - fu) * ((1 - fv) * open_c[i0, j0] + fv * open_c[i0, j1])
+ fu * ((1 - fv) * open_c[i1, j0] + fv * open_c[i1, j1]))
gx = dx[i, j]
gy = dy[i, j]
inv = 1.0 / math.sqrt(1.0 + gx * gx + gy * gy)
sh = (cz + sa_x * gx + sa_y * gy) * inv / cz
sh = min(max(sh, 0.0), 1.25) / 1.25
t = math.log(max(o, 1e-3) / lo) * inv_log
t = min(max(t, 0.0), 1.0)
L = 12.0 + 84.0 * ((1 - w) * (1 - t) + w * sh)
li = min(max(int(round(L * 2.55)), 0), 255)
h = int(round(math.degrees(math.atan2(gy, gx)))) % 360
out[i, j, 0] = lut[li, h, 0]
out[i, j, 1] = lut[li, h, 1]
out[i, j, 2] = lut[li, h, 2]
return out
_relief_color_numba_kernel = _kernel
return _relief_color_numba_kernel(np.ascontiguousarray(open_c), float(f),
np.ascontiguousarray(dx, dtype=np.float32),
np.ascontiguousarray(dy, dtype=np.float32),
lut, *_relief_params())
def generate_relief_oriente(dem_file, basename, vis_dir, resolution, shared=None):
"""Relief orienté : image RGB unique fusionnant openness locale et aspect.
L* (clarté) = openness positive locale (MNT détendancé, rayons courts)
65 % + ombrage directionnel 35 % ; teinte = orientation de la pente ;
chroma CIELAB fixe. Sortie : GeoTIFF RGB uint8 (rendu tel quel, comme
les fonds IGN).
"""
gpu_tag = " [GPU]" if _gpu_mod.HAS_GPU else ""
logger.info(f" → Relief orienté (openness locale × aspect){gpu_tag}...")
t0 = time.time()
output = vis_dir / f"{basename}_relief_oriente.tif"
try:
dem, dem_np, rows, cols, res, nan_mask, transform, crs = \
_prepare_dem_for_raycast(dem_file, shared, resolution)
res = float(res)
use_gpu = _gpu_mod.is_gpu_active()
filled = (shared.filled_gpu if shared is not None and use_gpu else None)
if filled is None:
filled = to_gpu(dem) if use_gpu else dem.astype(np.float32)
# 1. Grille décimée : bloc max (préserve les reliefs qui bornent
# l'horizon) et bloc moyen (support de la tendance à retirer)
f = max(1, int(round(RELIEF_GRID_M / res)))
if rows < 2 * f or cols < 2 * f:
f = 1
r2, c2 = (rows // f) * f, (cols // f) * f
blocks = filled[:r2, :c2].reshape(r2 // f, f, c2 // f, f)
coarse_max = blocks.max(axis=(1, 3))
coarse_mean = blocks.mean(axis=(1, 3))
res_c = res * f
# 2. Détendance (MNT − gaussienne) sur la grille décimée
trend = xp_gaussian_filter(coarse_mean, RELIEF_DETREND_M / res_c)
detrended = coarse_max - trend
del blocks, coarse_max, coarse_mean, trend
t_prep = time.time() - t0
# 3. Openness locale : angle d'horizon moyen (radians) sur la grille décimée
t1 = time.time()
angle, engine = _mean_horizon_angle(detrended, res_c, RELIEF_N_DIRS, RELIEF_RADII_M)
t_ray = time.time() - t1
on_gpu = engine == "GPU"
del detrended
# 4. Couleurs : agrandissement de l'openness, ombrage, table L* × teinte
t2 = time.time()
if shared is not None:
dy, dx = shared.dy, shared.dx
else:
dy, dx = np.gradient(to_cpu(filled).astype(np.float32), res)
lut = _relief_lut()
if on_gpu:
openness = _gpu_mod.xp_zoom(xp.degrees(angle), f, order=1) if f > 1 else xp.degrees(angle)
openness = _pad_to(xp, xp.asarray(openness), rows, cols)
rgb = to_cpu(_relief_colorize_xp(xp, openness, to_gpu(dx), to_gpu(dy), xp.asarray(lut)))
del openness
gpu_cleanup()
engine_color = "GPU"
else:
angle = to_cpu(angle)
try:
rgb = _relief_colorize_numba(np.degrees(angle).astype(np.float32), f, dx, dy, lut)
engine_color = "numba"
except ImportError:
openness = _gpu_mod.xp_zoom(np.degrees(angle), f, order=1) if f > 1 else np.degrees(angle)
rgb = _relief_colorize_xp(np, _pad_to(np, openness, rows, cols), dx, dy, lut)
engine_color = "numpy"
del angle, dx, dy
t_color = time.time() - t2
rgb[nan_mask] = RELIEF_NODATA_RGB
_save_tif(output, np.moveaxis(rgb, -1, 0), transform, crs, dtype='uint8', count=3)
logger.info(f" ✓ Relief orienté terminé ({time.time()-t0:.1f}s : "
f"préparation {t_prep:.1f}s, rayons {t_ray:.1f}s [{engine}], "
f"couleurs {t_color:.1f}s [{engine_color}])")
return output
except Exception as e:
logger.error(f" ✗ Erreur relief orienté: {e}", exc_info=True)
return None
def generate_mslrm(dem_file, basename, vis_dir, resolution, shared=None):
"""Multi-Scale Relief Model (MSRM) - LRM at adaptive scales combined (GPU if available).