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,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: '<div class="ltile loverview"></div>',
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: '<div class="ltile loverview"></div>',
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 <img> 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 <img> 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();
}
}

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'))
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):