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