script-volume-auto/python/stats_helper.py
Nicolas Fryder 9d6ebf5177 Fiabilise le pipeline de volume et corrige plusieurs failles de securite
Pipeline: corrige le biais de sous-echantillonnage sur median_nn (facteur
sqrt(total/echantillon), gonflait le pas spatial jusqu'a x20 sur les gros
nuages), applique un decalage global X/Y pour la precision float32 en
coordonnees projetees (Lambert-93), fixe la portee de remplissage des trous
sur la grille du niveau 0 (au lieu de croitre a chaque niveau), utilise un
percentile robuste pour zref au lieu du minimum brut, et filtre plus
precisement le fichier de grille parasite genere par -VOLUME.

Securite: valide l'id de run (uuid) sur toutes les routes avant de construire
un chemin filesystem (traversee de repertoire post-auth via zip/csv/bin/images),
assainit le nom de fichier uploade avant multer, retire enableCors() (surface
inutile), passe le conteneur en utilisateur non-root.

Documente l'ensemble de la methode et ses limites dans CONCEPT.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 18:13:27 +02:00

80 lines
2.6 KiB
Python

#!/usr/bin/env python3
"""
Calcule des statistiques (distance moyenne/mediane au plus proche voisin, bbox)
a partir d'un echantillon de points XYZ (fichier ASCII exporte par CloudCompare).
Usage:
python3 stats_helper.py --input sample.xyz
Sortie: un objet JSON unique sur stdout.
"""
import argparse
import json
import sys
import numpy as np
from scipy.spatial import cKDTree
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, help="Fichier ASCII XYZ (espace separe)")
args = parser.parse_args()
try:
pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2))
except Exception as exc: # fichier vide, format inattendu, etc.
print(json.dumps({"error": f"lecture impossible: {exc}"}))
return 1
if pts.ndim == 1:
# une seule ligne -> un seul point
pts = pts.reshape(1, -1)
count = int(pts.shape[0])
if count < 2:
print(json.dumps({"error": f"pas assez de points dans l'echantillon ({count})"}))
return 1
bbox = {
"xmin": float(np.min(pts[:, 0])),
"xmax": float(np.max(pts[:, 0])),
"ymin": float(np.min(pts[:, 1])),
"ymax": float(np.max(pts[:, 1])),
"zmin": float(np.min(pts[:, 2])),
"zmax": float(np.max(pts[:, 2])),
}
# Percentiles bas de Z : contrairement a zmin (sensible a un unique point aberrant, ex. bruit
# multi-echo), un percentile est un estimateur robuste du plan de reference et n'est PAS biaise
# par le sous-echantillonnage (la distribution marginale de Z ne depend pas de la densite de
# points, au contraire de la distance au plus proche voisin). Voir CONCEPT.md §7.7.
z_p01 = float(np.percentile(pts[:, 2], 0.1))
z_p1 = float(np.percentile(pts[:, 2], 1.0))
tree = cKDTree(pts)
# k=2 : le plus proche voisin de chaque point est le point lui-meme (distance 0),
# le 2e plus proche est le vrai plus proche voisin.
dists, _ = tree.query(pts, k=2, workers=-1)
nn_dist = dists[:, 1]
nn_dist = nn_dist[np.isfinite(nn_dist) & (nn_dist > 0)]
if nn_dist.size == 0:
print(json.dumps({"error": "distances au plus proche voisin toutes nulles/invalides (points dupliques ?)"}))
return 1
result = {
"count": count,
"mean_nn": float(np.mean(nn_dist)),
"median_nn": float(np.median(nn_dist)),
"std_nn": float(np.std(nn_dist)),
"bbox": bbox,
"z_p01": z_p01,
"z_p1": z_p1,
"approximate": True,
}
print(json.dumps(result))
return 0
if __name__ == "__main__":
sys.exit(main())