Files
lidar_rendu/lidar_pipeline/quality.py
2026-09-27 15:11:47 +02:00

151 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Qualité des données LiDAR par dalle : densité de points sol et date d'acquisition.
Un sidecar JSON par dalle (output/quality/{basename}.json) est écrit par le
pipeline, recopié dans l'inventaire (index_tiles.json, table `quality`) et
conservé sur les machines légères : l'encart qualité de l'export PDF le lit
depuis le disque local, amont joignable ou non.
numpy n'est importé que par le calcul : l'image légère (sans numpy) lit et
écrit les sidecars.
"""
import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
logger = logging.getLogger("lidar")
QUALITY_VERSION = 1
QUALITY_DIRNAME = "quality"
DENSITY_CELL_M = 50.0 # maille de la grille de densité (20 × 20 par dalle)
EMPTY_PIXEL_M = 0.2 # pixel du MNT : part sans point sol ≈ part interpolée
_GPS_EPOCH = datetime(1980, 1, 6, tzinfo=timezone.utc)
_GPS_ADJUSTED_OFFSET = 1e9 # temps GPS ajusté standard = secondes GPS − 1e9
def gps_adjusted_to_date(t):
"""Temps GPS ajusté standard → date ISO (UTC ; secondes intercalaires
négligées : précision au jour)."""
return (_GPS_EPOCH + timedelta(seconds=float(t) + _GPS_ADJUSTED_OFFSET)).date().isoformat()
def compute_quality(x, y, gps_time, bounds, gps_adjusted=True, header_date=None):
"""Mesure la qualité d'une dalle à partir de ses points sol.
Args:
x, y: coordonnées L93 des points sol.
gps_time: temps GPS des points (ou None).
bounds: emprise nominale (min_x, min_y, max_x, max_y) ; les points
hors emprise (bande de raccord) sont ignorés.
gps_adjusted: True si gps_time est en temps GPS ajusté standard.
header_date: date ISO de l'en-tête LAS (repli des dates).
Returns:
dict JSON-sérialisable (cf. spec, section 1).
"""
import numpy as np
min_x, min_y, max_x, max_y = bounds
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
inside = (x >= min_x) & (x < max_x) & (y >= min_y) & (y < max_y)
t = None
if gps_time is not None and gps_adjusted:
t = np.asarray(gps_time, dtype=np.float64)
t = t[inside] if len(t) == len(x) else None
x, y = x[inside], y[inside]
n = len(x)
width, height = max_x - min_x, max_y - min_y
nx = int(round(width / DENSITY_CELL_M))
ny = int(round(height / DENSITY_CELL_M))
cx = np.clip(((x - min_x) / DENSITY_CELL_M).astype(np.int64), 0, nx - 1)
cy = np.clip(((max_y - y) / DENSITY_CELL_M).astype(np.int64), 0, ny - 1)
counts = np.bincount(cy * nx + cx, minlength=nx * ny).reshape(ny, nx)
grid = np.round(counts / (DENSITY_CELL_M ** 2), 2)
px = int(round(width / EMPTY_PIXEL_M))
py = int(round(height / EMPTY_PIXEL_M))
occupied = np.zeros(px * py, dtype=bool)
if n:
ix = np.clip(((x - min_x) / EMPTY_PIXEL_M).astype(np.int64), 0, px - 1)
iy = np.clip(((max_y - y) / EMPTY_PIXEL_M).astype(np.int64), 0, py - 1)
occupied[iy * px + ix] = True
empty_fraction = 1.0 - float(occupied.sum()) / (px * py)
if t is not None and len(t):
start, end = gps_adjusted_to_date(t.min()), gps_adjusted_to_date(t.max())
source = "gps"
elif header_date:
start = end = header_date
source = "header"
else:
start = end = source = None
return {
"version": QUALITY_VERSION,
"ground_density": round(n / (width * height), 6),
"density_grid": grid.tolist(),
"empty_fraction": round(empty_fraction, 4),
"acq_start": start,
"acq_end": end,
"acq_source": source,
}
def quality_path(output_dir, basename):
"""Chemin du sidecar qualité d'une dalle."""
return Path(output_dir) / QUALITY_DIRNAME / f"{basename}.json"
def write_quality(output_dir, basename, data):
"""Écrit le sidecar (atomique) ; False si le contenu est déjà identique."""
path = quality_path(output_dir, basename)
payload = json.dumps(data, ensure_ascii=False, sort_keys=True)
try:
if path.read_text(encoding="utf-8") == payload:
return False
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(payload)
os.replace(tmp, path)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
raise
return True
def read_quality(output_dir, basename):
"""Sidecar d'une dalle, ou None (absent, illisible, autre version)."""
try:
data = json.loads(quality_path(output_dir, basename).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or data.get("version") != QUALITY_VERSION:
return None
return data
def load_quality_table(output_dir):
"""Tous les sidecars valides : {basename: sidecar}."""
folder = Path(output_dir) / QUALITY_DIRNAME
table = {}
if not folder.is_dir():
return table
for f in sorted(folder.glob("*.json")):
data = read_quality(output_dir, f.stem)
if data is not None:
table[f.stem] = data
return table