Une mosaïque d overview par zone géographique, pas une grille globale

This commit is contained in:
Antoine Jacquin
2026-09-20 00:40:32 +02:00
parent 4a13cdb3f5
commit eec8421f23
2 changed files with 210 additions and 148 deletions

View File

@ -917,43 +917,52 @@ def _build_subtiles(tile, offered_viz_keys, output_dir, sub_dir_name):
return usable return usable
def _build_overview(dalles, output_dir): def _build_overview(zone_records, output_dir):
"""Assemble la mosaïque d'overview par couche pour la vue dézoomée. """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 Une image WebP par (zone géographique × couche), couvrant la grille L93
sans dalle restent transparentes), assemblée depuis les vignettes des dalles de la zone (cases sans dalle transparentes), assemblée depuis
index_thumbs (256 px/km) : au dézoom l'interface l'affiche à la place des les vignettes index_thumbs (256 px/km) : au dézoom l'interface l'affiche
vignettes individuelles — 1 requête et ~30 Mo décodés par couche au lieu à la place des vignettes individuelles — 1 requête et ~30 Mo décodés par
de centaines. Résolution plafonnée à 256 px/km et 3072 px de côté : couche et par zone au lieu de centaines. Une mosaïque par zone : les
l'overview n'est affiché que tant qu'il reste au moins 1:1 avec l'écran, grilles distantes (plusieurs secteurs, parfois à des centaines de km)
jamais étiré donc jamais flou. 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: Args:
dalles: enregistrements de dalles (zone_tile_records) avec col/row, zone_records: enregistrements par zone ({'tiles': [dalle...]}) avec
corners WGS84 et viz[k]['thumb'] (URL relative à output_dir). col/row, corners WGS84 et viz[k]['thumb'] (URL relative).
output_dir: dossier de sortie racine. output_dir: dossier de sortie racine.
Returns: Returns:
{viz_key: {url, w, h, px_per_km, km_w, km_h, corners}} ou {} si {viz_key: {'items': [{url, w, h, px_per_km, km_w, km_h, corners}]}}
grille trop petite, coins manquants ou PIL indisponible. ou {} si aucune zone assez grande ou PIL indisponible.
""" """
if len(dalles) < _OVERVIEW_MIN_DALLES:
return {}
try: try:
from PIL import Image as PILImage from PIL import Image as PILImage
except ImportError: except ImportError:
logger.warning("PIL indisponible — overview non générée") logger.warning("PIL indisponible — overview non générée")
return {} return {}
have = [t for t in dalles if t.get('corners')] try:
if not have: resample = PILImage.Resampling.LANCZOS
return {} except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
out_dir = output_dir / _OVERVIEW_DIR
out_dir.mkdir(parents=True, exist_ok=True)
overview = {}
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) min_col = min(t['col'] for t in have)
max_col = max(t['col'] for t in have) max_col = max(t['col'] for t in have)
min_row = min(t['row'] for t in have) min_row = min(t['row'] for t in have)
max_row = max(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 # Lattice exact des coins (km L93 → WGS84) : les coins partagés de
# voisines proviennent de la même transformation pyproj → cohérents, la # dalles voisines proviennent de la même transformation pyproj →
# mosaïque s'emboîte exactement comme les vignettes individuelles. # cohérents, la mosaïque s'emboîte exactement comme les vignettes.
lattice = {} lattice = {}
for t in have: for t in have:
c = t['corners'] c = t['corners']
@ -964,24 +973,20 @@ def _build_overview(dalles, output_dir):
quad = [(min_col, min_row), (max_col + 1, min_row), quad = [(min_col, min_row), (max_col + 1, min_row),
(max_col + 1, max_row + 1), (min_col, max_row + 1)] (max_col + 1, max_row + 1), (min_col, max_row + 1)]
if any(k not in lattice for k in quad): if any(k not in lattice for k in quad):
return {} 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_w = max_col + 1 - min_col
km_h = max_row + 1 - min_row km_h = max_row + 1 - min_row
px_per_km = max(4, min(_OVERVIEW_PX_PER_KM, px_per_km = max(4, min(_OVERVIEW_PX_PER_KM,
_OVERVIEW_MAX_PX // max(km_w, km_h))) _OVERVIEW_MAX_PX // max(km_w, km_h)))
w, h = km_w * px_per_km, km_h * px_per_km w, h = km_w * px_per_km, km_h * px_per_km
out_dir = output_dir / _OVERVIEW_DIR corners = [lattice[k] for k in quad]
out_dir.mkdir(parents=True, exist_ok=True)
try:
resample = PILImage.Resampling.LANCZOS
except AttributeError:
resample = getattr(PILImage, 'LANCZOS', 1)
viz_keys = [] viz_keys = []
for t in have: for t in have:
for k in t['viz']: for k in t['viz']:
if k not in viz_keys: if k not in viz_keys:
viz_keys.append(k) viz_keys.append(k)
overview = {}
for viz_key in viz_keys: for viz_key in viz_keys:
canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0)) canvas = PILImage.new('RGBA', (w, h), (0, 0, 0, 0))
pasted = 0 pasted = 0
@ -1004,18 +1009,19 @@ def _build_overview(dalles, output_dir):
pasted += 1 pasted += 1
if not pasted: if not pasted:
continue continue
out = out_dir / f"{viz_key}.webp" stem = f"{viz_key}_{min_col}_{min_row}"
out = out_dir / f"{stem}.webp"
try: try:
canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY) canvas.save(str(out), format='WEBP', quality=_OVERVIEW_QUALITY)
except Exception as e: except Exception as e:
logger.warning(f"Overview {viz_key} non écrite : {e}") logger.warning(f"Overview {stem} non écrite : {e}")
continue continue
overview[viz_key] = { overview.setdefault(viz_key, {'items': []})['items'].append({
'url': f"{_OVERVIEW_DIR}/{out.name}{_url_version(_mtime(out))}", 'url': f"{_OVERVIEW_DIR}/{out.name}{_url_version(_mtime(out))}",
'w': w, 'h': h, 'px_per_km': px_per_km, 'w': w, 'h': h, 'px_per_km': px_per_km,
'km_w': km_w, 'km_h': km_h, 'km_w': km_w, 'km_h': km_h,
'corners': [lattice[k] for k in quad], 'corners': corners,
} })
return overview return overview
@ -1256,14 +1262,15 @@ def build_index(output_dir, output_format='avif'):
if n_sous: if n_sous:
logger.info(f" {n_sous} sous-tuiles générée(s) pour {n_dalles} dalle(s)") 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 # Mosaïques d'overview (vue dézoomée) : une image par zone et par couche,
# toute la grille, assemblée depuis les vignettes des dalles. # assemblée depuis les vignettes des dalles de la zone.
dalle_records = [t for zr in zone_records for t in zr['tiles']] overview = _build_overview(zone_records, output_dir)
overview = _build_overview(dalle_records, output_dir)
if overview: if overview:
first = next(iter(overview.values())) n_items = sum(len(v['items']) for v in overview.values())
logger.info(f" Overview : {len(overview)} couche(s) " first = next(iter(overview.values()))['items'][0]
f"({first['w']}×{first['h']} px, {first['px_per_km']} px/km)") 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 # 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 # /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 // 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 // s'efface (fondu 300 ms) puis ne libère son src qu'une fois le gagnant
// affiché. // 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 ovOn = false; // palier courant = overview ?
let ovLoser = null; // bascule en cours : {imgs, winners, since, phase} let ovLoser = null; // bascule en cours : {imgs, winners, since, phase}
const OV_HOLD_POLL_MS = 120; // sondage « gagnant affiché ? » const OV_HOLD_POLL_MS = 120; // sondage « gagnant affiché ? »
@ -2369,12 +2376,30 @@ 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. // couche (pas de const OVERVIEW) — alias sûr, jamais de référence directe.
const OVM = (typeof OVERVIEW !== 'undefined' && OVERVIEW) ? OVERVIEW : {}; 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() { function registerOverview() {
const meta = ovMeta(); const items = ovItems();
if (!meta || ovEntry || !map) return; if (!items.length || !map) return;
// Ancre = coin NW de la grille : la matrice mappe l'image depuis (0,0). 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 nw = meta.corners[3];
const icon = L.divIcon({ className: 'ltile-anchor', const icon = L.divIcon({ className: 'ltile-anchor',
html: '<div class="ltile loverview"></div>', html: '<div class="ltile loverview"></div>',
@ -2384,11 +2409,13 @@ function registerOverview() {
pane: 'lidarTiles', zIndexOffset: -100000 pane: 'lidarTiles', zIndexOffset: -100000
}).addTo(map); }).addTo(map);
const el = mk.getElement(); const el = mk.getElement();
ovEntry = { ovEntries.push({
mk: mk, div: el ? el.querySelector('.ltile') : null, imgs: {}, mk: mk, div: el ? el.querySelector('.ltile') : null, imgs: {},
meta: meta,
latlngs: meta.corners.map(c => L.latLng(c[0], c[1])), latlngs: meta.corners.map(c => L.latLng(c[0], c[1])),
bounds: L.latLngBounds(meta.corners) bounds: L.latLngBounds(meta.corners)
}; });
});
} }
// Homographie 4 correspondances → matrix3d CSS (colonne-major), ou null. // Homographie 4 correspondances → matrix3d CSS (colonne-major), ou null.
@ -2425,18 +2452,18 @@ function quadMatrix3d(src, dst) {
} }
function layoutOverview() { function layoutOverview() {
if (!ovEntry || !ovEntry.div) return;
const meta = ovMeta();
if (!meta) return;
const zoom = map.getZoom(); const zoom = map.getZoom();
const anchor = map.project(ovEntry.latlngs[3], zoom); 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]. // 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 dst = [3, 2, 1, 0].map(i => {
const p = map.project(ovEntry.latlngs[i], zoom); const p = map.project(e.latlngs[i], zoom);
return [p.x - anchor.x, p.y - anchor.y]; 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 m = quadMatrix3d([[0, 0], [meta.w, 0], [meta.w, meta.h], [0, meta.h]], dst);
const div = ovEntry.div; const div = e.div;
div.style.width = meta.w + 'px'; div.style.width = meta.w + 'px';
div.style.height = meta.h + 'px'; div.style.height = meta.h + 'px';
div.style.transformOrigin = '0 0'; div.style.transformOrigin = '0 0';
@ -2444,15 +2471,21 @@ function layoutOverview() {
? 'matrix3d(' + m.join(',') + ')' ? 'matrix3d(' + m.join(',') + ')'
: 'translate(-9999px, -9999px)'; // homographie insoluble : hors écran : 'translate(-9999px, -9999px)'; // homographie insoluble : hors écran
} }
}
// Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution // 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() { function overviewWanted() {
const meta = ovMeta(); const items = ovItems();
if (!meta || !map._loaded) return false; if (!items.length || !ovEntries.length || !map._loaded) return false;
const zoom = map.getZoom(); const zoom = map.getZoom();
const sw = map.project(meta.corners[0], zoom), se = map.project(meta.corners[1], zoom); for (const it of items) {
return Math.hypot(se.x - sw.x, se.y - sw.y) / meta.km_w <= meta.px_per_km; 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é // 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'; img.style.mixBlendMode = state.layers.blend[k] || 'normal';
}); });
} }
// Overview : mêmes couches actives, une <img> par couche disponible. // Overview : mêmes couches actives, une <img> par couche et par mosaïque.
if (ovEntry && ovEntry.div) { for (const e of ovEntries) {
for (const k of Object.keys(ovEntry.imgs)) { if (!e.div) continue;
if (act.includes(k) && OVM[k]) continue; for (const k of Object.keys(e.imgs)) {
ovEntry.imgs[k].src = ''; if (act.includes(k) && ovItemFor(k, e)) continue;
ovEntry.imgs[k].remove(); e.imgs[k].src = '';
delete ovEntry.imgs[k]; e.imgs[k].remove();
delete e.imgs[k];
} }
act.forEach((k, i) => { act.forEach((k, i) => {
if (!OVM[k]) return; if (!ovItemFor(k, e)) return;
let img = ovEntry.imgs[k]; let img = e.imgs[k];
if (!img) { if (!img) {
img = document.createElement('img'); img = document.createElement('img');
img.dataset.layer = k; img.dataset.layer = k;
img.alt = ''; img.alt = '';
img.decoding = 'async'; img.decoding = 'async';
ovEntry.div.appendChild(img); e.div.appendChild(img);
ovEntry.imgs[k] = img; e.imgs[k] = img;
} }
img.classList.remove('fading'); img.classList.remove('fading');
img.style.zIndex = String(i + 1); img.style.zIndex = String(i + 1);
@ -2652,7 +2686,7 @@ function updateImages() {
// Bascule de palier overview ↔ vignettes (uniquement ici : zoomend/moveend, // Bascule de palier overview ↔ vignettes (uniquement ici : zoomend/moveend,
// jamais pendant l'animation de zoom). À la bascule le perdant garde son // 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. // 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 (wantOv !== ovOn) {
if (ovLoser) { if (ovLoser) {
clearTimeout(ovLoser.timer); clearTimeout(ovLoser.timer);
@ -2706,11 +2740,12 @@ function updateImages() {
} }
} }
// Overview : chargée quand son palier est actif, perdante sinon. // Overview : chargée quand son palier est actif, perdante sinon.
if (ovEntry) { for (const e of ovEntries) {
for (const k in ovEntry.imgs) { for (const k in e.imgs) {
const img = ovEntry.imgs[k]; const img = e.imgs[k];
if (ovOn && OVM[k]) { const item = ovOn ? ovItemFor(k, e) : null;
plan.push([img, OVM[k].url]); if (item) {
plan.push([img, item.url]);
} else if (img.getAttribute('src') !== null) { } else if (img.getAttribute('src') !== null) {
img.dataset.want = ''; img.dataset.want = '';
if (ovLoser && !img.dataset.loser) { if (ovLoser && !img.dataset.loser) {
@ -4287,13 +4322,12 @@ function mergeTilesData(data) {
let ovChanged = false; let ovChanged = false;
if (data.overview) { if (data.overview) {
for (const k of Object.keys(data.overview)) { for (const k of Object.keys(data.overview)) {
const cur = OVM[k]; if (JSON.stringify(OVM[k]) === JSON.stringify(data.overview[k])) continue;
if (cur && cur.url === data.overview[k].url) continue;
OVM[k] = data.overview[k]; OVM[k] = data.overview[k];
ovChanged = true; ovChanged = true;
} }
if (ovChanged && !ovEntry) { if (ovChanged) {
registerOverview(); registerOverview(); // (re)crée les marqueurs si la géométrie a changé
layoutOverview(); layoutOverview();
} }
} }

View File

@ -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')) data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
ov = data['overview'] ov = data['overview']
assert 'hillshade_multi' in ov and 'svf' in ov 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 (output_dir / meta['url'].split('?')[0]).is_file()
assert meta['km_w'] == 3 and meta['km_h'] == 2 assert meta['km_w'] == 3 and meta['km_h'] == 2
assert meta['w'] == 3 * meta['px_per_km'] 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 # La coquille HTML embarque la constante pour l'interface
html = (output_dir / "index.html").read_text(encoding='utf-8') html = (output_dir / "index.html").read_text(encoding='utf-8')
assert "const OVERVIEW" in html 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): def test_overview_skipped_for_small_grids(tmp_path):