Suppression de run, parametres CC exposes, pourcentages vs L0, carte de comparaison unique
- 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.
This commit is contained in:
parent
24aa2e95db
commit
051209b022
10 changed files with 483 additions and 163 deletions
47
CLAUDE.md
47
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
|
||||
<valeur>`), `INTERP` (necessite `-MAX_EDGE_LENGTH <valeur>`), `KRIGING` (necessite `-KRIGING_KNN
|
||||
<n>`). Voir `emptyFillArgs()` dans `pipeline.service.ts`.
|
||||
- **`-PROJ <AVG|MIN|MAX|MED>`** (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).
|
||||
|
|
|
|||
17
README.md
17
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).
|
||||
|
|
|
|||
145
public/app.js
145
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() {
|
|||
<td class="py-2 px-3 text-sm text-slate-400">${new Date(r.createdAt).toLocaleString('fr-FR')}</td>
|
||||
<td class="py-2 px-3 text-sm">${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'}</td>
|
||||
<td class="py-2 px-3 text-sm">${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<button class="text-red-400 hover:text-red-300 text-xs" onclick="event.stopPropagation(); deleteRun('${r.id}')">Supprimer</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
app.innerHTML = `
|
||||
|
|
@ -81,6 +91,7 @@ async function renderList() {
|
|||
<tr>
|
||||
<th class="py-2 px-3">ID</th><th class="py-2 px-3">Fichier</th><th class="py-2 px-3">Statut</th>
|
||||
<th class="py-2 px-3">Cree le</th><th class="py-2 px-3">Niveaux OK</th><th class="py-2 px-3">Ecart volume (L0 -> dernier)</th>
|
||||
<th class="py-2 px-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
|
|
@ -88,6 +99,16 @@ async function renderList() {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
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() {
|
|||
<p class="text-xs text-slate-500">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.</p>
|
||||
</div>
|
||||
|
||||
<details class="border border-slate-800 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<details class="border border-slate-800 rounded-lg p-3" open>
|
||||
<summary class="cursor-pointer text-sm text-slate-400">Parametres CloudCompare (mirroir de la boite "Compute Volume")</summary>
|
||||
<div class="mt-3 space-y-4">
|
||||
<div id="zref-override-wrap">
|
||||
<label class="block text-sm mb-1">Plan de reference Z (override, mode Z constant uniquement)</label>
|
||||
<label class="block text-sm mb-1">Plan de reference Z / hauteur par defaut (override, mode Z constant uniquement)</label>
|
||||
<input type="number" step="any" name="zrefOverride" placeholder="auto = Zmin de l'echantillon"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
|
|
@ -142,11 +163,54 @@ function renderNew() {
|
|||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Distance max de remplissage des trous en metres (override)</label>
|
||||
<label class="block text-sm mb-1">Multiplicateur de grille (grid step = pas spatial x ce nombre)</label>
|
||||
<input type="number" step="any" min="0.1" name="gridStepMultiplierOverride" placeholder="auto = 2"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Cellules vides (Empty cells)</label>
|
||||
<select name="emptyCellStrategy" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="interpolate" selected>Interpoler (Delaunay, borne)</option>
|
||||
<option value="leave_empty">Laisser vide</option>
|
||||
<option value="min_height">Hauteur minimum</option>
|
||||
<option value="max_height">Hauteur maximum</option>
|
||||
<option value="custom_height">Hauteur personnalisee</option>
|
||||
<option value="kriging">Krigeage</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="max-edge-wrap">
|
||||
<label class="block text-sm mb-1">Distance max de remplissage / Delaunay (m)</label>
|
||||
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau x3"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
<p class="text-xs text-slate-500 mt-1">Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.</p>
|
||||
</div>
|
||||
<div id="custom-height-wrap" class="hidden">
|
||||
<label class="block text-sm mb-1">Hauteur personnalisee (m)</label>
|
||||
<input type="number" step="any" name="customHeightValue" placeholder="ex: 0"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div id="kriging-knn-wrap" class="hidden">
|
||||
<label class="block text-sm mb-1">Krigeage : nombre de voisins (KNN)</label>
|
||||
<input type="number" step="1" min="1" name="krigingKnn" placeholder="auto = 8"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Hauteur de cellule (projection)</label>
|
||||
<select name="projectionType" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="avg" selected>Hauteur moyenne</option>
|
||||
<option value="min">Hauteur minimum</option>
|
||||
<option value="max">Hauteur maximum</option>
|
||||
<option value="med">Hauteur mediane</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Direction de projection</label>
|
||||
<select name="vertDir" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="2" selected>Z</option>
|
||||
<option value="0">X</option>
|
||||
<option value="1">Y</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button>
|
||||
|
|
@ -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}<br/><span class="text-xs text-slate-400">${fmt(abs, 1)}% abs · ${fmtPct(rel, 1)}</span>`;
|
||||
}
|
||||
|
||||
function levelRow(l, l0) {
|
||||
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
|
||||
const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
|
||||
return `
|
||||
|
|
@ -238,18 +322,19 @@ function levelRow(l) {
|
|||
<td class="py-2 px-3 font-medium">${l.level}</td>
|
||||
<td class="py-2 px-3">${stepCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${pointsCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3">${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? '<span class="badge bg-amber-600/30 text-amber-300 ml-1">a verifier</span>' : ''}${statusTxt}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'}</td>
|
||||
<td class="py-2 px-3">${statusBadge(l.status)}${statusTxt}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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 ? `
|
||||
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
|
||||
|
|
@ -257,13 +342,14 @@ function paintDetail(run) {
|
|||
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th>
|
||||
<th class="py-2 px-3">Max edge (remplissage)</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
|
||||
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
|
||||
<th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Volume ajoute</th><th class="py-2 px-3">Volume retire</th>
|
||||
<th class="py-2 px-3">Surface</th><th class="py-2 px-3">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${r.levels.map((l) => levelRow(l)).join('')}</tbody>
|
||||
<tbody>${r.levels.map((l) => levelRow(l, l0)).join('')}</tbody>
|
||||
</table>
|
||||
<p class="text-xs text-slate-500 p-2">% abs = valeur / niveau 0 · % relatif = ecart vs niveau 0 (+ ou -)</p>
|
||||
</div>` : '';
|
||||
|
||||
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`})</p>
|
||||
</div>` : '';
|
||||
|
||||
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 ? `
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
|
||||
${!isCompare ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>` : ''}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial (${r.params.gridStepMultiplierSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Cellules vides</dt><dd>${strategyLabels[r.params.emptyCellStrategy] || r.params.emptyCellStrategy}</dd></div>
|
||||
${r.params.emptyCellStrategy === 'interpolate' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>` : ''}
|
||||
${r.params.emptyCellStrategy === 'custom_height' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur personnalisee</dt><dd>${fmt(r.params.customHeightValue, 4)}</dd></div>` : ''}
|
||||
${r.params.emptyCellStrategy === 'kriging' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Krigeage KNN</dt><dd>${r.params.krigingKnn}</dd></div>` : ''}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur de cellule</dt><dd>${projLabels[r.params.projectionType] || r.params.projectionType}</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Direction</dt><dd>${dirLabels[r.params.vertDir]}</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
|
||||
</dl>
|
||||
${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) => `
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
|
||||
<img src="${API}/${run.id}/images/${c.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}</p>
|
||||
<img src="${API}/${run.id}/images/${l.diffMapImage.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${l.level}</p>
|
||||
</div>`).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) {
|
|||
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuages assembles (.bin)</a>
|
||||
<a href="${API}/${run.id}/download/csv" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Rapport (.csv)</a>
|
||||
<a href="${API}/${run.id}/download/zip" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Dossier complet (.zip)</a>
|
||||
<button class="text-red-400 hover:text-red-300 text-sm ml-auto" onclick="deleteRun('${run.id}').then(() => location.hash = '#/')">Supprimer ce run</button>
|
||||
</div>` : '';
|
||||
|
||||
const errorBlock = run.error ? `<div class="bg-red-950/40 border border-red-900 rounded-lg p-3 text-sm text-red-300 mt-4">${escapeHtml(run.error)}</div>` : '';
|
||||
|
|
@ -317,7 +420,7 @@ function paintDetail(run) {
|
|||
${params}
|
||||
${levelsTable}
|
||||
${downloads}
|
||||
${heightmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de hauteur par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${heightmaps}</div>` : ''}
|
||||
${diffmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de comparaison par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${diffmaps}</div>` : ''}
|
||||
${summaryCharts ? `<h2 class="text-lg font-semibold mt-8 mb-3">Graphiques de synthese</h2><div class="grid grid-cols-1 md:grid-cols-3 gap-3">${summaryCharts}</div>` : ''}
|
||||
<h2 class="text-lg font-semibold mt-8 mb-2">Journal</h2>
|
||||
<pre class="bg-black/60 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 overflow-x-auto max-h-64 overflow-y-auto">${escapeHtml(run.log || '')}</pre>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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("Altitude (m, moyenne par cellule)")
|
||||
ax.set_title(args.title or Path(args.input).stem)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<CloudPrepResult> {
|
||||
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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue