Compare commits

...

2 commits

Author SHA1 Message Date
9d6ebf5177 Fiabilise le pipeline de volume et corrige plusieurs failles de securite
Pipeline: corrige le biais de sous-echantillonnage sur median_nn (facteur
sqrt(total/echantillon), gonflait le pas spatial jusqu'a x20 sur les gros
nuages), applique un decalage global X/Y pour la precision float32 en
coordonnees projetees (Lambert-93), fixe la portee de remplissage des trous
sur la grille du niveau 0 (au lieu de croitre a chaque niveau), utilise un
percentile robuste pour zref au lieu du minimum brut, et filtre plus
precisement le fichier de grille parasite genere par -VOLUME.

Securite: valide l'id de run (uuid) sur toutes les routes avant de construire
un chemin filesystem (traversee de repertoire post-auth via zip/csv/bin/images),
assainit le nom de fichier uploade avant multer, retire enableCors() (surface
inutile), passe le conteneur en utilisateur non-root.

Documente l'ensemble de la methode et ses limites dans CONCEPT.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 18:13:27 +02:00
051209b022 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.
2026-07-10 17:03:33 +02:00
16 changed files with 1039 additions and 189 deletions

View file

@ -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).

407
CONCEPT.md Normal file
View file

@ -0,0 +1,407 @@
# Estimation de volume 2.5D par nuage de points et sensibilité à la décimation spatiale
Document de référence méthodologique, destiné à être adapté en section « Matériel et méthodes »
d'une publication. Il décrit le protocole implémenté par ce dépôt, les grandeurs mesurées, et
— section 7 — les limites et biais connus qui doivent être déclarés ou corrigés avant publication.
---
## 1. Question de recherche
L'estimation du volume d'un amas (stock de matériaux, tas, dépôt) à partir d'un nuage de points
aéroporté est une opération courante mais dont la **robustesse à la densité de points** est rarement
quantifiée. La question posée ici est double :
1. Le volume estimé à partir d'un nuage LiDAR HD (IGN) est-il **comparable** à une mesure de
référence ?
2. **Dans quelle mesure varie-t-il** lorsque la densité de points est dégradée de façon contrôlée ?
Le second point conditionne le premier : si le volume est stable sur deux ordres de grandeur de
densité, l'estimation ne dépend pas de la campagne d'acquisition ; s'il dérive, tout chiffre publié
doit être accompagné de la densité à laquelle il a été obtenu.
---
## 2. Grandeur mesurée
Le volume est calculé selon la méthode **2.5D par différence de surfaces rasterisées**, telle
qu'implémentée par CloudCompare 2.13.2 (commande `-VOLUME`).
Soit une direction de projection verticale `d` (par défaut Z), et une grille régulière de pas `g`
dans le plan orthogonal à `d`. Pour chaque cellule `c` de la grille, on définit :
- `z_ceil(c)` : hauteur de la surface supérieure (le « plafond ») ;
- `z_ground(c)` : hauteur de la surface inférieure (le « sol ») ;
et le volume est la somme de Riemann
```
V = g² · Σ_{c ∈ C_match} ( z_ceil(c) z_ground(c) )
```
`C_match` est l'ensemble des cellules pour lesquelles les deux hauteurs sont définies. La méthode
décompose `V` en un volume **ajouté** (cellules où `z_ceil > z_ground`) et un volume **retiré**
(cellules où `z_ceil < z_ground`) ; la grandeur `V` rapportée est le **volume net**, c'est-à-dire leur
différence signée. Cette distinction importe dès que les deux surfaces s'intersectent.
Il s'agit donc d'une quadrature par la règle du point milieu sur la fonction de hauteur
`h(x,y) = z_ceil z_ground`. L'erreur de quadrature est en `O(g²·‖∇²h‖)` : elle est nulle pour une
surface plane, et croît avec la courbure. Cette méthode suppose que la surface est un **graphe** de
`(x,y)` : elle ne peut représenter ni surplomb, ni cavité fermée. Cette hypothèse est admissible pour
un amas de matériaux, et constitue une limite explicite du protocole.
### 2.1. Deux modes de définition du sol
**Mode « plan de référence constant » (`const_height`).** Un seul nuage est fourni ; il tient lieu de
surface supérieure. La surface inférieure est le plan `z = z_ref`, avec `z_ref` par défaut égal à
l'altitude minimale du nuage.
**Mode « comparaison de deux nuages » (`cloud_compare`).** Deux nuages sont fournis : la surface
supérieure de l'amas, et une surface de base (socle, plan d'ajustement, terrain naturel avant dépôt).
La surface inférieure est la seconde.
> **Résultat expérimental déterminant.** Le mode `const_height` n'est valide que si le nuage ne
> contient **que** l'objet mesuré. Dès que le fichier inclut du terrain environnant, `z_ref` (altitude
> minimale globale) n'a plus aucun rapport avec la base réelle de l'amas, et le volume obtenu est
> arbitraire. Sur un amas industriel de ~150 m d'emprise, le mode `const_height` a donné ≈ 50 m³,
> contre ≈ 14 244 m³ en mode `cloud_compare` avec une base ajustée sous l'amas — soit un facteur 285.
> Une inspection de la carte de hauteurs a confirmé que les deux nuages ont bien la même emprise au
> sol et que le second chiffre est le bon. **Toute publication utilisant le mode `const_height` doit
> justifier que le nuage a été préalablement segmenté.**
---
## 3. Protocole de dégradation contrôlée
### 3.1. Estimation de la résolution native
La résolution spatiale native `s₀` du nuage est estimée par la **médiane des distances au plus proche
voisin** (structure KD-tree), calculée sur un échantillon de points du nuage. Pour un semis
approximativement poissonnien de densité surfacique `ρ` (points·m⁻²), cette médiane vaut
```
median_NN = sqrt( ln 2 / (π ρ) ) ≈ 0.4697 / sqrt(ρ)
```
Elle constitue donc un estimateur direct de l'espacement inter-points.
> Voir §7.1 : l'implémentation actuelle calcule cette médiane sur un sous-échantillon aléatoire de
> taille fixe, ce qui introduit un biais multiplicatif connu qu'il faut corriger.
### 3.2. Chaîne de décimation
On construit **six niveaux** de densité, indexés `n = 0…5`. Le pas spatial cible du niveau `n` est
```
s(n) = s₀ · 2ⁿ
```
Le niveau `n` est obtenu en appliquant un **sous-échantillonnage spatial** (contrainte de distance
minimale `s(n)` entre points conservés ; `-SS SPATIAL` de CloudCompare) au **nuage du niveau `n1`**,
et non au nuage original. Ce chaînage garantit que chaque niveau est un sous-ensemble strict du
précédent, donc que la comparaison isole bien l'effet d'une perte d'information monotone.
> **Point à ne pas mal interpréter.** Le pas spatial double à chaque niveau. Comme les points
> échantillonnent une surface (dimension 2), le **nombre de points est divisé par ≈ 4** par niveau.
> Au niveau 5 le pas est ×32 mais le nombre de points est de l'ordre de **1/1000** du niveau 0.
> Le nombre de points effectivement obtenu n'est jamais garanti a priori : `-SS SPATIAL` impose une
> distance minimale, pas un taux de décimation.
Le niveau 0 diffère selon le mode :
| Mode | Niveau 0 | Niveaux 15 |
|---|---|---|
| `const_height` | nuage intégral, non décimé | `s(n) = s₀·2ⁿ` |
| `cloud_compare` | nuages **appariés en densité** au pas `s₀` | `s(n) = s₀·2ⁿ`, appliqué aux deux nuages |
**Appariement de densité (mode `cloud_compare`).** Comparer une surface supérieure à 10 pts·m⁻² avec
une base à 0.5 pt·m⁻² fait porter au résultat la résolution de la plus grossière, sans que cela soit
visible. On calcule donc `median_NN` séparément pour chaque nuage, on retient
`s₀ = max(median_NN_top, median_NN_bottom)`, et on ramène le nuage le plus dense à ce pas par un
sous-échantillonnage spatial unique. L'opération est un no-op sur le nuage le plus clairsemé (un
filtre de distance minimale ne peut pas densifier). Le niveau 0 du mode `cloud_compare` est donc
lui-même déjà décimé — les écarts relatifs sont exprimés par rapport à **ce** niveau.
La décimation des deux nuages est ensuite **synchronisée** (même `s(n)`). Si l'un des deux passe sous
un seuil de 25 points, la progression s'arrête pour les deux : les deux surfaces sont nécessaires à
une comparaison valide.
### 3.3. Grille de calcul adaptative
Le pas de la grille de quadrature du niveau `n` est
```
g(n) = s(n) · k, k = 2 par défaut
```
Ce choix est **essentiel et contre-intuitif**. Une première version du protocole figeait `g` à la
résolution du niveau 0 « pour garder les niveaux comparables ». C'est exactement l'inverse de ce qu'il
faut faire : dès que la décimation devient plus grossière que cette grille fixe, chaque cellule cesse
de contenir un point, et l'indicateur de couverture (§4) s'effondre mécaniquement
(99 % → 58 % → 13 % → 3 % → 0.9 % → 0.2 % sur les cinq niveaux). Cet effondrement mesure un
**désalignement grille/densité**, pas une perte d'information : il ne dit rien sur la robustesse du
volume.
En liant `g(n)` à `s(n)`, chaque niveau est intégré sur une grille adaptée à sa propre densité. La
contrepartie, qui doit être déclarée, est que le volume `V(n)` varie alors sous l'effet de **deux**
causes confondues : la perte de points, et le changement de pas de quadrature (erreur en `O(g²)`).
Le protocole ne les sépare pas. Une expérience de contrôle à `g` fixé permet de borner la seconde.
### 3.4. Traitement des occlusions (remplissage de cellules vides)
Un nuage aéroporté comporte des cellules vides (occlusions, zones d'ombre). Laissées vides, elles sont
exclues de `C_match` et le volume est sous-estimé. La commande `-VOLUME` de CloudCompare **n'expose
aucune option de remplissage** : la vérification du code source (`qCC/ccCommandRaster.cpp`,
`CommandVolume25D::process`) montre que la stratégie `LEAVE_EMPTY` y est codée en dur pour les deux
surfaces, alors même que les constantes des autres stratégies existent dans le fichier et sont
consommées par `CommandRasterize::process`.
Le protocole contourne cette limite en **rasterisant et comblant chaque nuage séparément** avant le
calcul de volume (`-RASTERIZE -GRID_STEP g -EMPTY_FILL … -OUTPUT_CLOUD`), puis en soumettant à
`-VOLUME` la grille comblée plutôt que le nuage original. Le nuage brut, non comblé, reste utilisé
pour le chaînage de la décimation, afin que l'interpolation d'un niveau ne se propage pas au suivant.
Six stratégies sont exposées, reproduisant la boîte de dialogue « Compute Volume » de l'interface
graphique : `LEAVE_EMPTY`, `MIN_H`, `MAX_H`, `CUSTOM_H`, `INTERP` (interpolation de Delaunay bornée
par une longueur d'arête maximale `L`), `KRIGING`. La stratégie retenue par défaut est `INTERP`.
**Rôle de `L` (longueur d'arête maximale).** `L` borne la portée du remplissage : un trou de diamètre
supérieur à `L` n'est pas comblé. C'est ce qui empêche l'interpolation de « recoudre » les
concavités réelles du contour de l'amas, qui doivent rester vides. Vérification sur un nuage
synthétique comportant une occlusion circulaire de rayon 0.6 m :
| Configuration | Cellules appariées | Trou comblé |
|---|---|---|
| Sans remplissage | 96.4 % | non (volume sous-estimé) |
| `INTERP`, `L = 0.30 m` (> diamètre du trou) | 98.9 % | oui |
| `INTERP`, `L = 0.08 m` (< diamètre du trou) | 96.4 % | non (identique au cas sans remplissage) |
Le paramètre se comporte donc bien comme une borne de portée. Sa valeur par défaut est `L(n) = 3·g(n)`.
> Voir §7.3 : `L(n)` croissant avec `n` introduit un biais systématique qui **doit** être neutralisé
> pour l'étude de sensibilité (`L` fixé en absolu).
### 3.5. Paramètres exposés
| Paramètre | Rôle | Défaut |
|---|---|---|
| `s₀` (`initialStep`) | résolution native | médiane NN |
| `k` (`gridStepMultiplier`) | `g(n) = s(n)·k` | 2 |
| `L` (`maxEdgeLength`) | portée du remplissage `INTERP` | `3·g(n)` |
| `z_ref` | plan de référence (mode `const_height`) | altitude min du nuage |
| stratégie de remplissage | `LEAVE_EMPTY`…`KRIGING` | `INTERP` |
| projection par cellule | `AVG` / `MIN` / `MAX` / `MED` | `AVG` |
| direction verticale | X / Y / Z | Z |
| facteur, nombre de niveaux | fixés par le protocole | 2, 5 |
Note d'implémentation : `-PROJ` (hauteur de cellule) n'existe que pour `-RASTERIZE``-VOLUME` a
`PROJ_AVERAGE_VALUE` codé en dur. Comme le nuage soumis à `-VOLUME` est déjà une grille comblée à un
point par cellule, le moyennage interne de `-VOLUME` est une passe-plat : c'est bien le `-PROJ` du
remplissage qui détermine le résultat. `-VERT_DIR` en revanche est accepté par les deux commandes et
leur est passé de façon cohérente.
---
## 4. Indicateurs rapportés
Pour chaque niveau `n` :
- `V(n)` : volume net (m³), et sa décomposition en volumes ajouté / retiré ;
- `S(n)` : surface projetée des cellules appariées (m²) ;
- `N(n)` : nombre de points de chaque nuage, et `N(n)/N(0)` ;
- `g(n)`, `s(n)`, `L(n)` ;
- **`matching cells %`** : proportion de cellules de la grille où les deux surfaces sont
simultanément définies, avec sa décomposition en cellules « ground » et « ceil » manquantes ;
- l'écart relatif `100·(V(n) V(0))/V(0)`, indicateur principal de robustesse.
Une **carte de comparaison** (différence de hauteur `z_ceil z_ground`, dégradé bleu→rouge, cellules
non appariées en gris) est produite par niveau. Elle est reconstruite en Python
(`binned_statistic_2d` + `matplotlib`) à partir d'exports ASCII des grilles comblées, plutôt que par
l'export GeoTIFF de CloudCompare (voir §6).
Tous les nuages exploitables des six niveaux sont assemblés dans un unique fichier `.bin`, comme
entités **distinctes** (le format `.bin` de CloudCompare supporte nativement une hiérarchie de
nuages ; il ne faut surtout pas fusionner les géométries), pour inspection visuelle a posteriori.
> Voir §7.4 : `matching cells %` est, dans l'implémentation actuelle, largement neutralisé par
> l'étape de remplissage. Il ne doit **pas** être présenté comme l'indicateur de robustesse principal.
---
## 5. Résultat de référence
Sur l'amas industriel de ~150 m d'emprise (`Amas 1 Nuage.las` vs `Plan d'ajustement NUAGE.las`), en
mode `cloud_compare` : `V ≈ 14 244 m³`, **stable à ± 4 % jusqu'au niveau 4** (facteur 16 sur le pas
spatial, nuage réduit à 129 points).
Ce résultat, s'il se confirme sur un échantillon d'amas plus large et avec les corrections de §7, est
le message principal : le volume 2.5D d'un amas est une grandeur **intégrale**, donc peu sensible à la
densité de points tant que la géométrie d'ensemble reste échantillonnée. Les erreurs locales de
hauteur, positives et négatives, se compensent dans la somme. La densité conditionne la précision du
**contour** et des détails de surface, pas celle du volume.
---
## 6. Contraintes d'implémentation (reproductibilité)
Ces points ne relèvent pas de la méthode mais conditionnent la reproductibilité de l'exécution. Ils
ont été vérifiés empiriquement sur CloudCompare 2.13.2 (build officiel Windows **et** paquet apt
Debian trixie) et contredisent parfois la documentation.
1. **`-VOLUME` ne produit son rapport texte que si `-AUTO_SAVE ON`.** Avec `-AUTO_SAVE OFF`, la
commande s'exécute, journalise `[2.5D VOLUME CALCULATION] finished`, et n'écrit **rien** — sans
erreur. Il faut réactiver l'auto-sauvegarde pour ce seul appel, et supprimer ensuite la grille
`*_HEIGHT_DIFFERENCE_*.bin` produite au passage.
2. **CloudCompare écrit ses sorties automatiques relativement au dossier du fichier chargé par `-O`**,
pas au répertoire de travail du processus. La recherche du rapport doit être récursive.
3. **Les grands nombres sont formatés avec une virgule comme séparateur de milliers** dans le rapport
(`Volume: 14,244.464657`). Un parsing naïf tronque silencieusement à la virgule : `14 244` devient
`14`. Ce bug a réellement produit un volume erroné avant d'être identifié.
4. **Le paquet Debian de CloudCompare n'embarque pas le plugin LAS/LAZ.** Les fichiers LAS/LAZ/COPC
sont convertis en ASCII XYZ via `laspy` en amont ; l'import ASCII fonctionne sans plugin.
5. **L'export GeoTIFF (`-RASTERIZE -OUTPUT_RASTER_Z`) provoque une assertion et un core dump** sur ce
paquet (GDAL non compilé). Les cartes sont donc rendues en Python.
6. **CloudCompare reste une application Qt même en `-SILENT`** et exige un serveur X (`Xvfb`).
7. Le CLI est une **machine à états séquentielle sur un seul processus** : chaque étape logique est un
appel indépendant avec son propre rechargement.
8. Chaque appel `-VOLUME` du protocole est journalisé, et le rapport texte brut de CloudCompare est
conservé (`raw/L{n}_volume_report.txt`), ce qui permet de rejouer le parsing indépendamment.
---
## 7. Limites, biais connus et corrections requises
Cette section doit être lue avant toute publication de chiffres issus de ce pipeline. Les points 7.1
et 7.2 sont des **défauts d'implémentation à corriger** ; 7.3 à 7.6 sont des **limites de protocole à
déclarer ou à traiter**.
### 7.1. Biais de sous-échantillonnage sur l'estimation de `s₀` (à corriger)
`median_NN` est actuellement calculé sur un sous-échantillon **aléatoire** de taille fixe
(50 000 points) tiré du nuage complet. La distance au plus proche voisin n'est pas invariante par
sous-échantillonnage : prélever une fraction `f` divise la densité par `f` et **multiplie
`median_NN` par `1/√f`**.
Sur une dalle LiDAR HD de 20 M de points (~10 pts·m⁻², `s₀` réel ≈ 0.15 m), `f = 2.5·10⁻³` et
l'estimation obtenue est ≈ **3 m**, soit un facteur 20. Tout le protocole en hérite : `g(0)`, `L(0)`
et l'ensemble des `s(n)`. Le biais est nul pour les nuages de moins de 50 000 points et croît en `√N`
au-delà — il est donc invisible sur les petits jeux de test.
**Correction.** Soit corriger analytiquement, `s₀ = median_NN_échantillon · √(N_échantillon / N_total)`
(le nombre total de points est déjà lu par le pipeline), soit — préférable car exact plutôt
qu'asymptotique — calculer `median_NN` sur une **emprise spatiale restreinte** (un pavé de la bbox
contenant ≤ 50 000 points), qui préserve la densité locale.
### 7.2. Précision numérique en coordonnées projetées (à corriger)
CloudCompare stocke les coordonnées en **simple précision (float32)**. En Lambert-93 (EPSG:2154), les
ordonnées valent ≈ 6.8·10⁶ m, où l'ULP de float32 est de **0.5 m**. Sans décalage global
(`-GLOBAL_SHIFT`, absent du pipeline), les coordonnées X/Y sont quantifiées sur un réseau de 0.5 m,
ce qui vide de son sens toute grille de pas inférieur au mètre. CloudCompare n'émet qu'un
avertissement de journal (`Entity has very big coordinates: original accuracy may be lost!`).
Le biais est passé inaperçu sur les jeux de test, dont les coordonnées semblent exprimées dans un
repère local. Il est **rédhibitoire sur du LiDAR HD IGN brut**. Noter que seules X et Y sont
affectées : Z ≈ 100200 m reste représentable au micromètre.
**Correction.** Le volume 2.5D étant invariant par translation, soustraire un décalage fixe
(coin de la bbox, arrondi) lors de la conversion LAS→ASCII, et consigner ce décalage dans le rapport.
### 7.3. Le remplissage devient plus agressif à chaque niveau (à traiter)
Avec le défaut `L(n) = 3·g(n) = 6·s(n)`, la portée du remplissage **double à chaque niveau**. Au
niveau 5, l'interpolation comble des lacunes 32 fois plus larges qu'au niveau 0, y compris des
concavités réelles du contour — précisément ce que `L` était censé empêcher. Le volume dérive donc
vers le haut avec `n` pour une raison étrangère à la décimation.
**Pour l'étude de sensibilité, `L` doit être fixé en valeur absolue, identique sur les six niveaux**
(le paramètre existe déjà en surcharge). Toute courbe `V(n)` produite avec `L(n)` proportionnel à
`g(n)` mélange deux effets et ne peut être publiée telle quelle.
### 7.4. `matching cells %` n'est plus un indicateur de couverture
Depuis que les nuages sont rasterisés et comblés au pas `g(n)` **avant** d'être soumis à `-VOLUME`,
qui les rasterise à nouveau au **même** pas `g(n)`, chaque cellule reçoit environ un point par
construction. En mode `const_height`, le sol est un plan constant, défini partout. `matching cells %`
ne mesure alors plus la couverture des données, mais essentiellement le **désalignement d'une
demi-cellule** entre la grille du `-RASTERIZE` (ancrée sur la bbox du nuage brut) et celle du
`-VOLUME` (ancrée sur la bbox de la grille comblée). Les valeurs de 9699 % observées sont cohérentes
avec cette interprétation.
L'indicateur reste utile avec la stratégie `LEAVE_EMPTY`, où il retrouve son sens originel. Il ne doit
pas être présenté comme le critère de robustesse quand le remplissage est actif. **L'indicateur de
robustesse à publier est l'écart relatif `V(n)/V(0) 1`**, accompagné de sa dispersion (§7.6).
### 7.5. Co-registration des grilles en mode `cloud_compare`
Les deux nuages sont rasterisés **indépendamment**, donc chacun sur une grille ancrée sur sa propre
bbox. Les centres de cellules des deux surfaces sont ainsi décalés d'une fraction de cellule non
contrôlée. `-VOLUME` les reprojette ensuite sur une grille commune : deux points verticalement
superposés peuvent tomber dans des cellules distinctes. L'erreur induite sur `z_ceil z_ground` est
d'ordre `pente · g(n)` et croît donc avec le niveau.
CloudCompare n'expose pas l'origine de la grille de rasterisation. La correction propre consiste à
effectuer la rasterisation des deux nuages en Python sur une grille commune explicitement ancrée. À
défaut, l'amplitude de l'effet doit être bornée expérimentalement (par exemple en translatant l'un des
nuages d'une fraction de `g` et en observant la dispersion de `V`).
### 7.6. Absence de quantification d'incertitude (à traiter)
Le protocole produit **une seule** valeur de `V` par niveau, sans barre d'erreur. Le
sous-échantillonnage spatial étant quasi déterministe (il dépend de l'ordre de parcours de l'octree),
une simple répétition ne produirait aucune variance.
Deux sources de dispersion doivent être échantillonnées pour publier un intervalle :
- **la réalisation de la décimation** : répéter chaque niveau avec un sous-échantillonnage aléatoire
à nombre de points équivalent, sur `M` tirages ;
- **la phase de la grille** : translater le nuage d'un décalage uniforme dans `[0, g)²` avant
rasterisation, sur `M` tirages, ce qui échantillonne directement l'erreur de quadrature.
`V(n)` doit alors être rapporté comme moyenne ± écart-type sur ces `M` réalisations.
Par ailleurs, le pipeline **n'est pas reproductible en l'état** : le sous-échantillonnage aléatoire
servant à estimer `s₀` et `z_ref` n'est pas graîné, ces deux quantités varient donc d'une exécution à
l'autre sur un même fichier. Une graine explicite doit être fixée et consignée.
### 7.7. Sensibilité de `z_ref` aux points aberrants
En mode `const_height`, `z_ref` est l'altitude **minimale** du nuage. Un unique point aberrant bas
(bruit, écho multiple) abaisse `z_ref` de `Δz` et gonfle le volume de `Δz · S`. Sur une emprise de
10 000 m², un aberrant à 10 cm sous le sol ajoute 1 000 m³. Un percentile robuste (p₀.₁ ou p₁) doit
être substitué au minimum, et la valeur retenue doit figurer dans le rapport. (Défaut supplémentaire
de l'implémentation actuelle : `z_ref` est tiré de la bbox du **sous-échantillon**, ce qui le rend en
outre biaisé vers le haut et aléatoire.)
### 7.8. Hypothèses structurelles du modèle 2.5D
- La surface est un graphe de `(x,y)` : ni surplomb, ni cavité fermée n'est représentable.
- L'emprise diagonale d'un amas dans sa bounding box (observée : ~150 × 113 m) n'invalide pas le
calcul mais gonfle le nombre de cellules vides, donc l'influence de la stratégie de remplissage.
- La grandeur rapportée est le volume **net**. Dès que les deux surfaces s'intersectent, il faut
présenter aussi les volumes ajouté et retiré ; le net seul est trompeur.
- Le format E57 est hors périmètre.
---
## 8. Synthèse du pipeline
```
LAS / LAZ / COPC.LAZ / BIN
└─ conversion ASCII XYZ (laspy) [+ décalage global — §7.2]
└─ estimation de s₀ = médiane NN (KD-tree) [+ correction de biais — §7.1]
└─ [cloud_compare] appariement de densité : s₀ = max(NN_top, NN_bottom)
└─ pour n = 0…5 :
├─ décimation spatiale au pas s(n) = s₀·2ⁿ, chaînée depuis le niveau n1
├─ rasterisation + remplissage des occlusions au pas g(n) = 2·s(n) [L fixe — §7.3]
├─ calcul de volume 2.5D sur les grilles comblées (CloudCompare -VOLUME)
├─ carte de comparaison z_ceil z_ground (matplotlib)
└─ arrêt si un nuage passe sous 25 points
└─ assemblage des 6 niveaux dans un .bin unique (entités distinctes)
└─ rapport CSV / JSON + graphiques de synthèse
```
Résultat attendu : la courbe `V(n)` et son écart relatif à `V(0)`, qui quantifient la robustesse de
l'estimation de volume à la dégradation de densité — sous réserve des corrections de la section 7.

View file

@ -52,10 +52,24 @@ COPY public ./public
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /data
# Utilisateur non-root : l'image debian:trixie-slim + nodejs (nodesource) n'a pas d'utilisateur
# "node" preexistant (contrairement a l'image officielle node:*). /app et /data sont chown AVANT
# la directive VOLUME pour que l'appartenance soit reprise par le volume nomme Coolify/Compose a
# sa premiere creation (Docker copie le contenu existant du repertoire dans un volume vide neuf).
# /tmp/.X11-unix doit exister et etre world-writable (mode 1777, meme convention que /tmp) AVANT
# de passer en non-root : Xvfb refuse de creer ce repertoire lui-meme si euid != 0
# ("_XSERVTransmkdir: ERROR: euid != 0"), il ne peut alors plus creer son socket X11 - verifie
# empiriquement, le healthcheck HTTP passe quand meme (Xvfb echoue silencieusement en arriere-plan)
# mais tout appel CloudCompare plante ensuite faute de display.
RUN useradd --create-home --uid 1000 --shell /usr/sbin/nologin appuser \
&& mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix \
&& chown -R appuser:appuser /app /data
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS "http://localhost:${PORT:-3000}/health" || exit 1
USER appuser
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -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).

View file

@ -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 &middot; ${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 &middot; % 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>

View file

@ -7,11 +7,21 @@ ne fournit pas le plugin LAS/LAZ - seulement le plugin "Core I/O").
Un .copc.laz est simplement lu comme un LAZ standard (les points sont
identiques, seule l'indexation octree COPC, inutile ici, est ignoree).
Decalage global X/Y : CloudCompare stocke les coordonnees en float32 en interne. En coordonnees
projetees (ex. Lambert-93 / EPSG:2154, Y ~ 6.8e6 m), l'ULP de float32 a cette magnitude vaut ~0.5m,
ce qui quantifie silencieusement la position des points sur une grille de 0.5m - incompatible avec
un pas de calcul de l'ordre du decimetre (voir CONCEPT.md §7.2). On soustrait donc un decalage fixe
(coin bas de la bbox, arrondi au metre, lu directement dans l'entete LAS - pas de second passage sur
les points) de X et Y avant ecriture. Le volume 2.5D etant invariant par translation, ce decalage
n'affecte aucun resultat ; il est renvoye en sortie pour tracabilite. Z n'est pas decale : en
alti-metrique (~10^2-10^3 m), la precision float32 y est deja largement suffisante.
Usage:
python3 las_to_xyz.py --input nuage.laz --output nuage.xyz
"""
import argparse
import json
import math
import sys
import laspy
@ -25,12 +35,18 @@ def main() -> int:
args = parser.parse_args()
try:
total = 0
with laspy.open(args.input) as reader, open(args.output, "w", encoding="ascii") as out:
for chunk in reader.chunk_iterator(2_000_000):
xyz = np.column_stack([np.asarray(chunk.x), np.asarray(chunk.y), np.asarray(chunk.z)])
np.savetxt(out, xyz, fmt="%.6f")
total += xyz.shape[0]
with laspy.open(args.input) as reader:
offset_x = math.floor(reader.header.mins[0])
offset_y = math.floor(reader.header.mins[1])
total = 0
with open(args.output, "w", encoding="ascii") as out:
for chunk in reader.chunk_iterator(2_000_000):
x = np.asarray(chunk.x) - offset_x
y = np.asarray(chunk.y) - offset_y
xyz = np.column_stack([x, y, np.asarray(chunk.z)])
np.savetxt(out, xyz, fmt="%.6f")
total += xyz.shape[0]
except Exception as exc:
print(json.dumps({"error": f"conversion LAS/LAZ impossible: {exc}"}))
return 1
@ -39,7 +55,7 @@ def main() -> int:
print(json.dumps({"error": "le fichier LAS/LAZ ne contient aucun point"}))
return 1
print(json.dumps({"ok": True, "count": total}))
print(json.dumps({"ok": True, "count": total, "offsetX": offset_x, "offsetY": offset_y}))
return 0

View file

@ -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)

View file

@ -44,6 +44,12 @@ def main() -> int:
"zmin": float(np.min(pts[:, 2])),
"zmax": float(np.max(pts[:, 2])),
}
# Percentiles bas de Z : contrairement a zmin (sensible a un unique point aberrant, ex. bruit
# multi-echo), un percentile est un estimateur robuste du plan de reference et n'est PAS biaise
# par le sous-echantillonnage (la distribution marginale de Z ne depend pas de la densite de
# points, au contraire de la distance au plus proche voisin). Voir CONCEPT.md §7.7.
z_p01 = float(np.percentile(pts[:, 2], 0.1))
z_p1 = float(np.percentile(pts[:, 2], 1.0))
tree = cKDTree(pts)
# k=2 : le plus proche voisin de chaque point est le point lui-meme (distance 0),
@ -62,6 +68,8 @@ def main() -> int:
"median_nn": float(np.median(nn_dist)),
"std_nn": float(np.std(nn_dist)),
"bbox": bbox,
"z_p01": z_p01,
"z_p1": z_p1,
"approximate": True,
}
print(json.dumps(result))

View file

@ -87,7 +87,7 @@ export class CcRunnerService {
* Recherche recursive : CloudCompare ecrit les rapports/rasters relativement au dossier du
* nuage charge via -O (ex: "levels/"), pas forcement au cwd du process.
*/
async findLatestFile(rootDir: string, opts: { prefix?: string; suffix?: string; after: number }): Promise<string | null> {
async findLatestFile(rootDir: string, opts: { prefix?: string; suffix?: string; mustContain?: string; after: number }): Promise<string | null> {
const candidates: Array<{ full: string; mtime: number }> = [];
const walk = async (dir: string): Promise<void> => {
@ -100,6 +100,7 @@ export class CcRunnerService {
}
if (opts.prefix && !entry.name.startsWith(opts.prefix)) continue;
if (opts.suffix && !entry.name.endsWith(opts.suffix)) continue;
if (opts.mustContain && !entry.name.includes(opts.mustContain)) continue;
const stat = await fs.stat(full);
const mtime = stat.mtimeMs;
if (mtime < opts.after - 2000) continue; // marge de securite horloge

View file

@ -21,10 +21,18 @@ export const config = {
// dynamiquement a chaque niveau, pas figee sur la resolution du niveau 0 - voir CLAUDE.md).
gridStepMultiplier: 2,
// Remplissage des trous (occlusions de scan) avant -VOLUME : distance max d'interpolation
// Delaunay = gridStep du niveau x ce multiplicateur. Empeche de combler les parties concaves
// du contour de l'amas (qui doivent rester "vides") tout en bouchant les petits trous de scan.
// Delaunay = gridStep DU NIVEAU 0 (initialStep x gridStepMultiplier) x ce multiplicateur, FIXE
// sur les 6 niveaux (pas recalcule a partir du gridStep de chaque niveau - sinon la portee de
// remplissage double a chaque niveau et finit par combler des concavites reelles du contour de
// l'amas, biais systematique croissant avec la decimation, voir CONCEPT.md §7.3).
// 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,

View file

@ -6,8 +6,10 @@ import { basicAuthMiddleware } from './auth/basic-auth.middleware';
import { config } from './config';
async function bootstrap() {
// Pas de CORS : app privee mono-utilisateur, servie et consommee depuis la meme origine (UI
// statique + API sur le meme port). enableCors() ferait aussi court-circuiter le preflight
// OPTIONS avant le middleware d'auth ci-dessous - surface inutile a garder ouverte.
const app = await NestFactory.create(AppModule, { bodyParser: true });
app.enableCors();
app.use(basicAuthMiddleware());
await app.listen(config.port, '0.0.0.0');
new Logger('Bootstrap').log(`Application demarree sur http://0.0.0.0:${config.port} (data dir: ${config.dataDir})`);

View file

@ -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);
@ -71,7 +95,7 @@ export class PipelineService {
// ---------- 1) Statistiques ----------
onProgress('Estimation du pas spatial (echantillonnage + KD-tree)...');
const t0 = Date.now();
const primaryStats = await this.computeStatsFor(primaryNorm, workDir, 'sample_primary.xyz');
const primaryStats = await this.computeStatsFor(primaryNorm.path, workDir, 'sample_primary.xyz');
let initialStep: number;
let zref: number | null = null;
@ -80,14 +104,17 @@ export class PipelineService {
if (!isCompare) {
initialStep = params.initialStepOverride ?? primaryStats.stats.median_nn;
zref = params.zrefOverride ?? primaryStats.stats.bbox.zmin;
// z_p1 (1er percentile) plutot que bbox.zmin : un minimum absolu est ecrase par un seul point
// aberrant (bruit multi-echo) et gonfle le volume de Δz x surface. Le percentile n'est en plus
// pas biaise par le sous-echantillonnage (contrairement a median_nn) - voir CONCEPT.md §7.7.
zref = params.zrefOverride ?? primaryStats.stats.z_p1;
zrefSource = params.zrefOverride !== undefined ? 'override' : 'auto';
onProgress(
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` +
`Zref=${zref.toFixed(4)} (${zrefSource}). [${Date.now() - t0}ms]`,
`Zref=${zref.toFixed(4)} (${zrefSource}, percentile 1%). [${Date.now() - t0}ms]`,
);
} else {
const secondaryStats = await this.computeStatsFor(secondaryNorm!, workDir, 'sample_secondary.xyz');
const secondaryStats = await this.computeStatsFor(secondaryNorm!.path, workDir, 'sample_secondary.xyz');
const topSpacing = primaryStats.stats.median_nn;
const bottomSpacing = secondaryStats.stats.median_nn;
const matchedSpacing = Math.max(topSpacing, bottomSpacing);
@ -117,6 +144,16 @@ export class PipelineService {
const rolesForThisRun: CloudRole[] = isCompare ? ['top', 'bottom'] : ['unique'];
// Portee de remplissage des trous FIXE sur tous les niveaux (derivee de la grille du niveau 0),
// et non recalculee a partir du gridStep de CHAQUE niveau. Avec un multiplicateur applique au
// gridStep de chaque niveau, la portee double a chaque niveau (x32 au niveau 5) et finit par
// combler des concavites reelles du contour de l'amas au lieu des seuls trous de scan -
// biais systematique croissant avec la decimation, voir CONCEPT.md §7.3. Le niveau 0 a le meme
// gridStep (= initialStep * gridStepMultiplier) en mode const_height (spatialStep=null) et en
// mode cloud_compare (spatialStep = initialStep * factor^0 = initialStep).
const gridStep0 = initialStep * gridStepMultiplier;
const maxEdgeLengthDefault = gridStep0 * config.maxEdgeLengthMultiplier;
for (let i = 0; i <= config.decimationSteps; i++) {
if (stopped) {
levels.push(this.skippedLevel(i, stopReason));
@ -126,18 +163,18 @@ 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 maxEdgeLength = params.maxEdgeLengthOverride ?? gridStep * config.maxEdgeLengthMultiplier;
const gridStep = (spatialStep ?? initialStep) * gridStepMultiplier;
const maxEdgeLength = params.maxEdgeLengthOverride ?? maxEdgeLengthDefault;
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 {
const cloudResults: CloudPrepResult[] = [];
for (const role of rolesForThisRun) {
const sourcePath = i === 0 ? (role === 'bottom' ? secondaryNorm! : primaryNorm) : prevRaw[role]!;
const sourcePath = i === 0 ? (role === 'bottom' ? secondaryNorm!.path : primaryNorm.path) : prevRaw[role]!;
const isFirstConversion = !isCompare && i === 0;
const res = await this.prepareCloudAtLevel({
level: i,
@ -146,11 +183,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 +211,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
diffMapImage: null,
warn: true,
status: anyBad.status,
message: anyBad.message,
@ -185,7 +227,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 +245,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 +265,32 @@ 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
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)) {
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas
// garder. Filtrage par prefixe (pas juste ".bin") : sur un run avec peu de points, les
// L{n}_*.bin bruts fraichement ecrits (chaines depuis prepareCloudAtLevel) tombent aussi
// dans la fenetre "after" de findLatestFile - un filtre ".bin" nu risquerait de supprimer
// le nuage brut du niveau au lieu de la grille parasite, cassant le chainage de decimation.
const strayGrid = await this.cc.findLatestFile(workDir, { prefix: '', suffix: '.bin', after: beforeVolume, mustContain: '_HEIGHT_DIFFERENCE_' });
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 +305,7 @@ export class PipelineService {
matchingCellsPct: vol.matchingCellsPct,
groundNonMatchingPct: vol.groundNonMatchingPct,
ceilNonMatchingPct: vol.ceilNonMatchingPct,
diffMapImage,
warn,
status: 'ok',
computeTimeMs: Date.now() - beforeVolume,
@ -253,7 +327,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 +335,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
diffMapImage: null,
warn: true,
status: 'error',
message: err.message,
@ -304,9 +379,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,
@ -316,6 +397,10 @@ export class PipelineService {
medianNn: primaryStats.stats.median_nn,
bboxApprox: primaryStats.stats.bbox,
},
coordinateOffset: {
primary: primaryNorm.offsetX !== null ? { x: primaryNorm.offsetX, y: primaryNorm.offsetY! } : null,
secondary: secondaryNorm?.offsetX !== null && secondaryNorm?.offsetX !== undefined ? { x: secondaryNorm.offsetX, y: secondaryNorm.offsetY! } : null,
},
},
levels,
mergedBinFile,
@ -334,14 +419,23 @@ export class PipelineService {
return report;
}
/** Convertit LAS/LAZ/COPC en ASCII (le paquet apt CloudCompare n'a pas le plugin LAS) ; laisse .bin tel quel. */
private async normalizeInput(inputPath: string, workDir: string, tag: string, onProgress: (msg: string) => void): Promise<string> {
/**
* Convertit LAS/LAZ/COPC en ASCII (le paquet apt CloudCompare n'a pas le plugin LAS) ; laisse .bin
* tel quel. Applique un decalage global X/Y pour preserver la precision float32 de CloudCompare
* en coordonnees projetees (voir CONCEPT.md §7.2).
*/
private async normalizeInput(
inputPath: string,
workDir: string,
tag: string,
onProgress: (msg: string) => void,
): Promise<{ path: string; offsetX: number | null; offsetY: number | null }> {
const lower = inputPath.toLowerCase();
if (!lower.endsWith('.las') && !lower.endsWith('.laz')) return inputPath;
if (!lower.endsWith('.las') && !lower.endsWith('.laz')) return { path: inputPath, offsetX: null, offsetY: null };
onProgress(`Conversion du nuage (${tag}) LAS/LAZ/COPC en ASCII...`);
const xyzPath = path.join(workDir, `input_${tag}.xyz`);
await this.py.convertLasToXyz(inputPath, xyzPath);
return xyzPath;
const { offsetX, offsetY } = await this.py.convertLasToXyz(inputPath, xyzPath);
return { path: xyzPath, offsetX, offsetY };
}
private async computeStatsFor(normalizedPath: string, workDir: string, sampleFileName: string): Promise<{ totalPointCount: number; stats: CloudStats }> {
@ -355,7 +449,22 @@ export class PipelineService {
}
const samplePath = path.join(workDir, sampleFileName);
await this.assertExists(samplePath, 'echantillonnage du nuage', res.stdout);
const stats = await this.py.computeStats(samplePath);
const rawStats = await this.py.computeStats(samplePath);
// Correction du biais de sous-echantillonnage (voir CONCEPT.md §7.1) : la distance mediane au
// plus proche voisin depend de la densite de points (median_nn ~ 1/sqrt(densite)). Un
// sous-echantillon de taille fixe (statsSampleCap) a une densite plus faible que le nuage
// complet des que ce dernier depasse statsSampleCap points, ce qui SURESTIME systematiquement
// median_nn/mean_nn d'un facteur sqrt(totalPointCount/sampleCount). Sans cette correction, un
// nuage LiDAR HD de 20M points (echantillon 50k, ratio 400x) voit son pas spatial estime
// gonfle d'un facteur ~20 - toute la grille de calcul et le remplissage de trous en heritent.
// No-op si le nuage complet est deja <= statsSampleCap (echantillon = nuage entier).
const correctionFactor = rawStats.count > 0 ? Math.sqrt(totalPointCount / rawStats.count) : 1;
const stats: CloudStats = {
...rawStats,
mean_nn: rawStats.mean_nn * correctionFactor,
median_nn: rawStats.median_nn * correctionFactor,
std_nn: rawStats.std_nn * correctionFactor,
};
return { totalPointCount, stats };
}
@ -376,6 +485,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
diffMapImage: null,
warn: true,
status: 'skipped',
message: reason,
@ -392,10 +502,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 +515,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 +563,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 +609,9 @@ export class PipelineService {
pointCount,
pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0,
binFile: binRelPath,
imageFile: imageRelPath,
},
rawBinAbs,
filledBinAbs,
filledXyzAbs,
};
}
@ -502,9 +619,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 +649,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(
[

View file

@ -33,19 +33,26 @@ 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,
sampleStats: { sampleCount: 100, meanNn: 0.02, medianNn: 0.02, bboxApprox: { xmin: 0, xmax: 1, ymin: 0, ymax: 1, zmin: 100, zmax: 101 } },
coordinateOffset: { primary: null, secondary: null },
},
levels: [
{
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 +60,7 @@ describe('reportToCsv', () => {
matchingCellsPct: 99,
groundNonMatchingPct: 1,
ceilNonMatchingPct: 0,
diffMapImage: 'images/L0_diffmap.png',
warn: false,
status: 'ok',
computeTimeMs: 500,
@ -61,7 +69,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 +77,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 +106,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 +116,7 @@ describe('reportToCsv', () => {
matchingCellsPct: 97,
groundNonMatchingPct: 3,
ceilNonMatchingPct: 0,
diffMapImage: 'images/L0_diffmap.png',
warn: false,
status: 'ok',
computeTimeMs: 300,

View file

@ -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: {
@ -64,6 +85,12 @@ export interface RunReport {
medianNn: number;
bboxApprox: { xmin: number; xmax: number; ymin: number; ymax: number; zmin: number; zmax: number };
};
/**
* Decalage (m, arrondi) soustrait de X/Y a la conversion LAS->ASCII pour preserver la precision
* float32 de CloudCompare en coordonnees projetees (voir CONCEPT.md §7.2). null si le fichier
* d'entree n'etait pas LAS/LAZ (ex. .bin deja normalise) ou en l'absence de second nuage.
*/
coordinateOffset: { primary: { x: number; y: number } | null; secondary: { x: number; y: number } | null };
};
levels: LevelResult[];
mergedBinFile: string | null;

View file

@ -11,6 +11,9 @@ export interface CloudStats {
median_nn: number;
std_nn: number;
bbox: { xmin: number; xmax: number; ymin: number; ymax: number; zmin: number; zmax: number };
/** 0.1e et 1er percentile de Z : estimateurs robustes du plan de reference, voir CONCEPT.md §7.7 */
z_p01: number;
z_p1: number;
approximate: boolean;
}
@ -55,18 +58,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> {
@ -78,7 +86,14 @@ export class PyHelperService {
* On convertit donc les entrees LAS/LAZ/COPC en ASCII XYZ via laspy avant de les transmettre
* a CloudCompare, qui lit nativement le format ASCII sans plugin.
*/
async convertLasToXyz(inputPath: string, outputXyzPath: string): Promise<{ count: number }> {
/**
* offsetX/offsetY : decalage (en metres, arrondi au metre) soustrait de X/Y avant ecriture, voir
* CONCEPT.md §7.2. Necessaire en coordonnees projetees (ex. Lambert-93, X/Y ~ 10^6-10^7 m) ou la
* precision float32 interne de CloudCompare degraderait sinon la position des points a l'echelle
* du decimetre. Le volume 2.5D etant invariant par translation, ce decalage n'affecte pas le
* resultat - seul zref (mode const_height) reste en coordonnees Z non decalees.
*/
async convertLasToXyz(inputPath: string, outputXyzPath: string): Promise<{ count: number; offsetX: number; offsetY: number }> {
return this.runJson('las_to_xyz.py', ['--input', inputPath, '--output', outputXyzPath]);
}
}

View file

@ -19,11 +19,21 @@ 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'];
// Format uuidv4 genere par RunsService.createRun - toute route parametree par ":id" doit valider
// contre ce format AVANT de construire un chemin filesystem, sinon un id du type "..%2F..%2Fetc"
// (decode par Express dans les params de route) permet une traversee de repertoire post-auth
// (ex: telechargement/zip d'un dossier arbitraire du conteneur).
const RUN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertValidRunId(id: string): void {
if (!RUN_ID_RE.test(id)) throw new BadRequestException('Identifiant de run invalide');
}
function isAllowedFilename(name: string): boolean {
const lower = name.toLowerCase();
return ALLOWED_EXT.some((ext) => lower.endsWith(ext));
@ -36,7 +46,10 @@ const uploadStorage = diskStorage({
cb(null, dir);
},
filename: (_req, file, cb) => {
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}__${file.originalname}`);
// path.basename retire tout composant de repertoire (../, chemin absolu) d'un originalname
// fourni par le client, avant que multer ne le concatene au dossier de destination.
const safeName = path.basename(file.originalname);
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}__${safeName}`);
},
});
@ -72,7 +85,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 +110,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 +124,7 @@ export class RunsController {
originalFilename: top.originalname,
tmpFilePathSecondary: bottom.path,
originalFilenameSecondary: bottom.originalname,
params: { mode, initialStepOverride, maxEdgeLengthOverride },
params: { mode, ...sharedParams },
});
}
@ -105,16 +135,19 @@ export class RunsController {
@Get(':id')
detail(@Param('id') id: string) {
assertValidRunId(id);
return this.runs.getDetail(id);
}
@Delete(':id')
async remove(@Param('id') id: string) {
assertValidRunId(id);
return this.runs.delete(id);
}
@Get(':id/download/bin')
async downloadBin(@Param('id') id: string, @Res() res: Response) {
assertValidRunId(id);
const row = this.runs.getRowOrThrow(id);
const report = row.report_json ? JSON.parse(row.report_json) : null;
if (!report?.mergedBinFile) throw new NotFoundException('Fichier .bin fusionne non disponible pour ce run');
@ -124,6 +157,7 @@ export class RunsController {
@Get(':id/download/csv')
async downloadCsv(@Param('id') id: string, @Res() res: Response) {
assertValidRunId(id);
const filePath = path.join(this.runs.workDirFor(id), 'report.csv');
if (!fs.existsSync(filePath)) throw new NotFoundException('report.csv non disponible pour ce run');
res.download(filePath, `report_${id}.csv`);
@ -131,6 +165,7 @@ export class RunsController {
@Get(':id/download/zip')
async downloadZip(@Param('id') id: string, @Res() res: Response) {
assertValidRunId(id);
const dir = this.runs.workDirFor(id);
if (!fs.existsSync(dir)) throw new NotFoundException('Resultats non disponibles pour ce run');
@ -148,6 +183,7 @@ export class RunsController {
@Get(':id/images/:filename')
async image(@Param('id') id: string, @Param('filename') filename: string, @Res() res: Response) {
assertValidRunId(id);
const safeName = path.basename(filename);
if (safeName !== filename) throw new BadRequestException('Nom de fichier invalide');
const filePath = path.join(this.runs.workDirFor(id), 'images', safeName);
@ -165,4 +201,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;
}
}