- Nouveau mode "cloud_compare" (en plus de "const_height") : comparaison entre une surface
superieure et une limite inferieure, avec appariement de densite avant le niveau 0 puis
decimation synchronisee x2 sur 5 etapes. UI : selecteur de mode + upload double avec labels
explicites (surface superieure / limite inferieure).
- Remplissage des trous de scan (interpolation Delaunay bornee par max-edge-length) avant chaque
calcul de volume, dans les deux modes. -VOLUME n'ayant aucune option native pour ca (verifie dans
les sources CloudCompare), on rasterize+comble+exporte en nuage avant de le passer a -VOLUME.
Override utilisateur possible pour la distance max, afin de ne pas combler les parties concaves
du contour de l'objet mesure.
- Fix critique : CloudCompare formate les grands nombres avec une virgule comme separateur de
milliers ("Volume: 14,244.46") ; le parsing tronquait silencieusement a la premiere virgule
(14 244 devenait 14). Trouve en testant avec un vrai nuage industriel.
- Validation avec 2 vrais nuages utilisateur : le mode Z constant donnait ~50m3 (plan de reference
sans rapport avec la base reelle de l'amas, nuage contenant du contexte environnant) ; le nouveau
mode comparaison donne ~14 244m3, stable a +/-4% meme a 16x de decimation.
- CLAUDE.md mis a jour avec tous ces findings (limitation -VOLUME/LEAVE_EMPTY, technique de
contournement, piege du separateur de milliers, architecture du mode 2 nuages).
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generation des images du dossier de resultats:
|
|
- "heightmap" : construit une carte de hauteur (grille 2.5D) coloree en PNG directement a partir
|
|
d'un nuage ASCII XYZ (binning numpy). Le paquet apt CloudCompare (Debian) plante
|
|
sur l'export GeoTIFF (assertion GDAL manquante) - on reconstruit donc la grille
|
|
nous-memes plutot que de dependre de -RASTERIZE -OUTPUT_RASTER_Z.
|
|
- "summary" : genere les graphiques de synthese (volume/points/robustesse vs niveau de decimation)
|
|
a partir du report.json produit par le pipeline.
|
|
|
|
Usage:
|
|
python3 render_images.py heightmap --input cloud.xyz --gridstep 0.05 --output heightmap.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 cmd_heightmap(args: argparse.Namespace) -> int:
|
|
try:
|
|
pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2))
|
|
except Exception as exc:
|
|
print(json.dumps({"error": f"lecture xyz impossible: {exc}"}))
|
|
return 1
|
|
|
|
if pts.ndim == 1:
|
|
pts = pts.reshape(1, -1)
|
|
|
|
x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
|
|
xmin, xmax = float(np.min(x)), float(np.max(x))
|
|
ymin, ymax = float(np.min(y)), float(np.max(y))
|
|
nx = max(2, int(np.ceil((xmax - xmin) / args.gridstep)) + 1) if xmax > xmin else 2
|
|
ny = max(2, int(np.ceil((ymax - ymin) / args.gridstep)) + 1) if ymax > ymin else 2
|
|
# Grille bornee pour eviter une image demesuree si le pas est mal renseigne
|
|
nx, ny = min(nx, 2000), min(ny, 2000)
|
|
|
|
arr = None
|
|
if pts.shape[0] >= 1 and xmax > xmin and ymax > ymin:
|
|
stat, _, _, _ = binned_statistic_2d(x, y, z, statistic="mean", bins=[nx, ny])
|
|
arr = stat.T
|
|
|
|
fig, ax = plt.subplots(figsize=(6, 5.2), dpi=130)
|
|
if arr is None or not np.isfinite(arr).any():
|
|
ax.text(0.5, 0.5, "Aucune donnee exploitable", ha="center", va="center")
|
|
else:
|
|
im = ax.imshow(arr, cmap="terrain", origin="lower", extent=[xmin, xmax, ymin, ymax], aspect="auto")
|
|
cbar = fig.colorbar(im, ax=ax, shrink=0.85)
|
|
cbar.set_label("Altitude (m, moyenne par cellule)")
|
|
ax.set_title(args.title or Path(args.input).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]
|
|
matching = [lvl["matchingCellsPct"] for lvl in levels]
|
|
v0 = volumes[0] if volumes 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 de volume + robustesse (matching cells %) vs niveau ---
|
|
fig, ax1 = plt.subplots(figsize=(6, 4), dpi=130)
|
|
if v0:
|
|
delta_pct = [100.0 * (v - v0) / v0 for v in volumes]
|
|
ax1.plot(xs, delta_pct, marker="o", color="#dc2626", label="Ecart volume vs niveau 0 (%)")
|
|
ax1.set_xlabel("Niveau de decimation")
|
|
ax1.set_ylabel("Ecart volume (%)", color="#dc2626")
|
|
ax1.set_xticks(xs)
|
|
ax1.grid(alpha=0.3)
|
|
|
|
ax2 = ax1.twinx()
|
|
ax2.plot(xs, matching, marker="s", linestyle="--", color="#7c3aed", label="Matching cells (%)")
|
|
ax2.set_ylabel("Matching cells (%)", color="#7c3aed")
|
|
ax2.set_ylim(0, 105)
|
|
|
|
fig.suptitle("Robustesse : ecart de volume et couverture de grille")
|
|
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_heightmap = sub.add_parser("heightmap")
|
|
p_heightmap.add_argument("--input", required=True)
|
|
p_heightmap.add_argument("--gridstep", required=True, type=float)
|
|
p_heightmap.add_argument("--output", required=True)
|
|
p_heightmap.add_argument("--title", default=None)
|
|
p_heightmap.set_defaults(func=cmd_heightmap)
|
|
|
|
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())
|