Garder localement les sources des dalles sur une carte alimentée par l'amont

Avec LIDAR_SOURCE_URL (Pi), la maintenance rapatrie et conserve les sources
de chaque dalle dès qu'elle apparaît, sans attendre de visite : les niveaux
rendus à la volée restent disponibles quand le conteneur de rendu est
éteint. Seuls les paliers utiles sont pris (vignettes, quadrants 500 m ; la
dalle entière, doublon de ses quadrants, ne l'est pas) ; une source manquée
pendant que l'amont était éteint est reprise au scan suivant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 02:34:12 +02:00
parent 434b7f12fd
commit 93317dadd0
4 changed files with 85 additions and 1 deletions

View File

@ -91,6 +91,13 @@ pairs : à un zoom impair, elle réduit les tuiles du niveau supérieur (nettes)
Mesuré sur 8 dalles : environ 24 Mo de pyramide avant, 1,2 Mo après. Mesuré sur 8 dalles : environ 24 Mo de pyramide avant, 1,2 Mo après.
`LIDAR_TILE_EVEN_LEVELS=0` rétablit le stockage de tous les niveaux. `LIDAR_TILE_EVEN_LEVELS=0` rétablit le stockage de tous les niveaux.
Une carte alimentée par un serveur de dalles amont (`LIDAR_SOURCE_URL`, cas
du Pi) rapatrie et **garde localement** les sources de chaque dalle dès
qu'elle apparaît (vignettes et quadrants 500 m ; la dalle entière, doublon
de ses quadrants, n'est pas rapatriée), sans attendre de visite. Tous les
niveaux restent disponibles quand le conteneur de rendu est éteint ; une
source manquée pendant qu'il était éteint est reprise au scan suivant.
## Fiche de dalle et rose des vents ## Fiche de dalle et rose des vents
Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre Un clic sur la carte sélectionne la dalle LiDAR HD sous le curseur (cadre

View File

@ -169,6 +169,7 @@ def _bg_conf():
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(), _bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
"queued": set(), "guard": threading.Lock(), "snapshot": None, "queued": set(), "guard": threading.Lock(), "snapshot": None,
"prefetch": deque(),
"running": False, "running": False,
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0, "stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
"dernier_scan": None, "ajouts_dernier_scan": 0}} "dernier_scan": None, "ajouts_dernier_scan": 0}}
@ -236,6 +237,22 @@ def background_scan(output_dir=None):
continue continue
changed.setdefault(layer, []).append((col, row)) changed.setdefault(layer, []).append((col, row))
_bg["snapshot"] = snapshot _bg["snapshot"] = snapshot
# Carte alimentée par un serveur de dalles amont (Pi) : les sources des
# dalles nouvelles ou modifiées sont rapatriées et GARDÉES localement,
# sans attendre une visite — les niveaux rendus à la volée restent
# disponibles quand le conteneur de rendu est éteint.
# Toutes les dalles sont revérifiées (simples tests d'existence) : une
# source manquée pendant qu'il était éteint est reprise au scan suivant.
if tiles_mod.REMOTE_SOURCE_URL:
index = tiles_mod.source_index(output_dir)
pending = {str(s_.path) for s_ in _bg["prefetch"]}
for per_cell in index.values():
for tiers in per_cell.values():
for src in tiles_mod.sources_to_keep(tiers):
key = str(src.path)
if src.url is not None and key not in pending and not src.path.is_file():
_bg["prefetch"].append(src)
pending.add(key)
items = [] items = []
for layer, cells in changed.items(): for layer, cells in changed.items():
for col, row in cells: for col, row in cells:
@ -261,7 +278,17 @@ def background_scan(output_dir=None):
def _bg_process_one(): def _bg_process_one():
"""Traite une tuile de la file. Vrai si elle a dû être rendue.""" """Traite une source à rapatrier, sinon une tuile de la file. Vrai si
un travail a été fait (rapatriement ou rendu)."""
try:
src = _bg["prefetch"].popleft()
except IndexError:
src = None
if src is not None:
if src.ensure():
_bg["stats"]["sources_locales"] = _bg["stats"].get("sources_locales", 0) + 1
return True
return False # amont éteint : repris au prochain scan
item = _bg_pop() item = _bg_pop()
if item is None: if item is None:
return False return False

View File

@ -973,3 +973,37 @@ def test_background_scan_only_queues_stored_levels(tmp_path, monkeypatch):
zs = {z for (_l, z, _x, _y) in mapserve._bg["queue"]} zs = {z for (_l, z, _x, _y) in mapserve._bg["queue"]}
assert zs and all(tiles.zoom_cached(z, 2) for z in zs) assert zs and all(tiles.zoom_cached(z, 2) for z in zs)
assert zs <= {5, 7, 9, 11, 13, 15} assert zs <= {5, 7, 9, 11, 13, 15}
def test_remote_fed_map_keeps_sources_locally(tmp_path, monkeypatch):
"""Carte alimentée par l'amont : les sources nécessaires sont rapatriées
et gardées sans attendre de visite ; une source manquée (amont éteint)
est reprise au scan suivant ; la dalle entière (doublon) n'est pas prise."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
S = tiles._Source
thumb = S(tmp_path / "t.webp", (0, 0, 1000, 1000), 3.9, url="http://amont/t")
quads = [S(tmp_path / f"q{i}.avif", (0, 0, 500, 500), 0.2, url=f"http://amont/q{i}") for i in range(4)]
full = S(tmp_path / "d.avif", (0, 0, 1000, 1000), 0.2, url="http://amont/d")
assert tiles.sources_to_keep([[thumb], quads, [full]]) == [thumb] + quads
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont")
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
monkeypatch.setattr(tiles, "source_index", lambda *a, **k: {"relief_oriente": {(1, 2): [[thumb], quads, [full]]}})
for key, val in (("queue", __import__("collections").deque()), ("queued", set()),
("snapshot", None), ("prefetch", __import__("collections").deque())):
monkeypatch.setitem(mapserve._bg, key, val)
online = {"up": False}
def fake_fetch(url, dest):
if not online["up"]:
return False
dest.write_bytes(b"x"); return True
monkeypatch.setattr(tiles, "_fetch_source", fake_fetch)
mapserve.background_scan(tmp_path)
while mapserve._bg["prefetch"]:
mapserve._bg_process_one()
assert not any(s_.path.exists() for s_ in [thumb] + quads) # amont éteint
online["up"] = True
mapserve.background_scan(tmp_path) # dalle inchangée : reprise quand même
while mapserve._bg["prefetch"]:
mapserve._bg_process_one()
assert all(s_.path.exists() for s_ in [thumb] + quads) and not full.path.exists()

View File

@ -609,6 +609,22 @@ def clear_source_cache():
_source_cache.clear() _source_cache.clear()
def sources_to_keep(tiers):
"""Paliers qu'une machine autonome doit garder pour rendre TOUS ses
niveaux sans l'amont : du plus grossier jusqu'au premier palier qui
atteint la résolution la plus fine (une dalle entière de même résolution
que ses quadrants serait un doublon)."""
if not tiers:
return []
finest = min(group[0].res for group in tiers)
keep = []
for group in tiers:
keep.extend(group)
if group[0].res <= finest:
break
return keep
def _pick_tier(tiers, target_res): def _pick_tier(tiers, target_res):
"""Palier le plus grossier dont la résolution suffit à la tuile visée.""" """Palier le plus grossier dont la résolution suffit à la tuile visée."""
for group in tiers: for group in tiers: