From 342a0d221339d6039b121ec71a64ad50b66a1391 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 20 Sep 2026 00:26:18 +0200 Subject: [PATCH] =?UTF-8?q?Vue=20d=C3=A9zoom=C3=A9e=20en=20mosa=C3=AFque?= =?UTF-8?q?=20d'overview=20et=20cache=20immutable=20des=20images=20version?= =?UTF-8?q?n=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEPLOY_WEBAPP.md | 21 ++ lidar_pipeline/index.py | 412 ++++++++++++++++++++++++++-- lidar_pipeline/tests/test_index.py | 96 ++++++- lidar_pipeline/tests/test_webapp.py | 29 +- lidar_pipeline/webapp.py | 55 +++- 5 files changed, 563 insertions(+), 50 deletions(-) diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index c497cc2..79d2872 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -262,6 +262,27 @@ des générations. > `serve-webapp.sh` le fait automatiquement). Le téléphone accepte alors > l'avertissement « certificat non fiable » une fois. +## Performance réseau (HTTP/2) + +Le serveur interne (uvicorn) ne parle que **HTTP/1.1** : le navigateur y +limite chaque origine à 6 connexions simultanées. Peu contraignant depuis +l'optimisation de la carte (URLs versionnées servies `Cache-Control: +immutable` — zéro revalidation une fois chargées — et mosaïque d'overview à +la place des centaines de vignettes en vue dézoomée), mais pour un premier +chargement ou un parcours intensif, **passer par Traefik (HTTPS)** active +HTTP/2 entre le navigateur et le proxy : toutes les requêtes se multiplexent +sur une seule connexion (fin de la file d'attente des 6 connexions). + +- **Via l'entrée `websecure` de Traefik** (route `https://lidar.example.fr`…) + : HTTP/2 est déjà actif — comportement par défaut de Traefik sur TLS. + Vérification : `curl -sI --http2 https:/// | head -1` → `HTTP/2 200`. +- **En direct sur `http://:8973`** (LAN) : HTTP/1.1 — les navigateurs + ne négocient pas h2 en clair. Si le besoin s'en fait sentir, exposer une + seconde route Traefik vers le même service (h2 côté client, le proxy + parlant h1 à uvicorn en backend). +- Entre Traefik et le conteneur, rien à changer : la limite qui compte est + celle du navigateur vers le frontend. + ## Mise à jour Sur le Pi, après un `git pull` : diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 2559a64..4ce6cb9 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -305,6 +305,18 @@ _SUBTILE_DETAIL_VIZ = frozenset({'ortho', 'topo'}) # complet dès qu'une tuile dépasse ~300 px à l'écran. _MID_THUMB_SIZE = 640 +# Mosaïque d'overview (vue dézoomée) : une image WebP par couche couvrant +# toute la grille, assemblée depuis les vignettes des dalles. Sous un seuil +# de zoom l'interface l'affiche à la place des vignettes individuelles : +# 1 requête et ~30 Mo décodés par couche au lieu de centaines. La résolution +# est plafonnée : l'overview n'est affiché que tant qu'il reste au moins 1:1 +# avec l'écran (jamais flou). +_OVERVIEW_DIR = 'index_overview' +_OVERVIEW_MIN_DALLES = 6 # grille plus petite : les vignettes suffisent +_OVERVIEW_MAX_PX = 3072 # côté max de la mosaïque (~28 Mo décodés) +_OVERVIEW_PX_PER_KM = 256 # résolution cible (1:1 avec les vignettes) +_OVERVIEW_QUALITY = 80 + # Ordre préféré des couches (bas → haut de pile) et choix de la vignette de repli. _VIZ_FALLBACK_ORDER = [ 'hillshade_multi', 'svf', 'slope', 'mslrm', 'positive_openness', @@ -622,13 +634,13 @@ def _cached_file_fresh(path, src_mtime): def _url_version(mtime): """Suffixe d'invalidation de cache pour une URL image, ou '' si inconnu. - Les images de la carte sont servies par des montages statiques sans - Cache-Control : après une régénération, le cache heuristique du navigateur - peut continuer d'afficher l'ancien rendu (même URL = image considérée - fraîche). En suffixant chaque URL de la mtime de la source (?v=ms), un - recalcul change l'URL et force le rechargement — y compris en direct - pendant un run : pollLiveTiles fusionne les nouvelles données et - updateImages() voit un src différent. + Les images de la carte sont servies avec un cache immutable quand l'URL + porte ?v= : le suffixe DOIT donc identifier le contenu du fichier servi, + pas celui de sa source (une vignette recalculée après coup, ou un cache + webapp rapatrié, change de contenu sans que la source bouge). Chaque URL + est versionnée par la mtime de SON fichier. Un recalcul change l'URL et + force le rechargement — y compris en direct pendant un run : pollLiveTiles + fusionne les nouvelles données et updateImages() voit un src différent. """ return f"?v={int(mtime * 1000)}" if mtime is not None else "" @@ -876,12 +888,18 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): if cut_failed: _fallback_full_dalle(entries, viz_key, info) continue - v = _url_version(src_mtime) + # Chaque URL est versionnée par la mtime de SON fichier (cf. + # _url_version) : le chemin court « vignettes seules recalculées » + # ci-dessus ne change pas les URLs des AVIF/mid non réécrits — + # le cache immutable du navigateur reste valable pour eux. + def _file_v(name): + return _url_version(_mtime(output_dir / sub_dir_name / name)) + for (i, j), stem in stems.items(): entries[(i, j)]['viz'][viz_key] = { - '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}", + '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')}", } except Exception as e: logger.warning(f"Sous-tuilage abandonné pour {tile['dir_name']}: {e}") @@ -899,6 +917,108 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name): return usable +def _build_overview(dalles, output_dir): + """Assemble la mosaïque d'overview par couche pour la vue dézoomée. + + Une image WebP unique couvre toute la grille L93 des dalles (les cases + sans dalle restent transparentes), assemblée depuis les vignettes + index_thumbs (256 px/km) : au dézoom l'interface l'affiche à la place des + vignettes individuelles — 1 requête et ~30 Mo décodés par couche au lieu + de centaines. Résolution plafonnée à 256 px/km et 3072 px de côté : + l'overview n'est affiché que tant qu'il reste au moins 1:1 avec l'écran, + jamais étiré donc jamais flou. + + Args: + dalles: enregistrements de dalles (zone_tile_records) avec col/row, + corners WGS84 et viz[k]['thumb'] (URL relative à output_dir). + output_dir: dossier de sortie racine. + + Returns: + {viz_key: {url, w, h, px_per_km, km_w, km_h, corners}} ou {} si + grille trop petite, coins manquants ou PIL indisponible. + """ + if len(dalles) < _OVERVIEW_MIN_DALLES: + return {} + try: + from PIL import Image as PILImage + except ImportError: + logger.warning("PIL indisponible — overview non générée") + return {} + have = [t for t in dalles if t.get('corners')] + if not have: + return {} + min_col = min(t['col'] for t in have) + max_col = max(t['col'] for t in have) + min_row = min(t['row'] for t in have) + max_row = max(t['row'] for t in have) + # Lattice exact des coins (km L93 → WGS84) : les coins partagés de dalles + # voisines proviennent de la même transformation pyproj → cohérents, la + # mosaïque s'emboîte exactement comme les vignettes individuelles. + lattice = {} + for t in have: + c = t['corners'] + lattice[(t['col'], t['row'])] = c[0] # SW + lattice[(t['col'] + 1, t['row'])] = c[1] # SE + lattice[(t['col'] + 1, t['row'] + 1)] = c[2] # NE + lattice[(t['col'], t['row'] + 1)] = c[3] # NW + quad = [(min_col, min_row), (max_col + 1, min_row), + (max_col + 1, max_row + 1), (min_col, max_row + 1)] + if any(k not in lattice for k in quad): + return {} + km_w = max_col + 1 - min_col + km_h = max_row + 1 - min_row + px_per_km = max(4, min(_OVERVIEW_PX_PER_KM, + _OVERVIEW_MAX_PX // max(km_w, km_h))) + w, h = km_w * px_per_km, km_h * px_per_km + out_dir = output_dir / _OVERVIEW_DIR + out_dir.mkdir(parents=True, exist_ok=True) + try: + resample = PILImage.Resampling.LANCZOS + except AttributeError: + resample = getattr(PILImage, 'LANCZOS', 1) + viz_keys = [] + for t in have: + for k in t['viz']: + if k not in viz_keys: + viz_keys.append(k) + overview = {} + for viz_key in viz_keys: + canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0)) + pasted = 0 + for t in have: + info = t['viz'].get(viz_key) + if not info: + continue + src = output_dir / info['thumb'].split('?')[0] + try: + with PILImage.open(str(src)) as im: + cell = im.convert('RGBA') + except Exception as e: + logger.debug(f"Overview : vignette illisible {src.name} : {e}") + continue + if cell.size != (px_per_km, px_per_km): + cell = cell.resize((px_per_km, px_per_km), resample) + # Image ligne 0 = nord : la dalle de row max est en haut. + canvas.paste(cell, ((t['col'] - min_col) * px_per_km, + (max_row - t['row']) * px_per_km)) + pasted += 1 + if not pasted: + continue + out = out_dir / f"{viz_key}.webp" + try: + canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY) + except Exception as e: + logger.warning(f"Overview {viz_key} non écrite : {e}") + continue + overview[viz_key] = { + 'url': f"{_OVERVIEW_DIR}/{out.name}{_url_version(_mtime(out))}", + 'w': w, 'h': h, 'px_per_km': px_per_km, + 'km_w': km_w, 'km_h': km_h, + 'corners': [lattice[k] for k in quad], + } + return overview + + def _viz_src_dir(tile, info): """Dossier réel du fichier d'une visualisation. @@ -1068,13 +1188,18 @@ def build_index(output_dir, output_format='avif'): else: thumbs_generated += 1 viz_dir_name = _viz_src_dir(t, info).name - v = _url_version(src_mtime) + # Chaque URL versionnée par la mtime de SON fichier (cf. + # _url_version) : cache navigateur immutable possible. viz_thumbs[viz_key] = { - 'thumb': f"index_thumbs/{thumb_name}{v}", - 'full': f"visualisations/{viz_dir_name}/{info['filename']}{v}", + 'thumb': f"index_thumbs/{thumb_name}" + f"{_url_version(_mtime(thumb_path))}", + # La dalle source EST le fichier servi : sa mtime suffit. + 'full': f"visualisations/{viz_dir_name}/{info['filename']}" + f"{_url_version(src_mtime)}", } if mid_path.is_file(): - viz_thumbs[viz_key]['mid'] = f"index_thumbs/{mid_name}{v}" + viz_thumbs[viz_key]['mid'] = ( + f"index_thumbs/{mid_name}{_url_version(_mtime(mid_path))}") if regen: logger.info(f" [{tile_idx}/{n_tiles}] {t['dir_name']} — " @@ -1131,12 +1256,22 @@ def build_index(output_dir, output_format='avif'): if n_sous: logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") + # Mosaïque d'overview (vue dézoomée) : une image par couche couvrant + # toute la grille, assemblée depuis les vignettes des dalles. + dalle_records = [t for zr in zone_records for t in zr['tiles']] + overview = _build_overview(dalle_records, output_dir) + if overview: + first = next(iter(overview.values())) + logger.info(f" Overview : {len(overview)} couche(s) " + f"({first['w']}×{first['h']} px, {first['px_per_km']} px/km)") + # Export des données de carte (index_tiles.json) : la webapp les sert via # /api/tiles pour afficher les tuiles terminées en direct, sans recharger # la page — le pipeline le réécrit après chaque tuile en mode incrémental. ordered_viz, default_viz = _panel_selection(all_viz_keys) (output_dir / 'index_tiles.json').write_text(json.dumps({ 'tiles': display_tiles, + 'overview': overview, 'viz_meta': {k: {'label': VIZ_LABELS.get(k, k)} for k in ordered_viz}, 'stats': {'default_viz': default_viz, 'default_layers': default_layers_present(all_viz_keys), @@ -1146,7 +1281,8 @@ def build_index(output_dir, output_format='avif'): }, ensure_ascii=False), encoding='utf-8') _write_assets(output_dir) - html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format) + html = _render_html(display_tiles, global_bbox, all_viz_keys, output_format, + overview) html_path = output_dir / 'index.html' html_path.write_text(html, encoding='utf-8') @@ -1222,11 +1358,12 @@ def _json_for_script(obj): return json.dumps(obj, ensure_ascii=False).replace(" @@ -1715,6 +1854,14 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; } } .ltile:hover { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } .ltile.selected { outline-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } +/* Overview (mosaïque unique de la vue dézoomée) : pas de contour ni de + survol — il couvre toute la grille —, hors content-visibility (l'ancre + est un coin de grille souvent hors écran alors que la mosaïque couvre + l'écran : le navigateur pourrait sauter sa peinture) et l'échange de + palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle + affichée : jamais de trou ni de flash au dézoom. */ +.ltile.loverview { outline: none; pointer-events: none; content-visibility: visible; } +.ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; } /* --- Fiche tuile (onglet Tuile) --- */ .tile-info { padding: 6px 6px 10px; } @@ -2200,6 +2347,139 @@ function recomputeGridBounds() { for (const t of TILES) registerTile(t); recomputeGridBounds(); +// ============================================================ +// Overview : 1 image par couche pour la vue dézoomée +// ============================================================ +// La mosaïque (index_overview) couvre toute la grille ; tant qu'un kilomètre +// affiché reste plus petit que sa résolution (px_per_km), elle remplace +// toutes les vignettes : 1 requête et ~30 Mo décodés par couche au lieu de +// centaines. La bascule ne se joue qu'à zoomend/moveend — jamais pendant +// l'animation, le pane met alors le palier sortant à l'échelle d'un bloc — +// et sans trou : le gagnant se pré-décode (setImgSrc), et le perdant ne +// s'efface (fondu 300 ms) puis ne libère son src qu'une fois le gagnant +// affiché. +let ovEntry = null; // marqueur overview (même structure qu'une tuile) +let ovOn = false; // palier courant = overview ? +let ovLoser = null; // bascule en cours : {imgs, winners, since, phase} +const OV_HOLD_POLL_MS = 120; // sondage « gagnant affiché ? » +const OV_FADE_MS = 340; // fondu du perdant + marge de libération +const OV_HOLD_CAP_MS = 2500; // délai maxi avant de libérer quand même + +const ovMeta = () => { for (const k in OVERVIEW) return OVERVIEW[k]; return null; }; + +function registerOverview() { + const meta = ovMeta(); + if (!meta || ovEntry || !map) return; + // Ancre = coin NW de la grille : la matrice mappe l'image depuis (0,0). + const nw = meta.corners[3]; + const icon = L.divIcon({ className: 'ltile-anchor', + html: '
', + iconSize: [0, 0] }); + const mk = L.marker(L.latLng(nw[0], nw[1]), { + icon: icon, interactive: false, keyboard: false, + pane: 'lidarTiles', zIndexOffset: -100000 + }).addTo(map); + const el = mk.getElement(); + ovEntry = { + mk: mk, div: el ? el.querySelector('.ltile') : null, imgs: {}, + latlngs: meta.corners.map(c => L.latLng(c[0], c[1])), + bounds: L.latLngBounds(meta.corners) + }; +} + +// Homographie 4 correspondances → matrix3d CSS (colonne-major), ou null. +// La grille L93 n'est pas un parallélogramme exact en Mercator à l'échelle +// de la zone : la projection exacte des 4 coins fait tomber la mosaïque sur +// ceux des dalles (emboîtement parfait avec les vignettes). +function quadMatrix3d(src, dst) { + const A = [], b = []; + for (let i = 0; i < 4; i++) { + const x = src[i][0], y = src[i][1], u = dst[i][0], v = dst[i][1]; + A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]); b.push(u); + A.push([0, 0, 0, x, y, 1, -v * x, -v * y]); b.push(v); + } + const n = 8; + for (let c = 0; c < n; c++) { + let piv = c; + for (let r = c + 1; r < n; r++) + if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r; + if (Math.abs(A[piv][c]) < 1e-9) return null; + const tA = A[c]; A[c] = A[piv]; A[piv] = tA; + const tb = b[c]; b[c] = b[piv]; b[piv] = tb; + for (let r = 0; r < n; r++) { + if (r === c) continue; + const f = A[r][c] / A[c][c]; + for (let k = c; k < n; k++) A[r][k] -= f * A[c][k]; + b[r] -= f * b[c]; + } + } + const h = []; + for (let i = 0; i < n; i++) h.push(b[i] / A[i][i]); + const h11 = h[0], h21 = h[1], h31 = h[2], h12 = h[3], h22 = h[4], + h32 = h[5], h13 = h[6], h23 = h[7]; + return [h11, h12, 0, h13, h21, h22, 0, h23, 0, 0, 1, 0, h31, h32, 0, 1]; +} + +function layoutOverview() { + if (!ovEntry || !ovEntry.div) return; + const meta = ovMeta(); + if (!meta) return; + const zoom = map.getZoom(); + const anchor = map.project(ovEntry.latlngs[3], zoom); + // Coins image : NW(0,0) NE(w,0) SE(w,h) SW(0,h) — soit corners[3,2,1,0]. + const dst = [3, 2, 1, 0].map(i => { + const p = map.project(ovEntry.latlngs[i], zoom); + return [p.x - anchor.x, p.y - anchor.y]; + }); + const m = quadMatrix3d([[0, 0], [meta.w, 0], [meta.w, meta.h], [0, meta.h]], dst); + const div = ovEntry.div; + div.style.width = meta.w + 'px'; + div.style.height = meta.h + 'px'; + div.style.transformOrigin = '0 0'; + div.style.transform = m + ? 'matrix3d(' + m.join(',') + ')' + : 'translate(-9999px, -9999px)'; // homographie insoluble : hors écran +} + +// Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution +// de la mosaïque (affichée au pire à 1:1, jamais étirée donc jamais floue). +function overviewWanted() { + const meta = ovMeta(); + if (!meta || !map._loaded) return false; + const zoom = map.getZoom(); + const sw = map.project(meta.corners[0], zoom), se = map.project(meta.corners[1], zoom); + return Math.hypot(se.x - sw.x, se.y - sw.y) / meta.km_w <= meta.px_per_km; +} + +// Libération du palier perdant : seulement quand le gagnant est affiché +// (img.complete), au pire après OV_HOLD_CAP_MS — jamais de trou blanc. +function ovTick() { + if (!ovLoser) return; + const now = performance.now(); + if (ovLoser.phase === 'hold') { + const ready = !ovLoser.winners.length || + ovLoser.winners.every(i => i.complete); + if (ready || now - ovLoser.since > OV_HOLD_CAP_MS) { + ovLoser.phase = 'fade'; + for (const img of ovLoser.imgs) img.classList.add('fading'); + ovLoser.timer = setTimeout(ovTick, OV_FADE_MS); + return; + } + ovLoser.timer = setTimeout(ovTick, OV_HOLD_POLL_MS); + return; + } + // fade terminé : on libère le src (et la mémoire décodée) + for (const img of ovLoser.imgs) { + img.classList.remove('fading'); + delete img.dataset.loser; + img.dataset.want = ''; + img.removeAttribute('src'); + } + ovLoser = null; +} + +registerOverview(); + function entryDiv(entry) { if (!entry.div) { const el = entry.mk.getElement(); @@ -2250,6 +2530,7 @@ function layoutTiles() { entry.proj = { w: w, h: h }; entry.projZoom = zoom; } + layoutOverview(); // Resynchronise aussi les : au premier rendu les marqueurs viennent // tout juste d'entrer dans le DOM et syncTileLayers() initial les avait ratés. syncTileLayers(); @@ -2280,6 +2561,32 @@ function syncTileLayers() { div.appendChild(img); entry.imgs[k] = img; } + img.classList.remove('fading'); + img.style.zIndex = String(i + 1); + img.style.opacity = String(state.layers.opacity[k]); + img.style.mixBlendMode = state.layers.blend[k] || 'normal'; + }); + } + // Overview : mêmes couches actives, une par couche disponible. + if (ovEntry && ovEntry.div) { + for (const k of Object.keys(ovEntry.imgs)) { + if (act.includes(k) && OVERVIEW[k]) continue; + ovEntry.imgs[k].src = ''; + ovEntry.imgs[k].remove(); + delete ovEntry.imgs[k]; + } + act.forEach((k, i) => { + if (!OVERVIEW[k]) return; + let img = ovEntry.imgs[k]; + if (!img) { + img = document.createElement('img'); + img.dataset.layer = k; + img.alt = ''; + img.decoding = 'async'; + ovEntry.div.appendChild(img); + ovEntry.imgs[k] = img; + } + img.classList.remove('fading'); img.style.zIndex = String(i + 1); img.style.opacity = String(state.layers.opacity[k]); img.style.mixBlendMode = state.layers.blend[k] || 'normal'; @@ -2338,6 +2645,19 @@ function updateImages() { map.unproject(L.point(neP.x + margin, neP.y - margin), zoom)); const visBounds = expand(PRELOAD_MARGIN_PX); const midBounds = expand(MID_MARGIN_PX); + // Bascule de palier overview ↔ vignettes (uniquement ici : zoomend/moveend, + // jamais pendant l'animation de zoom). À la bascule le perdant garde son + // src (ovTick le libère une fois le gagnant affiché) : pas de trou. + const wantOv = ovEntry ? overviewWanted() : false; + if (wantOv !== ovOn) { + if (ovLoser) { + clearTimeout(ovLoser.timer); + for (const img of ovLoser.imgs) { img.classList.remove('fading'); delete img.dataset.loser; } + } + ovOn = wantOv; + ovLoser = { imgs: [], winners: [], since: performance.now(), + phase: 'hold', timer: 0 }; + } // Candidats pleine résolution : triés du plus proche au plus lointain du // centre, seuls les FULL_CAP premiers l'obtiennent. const fullWant = []; @@ -2352,7 +2672,20 @@ function updateImages() { for (const k in entry.imgs) { const img = entry.imgs[k]; const v = entry.t.viz[k]; - if (!visible || !v) { plan.push([img, null]); continue; } + if (!visible || !v || ovOn) { + // Overview actif : les vignettes visibles deviennent perdantes + // (libérées après affichage du gagnant), les autres se déchargent. + if (ovOn && visible && img.getAttribute('src') !== null) { + img.dataset.want = ''; + if (ovLoser && !img.dataset.loser) { + img.dataset.loser = '1'; + ovLoser.imgs.push(img); + } + continue; + } + 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) { @@ -2368,6 +2701,23 @@ function updateImages() { plan.push([img, want]); } } + // Overview : chargée quand son palier est actif, perdante sinon. + if (ovEntry) { + for (const k in ovEntry.imgs) { + const img = ovEntry.imgs[k]; + if (ovOn && OVERVIEW[k]) { + plan.push([img, OVERVIEW[k].url]); + } else if (img.getAttribute('src') !== null) { + img.dataset.want = ''; + if (ovLoser && !img.dataset.loser) { + img.dataset.loser = '1'; + ovLoser.imgs.push(img); + } + } else { + plan.push([img, null]); + } + } + } 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)])); @@ -2377,9 +2727,18 @@ function updateImages() { img.dataset.want = ''; if (img.getAttribute('src') !== null) img.removeAttribute('src'); } else { + img.classList.remove('fading'); setImgSrc(img, want); } } + if (ovLoser) { + if (!ovLoser.imgs.length) { ovLoser = null; return; } + // Gagnants de CE tour : tout qui vient de recevoir un src. Le + // perdant ne s'efface qu'une fois ceux-là affichés (ovTick). + ovLoser.winners = plan.filter(p => p[1]).map(p => p[0]); + if (!ovLoser.timer) + ovLoser.timer = setTimeout(ovTick, OV_HOLD_POLL_MS); + } } // ============================================================ @@ -3919,6 +4278,21 @@ function mergeTilesData(data) { metaChanged = true; } } + // Overview neuve (fin de rebuild pendant un run) : mosaïque recalculée → + // nouvelle URL ?v= → l' de la couche se recharge. + let ovChanged = false; + if (data.overview) { + for (const k of Object.keys(data.overview)) { + const cur = OVERVIEW[k]; + if (cur && cur.url === data.overview[k].url) continue; + OVERVIEW[k] = data.overview[k]; + ovChanged = true; + } + if (ovChanged && !ovEntry) { + registerOverview(); + layoutOverview(); + } + } if (changed) { recomputeGridBounds(); const statTiles = document.getElementById('statTiles'); @@ -3932,7 +4306,7 @@ function mergeTilesData(data) { } } if (changed || metaChanged) renderLayerPanel(); - if (changed) { + if (changed || ovChanged) { syncTileLayers(); layoutTiles(); // Une dalle qui vient d'apparaître prend le relais du polygone de // sélection (surlignage du marqueur désormais possible) diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 49cd1fc..7f0b073 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -512,11 +512,10 @@ def test_build_subtiles_regenerates_stale_crops(tmp_path): def test_urls_versioned_for_cache_busting(tmp_path): """Les URLs images changent quand une tuile est régénérée. - Les images sont servies par des montages statiques sans Cache-Control : - sans version d'URL, le navigateur garde son ancien rendu en cache même - après recalcul. Chaque URL (vignette, intermédiaire, pleine) porte la - mtime de sa source en ?v= — un recalcul change l'URL et force le - rechargement, y compris en direct pendant un run (--incremental-index). + Les URLs versionnées (?v=) sont servies avec un cache immutable : un + recalcul DOIT changer l'URL pour forcer le rechargement, y compris en + direct pendant un run (--incremental-index). Chaque URL porte la mtime + de SON fichier (cf. test_urls_versioned_by_served_file_mtime). """ import os import re @@ -700,3 +699,90 @@ def test_approx_wgs84_to_l93_roundtrip(): lon, lat = _approx_l93_to_wgs84(x, y) x2, y2 = _approx_wgs84_to_l93(lon, lat) assert abs(x2 - x) < 1e-6 and abs(y2 - y) < 1e-6 + +def test_overview_generated_for_large_grids(tmp_path): + """Grille >= 6 dalles : une mosaïque d'overview par couche + le payload.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + for col in (1000, 1001, 1002): + for row in (6881, 6882): + _make_fake_viz_dir(vis_dir, "t", col, row, ('hillshade_multi', 'svf')) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + ov = data['overview'] + assert 'hillshade_multi' in ov and 'svf' in ov + meta = ov['hillshade_multi'] + assert (output_dir / meta['url'].split('?')[0]).is_file() + assert meta['km_w'] == 3 and meta['km_h'] == 2 + assert meta['w'] == 3 * meta['px_per_km'] + assert meta['h'] == 2 * meta['px_per_km'] + assert len(meta['corners']) == 4 + # La coquille HTML embarque la constante pour l'interface + html = (output_dir / "index.html").read_text(encoding='utf-8') + assert "const OVERVIEW" in html + assert "index_overview/hillshade_multi.webp" in html + + +def test_overview_skipped_for_small_grids(tmp_path): + """Grille < 6 dalles : pas d'overview, les vignettes suffisent.""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',)) + _make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',)) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + assert data['overview'] == {} + + +def test_urls_versioned_by_served_file_mtime(tmp_path): + """?v= suit la mtime du fichier SERVI — prérequis du cache immutable. + + Une vignette recalculée après coup (source inchangée) change de contenu : + son URL doit changer aussi, sans toucher celles des fichiers intacts. + """ + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + _make_fake_viz_dir(vis_dir, "a", 1000, 6881, ('hillshade_multi',)) + # 0,2 m : la dalle passe par le sous-tuilage (vignettes/mid/AVIF propres) + _make_fake_viz_dir(vis_dir, "b", 1001, 6881, ('hillshade_multi',), + res_suffix='_r0p2') + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + import time + for t in data['tiles']: + for viz in t['viz'].values(): + for tier in ('thumb', 'mid', 'full'): + url = viz.get(tier) + if not url or '?v=' not in url: + continue + path, v = url.split('?v=') + assert v.isdigit() + # La version est la mtime ms du fichier servi lui-même + # (tolérance 1 s : granularité du système de fichiers). + assert abs(int(v) - int((output_dir / path).stat().st_mtime * 1000)) < 1000 + + # Vignette 0,5 m recalculée après coup : seule SON URL change. + tiles05 = [t for t in data['tiles'] if t.get('resolution') == 0.5] + url_before = tiles05[0]['viz']['hillshade_multi']['thumb'] + thumb_path = output_dir / url_before.split('?')[0] + time.sleep(0.05) + from PIL import Image as PILImage + PILImage.new('RGB', (30, 30), (200, 10, 10)).save(str(thumb_path), format='JPEG') + assert build_index(output_dir) is not None + data2 = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + tiles05b = [t for t in data2['tiles'] if t.get('resolution') == 0.5] + url_after = tiles05b[0]['viz']['hillshade_multi']['thumb'] + assert url_after.split('?')[0] == url_before.split('?')[0] + assert url_after != url_before diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index 4b18199..4c1dadd 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -385,9 +385,10 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch): "stats": {"n_tiles": 2}}), encoding="utf-8") (tmp_path / "index.html").write_text( "", encoding="utf-8") + + ";\nconst OVERVIEW = {};\n", encoding="utf-8") monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: { "tiles": [dist_neuve, dist_seule], + "overview": {"hillshade_multi": {"url": "index_overview/x.webp?v=2"}}, "viz_meta": {"aspect": {"label": "Aspect distant"}}, "stats": {"n_tiles": 2}, }) @@ -398,10 +399,12 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch): assert "distante-neuve" in names and "locale" in names and "distante-seule" in names assert "locale-perimee" not in names assert data["viz_meta"]["aspect"]["label"] == "Aspect distant" - # La coquille embarque la même liste fusionnée. + # La coquille embarque la même liste fusionnée (et l'overview distante). html = (tmp_path / "index.html").read_text(encoding="utf-8") assert "distante-neuve" in html and "distante-seule" in html assert "locale-perimee" not in html + assert "const OVERVIEW = {\"hillshade_multi\"" in html + assert data["overview"]["hillshade_multi"]["url"].endswith("?v=2") # Worker injoignable : fichiers laissés intacts. before = (tmp_path / "index_tiles.json").read_text(encoding="utf-8") monkeypatch.setattr(webapp, "_remote_tiles_data", lambda: None) @@ -409,12 +412,13 @@ def test_merge_remote_index_merges_files(tmp_path, monkeypatch): assert (tmp_path / "index_tiles.json").read_text(encoding="utf-8") == before -def test_static_mounts_served_no_cache(tmp_path): - """Les montages d'images servent Cache-Control: no-cache. +def test_static_mounts_cache_headers(tmp_path): + """Montages d'images : no-cache par défaut, immutable si l'URL porte ?v=. - Le ?v= des URLs suit la mtime de la source, pas celle du fichier servi - (vignette recalculée, cache webapp rapatrié) : sans revalidation imposée, - le cache heuristique du navigateur peut afficher l'ancien rendu. + ?v= suit la mtime du fichier servi (posée par build_index) : le contenu + désigné ne change jamais → cache navigateur 1 an sans revalidation. Sans + ?v= (index.html, rejeu manuel), no-cache force la revalidation 304 — un + rendu régénéré ne reste jamais périmé à l'écran. """ import asyncio from starlette.routing import Mount @@ -422,7 +426,8 @@ def test_static_mounts_served_no_cache(tmp_path): assert issubclass(webapp._OnDemandStaticFiles, webapp._NoCacheStaticFiles) mounted = {r.path: r for r in webapp.app.routes if isinstance(r, Mount)} - for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): + for name in ("index_thumbs", "index_subtiles", "index_overview", + "visualisations", "DTM"): route = mounted.get(f"/{name}") assert route is not None, f"montage /{name} absent" assert isinstance(route.app, webapp._NoCacheStaticFiles) @@ -431,13 +436,15 @@ def test_static_mounts_served_no_cache(tmp_path): (tmp_path / "x.jpg").write_bytes(b"1") srv = webapp._NoCacheStaticFiles(directory=str(tmp_path)) - async def fetch(): + async def fetch(query): scope = {"type": "http", "method": "GET", "path": "/x.jpg", - "headers": [], "query_string": b""} + "headers": [], "query_string": query} return await srv.get_response("x.jpg", scope) - resp = asyncio.run(fetch()) + resp = asyncio.run(fetch(b"")) assert resp.headers["cache-control"] == "no-cache, must-revalidate" + resp = asyncio.run(fetch(b"v=1712345678901")) + assert resp.headers["cache-control"] == "public, max-age=31536000, immutable" def test_stop_generation(monkeypatch): diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index 940b337..8728d30 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -172,7 +172,8 @@ if not (_assets_dir / "app.js").is_file(): from fastapi.staticfiles import StaticFiles as _StaticFiles from starlette.concurrency import run_in_threadpool -_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles") +_ONDEMAND_PREFIXES = ("visualisations", "index_thumbs", "index_subtiles", + "index_overview") _FETCH_SEMAPHORE = threading.Semaphore(4) _FETCH_LOCKS = {} _FETCH_LOCKS_GUARD = threading.Lock() @@ -259,20 +260,21 @@ def _fetch_remote_to_cache(rel_path): class _NoCacheStaticFiles(_StaticFiles): - """Fichiers statiques servis sans cache navigateur (revalidation 304). + """Fichiers statiques : cache immutable si l'URL est versionnée (?v=). - Les images (visualisations, vignettes, sous-tuiles, DTM) n'ont pas - toujours une URL invalidée après régénération : le ?v= suit la mtime de - la SOURCE, pas celle du fichier servi (vignette recalculée après coup, - cache webapp rapatrié…). Sans Cache-Control, le cache heuristique du - navigateur peut continuer d'afficher l'ancien rendu sur la même URL. - no-cache force la revalidation à chaque affichage — ETag/Last-Modified - la rend quasi gratuite quand le fichier n'a pas changé (réponse 304). + Les URLs des images portent ?v= (mtime du fichier servi, posée par + build_index) : le contenu qu'elles désignent ne change jamais → cache + immutable (1 an, jamais revalidé). Sans ?v= (index.html vu directement, + rejeu manuel d'URL), on garde no-cache : revalidation 304 quasi gratuite + via ETag/Last-Modified, et jamais de rendu périmé après régénération. """ async def get_response(self, path, scope): resp = await super().get_response(path, scope) - resp.headers["Cache-Control"] = "no-cache, must-revalidate" + resp.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + if _version_from_query(scope) is not None + else "no-cache, must-revalidate") return resp @@ -317,7 +319,8 @@ class _OnDemandStaticFiles(_NoCacheStaticFiles): return _fetch_remote_to_cache(rel) -for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): +for name in ("index_thumbs", "index_subtiles", "index_overview", + "visualisations", "DTM"): _dir = OUTPUT_DIR / name _dir.mkdir(parents=True, exist_ok=True) if name in _ONDEMAND_PREFIXES and GENERATION_URL: @@ -329,13 +332,21 @@ for name in ("index_thumbs", "index_subtiles", "visualisations", "DTM"): @app.get("/assets/{file_path:path}") -def assets(file_path: str): - """Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild).""" +def assets(file_path: str, v: str = None): + """Sert les fichiers de l'interface sans cache (régénérés à chaque rebuild). + + Exception : ?v= (hash de contenu posé par build_index) → cache immutable. + """ base = _assets_dir.resolve() p = (_assets_dir / file_path).resolve() if base not in p.parents or not p.is_file(): raise HTTPException(404, f"asset introuvable : {file_path}") - return FileResponse(str(p), headers={"Cache-Control": "no-cache, must-revalidate"}) + # ?v= : hash de contenu hexadécimal posé par build_index (l'URL change + # à chaque évolution du CSS/JS) → cache immutable. + cache = ("public, max-age=31536000, immutable" + if v + else "no-cache, must-revalidate") + return FileResponse(str(p), headers={"Cache-Control": cache}) # Méthodes de classification du sol acceptées (mêmes valeurs que --ground-classification). @@ -1033,8 +1044,13 @@ def tiles_data(stamp: Optional[float] = None): if PANEL_VIZ is not None: allowed = set(PANEL_VIZ) viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} + merged_overview = dict((data or {}).get("overview") or {}) + merged_overview.update((remote or {}).get("overview") or {}) return { "tiles": merged, + # Mosaïques d'overview : le distant prime (grille recalculée), + # le local complète les couches que le worker n'a plus. + "overview": merged_overview, "viz_meta": viz_meta, "stats": (remote or data or {}).get("stats") or {}, "stamp": current, @@ -1073,6 +1089,8 @@ def _merge_remote_index(): if k not in keys: merged.append(t) keys.add(k) + overview = dict(data.get("overview") or {}) + overview.update(remote.get("overview") or {}) viz_meta = dict(data.get("viz_meta") or {}) viz_meta.update(remote.get("viz_meta") or {}) from .index import PANEL_VIZ @@ -1081,6 +1099,7 @@ def _merge_remote_index(): viz_meta = {k: v for k, v in viz_meta.items() if k in allowed} path.write_text(json.dumps({ "tiles": merged, + "overview": overview, "viz_meta": viz_meta, # Les stats décrivent les réglages de l'INTERFACE (couches par # défaut) : elles viennent du rebuild local, fait avec le code de @@ -1091,7 +1110,9 @@ def _merge_remote_index(): }, ensure_ascii=False), encoding="utf-8") # Coquille HTML : la liste embarquée doit être la même (fusionnée). Le # JSON ne contient jamais de « ; » : la première séquence « ]; » après - # « const TILES = [ » termine forcément l'instruction. + # « const TILES = [ » termine forcément l'instruction (idem « }; » pour + # const OVERVIEW : à l'intérieur d'un objet JSON, « } » n'est suivi que + # de « , » « ] » ou « } »). html_path = OUTPUT_DIR / "index.html" try: html = html_path.read_text(encoding="utf-8") @@ -1099,6 +1120,10 @@ def _merge_remote_index(): "const TILES = " + json.dumps(merged, ensure_ascii=False) .replace("