Une mosaïque d overview par zone géographique, pas une grille globale
This commit is contained in:
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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):
|
||||
|
||||
Reference in New Issue
Block a user