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