Borner le comblement du MNT à l'enveloppe des points, ajouter la couche précision et un affichage relief/précision, encoder les sous-tuiles une seule fois en q75

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 15:07:04 +02:00
parent c5097e21a0
commit 90dbe61f3b
17 changed files with 994 additions and 593 deletions

View File

@ -6,7 +6,7 @@ Ce module produit aujourd'hui les artefacts dont le pipeline et la carte ont
besoin :
- registres partagés : VIZ_LABELS/VIZ_LEGENDS (libellés, légendes), défauts
d'affichage (DEFAULT_LAYERS/OPACITY/BLEND), correspondances mot-clé ↔
d'affichage (DEFAULT_VIZ, PRECISION_VIZ, VIEW_MODES), correspondances mot-clé ↔
étape du pipeline (KEYWORD_TO_STEP) ;
- vignettes (index_thumbs/) et sous-tuiles (index_subtiles/) servant de
paliers sources à la pyramide XYZ (tiles.py) ;
@ -46,6 +46,7 @@ VIZ_LABELS = {
'solar': 'Éclairage solaire',
'anomaly': 'Carte d\'anomalies',
'relief_oriente': 'Relief orienté',
'densite_sol': 'Précision (densité de points sol)',
'ortho': 'Orthophoto IGN',
'topo': 'Carte topographique IGN',
}
@ -185,6 +186,15 @@ VIZ_LEGENDS = {
'gradient': None,
'ticks': None,
},
'densite_sol': {
'title': 'Précision géométrique (densité de points sol)',
'legend': 'Points sol retenus pour le MNT, par m² (moyenne sur 3 × 3 m)\n16 gris, échelle log fixe : 2 niveaux = densité doublée\nNoir = ≤ 0,35 pt/m² ou aucun point (relief interpolé ou absent)\nBlanc = ≥ 45 pts/m²',
'description': 'Où le relief est mesuré (clair) et où il est interpolé (sombre) — en « produit » sur le relief, assombrit les zones peu fiables',
'cmap': 'gray',
'gradient': ('#000000', '#202020', '#404040', '#606060', '#808080',
'#a0a0a0', '#c0c0c0', '#e0e0e0', '#ffffff'),
'ticks': ('≤ 0,35 pt/m²', '≥ 45 pts/m²'),
},
'ortho': {
'title': 'Photographie Aérienne IGN',
'legend': 'Orthophotographie\nImage aérienne',
@ -203,21 +213,14 @@ VIZ_LEGENDS = {
},
}
# Couches activées par défaut à l'ouverture de la carte : seules celles-ci
# sont allumées, toutes les autres sont désactivées (mais restent accessibles
# via le panneau). Ordre = pile basse → haute : une couche opaque (opacité 1)
# doit rester en bas pour ne pas masquer les autres. Le relief orienté
# fusionne déjà openness et aspect en une seule image (plus d'empilement).
DEFAULT_LAYERS = ('relief_oriente',)
# Opacité par défaut des couches allumées (0–1) ; les absentes restent à 1.
DEFAULT_OPACITY = {}
# Mode de fusion (mix-blend-mode CSS) par couche : les couches d'ombrage en
# niveaux de gris fondent en « multiply » — elles assombrissent le relief sans
# délaver la rampe de couleurs en dessous, contrairement à la transparence.
# Les couches absentes restent en « normal » (simple transparence).
DEFAULT_BLEND = {'hillshade_multi': 'multiply', 'solar': 'multiply', 'svf': 'multiply'}
# Affichage de la carte : UNE couche principale (DEFAULT_VIZ) et la couche
# « précision » (densité de points sol), montrée seule ou superposée en
# « produit » sur la principale (assombrit les zones interpolées sans masquer
# le relief). Plus de pile de couches réordonnable.
PRECISION_VIZ = 'densite_sol'
VIEW_MODES = ('relief', 'precision', 'both') # principale / précision / les deux
DEFAULT_VIEW_MODE = 'relief'
DEFAULT_PRECISION_OPACITY = 0.6 # opacité de la précision en mode « les deux »
# Couche « principale » (mise en avant ; index_tiles.json + affichage en
# direct de la webapp) : le relief orienté, qui fusionne openness et aspect.
@ -228,7 +231,7 @@ DEFAULT_VIZ = 'relief_oriente'
# carte (panneau, tuiles XYZ, TileJSON, WMTS). Les autres visualisations
# restent calculables avec --only mais ne sont plus proposées.
# None = toutes les visualisations présentes sur disque.
PANEL_VIZ = ('relief_oriente',)
PANEL_VIZ = ('relief_oriente', 'densite_sol')
# Correspondance mot-clé de fichier de sortie → nom d'étape --only du pipeline
# (les trois visualisations dont le nom de sortie diffère du nom d'étape,
@ -255,14 +258,13 @@ def step_to_keyword(step):
return STEP_TO_KEYWORD.get(step, step)
def default_layers_present(all_viz_keys):
"""Couches activées par défaut (DEFAULT_LAYERS) présentes sur le disque.
Ordre conservé (DEFAULT_LAYERS) ; sert de base à l'état initial du
panneau (toutes les autres couches sont désactivées).
"""
present = set(all_viz_keys)
return [v for v in DEFAULT_LAYERS if v in present]
def default_main_layer(all_viz_keys):
"""Couche principale par défaut présente sur le disque (DEFAULT_VIZ, sinon
la première qui n'est pas la précision), ou None."""
keys = [k for k in all_viz_keys if k != PRECISION_VIZ]
if DEFAULT_VIZ in keys:
return DEFAULT_VIZ
return keys[0] if keys else None
def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
@ -289,11 +291,15 @@ def cells_with_all_viz(vis_dir, viz_keys, resolutions=(0.5,)):
# les viz exclues retombent en repli dalle entière.
_CARTO_SUBTILED_VIZ = ()
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
# speed 0 (lent/meilleur) → 10 (rapide) ; 6+8 quasi identique en taille,
# on garde 8 (2× plus rapide) pour 2500×2500 px.
_SUBTILE_AVIF_QUALITY = 55
# Encodage AVIF des sous-tuiles : q75 en 4:2:0, encodé UNE fois depuis le
# raster d'origine (write_subtiles appelé par tif_to_crop). Mesuré sur le
# relief orienté (2 dalles réelles, galerie de compression) : l'ancienne
# chaîne dalle q60 → sous-tuile q55 donnait 18,1 dB / SSIM 0,84 pour
# 3,9 Mo par dalle ; q75 unique 19,5 dB / SSIM 0,93 pour 8,8 Mo. Au-delà,
# le 4:2:0 plafonne (la teinte du relief, pixel par pixel, est moyennée par
# 2 × 2) : seul le 4:4:4 irait plus loin (q75 : 28 dB, 14 Mo).
# speed 9 : encodage rapide (cf. rendering.AVIF_SPEED).
_SUBTILE_AVIF_QUALITY = 75
_SUBTILE_AVIF_SPEED = 9
# Vignette de sous-tuile (px) : à la vue d'ensemble chaque sous-tuile active
@ -303,11 +309,15 @@ _SUBTILE_AVIF_SPEED = 9
_SUBTILE_THUMB_PX = 160
# Couches à contenu photographique ou traits fins (orthophoto, carte topo) :
# q55, calé sur des rampes de couleur, bave textes et linéaires — qualité
# relevée pour ces couches uniquement.
# qualité propre (égale aujourd'hui à celle des rampes de couleur).
_SUBTILE_AVIF_QUALITY_DETAIL = 75
_SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'})
# Couches en aplats de niveaux codés (cf. rendering.LOSSLESS_GRAY_KEYWORDS) :
# sous-tuiles en WebP sans perte (niveaux de gris ; 3× plus léger que l'AVIF
# q100, seul AVIF exact) et vignette intermédiaire sans perte.
_SUBTILE_LOSSLESS_VIZ = frozenset({'densite_sol'})
# Vignette intermédiaire (px) : pallier entre la vignette 256 px et l'image
# pleine résolution, pour éviter d'étirer la vignette ou de décoder l'AVIF
# complet dès qu'une tuile dépasse ~300 px à l'écran.
@ -759,6 +769,74 @@ def _fallback_full_dalle(entries, viz_key, info):
entry['viz'][viz_key] = dict(info)
def _subtile_ext(viz_key):
"""Extension des sous-tuiles pleine résolution d'une couche."""
return '.webp' if viz_key in _SUBTILE_LOSSLESS_VIZ else '.avif'
def _save_subtile_thumb(quad, path):
"""Écrit la vignette de sous-tuile (redimensionnée à _SUBTILE_THUMB_PX)."""
from PIL import Image as PILImage
scale = min(1.0, _SUBTILE_THUMB_PX / max(quad.size))
out = quad
if scale < 1.0:
out = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), PILImage.LANCZOS)
out.save(str(path), format='WEBP', quality=80)
def write_subtiles(output_dir, dir_name, viz_key, img, k, sub_dir_name='index_subtiles'):
"""Découpe une image de dalle (PIL, nord en haut) en k × k sous-tuiles :
pleine résolution, vignette intermédiaire et vignette.
Appelée par build_index depuis l'image de dalle, et par le pipeline
(rendering.tif_to_crop) directement depuis le raster d'origine : la
sous-tuile ne subit alors qu'un seul encodage avec perte (réencoder la
dalle AVIF cumulait deux pertes). Encodage : AVIF 4:2:0 q75
(_SUBTILE_AVIF_QUALITY, q75 aussi pour ortho/topo), WebP sans perte pour
les aplats de niveaux (_SUBTILE_LOSSLESS_VIZ). Lève en cas d'échec.
"""
from PIL import Image as PILImage
output_dir = Path(output_dir)
out_dir = output_dir / sub_dir_name
out_dir.mkdir(parents=True, exist_ok=True)
lossless = viz_key in _SUBTILE_LOSSLESS_VIZ
ext = _subtile_ext(viz_key)
other_ext = '.avif' if ext == '.webp' else '.webp'
quality = (_SUBTILE_AVIF_QUALITY_DETAIL if viz_key in _SUBTILE_DETAIL_VIZ
else _SUBTILE_AVIF_QUALITY)
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
if lossless:
img = img.convert('L')
W, H = img.size
for j in range(k):
for i in range(k):
stem = f"{dir_name}_{viz_key}_{i}_{j}"
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
left, right = round(W * i / k), round(W * (i + 1) / k)
top = round(H * (1 - (j + 1) / k))
bottom = round(H * (1 - j / k))
quad = img.crop((left, top, right, bottom))
if lossless:
quad.save(str(out_dir / (stem + ext)), format='WEBP', lossless=True)
else:
quad.save(str(out_dir / (stem + ext)), format='AVIF', quality=quality,
subsampling='4:2:0', speed=_SUBTILE_AVIF_SPEED)
# Anciens fichiers d'une génération précédente (autre format,
# vignette 256 px sans taille dans le nom)
(out_dir / (stem + other_ext)).unlink(missing_ok=True)
(out_dir / (stem + '_thumb.webp')).unlink(missing_ok=True)
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
mid_img = quad
if mid_scale < 1.0:
mid_img = quad.resize((max(1, int(quad.size[0] * mid_scale)),
max(1, int(quad.size[1] * mid_scale))), PILImage.LANCZOS)
mid_img.save(str(out_dir / (stem + '_mid.webp')), format='WEBP',
**({'lossless': True} if lossless else {'quality': 82}))
_save_subtile_thumb(quad, out_dir / (stem + f"_thumb{_SUBTILE_THUMB_PX}.webp"))
def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
"""Découpe une dalle en sous-tuiles (crops AVIF) pour la carte interactive.
@ -796,42 +874,32 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
}
try:
resample = getattr(PILImage, 'LANCZOS', 1)
thumb_suffix = f"_thumb{_SUBTILE_THUMB_PX}.webp"
def _save_thumb(quad, stem):
"""Écrit la vignette de sous-tuile (redimensionnée à _SUBTILE_THUMB_PX)."""
scale = min(1.0, _SUBTILE_THUMB_PX / max(quad.size))
out = quad
if scale < 1.0:
out = quad.resize((max(1, int(quad.size[0] * scale)),
max(1, int(quad.size[1] * scale))), resample)
out.save(str(output_dir / sub_dir_name / (stem + thumb_suffix)),
format='WEBP', quality=80)
for viz_key in offered_viz_keys:
info = tile['viz'].get(viz_key)
if not info:
continue
avif_quality = (_SUBTILE_AVIF_QUALITY_DETAIL
if viz_key in _SUBTILE_DETAIL_VIZ
else _SUBTILE_AVIF_QUALITY)
ext = _subtile_ext(viz_key)
stems = {key: f"{tile['dir_name']}_{viz_key}_{key[0]}_{key[1]}"
for key in entries}
# Régénère si au moins un fichier manque ou est périmé (dalle
# source recalculée depuis — comme les vignettes). L'URL pleine
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
# Des sous-tuiles écrites par le pipeline depuis le raster
# d'origine (tif_to_crop) sont plus récentes que la dalle : elles
# sont gardées telles quelles (un seul encodage avec perte).
src = output_dir / info['full'].split('?')[0]
src_mtime = _mtime(src)
def _fresh(name):
return _cached_file_fresh(output_dir / sub_dir_name / name, src_mtime)
# AVIF + vignette intermédiaire d'un côté, vignettes de l'autre :
# un changement de taille de vignette (dalle source inchangée)
# re-découpe depuis les AVIF de sous-tuiles déjà présents, sans
# ré-encoder ces derniers (des minutes de CPU par rebuild).
heavy_fresh = all(_fresh(stem + '.avif') and _fresh(stem + '_mid.webp')
# Pleine résolution + vignette intermédiaire d'un côté, vignettes
# de l'autre : un changement de taille de vignette (dalle source
# inchangée) re-découpe depuis les sous-tuiles déjà présentes, sans
# ré-encoder ces dernières (des minutes de CPU par rebuild).
heavy_fresh = all(_fresh(stem + ext) and _fresh(stem + '_mid.webp')
for stem in stems.values())
thumbs_fresh = all(_fresh(stem + thumb_suffix) for stem in stems.values())
if heavy_fresh and not thumbs_fresh:
@ -839,11 +907,11 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
f"{tile['dir_name']}/{viz_key} ({len(stems)} découpages)")
try:
for stem in stems.values():
with PILImage.open(str(output_dir / sub_dir_name / (stem + '.avif'))) as q:
with PILImage.open(str(output_dir / sub_dir_name / (stem + ext))) as q:
q.load()
if q.mode not in ('RGB', 'L'):
q = q.convert('RGB')
_save_thumb(q, stem)
_save_subtile_thumb(q, output_dir / sub_dir_name / (stem + thumb_suffix))
# Vignette 256 px d'une génération précédente
(output_dir / sub_dir_name / (stem + '_thumb.webp')).unlink(missing_ok=True)
except Exception as e:
@ -871,38 +939,11 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
_fallback_full_dalle(entries, viz_key, info)
if img is None:
continue
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
W, H = img.size
cut_failed = False
for (i, j), stem in stems.items():
# Image : ligne 0 = nord → la sous-tuile j (nord) est en haut
left, right = round(W * i / k), round(W * (i + 1) / k)
top = round(H * (1 - (j + 1) / k))
bottom = round(H * (1 - j / k))
quad = img.crop((left, top, right, bottom))
try:
quad.save(str(output_dir / sub_dir_name / (stem + '.avif')),
format='AVIF', quality=avif_quality,
speed=_SUBTILE_AVIF_SPEED)
except Exception as e:
logger.warning(f"Encodage AVIF impossible ({stem}), "
f"repli dalle entière pour {viz_key} : {e}")
cut_failed = True
break
# Supprime les anciens crops d'une génération précédente
# (repli .webp, vignette 256 px sans taille dans le nom)
(output_dir / sub_dir_name / (stem + '.webp')).unlink(missing_ok=True)
(output_dir / sub_dir_name / (stem + '_thumb.webp')).unlink(missing_ok=True)
mid_scale = min(1.0, _MID_THUMB_SIZE / max(quad.size))
mid_img = quad
if mid_scale < 1.0:
mid_img = quad.resize((max(1, int(quad.size[0] * mid_scale)),
max(1, int(quad.size[1] * mid_scale))), resample)
mid_img.save(str(output_dir / sub_dir_name / (stem + '_mid.webp')),
format='WEBP', quality=82)
_save_thumb(quad, stem)
if cut_failed:
try:
write_subtiles(output_dir, tile['dir_name'], viz_key, img, k, sub_dir_name)
except Exception as e:
logger.warning(f"Encodage des sous-tuiles impossible ({tile['dir_name']}), "
f"repli dalle entière pour {viz_key} : {e}")
_fallback_full_dalle(entries, viz_key, info)
continue
# Chaque URL est versionnée par la mtime de SON fichier (cf.
@ -916,7 +957,7 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
entries[(i, j)]['viz'][viz_key] = {
'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{_file_v(stem + thumb_suffix)}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{_file_v(stem + '_mid.webp')}",
'full': f"{sub_dir_name}/{stem}.avif{_file_v(stem + '.avif')}",
'full': f"{sub_dir_name}/{stem}{ext}{_file_v(stem + ext)}",
}
except Exception as e:
logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}")