script-volume-auto/python/render_images.py
Nicolas Fryder 051209b022 Suppression de run, parametres CC exposes, pourcentages vs L0, carte de comparaison unique
- Bouton "Supprimer" sur chaque run (liste + detail), DELETE deja expose cote API.
- Tous les parametres CloudCompare mirroires dans l'UI (mirroir de la boite "Compute Volume" du
  GUI) : strategie de remplissage (leave_empty/min/max/custom/interpolate/kriging), hauteur de
  cellule (moyenne/min/max/mediane, -PROJ), direction de projection (X/Y/Z, -VERT_DIR),
  multiplicateur de grille. Nuage comble desormais exporte en ASCII (pas .bin) : -VOLUME le lit
  nativement, evite un aller-retour inutile puisqu'il faut de toute facon un export ASCII pour la
  carte de comparaison.
- Tableau : volume/surface/volume ajoute/volume retire avec % absolu et % relatif vs niveau 0.
  Matching cells retire du tableau et des graphiques (mais toujours calcule/stocke, juste plus
  affiche).
- Carte de comparaison unique par niveau (bleu->rouge façon CloudCompare, colormap jet) a la place
  des heightmaps separees par nuage : diff = ceil - ground (ou ceil - plan constant), calculee
  nous-memes par binning numpy sur une grille commune.
- README + CLAUDE.md : avertissement explicite sur la persistance Coolify quand le build pack est
  "Application"/Dockerfile plutot que "Docker Compose" (docker-compose.yml et son volume nomme sont
  alors completement ignores par Coolify, il faut un volume "Storages" configure manuellement).
- Valide en local avec les 2 vrais nuages utilisateur (mode comparaison) : resultats identiques a
  avant (14 244 -> 14 722 m3 selon le niveau), cartes de comparaison generees a chaque niveau,
  bouton suppression teste via l'UI.
2026-07-10 17:03:33 +02:00

196 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""
Generation des images du dossier de resultats:
- "diffmap" : construit UNE carte de comparaison (grille 2.5D, degrade bleu->rouge façon
CloudCompare) entre le nuage du dessus (ceil) et soit un plan Z constant, soit le
nuage du dessous (ground). Le paquet apt CloudCompare (Debian) plante sur l'export
GeoTIFF (assertion GDAL manquante) - on reconstruit donc la grille nous-memes a
partir d'exports ASCII, plutot que de dependre du rasterizer CloudCompare.
- "summary" : genere les graphiques de synthese (volume/points vs niveau de decimation) a partir
du report.json produit par le pipeline.
Usage:
python3 render_images.py diffmap --ceil top.xyz --ground bottom.xyz --gridstep 0.05 --output diff.png --title "Niveau 0"
python3 render_images.py diffmap --ceil cloud.xyz --const-height 100.0 --gridstep 0.05 --output diff.png --title "Niveau 0"
python3 render_images.py summary --report report.json --outdir images/
"""
import argparse
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import binned_statistic_2d
def _load_xyz(path: str) -> np.ndarray:
pts = np.loadtxt(path, dtype=np.float64, usecols=(0, 1, 2))
if pts.ndim == 1:
pts = pts.reshape(1, -1)
return pts
def _grid_edges(xmin: float, xmax: float, ymin: float, ymax: float, step: float):
nx = max(2, int(np.ceil((xmax - xmin) / step)) + 1) if xmax > xmin else 2
ny = max(2, int(np.ceil((ymax - ymin) / step)) + 1) if ymax > ymin else 2
nx, ny = min(nx, 2000), min(ny, 2000) # borne pour eviter une image demesuree
return np.linspace(xmin, xmax, nx), np.linspace(ymin, ymax, ny)
def cmd_diffmap(args: argparse.Namespace) -> int:
try:
ceil_pts = _load_xyz(args.ceil)
ground_pts = _load_xyz(args.ground) if args.ground else None
except Exception as exc:
print(json.dumps({"error": f"lecture xyz impossible: {exc}"}))
return 1
if args.ground is None and args.const_height is None:
print(json.dumps({"error": "il faut soit --ground, soit --const-height"}))
return 1
xmin = float(np.min(ceil_pts[:, 0]))
xmax = float(np.max(ceil_pts[:, 0]))
ymin = float(np.min(ceil_pts[:, 1]))
ymax = float(np.max(ceil_pts[:, 1]))
if ground_pts is not None:
xmin = min(xmin, float(np.min(ground_pts[:, 0])))
xmax = max(xmax, float(np.max(ground_pts[:, 0])))
ymin = min(ymin, float(np.min(ground_pts[:, 1])))
ymax = max(ymax, float(np.max(ground_pts[:, 1])))
fig, ax = plt.subplots(figsize=(6.4, 5.2), dpi=130)
if xmax <= xmin or ymax <= ymin:
ax.text(0.5, 0.5, "Aucune donnee exploitable", ha="center", va="center")
else:
xedges, yedges = _grid_edges(xmin, xmax, ymin, ymax, args.gridstep)
ceil_stat, _, _, _ = binned_statistic_2d(ceil_pts[:, 0], ceil_pts[:, 1], ceil_pts[:, 2], statistic="mean", bins=[xedges, yedges])
if ground_pts is not None:
ground_stat, _, _, _ = binned_statistic_2d(ground_pts[:, 0], ground_pts[:, 1], ground_pts[:, 2], statistic="mean", bins=[xedges, yedges])
diff = (ceil_stat - ground_stat).T
else:
diff = (ceil_stat - args.const_height).T
if not np.isfinite(diff).any():
ax.text(0.5, 0.5, "Aucune donnee exploitable", ha="center", va="center")
else:
cmap = plt.get_cmap("jet").copy()
cmap.set_bad("#e5e7eb")
im = ax.imshow(diff, cmap=cmap, origin="lower", extent=[xmin, xmax, ymin, ymax], aspect="auto")
cbar = fig.colorbar(im, ax=ax, shrink=0.85)
cbar.set_label("Ecart de hauteur (m)")
ax.set_title(args.title or Path(args.ceil).stem)
ax.set_xlabel("X (m)")
ax.set_ylabel("Y (m)")
fig.tight_layout()
fig.savefig(args.output)
plt.close(fig)
print(json.dumps({"ok": True, "output": args.output}))
return 0
def cmd_summary(args: argparse.Namespace) -> int:
with open(args.report, "r", encoding="utf-8") as f:
report = json.load(f)
levels = [lvl for lvl in report.get("levels", []) if lvl.get("status") == "ok"]
if not levels:
print(json.dumps({"error": "aucun niveau exploitable dans le rapport"}))
return 1
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
xs = [lvl["level"] for lvl in levels]
volumes = [lvl["volume"] for lvl in levels]
surfaces = [lvl["surface"] for lvl in levels]
v0 = volumes[0] if volumes else None
s0 = surfaces[0] if surfaces else None
# En mode cloud_compare chaque niveau a 2 nuages (top/bottom) ; en mode const_height, 1 seul.
point_series = {}
for lvl in levels:
for c in lvl.get("clouds", []):
point_series.setdefault(c["role"], []).append(c["pointCount"])
role_colors = {"unique": "#16a34a", "top": "#16a34a", "bottom": "#ea580c"}
role_labels = {"unique": "Points", "top": "Points (haut)", "bottom": "Points (bas)"}
# --- Volume vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.plot(xs, volumes, marker="o", color="#2563eb")
ax.set_xlabel("Niveau de decimation")
ax.set_ylabel("Volume (m3)")
ax.set_title("Volume calcule vs niveau de decimation")
ax.set_xticks(xs)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(outdir / "volume_vs_level.png")
plt.close(fig)
# --- Nombre de points (log) vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
for role, series in point_series.items():
ax.semilogy(xs[: len(series)], series, marker="o", color=role_colors.get(role, "#16a34a"), label=role_labels.get(role, role))
ax.set_xlabel("Niveau de decimation")
ax.set_ylabel("Nombre de points (log)")
ax.set_title("Nombre de points vs niveau de decimation")
ax.set_xticks(xs)
ax.grid(alpha=0.3, which="both")
if len(point_series) > 1:
ax.legend()
fig.tight_layout()
fig.savefig(outdir / "points_vs_level.png")
plt.close(fig)
# --- Ecart relatif volume + surface vs niveau 0 ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
if v0:
delta_v = [100.0 * (v - v0) / v0 for v in volumes]
ax.plot(xs, delta_v, marker="o", color="#dc2626", label="Volume")
if s0:
delta_s = [100.0 * (s - s0) / s0 for s in surfaces]
ax.plot(xs, delta_s, marker="s", linestyle="--", color="#0ea5e9", label="Surface")
ax.set_xlabel("Niveau de decimation")
ax.set_ylabel("Ecart vs niveau 0 (%)")
ax.set_title("Robustesse : ecart de volume et de surface vs niveau 0")
ax.set_xticks(xs)
ax.grid(alpha=0.3)
ax.axhline(0, color="#94a3b8", linewidth=1)
ax.legend()
fig.tight_layout()
fig.savefig(outdir / "robustness_vs_level.png")
plt.close(fig)
print(json.dumps({"ok": True, "outdir": str(outdir)}))
return 0
def main() -> int:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
p_diffmap = sub.add_parser("diffmap")
p_diffmap.add_argument("--ceil", required=True)
p_diffmap.add_argument("--ground", default=None)
p_diffmap.add_argument("--const-height", dest="const_height", default=None, type=float)
p_diffmap.add_argument("--gridstep", required=True, type=float)
p_diffmap.add_argument("--output", required=True)
p_diffmap.add_argument("--title", default=None)
p_diffmap.set_defaults(func=cmd_diffmap)
p_summary = sub.add_parser("summary")
p_summary.add_argument("--report", required=True)
p_summary.add_argument("--outdir", required=True)
p_summary.set_defaults(func=cmd_summary)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())