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