diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py
index 5f29b3a..198729f 100644
--- a/lidar_pipeline/index.py
+++ b/lidar_pipeline/index.py
@@ -201,7 +201,7 @@ 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.
-DEFAULT_LAYERS = ('slope', 'aspect', 'positive_openness', 'roughness')
+DEFAULT_LAYERS = ('slope', 'aspect', 'positive_openness')
# Couche « principale » (mise en avant ; index_tiles.json + affichage en
# direct de la webapp).
@@ -273,6 +273,12 @@ _CARTO_SUBTILED_VIZ = ()
_SUBTILE_AVIF_QUALITY = 55
_SUBTILE_AVIF_SPEED = 8
+# Vignette de sous-tuile (px) : à la vue d'ensemble chaque sous-tuile active
+# décode sa vignette en navigateur — 160 px couvre l'affichage jusqu'à ~220 px
+# à l'écran et réduit la mémoire décodée d'un facteur ~2,5 vs 256 px. La taille
+# est encodée dans le nom de fichier : la changer invalide le cache.
+_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.
@@ -751,6 +757,18 @@ 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:
@@ -765,12 +783,33 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
# porte un suffixe ?v= d'invalidation : on l'ôte pour le chemin.
src = output_dir / info['full'].split('?')[0]
src_mtime = _mtime(src)
- all_exist = all(
- _cached_file_fresh(output_dir / sub_dir_name / (stem + '.avif'), src_mtime)
- and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_thumb.webp'), src_mtime)
- and _cached_file_fresh(output_dir / sub_dir_name / (stem + '_mid.webp'), src_mtime)
- for stem in stems.values())
- if not all_exist:
+
+ 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')
+ for stem in stems.values())
+ thumbs_fresh = all(_fresh(stem + thumb_suffix) for stem in stems.values())
+ if heavy_fresh and not thumbs_fresh:
+ logger.info(f" Vignettes de sous-tuiles recalculées : "
+ 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:
+ q.load()
+ if q.mode not in ('RGB', 'L'):
+ q = q.convert('RGB')
+ _save_thumb(q, stem)
+ # 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:
+ logger.debug(f"Re-découpe des vignettes impossible ({viz_key}) : {e}")
+ heavy_fresh = False
+ if not heavy_fresh:
logger.info(f" Sous-tuiles recalculées : {tile['dir_name']}/{viz_key} "
f"({len(stems)} découpages)")
try:
@@ -799,8 +838,10 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
f"repli dalle entière pour {viz_key} : {e}")
cut_failed = True
break
- # Supprime l'ancien crop .webp d'une génération précédente
+ # 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:
@@ -808,19 +849,14 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
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)
- scale = min(1.0, 256 / max(quad.size))
- if scale < 1.0:
- quad = quad.resize((max(1, int(quad.size[0] * scale)),
- max(1, int(quad.size[1] * scale))), resample)
- quad.save(str(output_dir / sub_dir_name / (stem + '_thumb.webp')),
- format='WEBP', quality=80)
+ _save_thumb(quad, stem)
if cut_failed:
_fallback_full_dalle(entries, viz_key, info)
continue
v = _url_version(src_mtime)
for (i, j), stem in stems.items():
entries[(i, j)]['viz'][viz_key] = {
- 'thumb': f"{sub_dir_name}/{stem}_thumb.webp{v}",
+ 'thumb': f"{sub_dir_name}/{stem}{thumb_suffix}{v}",
'mid': f"{sub_dir_name}/{stem}_mid.webp{v}",
'full': f"{sub_dir_name}/{stem}.avif{v}",
}
@@ -2092,6 +2128,7 @@ function syncTileLayers() {
img.dataset.layer = k;
img.alt = '';
img.decoding = 'async';
+ img.loading = 'lazy';
div.appendChild(img);
entry.imgs[k] = img;
}
@@ -2102,38 +2139,96 @@ function syncTileLayers() {
updateImages();
}
-// Bascule vignette (256 px) ↔ vignette intermédiaire (640 px) ↔ image
-// pleine résolution selon le zoom : en vue d'ensemble la vignette suffit ;
-// au-delà de ~900 px à l'écran on charge l'image complète pour conserver
-// les détails, l'intermédiaire faisant la transition. En dézoomant on
-// retombe sur la vignette pour libérer la mémoire. Visibilité et tailles
-// sont calculées en coordonnées projetées : aucun reflow.
-const PRELOAD_MARGIN_PX = 300;
-const FULL_RES_PX = 900;
+// Bascule vignette ↔ vignette intermédiaire (640 px) ↔ image pleine
+// résolution selon le zoom, avec trois garde-fous mémoire :
+// - hors de l'écran (+ marge de préchargement), l'
est vidée de son
+// src : ni téléchargement ni décodage pour les tuiles éloignées (et à
+// l'ouverture de la page, seules les tuiles visibles se chargent) ;
+// - la pleine résolution (≈ 25 Mo décodés par AVIF 2500²) ne se charge
+// que pour les sous-tuiles à l'écran — l'anneau de 150 px ne précharge
+// que le palier intermédiaire, pour que les tuiles qui entrent à l'écran
+// lors d'un pan soient déjà nettes ;
+// - le nombre d'images pleine résolution est plafonné (FULL_CAP) : au-delà,
+// les sous-tuiles les plus éloignées du centre retombent sur le palier
+// intermédiaire. En dézoomant, chaque palier libère la mémoire décodée.
+// Les changements de palier passent par setImgSrc : l'image est pré-décodée
+// en tâche de fond puis échangée d'un coup — jamais de flash blanc au zoom
+// ou au pan. Visibilité et tailles sont calculées en coordonnées projetées :
+// aucun reflow.
+const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran
+const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide)
+const BIG_PX = 220; // en dessous, la vignette suffit
+const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire
+const FULL_CAP = 24; // images pleine résolution max, toutes couches
+
+// Change le src d'une
sans flash : l'image est d'abord téléchargée et
+// décodée hors écran, le src n'est posé qu'une fois prête (le cache du
+// navigateur sert alors la bitmap décodée instantanément). dataset.want
+// invalide l'échange si un updateImages plus récent a décidé d'un autre
+// palier entre-temps (pan rapide pendant le décodage).
+function setImgSrc(img, url) {
+ if (img.src.endsWith(url)) { img.dataset.want = url; return; }
+ img.dataset.want = url;
+ const pre = new Image();
+ pre.decoding = 'async';
+ pre.src = url;
+ const apply = () => { if (img.dataset.want === url) img.src = url; };
+ if (pre.decode) pre.decode().then(apply, apply);
+ else apply();
+}
function updateImages() {
if (!map._loaded) return; // vue pas encore posée (fitView) : rien à faire
const zoom = map.getZoom();
- const b = map.getBounds();
- const swP = map.project(b.getSouthWest(), zoom);
- const neP = map.project(b.getNorthEast(), zoom);
- const visBounds = L.latLngBounds(
- map.unproject(L.point(swP.x - PRELOAD_MARGIN_PX, swP.y + PRELOAD_MARGIN_PX), zoom),
- map.unproject(L.point(neP.x + PRELOAD_MARGIN_PX, neP.y - PRELOAD_MARGIN_PX), zoom));
+ const vp = map.getBounds();
+ const swP = map.project(vp.getSouthWest(), zoom);
+ const neP = map.project(vp.getNorthEast(), zoom);
+ const ctrP = L.point((swP.x + neP.x) / 2, (swP.y + neP.y) / 2);
+ const expand = (margin) => L.latLngBounds(
+ map.unproject(L.point(swP.x - margin, swP.y + margin), zoom),
+ map.unproject(L.point(neP.x + margin, neP.y - margin), zoom));
+ const visBounds = expand(PRELOAD_MARGIN_PX);
+ const midBounds = expand(MID_MARGIN_PX);
+ // Candidats pleine résolution : triés du plus proche au plus lointain du
+ // centre, seuls les FULL_CAP premiers l'obtiennent.
+ const fullWant = [];
+ const plan = [];
for (const entry of markers.values()) {
const visible = visBounds.intersects(entry.bounds);
+ const onScreen = vp.intersects(entry.bounds);
+ const near = midBounds.intersects(entry.bounds);
const p = tileSize(entry);
- const big = visible && (p.w > 300 || p.h > 300);
+ const big = near && (p.w > BIG_PX || p.h > BIG_PX);
+ const dist = map.project(entry.bounds.getCenter(), zoom).distanceTo(ctrP);
for (const k in entry.imgs) {
- const v = entry.t.viz[k];
- if (!v) continue;
- // Les couches quasi transparentes n'ont pas besoin de pleine résolution
- let want = v.thumb;
- if (big && state.layers.opacity[k] > 0.15) {
- want = (v.mid && Math.max(p.w, p.h) < FULL_RES_PX) ? v.mid : v.full;
- }
const img = entry.imgs[k];
- if (!img.src.endsWith(want)) img.src = want;
+ const v = entry.t.viz[k];
+ if (!visible || !v) { plan.push([img, null]); continue; }
+ let want = v.thumb;
+ // Les couches quasi transparentes n'ont pas besoin de pleine résolution
+ if (big && state.layers.opacity[k] > 0.15) {
+ if (!onScreen) {
+ want = v.mid || v.thumb; // anneau : préchargement intermédiaire
+ } else if (Math.max(p.w, p.h) < FULL_RES_PX) {
+ want = v.mid || v.full;
+ } else {
+ fullWant.push({ img: img, v: v, dist: dist });
+ continue;
+ }
+ }
+ plan.push([img, want]);
+ }
+ }
+ fullWant.sort((a, b) => a.dist - b.dist);
+ fullWant.forEach((c, i) => plan.push(
+ [c.img, i < FULL_CAP ? c.v.full : (c.v.mid || c.v.thumb)]));
+ for (const pair of plan) {
+ const img = pair[0], want = pair[1];
+ if (!want) {
+ img.dataset.want = '';
+ if (img.getAttribute('src') !== null) img.removeAttribute('src');
+ } else {
+ setImgSrc(img, want);
}
}
}
@@ -2310,11 +2405,8 @@ const BUILTIN_PRESETS = [
{ label: 'Cavités', on: ['slope', 'positive_openness'],
opacity: { slope: 0.5, positive_openness: 1 },
osm: { on: false } },
- { label: 'Rugosité', on: ['roughness'],
- opacity: {},
- osm: { on: true, opacity: 0.3 } },
- { label: 'Tout', on: ['slope', 'aspect', 'positive_openness', 'roughness'],
- opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55, roughness: 0.8 },
+ { label: 'Tout', on: ['slope', 'aspect', 'positive_openness'],
+ opacity: { slope: 0.8, aspect: 0.8, positive_openness: 0.55 },
osm: { on: true, opacity: 0.85, dark: true } },
];
diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py
index ae04341..8d4b87d 100644
--- a/lidar_pipeline/tests/test_index.py
+++ b/lidar_pipeline/tests/test_index.py
@@ -239,12 +239,12 @@ def test_build_index_generates_html(tmp_path):
assert "Couches" in app_js or "layers" in app_js
# Barre de génération : sélecteur restreint au panneau (PANEL_VIZ =
# DEFAULT_LAYERS ; valeurs = noms d'étapes --only). Les couches hors
- # panneau (svf, hillshade, wavelet) ne sont plus proposées.
+ # panneau (svf, hillshade, wavelet, roughness) ne sont plus proposées.
assert 'id="genViz"' in content
assert 'value="slope"' in content
assert 'value="pos_open"' in content
assert 'value="aspect"' in content
- assert 'value="roughness"' in content
+ assert 'value="roughness"' not in content
assert 'value="wavelet"' not in content
assert 'value="svf"' not in content
assert 'value="hillshade"' not in content