- 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.
196 lines
7.7 KiB
Python
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())
|