From eec8421f232718b737623675350c659f0677b690 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 20 Sep 2026 00:40:32 +0200 Subject: [PATCH] =?UTF-8?q?Une=20mosa=C3=AFque=20d=20overview=20par=20zone?= =?UTF-8?q?=20g=C3=A9ographique,=20pas=20une=20grille=20globale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lidar_pipeline/index.py | 326 ++++++++++++++++------------- lidar_pipeline/tests/test_index.py | 32 ++- 2 files changed, 210 insertions(+), 148 deletions(-) diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 15949a4..6d3564d 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -917,105 +917,111 @@ 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. +def _build_overview(zone_records, output_dir): + """Assemble les mosaïques 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. + Une image WebP par (zone géographique × couche), couvrant la grille L93 + des dalles de la zone (cases sans dalle 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 et par zone au lieu de centaines. Une mosaïque par zone : les + grilles distantes (plusieurs secteurs, parfois à des centaines de km) + n'ont pas de coins partagés et surtout pas la même échelle utile. + 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). + zone_records: enregistrements par zone ({'tiles': [dalle...]}) avec + col/row, corners WGS84 et viz[k]['thumb'] (URL relative). 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. + {viz_key: {'items': [{url, w, h, px_per_km, km_w, km_h, corners}]}} + ou {} si aucune zone assez grande 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) + out_dir = output_dir / _OVERVIEW_DIR + out_dir.mkdir(parents=True, exist_ok=True) overview = {} - for viz_key in viz_keys: - canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0)) - pasted = 0 + for zone in zone_records: + have = [t for t in zone['tiles'] if t.get('corners')] + if len(have) < _OVERVIEW_MIN_DALLES: + continue + 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. + lattice = {} for t in have: - info = t['viz'].get(viz_key) - if not info: + 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): + logger.debug(f"Overview : coins de grille absents pour " + f"{zone.get('label', '?')} — zone ignorée") + continue + 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 + corners = [lattice[k] for k in quad] + viz_keys = [] + for t in have: + for k in t['viz']: + if k not in viz_keys: + viz_keys.append(k) + 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 - src = output_dir / info['thumb'].split('?')[0] + stem = f"{viz_key}_{min_col}_{min_row}" + out = out_dir / f"{stem}.webp" try: - with PILImage.open(str(src)) as im: - cell = im.convert('RGBA') + canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY) except Exception as e: - logger.debug(f"Overview : vignette illisible {src.name} : {e}") + logger.warning(f"Overview {stem} non écrite : {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], - } + overview.setdefault(viz_key, {'items': []})['items'].append({ + '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': corners, + }) return overview @@ -1256,14 +1262,15 @@ 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) + # Mosaïques d'overview (vue dézoomée) : une image par zone et par couche, + # assemblée depuis les vignettes des dalles de la zone. + overview = _build_overview(zone_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)") + n_items = sum(len(v['items']) for v in overview.values()) + first = next(iter(overview.values()))['items'][0] + logger.info(f" Overview : {len(overview)} couche(s) × zones, " + f"{n_items} mosaïque(s) (ex. {first['w']}×{first['h']} px, " + f"{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 @@ -2358,7 +2365,7 @@ recomputeGridBounds(); // 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) +const ovEntries = []; // un marqueur par mosaïque de zone (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é ? » @@ -2369,26 +2376,46 @@ const OV_HOLD_CAP_MS = 2500; // délai maxi avant de libérer quand même // couche (pas de const OVERVIEW) — alias sûr, jamais de référence directe. const OVM = (typeof OVERVIEW !== 'undefined' && OVERVIEW) ? OVERVIEW : {}; -const ovMeta = () => { for (const k in OVM) return OVM[k]; return null; }; +// Géométrie des mosaïques (une par zone ; toutes les couches la partagent, +// au tri près — d'où l'appariement par coin SW dans ovItemFor). +const ovItems = () => { + for (const k in OVM) return (OVM[k] && OVM[k].items) || []; + return []; +}; +const ovItemFor = (k, e) => { + const items = (OVM[k] && OVM[k].items) || []; + return items.find(it => it.corners[0][0] === e.meta.corners[0][0] + && it.corners[0][1] === e.meta.corners[0][1]) || null; +}; + +let ovSig = null; // géométrie enregistrée (anti-recréation au merge live) 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) - }; + const items = ovItems(); + if (!items.length || !map) return; + const sig = JSON.stringify(items.map(i => i.corners)); + if (sig === ovSig) return; + ovSig = sig; + for (const e of ovEntries) e.mk.remove(); + ovEntries.length = 0; + items.forEach(meta => { + // Ancre = coin NW de la zone : 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(); + ovEntries.push({ + mk: mk, div: el ? el.querySelector('.ltile') : null, imgs: {}, + meta: meta, + 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. @@ -2425,34 +2452,40 @@ function quadMatrix3d(src, dst) { } 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 + for (const e of ovEntries) { + if (!e.div) continue; + const meta = e.meta; + const anchor = map.project(e.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(e.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 = e.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). +// d'UNE QUELCONQUE des mosaïques (bascule globale, conservatrice : la zone à +// la résolution la plus grossière décide — affichée au pire à 1:1, jamais +// étirée donc jamais floue). function overviewWanted() { - const meta = ovMeta(); - if (!meta || !map._loaded) return false; + const items = ovItems(); + if (!items.length || !ovEntries.length || !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; + for (const it of items) { + const sw = map.project(it.corners[0], zoom), se = map.project(it.corners[1], zoom); + if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km) return false; + } + return true; } // Libération du palier perdant : seulement quand le gagnant est affiché @@ -2571,24 +2604,25 @@ function syncTileLayers() { 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) && OVM[k]) continue; - ovEntry.imgs[k].src = ''; - ovEntry.imgs[k].remove(); - delete ovEntry.imgs[k]; + // Overview : mêmes couches actives, une par couche et par mosaïque. + for (const e of ovEntries) { + if (!e.div) continue; + for (const k of Object.keys(e.imgs)) { + if (act.includes(k) && ovItemFor(k, e)) continue; + e.imgs[k].src = ''; + e.imgs[k].remove(); + delete e.imgs[k]; } act.forEach((k, i) => { - if (!OVM[k]) return; - let img = ovEntry.imgs[k]; + if (!ovItemFor(k, e)) return; + let img = e.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; + e.div.appendChild(img); + e.imgs[k] = img; } img.classList.remove('fading'); img.style.zIndex = String(i + 1); @@ -2652,7 +2686,7 @@ function updateImages() { // 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; + const wantOv = ovEntries.length ? overviewWanted() : false; if (wantOv !== ovOn) { if (ovLoser) { clearTimeout(ovLoser.timer); @@ -2706,11 +2740,12 @@ function updateImages() { } } // 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 && OVM[k]) { - plan.push([img, OVM[k].url]); + for (const e of ovEntries) { + for (const k in e.imgs) { + const img = e.imgs[k]; + const item = ovOn ? ovItemFor(k, e) : null; + if (item) { + plan.push([img, item.url]); } else if (img.getAttribute('src') !== null) { img.dataset.want = ''; if (ovLoser && !img.dataset.loser) { @@ -4287,13 +4322,12 @@ function mergeTilesData(data) { let ovChanged = false; if (data.overview) { for (const k of Object.keys(data.overview)) { - const cur = OVM[k]; - if (cur && cur.url === data.overview[k].url) continue; + if (JSON.stringify(OVM[k]) === JSON.stringify(data.overview[k])) continue; OVM[k] = data.overview[k]; ovChanged = true; } - if (ovChanged && !ovEntry) { - registerOverview(); + if (ovChanged) { + registerOverview(); // (re)crée les marqueurs si la géométrie a changé layoutOverview(); } } diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 7f0b073..eb7fc2a 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -715,7 +715,9 @@ def test_overview_generated_for_large_grids(tmp_path): 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'] + items = ov['hillshade_multi']['items'] + assert len(items) == 1 # une seule zone géographique + meta = items[0] 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'] @@ -724,7 +726,33 @@ def test_overview_generated_for_large_grids(tmp_path): # 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 + assert "index_overview/hillshade_multi_" in html + + +def test_overview_one_mosaic_per_zone(tmp_path): + """Zones géographiques distantes : une mosaïque PAR ZONE (pas une grille + globale étirée sur des centaines de km aux coins introuvables).""" + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + # Deux clusters de 2×3 dalles, à ~400 km l'un de l'autre + for col in (1000, 1001, 1002): + for row in (6881, 6882): + _make_fake_viz_dir(vis_dir, "a", col, row, ('hillshade_multi',)) + for col in (1400, 1401, 1402): + for row in (6481, 6482): + _make_fake_viz_dir(vis_dir, "b", col, row, ('hillshade_multi',)) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + items = data['overview']['hillshade_multi']['items'] + assert len(items) == 2 + assert items[0]['km_w'] == 3 and items[0]['km_h'] == 2 + assert items[1]['km_w'] == 3 and items[1]['km_h'] == 2 + # Les deux mosaïques sont distinctes (coins différents) + assert items[0]['corners'] != items[1]['corners'] def test_overview_skipped_for_small_grids(tmp_path):