Rendre la carte autonome, télécharger la pyramide en fond, refermer les trous de zoom et borner les workers GPU par la VRAM

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 11:23:51 +02:00
parent 934e2dab55
commit 147a469ddc
10 changed files with 586 additions and 37 deletions

View File

@ -37,7 +37,11 @@
- **Pyramide mise à jour pendant le rendu** : le pipeline régénère l'inventaire après chaque dalle **par défaut** (`incremental_index` sauf `--no-index`, quel que soit le lanceur), avec passe différée quand l'anti-rebond de 3 s ignore une dalle. Côté carte, `_bg_poller` surveille l'inventaire toutes les `BG_WATCH_S` = 10 s (fichier local ou charge utile amont) et lance un scan dès qu'il change ; les tuiles des dalles modifiées passent **en tête** de file (`_bg_enqueue(front=True)`), devant l'arriéré. - **Pyramide mise à jour pendant le rendu** : le pipeline régénère l'inventaire après chaque dalle **par défaut** (`incremental_index` sauf `--no-index`, quel que soit le lanceur), avec passe différée quand l'anti-rebond de 3 s ignore une dalle. Côté carte, `_bg_poller` surveille l'inventaire toutes les `BG_WATCH_S` = 10 s (fichier local ou charge utile amont) et lance un scan dès qu'il change ; les tuiles des dalles modifiées passent **en tête** de file (`_bg_enqueue(front=True)`), devant l'arriéré.
- **Pyramide à stockage réduit** : `tiles.zoom_cached` — seuls les niveaux standard pairs ≤ `TILE_CACHE_MAX_Z` (16) vont sur disque (une tuile @2x de z vaut une 256 px de z+1) ; les autres sont rendus à la volée (`get_tile`, cache mémoire `_mem_tiles`), même en `LIDAR_TILE_CACHE_ONLY`. Interface en AVIF (`@2x.avif`) avec `EvenLevelTileLayer` (niveau de tuile arrondi au pair supérieur) ; maintenance de fond en AVIF sur les seuls niveaux stockés. ~24 Mo → 1,2 Mo sur 8 dalles. - **Pyramide à stockage réduit** : `tiles.zoom_cached` — seuls les niveaux standard pairs ≤ `TILE_CACHE_MAX_Z` (16) vont sur disque (une tuile @2x de z vaut une 256 px de z+1) ; les autres sont rendus à la volée (`get_tile`, cache mémoire `_mem_tiles`), même en `LIDAR_TILE_CACHE_ONLY`. Interface en AVIF (`@2x.avif`) avec `EvenLevelTileLayer` (niveau de tuile arrondi au pair supérieur) ; maintenance de fond en AVIF sur les seuls niveaux stockés. ~24 Mo → 1,2 Mo sur 8 dalles.
- **Mémoire bornée de la carte (Pi, `mem_limit: 1g`)** : `_open_source` (`tiles.py`) met en cache une **copie détachée** de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ; `Dockerfile.maps` fixe `MALLOC_MMAP_THRESHOLD_=1048576` pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik). - **Mémoire bornée de la carte (Pi, `mem_limit: 1g`)** : `_open_source` (`tiles.py`) met en cache une **copie détachée** de chaque source (une image AVIF ouverte garde son décodeur, ~18 Mo de plus par quadrant 2500²) et compte 4 octets/pixel (PIL stocke le RGB sur 32 bits) ; `Dockerfile.maps` fixe `MALLOC_MMAP_THRESHOLD_=1048576` pour que glibc rende les grands tampons au système. Sans ces deux points, la navigation à fort zoom (niveaux rendus à la volée) montait à ~700 Mo de RSS et le conteneur était tué en boucle par l'OOM killer (502 côté Traefik).
- **Carte autonome (Pi sans worker)** : amont (`LIDAR_SOURCE_URL`/`LIDAR_MAPS_URL`) éteint ⇒ la carte reste servie depuis le disque. `_remote_payload` mémorise aussi l'échec (TTL 60 s, délai 10 s : sinon chaque requête repayait le délai réseau et `/api/map/meta` ne répondait plus), puis l'index local (`_build_index`) prend le relais ; coupe-circuits sur les sources (`_SOURCE_OFFLINE`, 60 s) et les tuiles (`_UPSTREAM`, réarmé à expiration) ; source rapatriée datée à sa version amont (`os.utime`) pour que l'index local garde les mêmes dates ; pas de requête amont pour une tuile sans dalle quand l'inventaire vient de l'amont. En cache seule, une tuile périmée reste servie (`no-store`, `X-Tile-Pending`) en attendant la nouvelle.
- **Pyramide téléchargée en tâche de fond** : avec `LIDAR_MAPS_URL`, la maintenance (`_bg_process_one`) TÉLÉCHARGE les tuiles des niveaux stockés depuis l'amont (stat `telechargees`), sans attendre l'affichage ; rendu local en repli si l'amont ne répond pas.
- **Première apparition des dalles (`_apply_seen`, `index_xyz/.sources_seen.json`)** : date effective d'une source = max(version, première entrée dans l'inventaire). Une dalle écrite avant mais inventoriée après une tuile (TTL de l'index amont, anti-rebond de l'inventaire) périme donc la tuile — sinon trou permanent à ce niveau, sur disque, en mémoire et dans le navigateur (stamp `?v=` inchangé). Registre persistant ; absent avec un cache existant (mise à jour) ⇒ tout est périmé une fois.
- **Encodage AVIF rapide** : `AVIF_SPEED = 9` (`rendering.py`, `tiles.py`, `_SUBTILE_AVIF_SPEED` dans `index.py`) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche. - **Encodage AVIF rapide** : `AVIF_SPEED = 9` (`rendering.py`, `tiles.py`, `_SUBTILE_AVIF_SPEED` dans `index.py`) — dalle 5000 × 5000 px encodée en 0,6 s au lieu de 4 s (+3 % de taille, −0,3 dB) ; l'encodage était l'étape la plus longue du rendu d'une couche.
- **Workers bornés par la VRAM** : chaque processus du pool prend une place fixe à sa création (`_init_worker_slot` dans `pipeline.py`, file multiprocessing) calculée par `gpu_worker_slots` (`gpu.py`) : au plus (VRAM libre − `LIDAR_GPU_RESERVE_MIB` 512) / `LIDAR_GPU_WORKER_MIB` (2048, pic estimé : contexte CUDA + calage conjoint CuPy en float64 + EDT de `_fill_nans`) workers par GPU, au moins un, entrelacés ; l'excédent tourne en CPU (`force_cpu`). L'ancien round-robin par numéro de fichier mettait 6 workers par RTX 5060 de 8 Go avec `LIDAR_WORKERS=auto` (12) : OOM. VRAM illisible (nvidia-smi en échec) : round-robin sans borne.
- **Préparation sur GPU** : `_fill_nans` (transformée de distance `cupyx`) et les gradients de `SharedDEM` passent sur GPU quand il est actif (repli CPU). `NUMBA_CACHE_DIR=/tmp/numba-cache` (Dockerfile) évite la recompilation des noyaux numba à chaque worker. - **Préparation sur GPU** : `_fill_nans` (transformée de distance `cupyx`) et les gradients de `SharedDEM` passent sur GPU quand il est actif (repli CPU). `NUMBA_CACHE_DIR=/tmp/numba-cache` (Dockerfile) évite la recompilation des noyaux numba à chaque worker.
- **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98). - **Default output is AVIF**, not WebP. Use `--format webp` for WebP. Quality default is 60 (visually lossless on smooth color ramps, ~÷3 vs q98).
- **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`. - **Calage vertical des faisceaux de vol** : chaque tuile mélange plusieurs passes (1-2 `PointSourceId` par passe) parfois biaisées verticalement de quelques cm (±2,5 cm mesurés sur 1000_6882). `create_dtm_fast` mesure l'offset robuste de chaque faisceau (points sol, maille 1 m, surface médiane itérée 3×) et retranche les offsets ≥ 0,5 cm (`STRIP_ALIGN_THRESHOLD` dans `dtm.py`) avant rastérisation. Offsets calculés **par tuile** (ils dérivent le long d'une ligne de vol : jamais de table globale), mémoïsés par LAS sol, consignés dans `DTM/*_dtm*_stripalign.json` (sidecar de cache : absent, ou version/seuil/paramètres différents ⇒ régénération du DTM). Désactivable : `--no-strip-align`.

View File

@ -36,6 +36,11 @@ services:
# Les générations lancées depuis une carte distante utilisent le GPU # Les générations lancées depuis une carte distante utilisent le GPU
- LIDAR_GPU=1 - LIDAR_GPU=1
- LIDAR_WORKERS=auto - LIDAR_WORKERS=auto
# Workers par GPU bornés par la VRAM libre au lancement du run
# ((libre − réserve) / pic d'un worker) ; l'excédent tourne en CPU.
# Pic estimé à 2048 Mo : à ajuster après mesure (nvidia-smi pendant un run).
# - LIDAR_GPU_WORKER_MIB=2048
# - LIDAR_GPU_RESERVE_MIB=512
# Une ligne par worker : BLAS/OpenMP mono-thread, sinon 12 workers × N # Une ligne par worker : BLAS/OpenMP mono-thread, sinon 12 workers × N
# threads écrasent les 14 cœurs (load 68+ observé pendant les runs) # threads écrasent les 14 cœurs (load 68+ observé pendant les runs)
- OMP_NUM_THREADS=1 - OMP_NUM_THREADS=1

View File

@ -294,6 +294,76 @@ def set_active_gpu(gpu_id):
_init_gpu() _init_gpu()
def force_cpu():
"""Interdit le GPU à ce processus (worker en excédent de VRAM).
Aucun candidat ne survit au filtre et CUDA_VISIBLE_DEVICES vide empêche
CuPy de créer un contexte (~300 Mo de VRAM par processus sinon).
"""
global _restricted_gpu_ids
_restricted_gpu_ids = []
os.environ['CUDA_VISIBLE_DEVICES'] = ''
_init_gpu()
# Pic de VRAM d'un worker (Mo) : contexte CUDA (~300) + calage conjoint des
# lignes sur CuPy (~70-100 o par point sol en float64, ~15 M points par dalle
# 0,2 m + bande de 100 m) + transformée de distance de _fill_nans (indices
# int32 ×2 sur ~7000² px ≈ 400 Mo) ; le pool CuPy garde ses blocs entre deux
# étapes. Estimation depuis le code, à ajuster par LIDAR_GPU_WORKER_MIB.
GPU_WORKER_MIB = int(os.environ.get("LIDAR_GPU_WORKER_MIB", "2048") or 2048)
# VRAM laissée libre sur chaque GPU (affichage, autres processus).
GPU_RESERVE_MIB = int(os.environ.get("LIDAR_GPU_RESERVE_MIB", "512") or 512)
def gpu_free_mib():
"""VRAM libre par GPU (indice hôte → Mo) via nvidia-smi, sans contexte CUDA.
Dictionnaire vide si la mesure échoue : l'appelant ne borne alors rien.
"""
try:
import subprocess
result = subprocess.run(
['nvidia-smi', '--query-gpu=index,memory.free',
'--format=csv,noheader,nounits'],
capture_output=True, text=True, timeout=15,
)
if result.returncode != 0:
return {}
free = {}
for line in result.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(',')]
if len(parts) >= 2:
free[int(parts[0])] = int(parts[1])
return free
except Exception:
return {}
def gpu_worker_slots(gpu_ids, n_workers, free_mib=None):
"""Place de chaque worker du pool : indice GPU, -1 (CPU forcé) ou None.
Chaque GPU reçoit au plus (VRAM libre − réserve) / GPU_WORKER_MIB
workers (au moins un), entrelacés entre GPU ; l'excédent tourne en CPU
au lieu de saturer la VRAM (OOM). Sans GPU : None partout (choix laissé
au worker). VRAM inconnue : round-robin sans borne (comportement
historique).
"""
if not gpu_ids:
return [None] * n_workers
if free_mib is None:
free_mib = gpu_free_mib()
if not all(g in free_mib for g in gpu_ids):
return [gpu_ids[i % len(gpu_ids)] for i in range(n_workers)]
capacity = {g: max(1, (free_mib[g] - GPU_RESERVE_MIB) // GPU_WORKER_MIB)
for g in gpu_ids}
slots = []
for rank in range(max(capacity.values())):
slots += [g for g in gpu_ids if capacity[g] > rank]
slots = slots[:n_workers]
return slots + [-1] * (n_workers - len(slots))
def _gpu_available(): def _gpu_available():
"""Check if GPU is usable right now.""" """Check if GPU is usable right now."""
try: try:

View File

@ -278,27 +278,46 @@ def background_scan(output_dir=None):
def _bg_process_one(): def _bg_process_one():
"""Traite une source à rapatrier, sinon une tuile de la file. Vrai si """Traite une tuile de la file, sinon une source à rapatrier. Vrai si
un travail a été fait (rapatriement ou rendu).""" un travail a été fait (téléchargement, rendu ou rapatriement).
try:
src = _bg["prefetch"].popleft() Tuiles d'abord : légères et visibles tout de suite, elles ne doivent pas
except IndexError: attendre des milliers de sources (utiles aux seuls forts zooms rendus à
src = None la volée). Les tuiles déjà à jour sont passées sans pause.
if src is not None: """
conf = _bg_conf()
while True:
item = _bg_pop()
if item is None:
break
layer, z, x, y = item
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y,
conf["scale"], conf["fmt"])
if state == "pending":
break
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1
if item is None:
try:
src = _bg["prefetch"].popleft()
except IndexError:
return False
if src.ensure(): if src.ensure():
_bg["stats"]["sources_locales"] = _bg["stats"].get("sources_locales", 0) + 1 _bg["stats"]["sources_locales"] = _bg["stats"].get("sources_locales", 0) + 1
return True return True
return False # amont éteint : repris au prochain scan return False # amont éteint : repris au prochain scan
item = _bg_pop() if MAPS_URL:
if item is None: # Serveur de tuiles amont : la pyramide est TÉLÉCHARGÉE en tâche de
return False # fond (quelques dizaines de Ko, aucun décodage de dalle) ; la carte
layer, z, x, y = item # se remplit sans visite et reste servie ensuite sans l'amont.
conf = _bg_conf() suffix = "@2x" if conf["scale"] == 2 else ""
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y, data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{conf['fmt']}")
conf["scale"], conf["fmt"]) if data:
if state != "pending": tiles_mod._write_atomic(
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1 tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y,
return False conf["scale"], conf["fmt"]), data)
_bg["stats"]["telechargees"] = _bg["stats"].get("telechargees", 0) + 1
return True
# Amont absent ou éteint : rendu local depuis les sources gardées (autonomie).
tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"]) tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"])
_bg["stats"]["rendues"] += 1 _bg["stats"]["rendues"] += 1
return True return True
@ -539,7 +558,8 @@ def _upstream_mark(ok):
_UPSTREAM.update(fails=0, until=0.0) _UPSTREAM.update(fails=0, until=0.0)
return return
_UPSTREAM["fails"] += 1 _UPSTREAM["fails"] += 1
if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and _UPSTREAM["until"] == 0.0: # Réarmé après expiration : un amont resté éteint est resuspendu.
if _UPSTREAM["fails"] >= _UPSTREAM_OFFLINE_AFTER and not _upstream_offline():
_UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS _UPSTREAM["until"] = time.time() + _UPSTREAM_OFFLINE_SECONDS
logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min") logger.info("Serveur de tuiles amont injoignable — tentatives suspendues 2 min")
@ -602,7 +622,11 @@ def _tile_bytes(layer, z, x, y, scale, fmt):
except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte except Exception as e: # noqa: BLE001 — une tuile ne casse pas la carte
logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}") logger.warning(f"Rendu de tuile impossible ({layer} {z}/{x}/{y}) : {e}")
data = None data = None
if data is None and MAPS_URL: # Inventaire venu de l'amont sans aucune dalle sous la tuile :
# l'amont n'en sait pas plus, inutile de l'interroger.
if data is None and MAPS_URL and not (
tiles_mod.REMOTE_SOURCE_URL
and not tiles_mod._contributing(OUTPUT_DIR, layer, z, x, y, scale)):
suffix = "@2x" if scale == 2 else "" suffix = "@2x" if scale == 2 else ""
data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}") data = _fetch_upstream(f"tiles/{layer}/{z}/{x}/{y}{suffix}.{fmt}")
if data: if data:
@ -664,11 +688,20 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
empty = False empty = False
finally: finally:
_release_lock(key, entry) _release_lock(key, entry)
if pending:
# Tuile périmée (dalle régénérée) : l'ancienne reste affichée en
# attendant la nouvelle, plutôt qu'un trou transparent.
try:
data = tiles_mod.tile_cache_path(OUTPUT_DIR, layer, z, x, y,
scale, fmt).read_bytes()
except OSError:
data = None
else: else:
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt) data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
pending = False pending = False
empty = data is None empty = data is None
if empty or pending: stale = pending and bool(data)
if empty or (pending and not stale):
data = _transparent_tile(scale, fmt) data = _transparent_tile(scale, fmt)
headers = dict(_cors_headers()) headers = dict(_cors_headers())
if pending: if pending:
@ -679,7 +712,7 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
else: else:
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
else "public, max-age=300, must-revalidate") else "public, max-age=300, must-revalidate")
headers["X-Tile-Empty"] = "1" if (empty or pending) else "0" headers["X-Tile-Empty"] = "1" if (empty or (pending and not stale)) else "0"
return Response(content=data, media_type=_MEDIA[fmt], headers=headers) return Response(content=data, media_type=_MEDIA[fmt], headers=headers)

View File

@ -91,7 +91,7 @@ from .visualizations import (
generate_anomaly_mask, generate_anomaly_mask,
generate_relief_oriente, generate_relief_oriente,
) )
from .gpu import gpu_cleanup, num_gpus, available_gpu_ids, restrict_gpus, safe_gpu_call from .gpu import gpu_cleanup, num_gpus, available_gpu_ids, restrict_gpus, safe_gpu_call, gpu_worker_slots
from .ign import generate_ign_overlay from .ign import generate_ign_overlay
from .rendering import tif_to_crop from .rendering import tif_to_crop
@ -750,13 +750,27 @@ class LidarArchaeoPipeline:
logger.info(f"Traitement parallèle avec {self.workers} workers...") logger.info(f"Traitement parallèle avec {self.workers} workers...")
logger.info(f"Fichiers: {len(files)}") logger.info(f"Fichiers: {len(files)}")
with ProcessPoolExecutor(max_workers=self.workers) as executor: # Une place fixe par processus du pool (GPU ou CPU), prise à sa
# Round-robin assign each file to a real GPU host index # création : au plus « VRAM libre / pic d'un worker » processus par
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids() # GPU. L'affectation par numéro de fichier (round-robin) laissait
# 6 workers sur chaque GPU de 8 Go avec LIDAR_WORKERS=auto : OOM.
active_ids = self.gpu_ids if self.gpu_ids else available_gpu_ids()
slots = gpu_worker_slots(active_ids, self.workers)
if active_ids:
per_gpu = ", ".join(f"GPU {g} : {slots.count(g)}" for g in active_ids)
n_cpu = slots.count(-1)
logger.info(f"Répartition des workers : {per_gpu}"
+ (f", CPU : {n_cpu} (VRAM insuffisante)" if n_cpu else ""))
slot_queue = multiprocessing.Queue()
for slot in slots:
slot_queue.put(slot)
with ProcessPoolExecutor(max_workers=self.workers,
initializer=_init_worker_slot,
initargs=(slot_queue,)) as executor:
resolutions_str = ','.join(str(r) for r in self.resolutions) resolutions_str = ','.join(str(r) for r in self.resolutions)
future_to_file = { future_to_file = {
executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.quality, self.only_viz, self.skip_viz, self.output_format, active_ids[file_idx % len(active_ids)] if active_ids else None, self.openness_downsample, self.edge_buffer): laz_file executor.submit(_process_file_standalone, str(laz_file), str(self.input_dir), str(self.output_dir), resolutions_str, self.force, self.ground_method, self.ign_classes, self.force_classify, self.keep_tif, self.quality, self.only_viz, self.skip_viz, self.output_format, None, self.openness_downsample, self.edge_buffer): laz_file
for file_idx, laz_file in enumerate(files) for laz_file in files
} }
done = 0 done = 0
t_deadline = time.time() + 7200 t_deadline = time.time() + 7200
@ -857,6 +871,26 @@ class LidarArchaeoPipeline:
logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}") logger.warning(f" Note: Impossible de supprimer les fichiers temporaires: {e}")
def _init_worker_slot(slot_queue):
"""Initialiseur du pool : le processus prend sa place une fois pour toutes.
Indice GPU → set_active_gpu ; -1 → CPU forcé (VRAM insuffisante) ;
None ou file vide → choix laissé au worker (pas de GPU détecté).
"""
import queue
from . import gpu
try:
slot = slot_queue.get_nowait()
except queue.Empty:
return
if slot is None:
return
if slot < 0:
gpu.force_cpu()
else:
gpu.set_active_gpu(slot)
def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=60, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0): def _process_file_standalone(laz_file_str, input_dir, output_dir, resolution, force=False, ground_method='auto', ign_classes="sol", force_classify=False, keep_tif=False, quality=60, only_viz=None, skip_viz=None, output_format='avif', gpu_id=None, openness_downsample=None, edge_buffer=0.0):
"""Standalone function for multiprocessing — creates its own pipeline instance. """Standalone function for multiprocessing — creates its own pipeline instance.

View File

@ -199,4 +199,57 @@ def test_safe_gpu_call_retries_on_cpu_for_any_error(monkeypatch):
gpu.safe_gpu_call(g, 1) gpu.safe_gpu_call(g, 1)
raise AssertionError("devait relancer l'erreur") raise AssertionError("devait relancer l'erreur")
except ValueError: except ValueError:
pass pass
def test_gpu_worker_slots_bounded_by_free_vram(monkeypatch):
"""Places GPU par la VRAM libre : l'excédent de workers passe en CPU.
Cas réel : LIDAR_WORKERS=auto = 12 workers sur 2 RTX 5060 (8 Go) —
6 workers par GPU, soit bien plus que la VRAM libre ne tient au pic
(calage des lignes + comblement GPU) : OOM.
"""
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "GPU_WORKER_MIB", 2000)
monkeypatch.setattr(gpu, "GPU_RESERVE_MIB", 500)
free = {0: 7500, 1: 4600}
slots = gpu.gpu_worker_slots([0, 1], 12, free_mib=free)
assert len(slots) == 12
assert slots.count(0) == 3 # (7500 - 500) // 2000
assert slots.count(1) == 2 # (4600 - 500) // 2000
assert slots.count(-1) == 7 # reste en CPU
# GPU d'abord, entrelacés : les premiers workers créés se répartissent
assert slots[:4] == [0, 1, 0, 1]
# Moins de workers que de places : aucun CPU forcé
assert gpu.gpu_worker_slots([0, 1], 3, free_mib=free) == [0, 1, 0]
def test_gpu_worker_slots_without_gpu_or_vram_info():
"""Sans GPU : tout en CPU implicite (None). VRAM inconnue : round-robin
historique (pas de bornage sans mesure)."""
from lidar_pipeline import gpu
assert gpu.gpu_worker_slots([], 4, free_mib={}) == [None] * 4
assert gpu.gpu_worker_slots([0, 1], 4, free_mib={}) == [0, 1, 0, 1]
def test_gpu_worker_slots_keeps_one_gpu_worker_when_tight(monkeypatch):
"""GPU presque plein : au moins une place pour que le GPU serve encore
(le repli CPU de safe_gpu_call couvre l'OOM éventuel)."""
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "GPU_WORKER_MIB", 2000)
monkeypatch.setattr(gpu, "GPU_RESERVE_MIB", 500)
assert gpu.gpu_worker_slots([0], 3, free_mib={0: 900}) == [0, -1, -1]
def test_force_cpu_disables_gpu_selection(monkeypatch):
"""force_cpu() : aucun candidat GPU, CuPy jamais initialisé."""
import os
from lidar_pipeline import gpu
monkeypatch.setattr(gpu, "_restricted_gpu_ids", None)
monkeypatch.setattr(gpu, "_gpu_candidates", [(0, "X", "12.0", 8000, 1, 12)])
monkeypatch.setattr(gpu, "_gpu_initialized", False)
monkeypatch.setattr(gpu, "_env_set_by_init", False)
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
gpu.force_cpu()
assert gpu.available_gpu_ids() == []
assert gpu.HAS_GPU is False
assert os.environ.get("CUDA_VISIBLE_DEVICES") == ""

View File

@ -1032,3 +1032,140 @@ def test_remote_fed_map_keeps_sources_locally(tmp_path, monkeypatch):
while mapserve._bg["prefetch"]: while mapserve._bg["prefetch"]:
mapserve._bg_process_one() mapserve._bg_process_one()
assert all(s_.path.exists() for s_ in [thumb] + quads) and not full.path.exists() assert all(s_.path.exists() for s_ in [thumb] + quads) and not full.path.exists()
def _bg_reset(monkeypatch, mapserve):
import collections
for key, val in (("queue", collections.deque()), ("queued", set()),
("snapshot", None), ("prefetch", collections.deque()),
("stats", {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
"dernier_scan": None, "ajouts_dernier_scan": 0})):
monkeypatch.setitem(mapserve._bg, key, val)
def test_background_downloads_tiles_from_upstream(tmp_path, monkeypatch):
"""Maintenance de fond + LIDAR_MAPS_URL : les tuiles de la pyramide sont
TÉLÉCHARGÉES en tâche de fond (pas seulement à l'affichage), sans rendu
local — la carte se remplit même sans visite et reste servie ensuite
sans l'amont."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch)
_bg_reset(monkeypatch, mapserve)
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
AVIF = b"\x00\x00\x00\x20ftypavif-TUILE-AMONT"
calls = []
monkeypatch.setattr(mapserve, "_fetch_upstream",
lambda rel: calls.append(rel) or AVIF)
def no_render(*a, **k):
raise AssertionError("rendu local inutile : la tuile vient de l'amont")
monkeypatch.setattr(tiles, "get_tile", no_render)
z = 13
x, y = _tile_of_cell(1054, 6882, z)
mapserve._bg_enqueue([("aspect", z, x, y)])
assert mapserve._bg_process_one() is True
assert calls == [f"tiles/aspect/{z}/{x}/{y}@2x.avif"]
cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif")
assert cache.read_bytes() == AVIF
assert mapserve._bg["stats"]["telechargees"] == 1
# Fraîche : servie en cache seule sans amont ni rendu
data, state = tiles.cached_tile(tmp_path, "aspect", z, x, y, 2, "avif")
assert state == "fresh" and data == AVIF
def test_background_renders_locally_when_upstream_down(tmp_path, monkeypatch):
"""Amont éteint : la maintenance rend la tuile localement (autonomie)."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch)
_bg_reset(monkeypatch, mapserve)
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: None)
z = 13
x, y = _tile_of_cell(1054, 6882, z)
mapserve._bg_enqueue([("aspect", z, x, y)])
assert mapserve._bg_process_one() is True
assert tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif").is_file()
assert mapserve._bg["stats"]["rendues"] == 1
def test_upstream_breaker_rearms_after_expiry(monkeypatch):
"""La suspension expirée, de nouveaux échecs la relancent (sinon chaque
tuile repaie le délai réseau d'un amont éteint, indéfiniment)."""
import lidar_pipeline.mapserve as mapserve
monkeypatch.setattr(mapserve, "_UPSTREAM", {"fails": 0, "until": 0.0})
for _ in range(3):
mapserve._upstream_mark(False)
assert mapserve._upstream_offline() is True
mapserve._UPSTREAM["until"] = 1.0 # suspension expirée
assert mapserve._upstream_offline() is False
mapserve._upstream_mark(False) # nouvel échec : resuspendu
assert mapserve._upstream_offline() is True
def test_empty_tile_skips_upstream_when_inventory_is_upstream(tmp_path, monkeypatch):
"""Inventaire venu de l'amont (LIDAR_SOURCE_URL) et aucune dalle sous la
tuile : l'amont n'en sait pas plus — pas de requête (20 s par tuile vide
quand il est éteint)."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch)
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_contributing", lambda *a, **k: [])
calls = []
monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: calls.append(rel))
resp = asyncio.run(mapserve.tile("aspect", 15, 1, "1.png"))
assert calls == []
assert resp.headers["X-Tile-Empty"] == "1"
def test_cache_only_serves_stale_tile_while_pending(tmp_path, monkeypatch):
"""Cache seule, tuile périmée (dalle régénérée) et amont muet : l'ancienne
tuile reste affichée en attendant la nouvelle — pas un trou transparent."""
import os
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch)
monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1")
monkeypatch.setattr(mapserve, "MAPS_URL", "")
z = 13
x, y = _tile_of_cell(1054, 6882, z)
cache = tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 2, "avif")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(b"ANCIENNE-TUILE")
os.utime(cache, (1_000_000, 1_000_000)) # plus vieille que la dalle
assert tiles.cached_tile(tmp_path, "aspect", z, x, y, 2, "avif")[1] == "pending"
resp = asyncio.run(mapserve.tile("aspect", z, x, f"{y}@2x.avif"))
assert resp.body == b"ANCIENNE-TUILE"
assert resp.headers["Cache-Control"] == "no-store" # redemandée plus tard
assert resp.headers["X-Tile-Pending"] == "1"
assert resp.headers["X-Tile-Empty"] == "0"
def test_background_tiles_before_sources(tmp_path, monkeypatch):
"""Tuiles d'abord (légères, visibles tout de suite), sources ensuite :
des milliers de sources à rapatrier ne retardent pas la pyramide."""
import lidar_pipeline.mapserve as mapserve
from lidar_pipeline import tiles
_setup(tmp_path, monkeypatch)
_bg_reset(monkeypatch, mapserve)
monkeypatch.setattr(mapserve, "MAPS_URL", "http://amont:8975")
monkeypatch.setattr(mapserve, "_fetch_upstream", lambda rel: b"TUILE")
fetched = []
class Src:
def ensure(self):
fetched.append(1)
return True
mapserve._bg["prefetch"].append(Src())
z = 13
x, y = _tile_of_cell(1054, 6882, z)
mapserve._bg_enqueue([("aspect", z, x, y)])
assert mapserve._bg_process_one() is True
assert fetched == [] and mapserve._bg["stats"]["telechargees"] == 1
assert mapserve._bg_process_one() is True
assert fetched == [1]

View File

@ -308,3 +308,18 @@ class TestResolveWorkers:
from lidar_pipeline.pipeline import resolve_workers from lidar_pipeline.pipeline import resolve_workers
assert resolve_workers('abc') == 1 assert resolve_workers('abc') == 1
assert resolve_workers(None) == 1 assert resolve_workers(None) == 1
def test_worker_slot_initializer(self, monkeypatch):
"""Chaque processus du pool prend UNE place à sa création : GPU
(set_active_gpu) ou CPU forcé (-1) ; None ou file vide = libre."""
import queue
from lidar_pipeline import gpu, pipeline
calls = []
monkeypatch.setattr(gpu, "set_active_gpu", lambda i: calls.append(("gpu", i)))
monkeypatch.setattr(gpu, "force_cpu", lambda: calls.append(("cpu",)))
q = queue.Queue()
for slot in (1, -1, None):
q.put(slot)
for _ in range(4): # 4e appel : file vide
pipeline._init_worker_slot(q)
assert calls == [("gpu", 1), ("cpu",)]

View File

@ -571,3 +571,119 @@ def test_unstored_level_rendered_on_the_fly_without_disk(tmp_path, monkeypatch):
assert data and data[:4] == b"\x89PNG" assert data and data[:4] == b"\x89PNG"
assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 1, "png").exists() assert not tiles.tile_cache_path(tmp_path, "aspect", z, x, y, 1, "png").exists()
assert tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png") == data and len(calls) == 1 assert tiles.get_tile(tmp_path, "aspect", z, x, y, 1, "png") == data and len(calls) == 1
def test_remote_payload_failure_not_retried_each_call(monkeypatch):
"""Amont injoignable sans inventaire connu : l'échec est mémorisé (TTL).
Sinon chaque appel (plusieurs par /api/map/meta) repaie le délai de
connexion : l'interface ne se chargeait plus quand le worker était éteint.
"""
import urllib.request
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "REMOTE_SOURCE_URL", "http://amont:8973")
monkeypatch.setattr(tiles, "_remote_cache",
{"payload": None, "at": 0.0, "index": None, "root": None})
calls = []
def boom(*a, **k):
calls.append(1)
raise OSError("hôte injoignable")
monkeypatch.setattr(urllib.request, "urlopen", boom)
for _ in range(3):
assert tiles._remote_payload() is None
assert len(calls) == 1
def test_fetch_source_offline_breaker(tmp_path, monkeypatch):
"""Source amont en échec : les suivantes échouent sans attendre le délai
réseau pendant la suspension (la maintenance passe au rendu local)."""
import urllib.request
from lidar_pipeline import tiles
monkeypatch.setattr(tiles, "_SOURCE_OFFLINE", {"until": 0.0})
calls = []
def boom(*a, **k):
calls.append(1)
raise OSError("hôte injoignable")
monkeypatch.setattr(urllib.request, "urlopen", boom)
assert tiles._fetch_source("http://amont/a", tmp_path / "a.avif") is False
assert tiles._fetch_source("http://amont/b", tmp_path / "b.avif") is False
assert len(calls) == 1
def test_fetched_source_dated_to_upstream_version(tmp_path, monkeypatch):
"""Une source rapatriée porte la date de version amont : quand l'amont
s'éteint, l'index local retrouve les mêmes dates et les tuiles déjà
faites restent fraîches (pas de pyramide entière à refaire)."""
from lidar_pipeline import tiles
def fake_fetch(url, dest):
dest.write_bytes(b"x")
return True
monkeypatch.setattr(tiles, "_fetch_source", fake_fetch)
src = tiles._Source(tmp_path / "q.avif", (0, 0, 1, 1), 0.2,
url="http://amont/q.avif", version=1700000000000)
assert src.ensure() is True
assert (tmp_path / "q.avif").stat().st_mtime == 1700000000.0
def test_tile_refreshed_when_older_dalle_appears(tmp_path):
"""Dalle entrée dans l'inventaire APRÈS le rendu d'une tuile qui la couvre,
mais avec une date de version plus ancienne (écrite avant, inventoriée
après) : la tuile doit être périmée — sinon trou permanent à ce niveau,
sur le disque, en mémoire et dans le navigateur (stamp inchangé)."""
import io
import os
from PIL import Image
from lidar_pipeline import tiles
tiles.clear_source_cache()
tiles._mem_tiles.clear()
z = 10
assert _tile_of_cell(1054, 6882, z) == _tile_of_cell(1055, 6882, z)
x, y = _tile_of_cell(1054, 6882, z)
_make_dalle(tmp_path, 1054, 6882, ["slope"], color=(200, 30, 30))
tiles.source_index(tmp_path, force=True)
first = tiles.get_tile(tmp_path, "slope", z, x, y)
assert first is not None
stamp = tiles.tiles_stamp(tmp_path)
_make_dalle(tmp_path, 1055, 6882, ["slope"], color=(30, 200, 30))
for f in tmp_path.rglob("*1055_6882*"):
os.utime(f, (1_000_000, 1_000_000)) # version antérieure à la tuile
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
second = tiles.get_tile(tmp_path, "slope", z, x, y)
img = Image.open(io.BytesIO(second)).convert("RGBA")
assert any(g > 150 and r < 80 and a == 255
for r, g, b, a in img.getdata()), "nouvelle dalle absente de la tuile"
assert tiles.tiles_stamp(tmp_path) > stamp
# Registre persistant : un redémarrage ne réinvalide rien
tiles._index_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"
tiles.clear_source_cache()
def test_existing_cache_without_registry_is_refreshed_once(tmp_path):
"""Mise à jour : cache de tuiles présent mais pas de registre — les
tuiles existantes (peut-être trouées) sont périmées une seule fois."""
from lidar_pipeline import tiles
tiles._seen_cache.clear()
tiles._mem_tiles.clear()
z = 10
x, y = _tile_of_cell(1054, 6882, z)
_make_dalle(tmp_path, 1054, 6882, ["slope"])
tiles.source_index(tmp_path, force=True)
assert tiles.get_tile(tmp_path, "slope", z, x, y) is not None
(tmp_path / tiles.TILE_DIRNAME / tiles._SEEN_FILE).unlink() # version précédente
tiles._seen_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "pending"
tiles.get_tile(tmp_path, "slope", z, x, y)
tiles._seen_cache.clear()
tiles.source_index(tmp_path, force=True)
assert tiles.cached_tile(tmp_path, "slope", z, x, y)[1] == "fresh"

View File

@ -69,6 +69,12 @@ FETCH_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_FETCH_WORKERS", "2") or 2)
_fetch_sem = threading.Semaphore(FETCH_WORKERS) _fetch_sem = threading.Semaphore(FETCH_WORKERS)
_fetch_locks = {} _fetch_locks = {}
_fetch_guard = threading.Lock() _fetch_guard = threading.Lock()
# Coupe-circuit des sources : après un échec, plus aucune tentative pendant
# _SOURCE_OFFLINE_S — sans lui, chaque source d'une file de rapatriement
# repaierait le délai réseau d'un amont éteint et la maintenance ne rendrait
# plus rien (la carte doit rester autonome).
_SOURCE_OFFLINE_S = 60.0
_SOURCE_OFFLINE = {"until": 0.0}
# Index des sources reconstruit au plus toutes les _INDEX_TTL secondes (ou dès # Index des sources reconstruit au plus toutes les _INDEX_TTL secondes (ou dès
# qu'un dossier change de mtime : ajout/suppression de dalle). # qu'un dossier change de mtime : ajout/suppression de dalle).
@ -201,7 +207,7 @@ class _Source:
pour la péremption des tuiles, avant même tout téléchargement. pour la péremption des tuiles, avant même tout téléchargement.
""" """
__slots__ = ("path", "bounds", "res", "url", "version") __slots__ = ("path", "bounds", "res", "url", "version", "seen")
def __init__(self, path, bounds, res, url=None, version=None): def __init__(self, path, bounds, res, url=None, version=None):
self.path = path self.path = path
@ -209,20 +215,35 @@ class _Source:
self.res = res # résolution nominale (m/px) self.res = res # résolution nominale (m/px)
self.url = url self.url = url
self.version = version self.version = version
self.seen = 0.0 # 1re apparition de la dalle dans l'inventaire
def mtime(self): def mtime(self):
"""Date de référence pour la péremption des tuiles : version de la
source, repoussée à sa première apparition dans l'inventaire (une
dalle écrite avant mais inventoriée après une tuile la périme)."""
if self.version is not None: if self.version is not None:
return self.version / 1000.0 base = self.version / 1000.0
try: else:
return self.path.stat().st_mtime try:
except OSError: base = self.path.stat().st_mtime
return None except OSError:
return None
return max(base, self.seen)
def ensure(self): def ensure(self):
"""Garantit la présence locale de l'image (rapatriement si besoin).""" """Garantit la présence locale de l'image (rapatriement si besoin)."""
if self.url is None or self.path.is_file(): if self.url is None or self.path.is_file():
return self.path.is_file() return self.path.is_file()
return _fetch_source(self.url, self.path) if not _fetch_source(self.url, self.path):
return False
if self.version is not None:
# Datée à sa version amont : l'index local (amont éteint) retrouve
# la même date et les tuiles déjà faites restent fraîches.
try:
os.utime(self.path, (self.version / 1000.0, self.version / 1000.0))
except OSError:
pass
return True
def _remote_thumb_px(k): def _remote_thumb_px(k):
@ -238,6 +259,8 @@ def _fetch_source(url, dest):
with lock: with lock:
if dest.is_file(): if dest.is_file():
return True return True
if time.time() < _SOURCE_OFFLINE["until"]:
return False
try: try:
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"User-Agent": "lidar-maps-source"}) url, headers={"User-Agent": "lidar-maps-source"})
@ -247,6 +270,7 @@ def _fetch_source(url, dest):
data = r.read() data = r.read()
except Exception as e: # noqa: BLE001 — amont éteint : tuile partielle except Exception as e: # noqa: BLE001 — amont éteint : tuile partielle
logger.debug(f"Source amont indisponible ({url}) : {e}") logger.debug(f"Source amont indisponible ({url}) : {e}")
_SOURCE_OFFLINE["until"] = time.time() + _SOURCE_OFFLINE_S
return False return False
_write_atomic(dest, data) _write_atomic(dest, data)
logger.info(f"Source rapatriée : {dest.name} ({len(data) / 1e6:.1f} Mo)") logger.info(f"Source rapatriée : {dest.name} ({len(data) / 1e6:.1f} Mo)")
@ -258,13 +282,15 @@ def _remote_payload(force=False):
import json import json
import urllib.request import urllib.request
now = time.time() now = time.time()
if not force and _remote_cache["payload"] is not None \ # Échec mémorisé lui aussi (TTL) : amont éteint sans inventaire connu, un
# appel par TTL au plus — pas un délai réseau à chaque requête de la carte.
if not force and _remote_cache["at"] \
and now - _remote_cache["at"] < _REMOTE_TTL: and now - _remote_cache["at"] < _REMOTE_TTL:
return _remote_cache["payload"] return _remote_cache["payload"]
try: try:
req = urllib.request.Request(f"{REMOTE_SOURCE_URL}/api/tiles", req = urllib.request.Request(f"{REMOTE_SOURCE_URL}/api/tiles",
headers={"User-Agent": "lidar-maps-source"}) headers={"User-Agent": "lidar-maps-source"})
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=10) as r:
payload = json.loads(r.read().decode("utf-8")) payload = json.loads(r.read().decode("utf-8"))
except Exception as e: # noqa: BLE001 — on garde le dernier index connu except Exception as e: # noqa: BLE001 — on garde le dernier index connu
logger.warning(f"Index amont injoignable ({REMOTE_SOURCE_URL}) : {e}") logger.warning(f"Index amont injoignable ({REMOTE_SOURCE_URL}) : {e}")
@ -481,6 +507,61 @@ def _build_index(output_dir):
return layers return layers
_SEEN_FILE = ".sources_seen.json"
_seen_cache = {}
_seen_lock = threading.Lock()
def _apply_seen(output_dir, layers):
"""Date chaque dalle de sa première apparition dans l'inventaire.
Une dalle peut entrer dans l'inventaire APRÈS le rendu d'une tuile qui la
couvre tout en portant une date plus ancienne (écrite avant, inventoriée
après : TTL de l'index amont, anti-rebond de l'inventaire). Comparer les
seules dates de version laisserait la tuile « fraîche » — trou permanent
à ce niveau, jusque dans le navigateur (stamp inchangé). Registre
persistant (index_xyz/.sources_seen.json) : un redémarrage ne périme
rien ; au tout premier inventaire, rien n'est daté (tout est à rendre).
"""
import json
path = Path(output_dir) / TILE_DIRNAME / _SEEN_FILE
with _seen_lock:
seen = _seen_cache.get(str(path))
first = False
if seen is None:
try:
seen = {k: float(v) for k, v in
json.loads(path.read_text(encoding="utf-8")).items()}
except (OSError, ValueError, AttributeError):
# Pas de registre : tout premier inventaire (rien à périmer),
# sauf si un cache de tuiles existe déjà (mise à jour) — ses
# tuiles ont pu être faites avant l'arrivée de dalles : toutes
# sont périmées une fois (l'ancienne reste servie en attendant).
cache_dir = path.parent
upgraded = cache_dir.is_dir() and any(
p.is_dir() for p in cache_dir.iterdir())
seen, first = {}, not upgraded
_seen_cache[str(path)] = seen
now = time.time()
added = False
for layer, per_cell in layers.items():
for (col, row), tiers in per_cell.items():
key = f"{layer}|{col}|{row}"
at = seen.get(key)
if at is None:
at = seen[key] = 0.0 if first else now
added = True
if at:
for group in tiers:
for src in group:
src.seen = at
if added or first:
try:
_write_atomic(path, json.dumps(seen).encode("utf-8"))
except Exception as e: # noqa: BLE001 — registre perdu : péremption par version seule
logger.debug(f"Registre des dalles non écrit ({path}) : {e}")
def source_index(output_dir, force=False): def source_index(output_dir, force=False):
"""Index des sources, mémoïsé (TTL + mtime des dossiers surveillés).""" """Index des sources, mémoïsé (TTL + mtime des dossiers surveillés)."""
output_dir = Path(output_dir) output_dir = Path(output_dir)
@ -504,6 +585,7 @@ def source_index(output_dir, force=False):
layers = _build_index(output_dir) # amont muet : cache local seul layers = _build_index(output_dir) # amont muet : cache local seul
else: else:
layers = _build_index(output_dir) layers = _build_index(output_dir)
_apply_seen(output_dir, layers)
_index_cache[key] = {"layers": layers, "stamp": stamp, "at": now} _index_cache[key] = {"layers": layers, "stamp": stamp, "at": now}
return layers return layers