diff --git a/CLAUDE.md b/CLAUDE.md index 5f5fec6..633f41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,10 +88,27 @@ E57 volontairement hors perimetre. fournir qu'UN SEUL nom de fichier a `-SAVE_CLOUDS FILE`. - Le nuage "comble" est un nuage-grille (un point par cellule non vide, ex: 39971 points pour une grille 201x201 avec quelques cellules hors de l'enveloppe convexe) — PAS le nuage original avec des - points ajoutes. On le garde uniquement pour le calcul de volume (`raw/L{n}_filled.bin`) ; le nuage - BRUT (non comble) reste utilise pour l'export, les cartes de hauteur et le chainage de decimation, - afin de montrer les vrais trous de scan et ne pas faire chainer l'interpolation d'un niveau vers - le suivant (qui composerait l'erreur). + points ajoutes. On le garde uniquement pour le calcul de volume et la carte de comparaison + (`raw/L{n}_filled.xyz`, en **ASCII** — pas `.bin` : `-VOLUME` lit l'ASCII nativement, et ca evite + un aller-retour BIN inutile puisqu'on a de toute facon besoin d'un export ASCII pour la carte de + comparaison Python). Le nuage BRUT (non comble, en `.bin`) reste utilise pour l'assemblage final et + le chainage de decimation, afin de ne pas faire chainer l'interpolation d'un niveau vers le suivant + (qui composerait l'erreur). + - **Toutes les strategies `-EMPTY_FILL` de CloudCompare sont exposees dans l'UI** (mirroir de la + boite de dialogue "Compute Volume" du GUI), pas seulement INTERP : `LEAVE_EMPTY` (aucun flag), + `MIN_H`/`MAX_H` (hauteur min/max des cellules voisines), `CUSTOM_H` (necessite `-CUSTOM_HEIGHT + `), `INTERP` (necessite `-MAX_EDGE_LENGTH `), `KRIGING` (necessite `-KRIGING_KNN + `). Voir `emptyFillArgs()` dans `pipeline.service.ts`. + - **`-PROJ `** (type de projection par cellule, "Hauteur de cellule" dans le GUI) et + **`-VERT_DIR <0|1|2>`** (direction de projection X/Y/Z) sont egalement exposes. Contrairement a + `-EMPTY_FILL`, `-VOLUME` accepte bien `-VERT_DIR` nativement (verifie dans les sources) — on le + passe donc aux DEUX commandes (`-RASTERIZE` pour le remplissage, `-VOLUME` pour le calcul) afin + qu'elles restent coherentes. `-PROJ` en revanche n'existe QUE pour `-RASTERIZE` (comme + `-EMPTY_FILL` : `-VOLUME` a `PROJ_AVERAGE_VALUE` code en dur) — mais comme le nuage envoye a + `-VOLUME` est deja notre grille-comble (1 point/cellule), le "moyennage" interne de `-VOLUME` + devient une passe-plat : c'est bien NOTRE `-PROJ` (au moment du remplissage) qui determine le + resultat final, pas celui hardcode dans `-VOLUME`. `INV_VAR` (variance inverse) n'est pas expose : + necessite un champ scalaire dedie qu'on n'a pas dans ce pipeline. - **Piege critique de parsing** : CloudCompare formate les **grands nombres avec une virgule comme separateur de milliers** dans le rapport texte (`Volume: 14,244.464657`, pas `14244.464657`). Une @@ -191,10 +208,13 @@ Mais ce paquet est **allege par rapport au build officiel Windows** : plugin (verifie : `CloudCompare -O fichier.xyz` marche directement sur ce paquet). Un `.copc.laz` est lu par laspy comme un LAZ standard (l'indexation octree COPC est ignoree, seuls les points comptent ici). -2. **Export GeoTIFF cassé** (voir `-RASTERIZE` ci-dessus). **Solution retenue** : les cartes de hauteur - sont generees nous-memes (`python/render_images.py heightmap`) via export ASCII du nuage + - binning `scipy.stats.binned_statistic_2d` + rendu `matplotlib`, sans passer par le rasterizer - CloudCompare. +2. **Export GeoTIFF cassé** (voir `-RASTERIZE` ci-dessus). **Solution retenue** : les images sont + generees nous-memes (`python/render_images.py diffmap`) via export ASCII des nuages combles + + binning `scipy.stats.binned_statistic_2d` sur une grille commune + rendu `matplotlib` (colormap + `jet`, bleu->rouge façon CloudCompare), sans passer par le rasterizer CloudCompare. Une seule + image de comparaison par niveau (ceil vs ground/plan constant), pas une carte par nuage — + remplace l'ancienne approche (une heightmap par nuage) qui ne permettait pas de voir directement + l'ecart entre les deux surfaces. 3. **CloudCompare reste une appli Qt, meme en `-SILENT`** : plante avec `QXcbConnection: Could not connect to display` sans serveur X. Necessite `xvfb`. On demarre UN Xvfb persistant dans `docker/entrypoint.sh` (pas un `xvfb-run` par appel CLI, plus rapide et evite les conflits de @@ -235,6 +255,17 @@ manquantes), mais pourraient etre simplifies. - `docker-compose.yml` utilise un **volume nomme** (`app-data:/data`), pas un bind mount vers `./data` : Coolify re-clone le repo a chaque deploiement, un bind mount relatif au checkout perdrait les donnees (uploads, resultats, base sqlite) a chaque redeploy. + - **Mais** si la resource Coolify est de type "Application" (build pack Dockerfile, pas "Docker + Compose"), `docker-compose.yml` est **completement ignore** par Coolify, y compris son `volumes:`. + Le `VOLUME ["/data"]` du Dockerfile cree alors juste un volume anonyme a chaque nouveau conteneur, + jamais reattache au precedent -> toutes les donnees disparaissent a chaque redeploy sans aucune + erreur visible. Il faut explicitement ajouter un volume persistant sur `/data` dans l'onglet + "Storages" de l'application Coolify. Observe reellement sur ce projet : Coolify auto-detecte par + defaut un repo avec `package.json` comme un projet Node et choisit le build pack **Nixpacks** + (qui ignore le `Dockerfile` et tente de build un projet Node generique — echoue forcement ici, + aucune connaissance de CloudCompare/xvfb/Python). Il faut forcer manuellement le build pack sur + "Dockerfile" dans les reglages de l'application — et donc, une fois ce changement fait, ne pas + oublier le volume "Storages" puisqu'on est passe en mode "Application". - `HEALTHCHECK` dans le Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify l'utilise pour determiner si le deploiement a reussi. - Le port d'ecoute est configurable via `PORT` (`config.ts` lit `process.env.PORT`, defaut 3000). diff --git a/README.md b/README.md index be182c4..6a3b304 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,16 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui casse l'un de ces trois echoue le build, donc n'est jamais deploye. - `HEALTHCHECK` integre au Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify l'utilise pour verifier que le deploiement est reellement sain. -- Persistance : le volume `/data` doit etre monte comme un **volume nomme/gere par Coolify**, pas un - bind mount vers le checkout git (Coolify re-clone le repo a chaque deploiement — un bind mount - relatif au checkout perdrait toutes les donnees a chaque redeploy). Deux options : - - Resource **"Docker Compose"** dans Coolify pointant sur `docker-compose.yml` du repo : le volume - nomme `app-data` declare dedans est deja correct. - - Resource **"Application"** (Dockerfile) : configurer un volume persistant sur `/data` depuis - l'onglet "Storage" de Coolify, et le port expose sur `3000` (variable `PORT`, lue par l'appli). +- **Persistance — action requise cote Coolify, sinon les runs disparaissent a chaque redeploy** : + - Si la resource est de type **"Docker Compose"** (pointant sur `docker-compose.yml` du repo) : + le volume nomme `app-data:/data` declare dedans suffit, rien a faire de plus. + - Si la resource est de type **"Application"** (build pack Dockerfile — c'est le cas typique quand + on a du forcer le build pack sur "Dockerfile" pour eviter Nixpacks) : Coolify **ignore + completement `docker-compose.yml`**, y compris son `volumes:`. Sans configuration explicite, le + conteneur redemarre a chaque deploy avec un `/data` neuf (le `VOLUME ["/data"]` du Dockerfile cree + juste un volume anonyme, jamais reattache au precedent). **Il faut aller dans l'onglet "Storages" + de l'application Coolify et ajouter un volume persistant monte sur `/data`** — sans ca, tous les + runs (nuages, resultats, base sqlite) sont perdus a chaque redeploiement. - Variables d'environnement a definir dans Coolify : `AUTH_USERNAME` / `AUTH_PASSWORD_HASH` (obligatoire, voir "Authentification" plus haut), et optionnellement `MAX_UPLOAD_MB`, `STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS` si besoin de changer les defauts (voir tableau plus bas). diff --git a/public/app.js b/public/app.js index e87554e..0233a84 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,7 @@ async function api(path, opts) { try { const j = await res.json(); msg = j.message || j.error || msg; } catch {} throw new Error(msg); } + if (res.status === 204) return null; return res.json(); } @@ -22,6 +23,12 @@ function fmt(n, digits = 4) { return n.toLocaleString('fr-FR', { maximumFractionDigits: digits }); } +function fmtPct(n, digits = 1) { + if (n === null || n === undefined || !Number.isFinite(n)) return '-'; + const sign = n > 0 ? '+' : ''; + return sign + n.toLocaleString('fr-FR', { maximumFractionDigits: digits }) + '%'; +} + function roleLabel(role) { return role === 'top' ? 'haut' : role === 'bottom' ? 'bas' : ''; } @@ -68,6 +75,9 @@ async function renderList() { ${new Date(r.createdAt).toLocaleString('fr-FR')} ${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'} ${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'} + + + `).join(''); app.innerHTML = ` @@ -81,6 +91,7 @@ async function renderList() { IDFichierStatut Cree leNiveaux OKEcart volume (L0 -> dernier) + ${rows} @@ -88,6 +99,16 @@ async function renderList() { `; } +async function deleteRun(id) { + if (!confirm('Supprimer definitivement ce run (nuages, images, rapports) ?')) return; + try { + await api(`${API}/${id}`, { method: 'DELETE' }); + renderList(); + } catch (err) { + alert(`Echec de la suppression : ${err.message}`); + } +} + // ---------------- Nouveau run ---------------- function renderNew() { app.innerHTML = ` @@ -128,11 +149,11 @@ function renderNew() {

Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape.

-
- Options avancees (optionnel) -
+
+ Parametres CloudCompare (mirroir de la boite "Compute Volume") +
- +
@@ -142,11 +163,54 @@ function renderNew() { class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
- + + +
+
+ + +
+
+

Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.

+ + +
+ + +
+
+ + +
@@ -160,6 +224,10 @@ function renderNew() { const constFileInput = form.querySelector('input[name=file]'); const topFileInput = form.querySelector('input[name=fileTop]'); const bottomFileInput = form.querySelector('input[name=fileBottom]'); + const strategySelect = form.querySelector('select[name=emptyCellStrategy]'); + const maxEdgeWrap = document.getElementById('max-edge-wrap'); + const customHeightWrap = document.getElementById('custom-height-wrap'); + const krigingWrap = document.getElementById('kriging-knn-wrap'); form.querySelectorAll('input[name=mode]').forEach((radio) => { radio.addEventListener('change', () => { @@ -174,6 +242,12 @@ function renderNew() { }); constFileInput.required = true; + strategySelect.addEventListener('change', () => { + maxEdgeWrap.classList.toggle('hidden', strategySelect.value !== 'interpolate'); + customHeightWrap.classList.toggle('hidden', strategySelect.value !== 'custom_height'); + krigingWrap.classList.toggle('hidden', strategySelect.value !== 'kriging'); + }); + form.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(form); @@ -230,7 +304,17 @@ function stepCell(l) { return fmt(c.spatialStep, 5) + ' m'; } -function levelRow(l) { +/** Cellule valeur + % absolu vs L0 + % relatif vs L0 (+ ou -). */ +function metricCell(value, l0value, unit, digits) { + if (value === null || value === undefined) return '-'; + const main = `${fmt(value, digits)} ${unit}`; + if (l0value === null || l0value === undefined || l0value === 0) return main; + const abs = (value / l0value) * 100; + const rel = ((value - l0value) / l0value) * 100; + return `${main}
${fmt(abs, 1)}% abs · ${fmtPct(rel, 1)}`; +} + +function levelRow(l, l0) { const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : ''; const statusTxt = l.status === 'ok' ? '' : `
${escapeHtml(l.message || '')}
`; return ` @@ -238,18 +322,19 @@ function levelRow(l) { ${l.level} ${stepCell(l)} ${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'} - ${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'} ${pointsCell(l)} - ${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'} - ${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'} - ${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'} - ${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? 'a verifier' : ''}${statusTxt} + ${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'} + ${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'} + ${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'} + ${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'} + ${statusBadge(l.status)}${statusTxt} `; } function paintDetail(run) { const r = run.report; const isCompare = r && r.mode === 'cloud_compare'; + const l0 = r ? r.levels.find((l) => l.level === 0 && l.status === 'ok') : null; const levelsTable = r ? `
@@ -257,13 +342,14 @@ function paintDetail(run) { NiveauPas spatialGrille volume - Max edge (remplissage)Points - VolumeSurface - Matching cellsStatut + Points + VolumeVolume ajouteVolume retire + SurfaceStatut - ${r.levels.map((l) => levelRow(l)).join('')} + ${r.levels.map((l) => levelRow(l, l0)).join('')} +

% abs = valeur / niveau 0 · % relatif = ecart vs niveau 0 (+ ou -)

` : ''; const densityBlock = r && r.params.densityMatch ? ` @@ -274,20 +360,36 @@ function paintDetail(run) { (${r.params.densityMatch.preDecimatedRole === 'none' ? 'aucun nuage decime, deja comparables' : `nuage ${roleLabel(r.params.densityMatch.preDecimatedRole)} decime pour matcher`})

` : ''; + const strategyLabels = { + leave_empty: 'Laisser vide', + min_height: 'Hauteur minimum', + max_height: 'Hauteur maximum', + custom_height: 'Hauteur personnalisee', + interpolate: 'Interpoler (Delaunay)', + kriging: 'Krigeage', + }; + const projLabels = { avg: 'Moyenne', min: 'Minimum', max: 'Maximum', med: 'Mediane' }; + const dirLabels = { 0: 'X', 1: 'Y', 2: 'Z' }; + const params = r ? `
${!isCompare ? `
Zref
${fmt(r.params.zref, 4)} (${r.params.zrefSource})
` : ''}
Pas initial
${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})
-
Grille volume
x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau
-
Remplissage trous
x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})
+
Grille volume
x${r.params.gridStepMultiplier} le pas spatial (${r.params.gridStepMultiplierSource})
+
Cellules vides
${strategyLabels[r.params.emptyCellStrategy] || r.params.emptyCellStrategy}
+ ${r.params.emptyCellStrategy === 'interpolate' ? `
Remplissage trous
x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})
` : ''} + ${r.params.emptyCellStrategy === 'custom_height' ? `
Hauteur personnalisee
${fmt(r.params.customHeightValue, 4)}
` : ''} + ${r.params.emptyCellStrategy === 'kriging' ? `
Krigeage KNN
${r.params.krigingKnn}
` : ''} +
Hauteur de cellule
${projLabels[r.params.projectionType] || r.params.projectionType}
+
Direction
${dirLabels[r.params.vertDir]}
Facteur / etapes
x${r.params.factor} sur ${r.params.steps}
${densityBlock}` : ''; - const heightmaps = r ? r.levels.flatMap((l) => l.clouds.filter((c) => c.imageFile).map((c) => ({ level: l.level, c }))).map(({ level, c }) => ` + const diffmaps = r ? r.levels.filter((l) => l.diffMapImage).map((l) => `
- -

Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}

+ +

Niveau ${l.level}

`).join('') : ''; const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => ` @@ -300,6 +402,7 @@ function paintDetail(run) { Nuages assembles (.bin) Rapport (.csv) Dossier complet (.zip) + ` : ''; const errorBlock = run.error ? `
${escapeHtml(run.error)}
` : ''; @@ -317,7 +420,7 @@ function paintDetail(run) { ${params} ${levelsTable} ${downloads} - ${heightmaps ? `

Cartes de hauteur par niveau

${heightmaps}
` : ''} + ${diffmaps ? `

Cartes de comparaison par niveau

${diffmaps}
` : ''} ${summaryCharts ? `

Graphiques de synthese

${summaryCharts}
` : ''}

Journal

${escapeHtml(run.log || '')}
diff --git a/python/render_images.py b/python/render_images.py index 35fb4c1..4070450 100644 --- a/python/render_images.py +++ b/python/render_images.py @@ -1,15 +1,17 @@ #!/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. + - "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 heightmap --input cloud.xyz --gridstep 0.05 --output heightmap.png --title "Niveau 0" + 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 @@ -25,37 +27,64 @@ import numpy as np from scipy.stats import binned_statistic_2d -def cmd_heightmap(args: argparse.Namespace) -> int: +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: - pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2)) + 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 pts.ndim == 1: - pts = pts.reshape(1, -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 - 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) + 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]))) - 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(): + 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: - 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) + 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() @@ -79,8 +108,9 @@ def cmd_summary(args: argparse.Namespace) -> int: xs = [lvl["level"] for lvl in levels] volumes = [lvl["volume"] for lvl in levels] - matching = [lvl["matchingCellsPct"] 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 = {} @@ -117,22 +147,21 @@ def cmd_summary(args: argparse.Namespace) -> int: 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) + # --- Ecart relatif volume + surface vs niveau 0 --- + fig, ax = 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") + 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) @@ -145,12 +174,14 @@ 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_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) diff --git a/src/config.ts b/src/config.ts index e23b210..16e9fa4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,12 @@ export const config = { // du contour de l'amas (qui doivent rester "vides") tout en bouchant les petits trous de scan. // Voir CLAUDE.md : -VOLUME n'a PAS d'option de remplissage native, on passe par -RASTERIZE. maxEdgeLengthMultiplier: 3, + // Defauts des parametres CloudCompare exposes dans l'UI (mirroir de la boite de dialogue + // "Compute Volume" du GUI) - tous overridables par run, voir RunParamsInput. + emptyCellStrategyDefault: 'interpolate' as const, + projectionTypeDefault: 'avg' as const, + vertDirDefault: 2 as const, + krigingKnnDefault: 8, // En dessous de ce nombre de points, on arrete la decimation progressive (cloud devenu inexploitable) minPointsToContinue: 25, // Seuil d'alerte robustesse : si "matching cells %" du rapport CloudCompare passe sous ce seuil, diff --git a/src/pipeline/pipeline.service.ts b/src/pipeline/pipeline.service.ts index 7528cc3..4c3e4a9 100644 --- a/src/pipeline/pipeline.service.ts +++ b/src/pipeline/pipeline.service.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { config } from '../config'; import { CcRunnerService } from '../cloudcompare/cc-runner.service'; import { PyHelperService, CloudStats } from '../pyhelper/pyhelper.service'; -import { CloudRole, LevelCloudInfo, LevelResult, RunParamsInput, RunReport } from './types'; +import { CloudRole, EmptyCellStrategy, LevelCloudInfo, LevelResult, ProjectionType, RunParamsInput, RunReport, VertDir } from './types'; export function sanitizeBaseName(filename: string): string { const base = filename.replace(/\.[^./\\]+$/, ''); @@ -26,18 +26,36 @@ interface CloudPrepResult { info: LevelCloudInfo; /** Chemin absolu du nuage brut (non rempli), utilise pour chainer la decimation suivante */ rawBinAbs: string | null; - /** Chemin absolu du nuage avec trous combles (INTERP), utilise pour -VOLUME */ - filledBinAbs: string | null; + /** Chemin absolu (ASCII XYZ) du nuage avec trous combles, utilise pour -VOLUME et la carte de comparaison */ + filledXyzAbs: string | null; +} + +/** Convertit une strategie de remplissage en arguments CLI CloudCompare (-EMPTY_FILL ...). */ +function emptyFillArgs(strategy: EmptyCellStrategy, customHeightValue: number | undefined, krigingKnn: number, maxEdgeLength: number): string[] { + switch (strategy) { + case 'leave_empty': + return []; + case 'min_height': + return ['-EMPTY_FILL', 'MIN_H']; + case 'max_height': + return ['-EMPTY_FILL', 'MAX_H']; + case 'custom_height': + return ['-EMPTY_FILL', 'CUSTOM_H', '-CUSTOM_HEIGHT', String(customHeightValue ?? 0)]; + case 'interpolate': + return ['-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength)]; + case 'kriging': + return ['-EMPTY_FILL', 'KRIGING', '-KRIGING_KNN', String(krigingKnn)]; + } } /** * Orchestration complete d'un run. Deux modes : - * - const_height : 1 nuage, compare a un plan Z constant (comme avant). + * - const_height : 1 nuage, compare a un plan Z constant. * - cloud_compare : 2 nuages (surface du haut vs limite du bas), densites appariees au niveau 0 * puis decimation synchronisee x2 sur 5 etapes, compares l'un a l'autre a chaque niveau. - * Dans les deux cas, les trous de scan sont combles (interpolation Delaunay, bornee par - * max-edge-length) avant chaque calcul de volume - voir CLAUDE.md, -VOLUME n'a pas cette option - * nativement, on passe par -RASTERIZE en amont. + * Dans les deux cas, les trous de scan sont combles (strategie configurable, mirroir de la boite de + * dialogue "Compute Volume" de CloudCompare) avant chaque calcul de volume - voir CLAUDE.md, + * -VOLUME n'a pas ces options nativement, on passe par -RASTERIZE en amont. */ @Injectable() export class PipelineService { @@ -63,6 +81,12 @@ export class PipelineService { await fs.mkdir(rawDir, { recursive: true }); const isCompare = params.mode === 'cloud_compare'; + const gridStepMultiplier = params.gridStepMultiplierOverride ?? config.gridStepMultiplier; + const emptyCellStrategy: EmptyCellStrategy = params.emptyCellStrategy ?? config.emptyCellStrategyDefault; + const projectionType: ProjectionType = params.projectionType ?? config.projectionTypeDefault; + const vertDir: VertDir = params.vertDir ?? config.vertDirDefault; + const krigingKnn = params.krigingKnn ?? config.krigingKnnDefault; + const customHeightValue = params.customHeightValue; // ---------- 0) Normalisation du/des format(s) d'entree ---------- const primaryNorm = await this.normalizeInput(inputs.primaryPath, workDir, 'primary', onProgress); @@ -126,12 +150,12 @@ export class PipelineService { // Niveau 0 (const_height) = nuage complet, jamais decime : spatialStep=null. // Niveau 0 (cloud_compare) = pas apparie = initialStep (factor^0) ; niveaux suivants x2. const spatialStep = !isCompare && i === 0 ? null : initialStep * Math.pow(config.decimationFactor, i); - const gridStep = (spatialStep ?? initialStep) * config.gridStepMultiplier; + const gridStep = (spatialStep ?? initialStep) * gridStepMultiplier; const maxEdgeLength = params.maxEdgeLengthOverride ?? gridStep * config.maxEdgeLengthMultiplier; onProgress( `Niveau ${i} : ${spatialStep === null ? 'nuage complet' : `decimation (pas=${spatialStep.toFixed(5)}m)`}, ` + - `grille=${gridStep.toFixed(5)}m, remplissage trous (max edge=${maxEdgeLength.toFixed(5)}m)...`, + `grille=${gridStep.toFixed(5)}m, remplissage trous (${emptyCellStrategy}${emptyCellStrategy === 'interpolate' ? `, max edge=${maxEdgeLength.toFixed(5)}m` : ''})...`, ); try { @@ -146,11 +170,15 @@ export class PipelineService { sourcePath, isFirstConversion, levelsDir, - imagesDir, rawDir, workDir, gridStep, maxEdgeLength, + emptyCellStrategy, + customHeightValue, + krigingKnn, + projectionType, + vertDir, refPointCount: refPointCount[role] ?? 0, }); cloudResults.push(res); @@ -170,6 +198,7 @@ export class PipelineService { matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, + diffMapImage: null, warn: true, status: anyBad.status, message: anyBad.message, @@ -185,7 +214,17 @@ export class PipelineService { let volRes; if (!isCompare) { volRes = await this.cc.run( - ['-O', this.relFrom(workDir, cloudResults[0].filledBinAbs!), '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)], + [ + '-O', + this.relFrom(workDir, cloudResults[0].filledXyzAbs!), + '-VOLUME', + '-GRID_STEP', + String(gridStep), + '-CONST_HEIGHT', + String(zref), + '-VERT_DIR', + String(vertDir), + ], workDir, { autoSave: true }, ); @@ -193,12 +232,14 @@ export class PipelineService { volRes = await this.cc.run( [ '-O', - this.relFrom(workDir, cloudResults[0].filledBinAbs!), // top = ceil + this.relFrom(workDir, cloudResults[0].filledXyzAbs!), // top = ceil '-O', - this.relFrom(workDir, cloudResults[1].filledBinAbs!), // bottom = ground + this.relFrom(workDir, cloudResults[1].filledXyzAbs!), // bottom = ground '-VOLUME', '-GRID_STEP', String(gridStep), + '-VERT_DIR', + String(vertDir), ], workDir, { autoSave: true }, @@ -211,13 +252,29 @@ export class PipelineService { const vol = await this.cc.parseVolumeReportFile(reportFile); await fs.rename(reportFile, path.join(rawDir, `L${i}_volume_report.txt`)); - // AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder + // AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas + // garder (les nuages combles sont maintenant en ASCII, donc tout .bin frais ici est ce fichier) const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume }); - const filledPaths = new Set(cloudResults.map((r) => r.filledBinAbs)); - if (strayGrid && !filledPaths.has(strayGrid)) { + if (strayGrid) { await fs.unlink(strayGrid).catch(() => undefined); } + // ---------- Carte de comparaison (bleu->rouge, une seule image par niveau) ---------- + let diffMapImage: string | null = null; + try { + const pngName = `L${i}_diffmap.png`; + const pngAbs = path.join(imagesDir, pngName); + const title = `Niveau ${i}` + (spatialStep === null ? ' (complet)' : ` (pas=${spatialStep.toFixed(4)}m)`); + if (!isCompare) { + await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, null, zref, gridStep, pngAbs, title); + } else { + await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, cloudResults[1].filledXyzAbs!, null, gridStep, pngAbs, title); + } + diffMapImage = path.posix.join('images', pngName); + } catch (err: any) { + this.logger.warn(`Carte de comparaison niveau ${i} non generee: ${err.message}`); + } + const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold; levels.push({ @@ -232,6 +289,7 @@ export class PipelineService { matchingCellsPct: vol.matchingCellsPct, groundNonMatchingPct: vol.groundNonMatchingPct, ceilNonMatchingPct: vol.ceilNonMatchingPct, + diffMapImage, warn, status: 'ok', computeTimeMs: Date.now() - beforeVolume, @@ -253,7 +311,7 @@ export class PipelineService { level: i, gridStep, maxEdgeLength, - clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null })), + clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null })), volume: null, surface: null, addedVolume: null, @@ -261,6 +319,7 @@ export class PipelineService { matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, + diffMapImage: null, warn: true, status: 'error', message: err.message, @@ -304,9 +363,15 @@ export class PipelineService { zrefSource, initialStep, initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto', - gridStepMultiplier: config.gridStepMultiplier, + gridStepMultiplier, + gridStepMultiplierSource: params.gridStepMultiplierOverride !== undefined ? 'override' : 'auto', maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier, maxEdgeLengthSource, + emptyCellStrategy, + customHeightValue: customHeightValue ?? null, + krigingKnn: emptyCellStrategy === 'kriging' ? krigingKnn : null, + projectionType, + vertDir, factor: config.decimationFactor, steps: config.decimationSteps, densityMatch, @@ -376,6 +441,7 @@ export class PipelineService { matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, + diffMapImage: null, warn: true, status: 'skipped', message: reason, @@ -392,10 +458,11 @@ export class PipelineService { } /** - * Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale), export de la - * carte de hauteur, puis remplissage des trous (rasterize + interpolation Delaunay bornee) pour - * produire la version utilisee par -VOLUME. Le nuage BRUT (non rempli) est conserve pour - * chainer la decimation du niveau suivant et pour l'assemblage final. + * Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale) puis remplissage + * des trous (rasterize + strategie configurable, mirroir de la boite de dialogue CloudCompare) + * pour produire la version ASCII utilisee par -VOLUME et la carte de comparaison. Le nuage BRUT + * (non rempli, en .bin) est conserve pour chainer la decimation du niveau suivant et pour + * l'assemblage final. */ private async prepareCloudAtLevel(opts: { level: number; @@ -404,11 +471,15 @@ export class PipelineService { sourcePath: string; isFirstConversion: boolean; levelsDir: string; - imagesDir: string; rawDir: string; workDir: string; gridStep: number; maxEdgeLength: number; + emptyCellStrategy: EmptyCellStrategy; + customHeightValue: number | undefined; + krigingKnn: number; + projectionType: ProjectionType; + vertDir: VertDir; refPointCount: number; }): Promise { const { level, role, spatialStep, workDir, gridStep, maxEdgeLength, refPointCount } = opts; @@ -448,39 +519,42 @@ export class PipelineService { const rawBinAbs = path.join(workDir, binRelPath); - // Carte de hauteur (a partir du nuage BRUT, pour montrer les vrais trous de scan) - let imageRelPath: string | null = null; + // Remplissage des trous (rasterize + strategie configurable) pour le calcul de volume et la + // carte de comparaison. -VOLUME n'a pas ces options nativement (voir CLAUDE.md) : on + // rasterize+comble+exporte en ASCII AVANT de le passer a -VOLUME. + let filledXyzAbs: string | null = null; try { - const xyzExportRel = path.posix.join('raw', `L${level}${suffix}_points.xyz`); - await this.cc.run(['-O', binRelPath, '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', xyzExportRel], workDir); - const xyzAbs = path.join(workDir, xyzExportRel); - await this.assertExists(xyzAbs, `export ASCII niveau ${level}${suffix}`, ''); - const pngName = `L${level}${suffix}_heightmap.png`; - const pngAbs = path.join(opts.imagesDir, pngName); - const roleLabel = role === 'unique' ? '' : role === 'top' ? ' (haut)' : ' (bas)'; - const title = spatialStep === null ? `Niveau ${level}${roleLabel} (complet, ${pointCount} pts)` : `Niveau ${level}${roleLabel} (pas=${spatialStep.toFixed(4)}m, ${pointCount} pts)`; - await this.py.renderHeightmap(xyzAbs, gridStep, pngAbs, title); - imageRelPath = path.posix.join('images', pngName); - } catch (err: any) { - this.logger.warn(`Image niveau ${level}${suffix} non generee: ${err.message}`); - } - - // Remplissage des trous (INTERP Delaunay, borne par max-edge-length) pour le calcul de volume. - // -VOLUME n'a pas cette option nativement (voir CLAUDE.md) : on rasterize+comble+exporte en - // nuage AVANT de le passer a -VOLUME. - let filledBinAbs: string | null = null; - try { - const filledRel = path.posix.join('raw', `L${level}${suffix}_filled.bin`); + const filledRel = path.posix.join('raw', `L${level}${suffix}_filled.xyz`); + const fillArgs = emptyFillArgs(opts.emptyCellStrategy, opts.customHeightValue, opts.krigingKnn, maxEdgeLength); await this.cc.run( - ['-O', binRelPath, '-RASTERIZE', '-GRID_STEP', String(gridStep), '-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength), '-OUTPUT_CLOUD', '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', filledRel], + [ + '-O', + binRelPath, + '-RASTERIZE', + '-GRID_STEP', + String(gridStep), + '-VERT_DIR', + String(opts.vertDir), + '-PROJ', + opts.projectionType.toUpperCase(), + ...fillArgs, + '-OUTPUT_CLOUD', + '-C_EXPORT_FMT', + 'ASC', + '-PREC', + '6', + '-SAVE_CLOUDS', + 'FILE', + filledRel, + ], workDir, ); const filledAbs = path.join(workDir, filledRel); await this.assertExists(filledAbs, `remplissage niveau ${level}${suffix}`, ''); - filledBinAbs = filledAbs; + filledXyzAbs = filledAbs; } catch (err: any) { - this.logger.warn(`Remplissage niveau ${level}${suffix} echoue, utilisation du nuage brut pour le volume: ${err.message}`); - filledBinAbs = rawBinAbs; + this.logger.warn(`Remplissage niveau ${level}${suffix} echoue: ${err.message}`); + return this.badResult(level, role, spatialStep, `Remplissage des trous echoue au niveau ${level}${suffix}: ${err.message}`, 'error'); } return { @@ -491,10 +565,9 @@ export class PipelineService { pointCount, pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0, binFile: binRelPath, - imageFile: imageRelPath, }, rawBinAbs, - filledBinAbs, + filledXyzAbs, }; } @@ -502,9 +575,9 @@ export class PipelineService { return { status, message, - info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null }, + info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null }, rawBinAbs: null, - filledBinAbs: null, + filledXyzAbs: null, }; } } @@ -532,7 +605,7 @@ export function reportToCsv(report: RunReport): string { ]; const rows: string[] = []; for (const l of report.levels) { - const clouds = l.clouds.length > 0 ? l.clouds : [{ role: 'unique' as const, spatialStep: null, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null }]; + const clouds = l.clouds.length > 0 ? l.clouds : [{ role: 'unique' as const, spatialStep: null, pointCount: 0, pointRatio: 0, binFile: null }]; for (const c of clouds) { rows.push( [ diff --git a/src/pipeline/pipeline.util.spec.ts b/src/pipeline/pipeline.util.spec.ts index 6f30250..1241939 100644 --- a/src/pipeline/pipeline.util.spec.ts +++ b/src/pipeline/pipeline.util.spec.ts @@ -33,8 +33,14 @@ describe('reportToCsv', () => { initialStep: 0.02, initialStepSource: 'auto', gridStepMultiplier: 2, + gridStepMultiplierSource: 'auto', maxEdgeLengthMultiplier: 3, maxEdgeLengthSource: 'auto', + emptyCellStrategy: 'interpolate', + customHeightValue: null, + krigingKnn: null, + projectionType: 'avg', + vertDir: 2, factor: 2, steps: 1, densityMatch: null, @@ -45,7 +51,7 @@ describe('reportToCsv', () => { level: 0, gridStep: 0.04, maxEdgeLength: 0.12, - clouds: [{ role: 'unique', spatialStep: null, pointCount: 1000, pointRatio: 1, binFile: 'levels/L0_full.bin', imageFile: 'images/L0_heightmap.png' }], + clouds: [{ role: 'unique', spatialStep: null, pointCount: 1000, pointRatio: 1, binFile: 'levels/L0_full.bin' }], volume: 12.5, surface: 100, addedVolume: 12.5, @@ -53,6 +59,7 @@ describe('reportToCsv', () => { matchingCellsPct: 99, groundNonMatchingPct: 1, ceilNonMatchingPct: 0, + diffMapImage: 'images/L0_diffmap.png', warn: false, status: 'ok', computeTimeMs: 500, @@ -61,7 +68,7 @@ describe('reportToCsv', () => { level: 1, gridStep: 0.08, maxEdgeLength: 0.24, - clouds: [{ role: 'unique', spatialStep: 0.04, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null }], + clouds: [{ role: 'unique', spatialStep: 0.04, pointCount: 0, pointRatio: 0, binFile: null }], volume: null, surface: null, addedVolume: null, @@ -69,6 +76,7 @@ describe('reportToCsv', () => { matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, + diffMapImage: null, warn: true, status: 'skipped', message: 'decimation, pas assez de points\navec une virgule, ici', @@ -97,8 +105,8 @@ describe('reportToCsv', () => { gridStep: 0.04, maxEdgeLength: 0.12, clouds: [ - { role: 'top', spatialStep: 0.02, pointCount: 500, pointRatio: 1, binFile: 'levels/L0_top_full.bin', imageFile: null }, - { role: 'bottom', spatialStep: 0.02, pointCount: 480, pointRatio: 1, binFile: 'levels/L0_bottom_full.bin', imageFile: null }, + { role: 'top', spatialStep: 0.02, pointCount: 500, pointRatio: 1, binFile: 'levels/L0_top_full.bin' }, + { role: 'bottom', spatialStep: 0.02, pointCount: 480, pointRatio: 1, binFile: 'levels/L0_bottom_full.bin' }, ], volume: 5, surface: 50, @@ -107,6 +115,7 @@ describe('reportToCsv', () => { matchingCellsPct: 97, groundNonMatchingPct: 3, ceilNonMatchingPct: 0, + diffMapImage: 'images/L0_diffmap.png', warn: false, status: 'ok', computeTimeMs: 300, diff --git a/src/pipeline/types.ts b/src/pipeline/types.ts index 3322432..7e285a9 100644 --- a/src/pipeline/types.ts +++ b/src/pipeline/types.ts @@ -2,19 +2,27 @@ export type RunMode = 'const_height' | 'cloud_compare'; export type CloudRole = 'unique' | 'top' | 'bottom'; +/** Mirroir des strategies de remplissage de cellules vides de CloudCompare (-EMPTY_FILL). */ +export type EmptyCellStrategy = 'leave_empty' | 'min_height' | 'max_height' | 'custom_height' | 'interpolate' | 'kriging'; + +/** Mirroir de -PROJ (type de projection par cellule). INV_VAR omis (necessite un champ scalaire dedie, non applicable ici). */ +export type ProjectionType = 'avg' | 'min' | 'max' | 'med'; + +/** Mirroir de -VERT_DIR : 0=X, 1=Y, 2=Z. */ +export type VertDir = 0 | 1 | 2; + export interface LevelCloudInfo { role: CloudRole; spatialStep: number | null; // null pour le niveau 0 (nuage complet, non decime) pointCount: number; pointRatio: number; // vs le niveau 0 du meme role binFile: string | null; // chemin relatif au dossier du run (nuage brut, non rempli) - imageFile: string | null; // chemin relatif au dossier du run } export interface LevelResult { level: number; gridStep: number | null; // grille de calcul du volume a CE niveau (adaptee dynamiquement, voir CLAUDE.md) - maxEdgeLength: number | null; // limite de remplissage des trous (Delaunay) a CE niveau + maxEdgeLength: number | null; // limite de remplissage des trous (Delaunay) a CE niveau, si strategie = interpolate clouds: LevelCloudInfo[]; // 1 element en mode const_height, 2 (top puis bottom) en mode cloud_compare volume: number | null; surface: number | null; @@ -23,6 +31,7 @@ export interface LevelResult { matchingCellsPct: number | null; groundNonMatchingPct: number | null; ceilNonMatchingPct: number | null; + diffMapImage: string | null; // chemin relatif au dossier du run : carte de comparaison unique (bleu->rouge) warn: boolean; status: 'ok' | 'skipped' | 'error'; message?: string; @@ -33,7 +42,13 @@ export interface RunParamsInput { mode: RunMode; zrefOverride?: number; // mode const_height uniquement initialStepOverride?: number; - maxEdgeLengthOverride?: number; // les deux modes + gridStepMultiplierOverride?: number; + maxEdgeLengthOverride?: number; + emptyCellStrategy?: EmptyCellStrategy; // defaut 'interpolate' + customHeightValue?: number; // requis si emptyCellStrategy = 'custom_height' + krigingKnn?: number; // requis si emptyCellStrategy = 'kriging' + projectionType?: ProjectionType; // defaut 'avg' + vertDir?: VertDir; // defaut 2 (Z) } export interface RunReport { @@ -48,8 +63,14 @@ export interface RunReport { initialStep: number; initialStepSource: 'override' | 'auto'; gridStepMultiplier: number; + gridStepMultiplierSource: 'override' | 'auto'; maxEdgeLengthMultiplier: number; maxEdgeLengthSource: 'override' | 'auto'; + emptyCellStrategy: EmptyCellStrategy; + customHeightValue: number | null; + krigingKnn: number | null; + projectionType: ProjectionType; + vertDir: VertDir; factor: number; steps: number; densityMatch: { diff --git a/src/pyhelper/pyhelper.service.ts b/src/pyhelper/pyhelper.service.ts index ea67f41..c16fe62 100644 --- a/src/pyhelper/pyhelper.service.ts +++ b/src/pyhelper/pyhelper.service.ts @@ -55,18 +55,23 @@ export class PyHelperService { return this.runJson('stats_helper.py', ['--input', xyzSamplePath]); } - renderHeightmap(xyzPath: string, gridStep: number, outputPngPath: string, title: string): Promise { - return this.runJson('render_images.py', [ - 'heightmap', - '--input', - xyzPath, - '--gridstep', - String(gridStep), - '--output', - outputPngPath, - '--title', - title, - ]); + /** + * Carte de comparaison unique (bleu->rouge) entre le nuage du dessus (ceil) et soit un plan Z + * constant (mode const_height, groundXyzPath=null), soit le nuage du dessous (mode + * cloud_compare, constHeight=null). Remplace les cartes de hauteur separees par nuage. + */ + renderDiffmap( + ceilXyzPath: string, + groundXyzPath: string | null, + constHeight: number | null, + gridStep: number, + outputPngPath: string, + title: string, + ): Promise { + const args = ['diffmap', '--ceil', ceilXyzPath, '--gridstep', String(gridStep), '--output', outputPngPath, '--title', title]; + if (groundXyzPath) args.push('--ground', groundXyzPath); + if (constHeight !== null) args.push('--const-height', String(constHeight)); + return this.runJson('render_images.py', args); } renderSummary(reportJsonPath: string, outDir: string): Promise { diff --git a/src/runs/runs.controller.ts b/src/runs/runs.controller.ts index c351605..3072d8f 100644 --- a/src/runs/runs.controller.ts +++ b/src/runs/runs.controller.ts @@ -19,7 +19,7 @@ import * as fsp from 'fs/promises'; import * as path from 'path'; import { diskStorage } from 'multer'; import { config, uploadsDir } from '../config'; -import { RunMode } from '../pipeline/types'; +import { EmptyCellStrategy, ProjectionType, RunMode, VertDir } from '../pipeline/types'; import { RunsService } from './runs.service'; const ALLOWED_EXT = ['.las', '.laz', '.copc.laz', '.bin']; @@ -72,7 +72,24 @@ export class RunsController { const mode: RunMode = body.mode === 'cloud_compare' ? 'cloud_compare' : 'const_height'; const zrefOverride = this.parseOptionalNumber(body.zrefOverride); const initialStepOverride = this.parseOptionalNumber(body.initialStepOverride); + const gridStepMultiplierOverride = this.parseOptionalNumber(body.gridStepMultiplierOverride); const maxEdgeLengthOverride = this.parseOptionalNumber(body.maxEdgeLengthOverride); + const customHeightValue = this.parseOptionalNumber(body.customHeightValue); + const krigingKnn = this.parseOptionalNumber(body.krigingKnn); + const emptyCellStrategy = this.parseEmptyCellStrategy(body.emptyCellStrategy); + const projectionType = this.parseProjectionType(body.projectionType); + const vertDir = this.parseVertDir(body.vertDir); + + const sharedParams = { + gridStepMultiplierOverride, + initialStepOverride, + maxEdgeLengthOverride, + emptyCellStrategy, + customHeightValue, + krigingKnn, + projectionType, + vertDir, + }; if (mode === 'const_height') { const file = files.file?.[0]; @@ -80,7 +97,7 @@ export class RunsController { return this.runs.createRun({ tmpFilePath: file.path, originalFilename: file.originalname, - params: { mode, zrefOverride, initialStepOverride, maxEdgeLengthOverride }, + params: { mode, zrefOverride, ...sharedParams }, }); } @@ -94,7 +111,7 @@ export class RunsController { originalFilename: top.originalname, tmpFilePathSecondary: bottom.path, originalFilenameSecondary: bottom.originalname, - params: { mode, initialStepOverride, maxEdgeLengthOverride }, + params: { mode, ...sharedParams }, }); } @@ -165,4 +182,25 @@ export class RunsController { if (!Number.isFinite(n)) throw new BadRequestException(`Valeur numerique invalide: ${v}`); return n; } + + private parseEmptyCellStrategy(v: string | undefined): EmptyCellStrategy | undefined { + if (!v) return undefined; + const allowed: EmptyCellStrategy[] = ['leave_empty', 'min_height', 'max_height', 'custom_height', 'interpolate', 'kriging']; + if (!allowed.includes(v as EmptyCellStrategy)) throw new BadRequestException(`emptyCellStrategy invalide: ${v}`); + return v as EmptyCellStrategy; + } + + private parseProjectionType(v: string | undefined): ProjectionType | undefined { + if (!v) return undefined; + const allowed: ProjectionType[] = ['avg', 'min', 'max', 'med']; + if (!allowed.includes(v as ProjectionType)) throw new BadRequestException(`projectionType invalide: ${v}`); + return v as ProjectionType; + } + + private parseVertDir(v: string | undefined): VertDir | undefined { + if (v === undefined || v === '') return undefined; + const n = Number(v); + if (n !== 0 && n !== 1 && n !== 2) throw new BadRequestException(`vertDir invalide: ${v}`); + return n as VertDir; + } }