From 051209b022e793df2271195d253c8912dfa03066 Mon Sep 17 00:00:00 2001
From: Nicolas Fryder
Date: Fri, 10 Jul 2026 17:02:46 +0200
Subject: [PATCH] Suppression de run, parametres CC exposes, pourcentages vs
L0, carte de comparaison unique
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
CLAUDE.md | 47 ++++++--
README.md | 17 +--
public/app.js | 145 +++++++++++++++++++----
python/render_images.py | 133 +++++++++++++--------
src/config.ts | 6 +
src/pipeline/pipeline.service.ts | 181 ++++++++++++++++++++---------
src/pipeline/pipeline.util.spec.ts | 17 ++-
src/pipeline/types.ts | 27 ++++-
src/pyhelper/pyhelper.service.ts | 29 +++--
src/runs/runs.controller.ts | 44 ++++++-
10 files changed, 483 insertions(+), 163 deletions(-)
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) + ' %' : '-'}
+
+ Supprimer
+
`).join('');
app.innerHTML = `
@@ -81,6 +91,7 @@ async function renderList() {
ID Fichier Statut
Cree le Niveaux OK Ecart 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)
-
Lancer le run
@@ -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) {
Niveau Pas spatial Grille volume
- Max edge (remplissage) Points
- Volume Surface
- Matching cells Statut
+ Points
+ Volume Volume ajoute Volume retire
+ Surface Statut
-
${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)
+ Supprimer ce run
` : '';
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;
+ }
}