- Backend NestJS orchestrant CloudCompare CLI (stats, decimation x2 sur 5 etapes, volume, assemblage .bin) - Scripts Python (laspy, scipy, matplotlib) pour la conversion LAS/LAZ et la generation d'images - Frontend web simple (Tailwind CDN) avec historique des runs - Image Docker (debian:trixie-slim + cloudcompare apt + xvfb) prete pour deploiement Coolify - Lint/typecheck/tests unitaires integres comme portes de qualite au build Docker - CLAUDE.md documentant les comportements CloudCompare CLI verifies empiriquement
155 lines
5.7 KiB
Python
155 lines
5.7 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]
|
|
points = [lvl["pointCount"] for lvl in levels]
|
|
matching = [lvl["matchingCellsPct"] for lvl in levels]
|
|
v0 = volumes[0] if volumes else None
|
|
|
|
# --- 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)
|
|
ax.semilogy(xs, points, marker="o", color="#16a34a")
|
|
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")
|
|
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())
|