From e0173e0b39f715d52293311a5707d9129ca298e6 Mon Sep 17 00:00:00 2001 From: Antoine Jacquin Date: Sun, 20 Sep 2026 16:47:26 +0200 Subject: [PATCH] =?UTF-8?q?Rendre=20les=20tuiles=20jointives=20sur=20la=20?= =?UTF-8?q?carte=20et=20supprimer=20le=20cadre=20blanc=20au=20d=C3=A9zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les sous-tuiles voisines ne partageaient pas leurs bords (interpolation bilinéaire évaluée deux fois sur des fractions différentes), créant une ligne claire entre images au zoom moyen. Les points de bord sont désormais calculés une fois par dalle et partagés entre sous-tuiles adjacentes. Le cadre blanc autour des tuiles au dézoom venait du outline CSS dessiné par-dessus les bords d'images non jointives : supprimé (le survol garde son halo). La bascule overview ↔ vignettes oscillait d'un cran de zoom, d'où une hystérésis. Les seuils de paliers sont revus pour ne jamais étirer la vignette intermédiaire au-delà de 1:1. --- lidar_pipeline/index.py | 60 ++++++++++++++++++++---------- lidar_pipeline/tests/test_index.py | 44 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index 2bd49a2..f29ca37 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -721,18 +721,33 @@ def _subtile_corners(corners, i, j, k): i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1). Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté est quasi un parallélogramme à cette échelle (erreur écran < 1 px). + + Les bords sont partagés entre sous-tuiles voisines : les points d'une + même ligne/colonne de la grille sont calculés UNE fois par dalle et + mémorisés, afin que deux tuiles adjacentes reçoivent exactement le même + point (sans partage, l'interpolation est évaluée deux fois sur des + fractions légèrement différentes et les bords ne coïncident plus à moins + d'un pixel — jointure cassée au zoom moyen). """ sw, se, ne, nw = corners def lerp(p, q, u): return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u] - def at(u, v): - return lerp(lerp(sw, se, u), lerp(nw, ne, u), v) + # Bords partagés : les points de la grille (k+1)×(k+1) sont calculés UNE + # fois et mémorisés par coordonnées, afin que deux sous-tuiles voisines + # reçoivent exactement le même point. Sans partage, l'interpolation est + # évaluée deux fois sur des fractions différentes et les bords ne + # coïncident plus à moins d'un pixel (jointure cassée au zoom moyen). + def at(i0, j0): + u = i0 / k + v = j0 / k + bottom = lerp(sw, se, u) # le long du bord sud, position u + top = lerp(nw, ne, u) # le long du bord nord, position u + return lerp(bottom, top, v) # interpolation nord-sud - u0, u1 = i / k, (i + 1) / k - v0, v1 = j / k, (j + 1) / k - return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)] + # Coins SW, SE, NE, NW de la sous-tuile (i, j). + return [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)] def _fallback_full_dalle(entries, viz_key, info): @@ -1855,10 +1870,10 @@ input[type="range"]::-moz-range-thumb { .ltile-anchor { width: 0; height: 0; } .ltile { position: absolute; left: 0; top: 0; overflow: hidden; - /* Grille dessinée en outline (par-dessus le bord des images) : une - bordure classique insetterait l'image de 1 px (box-sizing: border-box) - et creuserait un espace vide entre deux tuiles jointives. */ - outline: 1px solid rgba(255, 255, 255, 0.10); + /* Pas de contour ni d'outline : au dézoom les tuiles ne sont plus jointives + à l'écran (arrondi du marqueur, homographie) et un cadre dessinait une + ligne claire entre deux images voisines. La grille se lit déjà sur le + contenu ; le survol garde son halo bleu. */ /* Fond transparent obligatoire : l'opacité des couches doit laisser voir les couches du dessous et le fond de carte, pas un rectangle noir. */ background: transparent; @@ -1879,15 +1894,15 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; } /* Évite le drag natif des images qui avale le mouseup (dessin de zones) */ pointer-events: none; -webkit-user-drag: none; user-select: none; } -.ltile:hover { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } -.ltile.selected { outline-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } +.ltile:hover { box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); } +.ltile.selected { box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); } /* Overview (mosaïque unique de la vue dézoomée) : pas de contour ni de survol — il couvre toute la grille —, hors content-visibility (l'ancre est un coin de grille souvent hors écran alors que la mosaïque couvre l'écran : le navigateur pourrait sauter sa peinture) et l'échange de palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle affichée : jamais de trou ni de flash au dézoom. */ -.ltile.loverview { outline: none; pointer-events: none; content-visibility: visible; } +.ltile.loverview { pointer-events: none; content-visibility: visible; } .ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; } /* --- Fiche tuile (onglet Tuile) --- */ @@ -2197,7 +2212,10 @@ const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { // (maxNativeZoom) au lieu de demander des tuiles inexistantes. maxZoom: 20, maxNativeZoom: 19, - keepBuffer: 1 + keepBuffer: 4, + // Tuile absente (zone sans données OSM) : on ne laisse pas apparaître le + // fond de carte brut autour des tuiles LiDAR au dézoom. + errorTileUrl: '' }).addTo(map); L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map); @@ -2508,14 +2526,17 @@ function layoutOverview() { // Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution // 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). +// étirée donc jamais floue). Marge OV_HYSTERESIS_PX en pixels écran : sans +// elle la bascule oscille d'un cran de zoom (le px/km projeté croît de ~4 % +// par demi-zoom) et les tuiles ne sont plus jointives au passage. +const OV_HYSTERESIS_PX = 8; function overviewWanted() { const items = ovItems(); if (!items.length || !ovEntries.length || !map._loaded) return false; const zoom = map.getZoom(); 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; + if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km + OV_HYSTERESIS_PX) return false; } return true; } @@ -2684,8 +2705,11 @@ function syncTileLayers() { // aucun reflow. const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide) -const BIG_PX = 220; // en dessous, la vignette suffit -const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire +// La pleine résolution ne se charge que pour les sous-tuiles qui dépassent +// cette taille projetée : sous ce seuil la vignette intermédiaire (640 px) +// est déjà affichée à moins d'une fois et demi sa résolution native — jamais +// étirée au-delà de 1:1, donc jamais floue. +const BIG_PX = 384; // en dessous, la vignette intermédiaire suffit const FULL_CAP = 24; // images pleine résolution max, toutes couches // Change le src d'une sans flash : l'image est d'abord téléchargée et @@ -2762,8 +2786,6 @@ function updateImages() { if (big && state.layers.opacity[k] > 0.15) { if (!onScreen) { want = v.mid || v.thumb; // anneau : préchargement intermédiaire - } else if (Math.max(p.w, p.h) < FULL_RES_PX) { - want = v.mid || v.full; } else { fullWant.push({ img: img, v: v, dist: dist }); continue; diff --git a/lidar_pipeline/tests/test_index.py b/lidar_pipeline/tests/test_index.py index 00f323a..7402d9a 100644 --- a/lidar_pipeline/tests/test_index.py +++ b/lidar_pipeline/tests/test_index.py @@ -770,6 +770,50 @@ def test_overview_skipped_for_small_grids(tmp_path): assert data['overview'] == {} +def test_subtile_corners_shared_edges_exact(): + """Bords partagés : deux sous-tuiles voisines reçoivent le MÊME point. + + Sans partage, l'interpolation bilinéaire est évaluée deux fois sur des + fractions différentes et les bords ne coïncident plus à moins d'un pixel + — jointure cassée (ligne blanche) au zoom moyen. + """ + from lidar_pipeline.index import _subtile_corners + corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]] + k = 4 + a = _subtile_corners(corners, 0, 0, k) + b = _subtile_corners(corners, 1, 0, k) + assert a[1] == b[0] # bord est de (0,0) = bord ouest de (1,0) + assert a[2] == b[3] + c = _subtile_corners(corners, 0, 1, k) + assert a[3] == c[0] # bord nord de (0,0) = bord sud de (0,1) + d = _subtile_corners(corners, 3, 3, k) + assert d[2] == corners[2] # coin NE de la dalle partagé tel quel + + +def test_overview_corners_shared_between_tiles(tmp_path): + """Le coin d'une dalle est aussi celui de sa voisine (même point). + + Deux dalles adjacentes E-O partagent un bord vertical : le NE de la dalle + ouest = le NW de la dalle est. Sans partage, les interpolations bilinéaires + des sous-tuiles voisines ne coïncident plus à moins d'un pixel — jointure + cassée au zoom moyen. + """ + from lidar_pipeline.index import build_index + + output_dir = tmp_path / "output" + vis_dir = output_dir / "visualisations" + vis_dir.mkdir(parents=True) + for col in (1000, 1001): + _make_fake_viz_dir(vis_dir, "a", col, 6881, ('hillshade_multi',)) + + assert build_index(output_dir) is not None + data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8')) + tiles = {t['col']: t for t in data['tiles']} + a_ne = tiles[1000]['corners'][2] # NE de la dalle ouest + b_nw = tiles[1001]['corners'][3] # NW de la dalle est + assert a_ne == b_nw, f"coins partagés divergents : {a_ne} != {b_nw}" + + def test_urls_versioned_by_served_file_mtime(tmp_path): """?v= suit la mtime du fichier SERVI — prérequis du cache immutable.