Compare commits
2 commits
24aa2e95db
...
9d6ebf5177
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6ebf5177 | |||
| 051209b022 |
16 changed files with 1039 additions and 189 deletions
47
CLAUDE.md
47
CLAUDE.md
|
|
@ -88,10 +88,27 @@ E57 volontairement hors perimetre.
|
|||
fournir qu'UN SEUL nom de fichier a `-SAVE_CLOUDS FILE`.
|
||||
- Le nuage "comble" est un nuage-grille (un point par cellule non vide, ex: 39971 points pour une
|
||||
grille 201x201 avec quelques cellules hors de l'enveloppe convexe) — PAS le nuage original avec des
|
||||
points ajoutes. On le garde uniquement pour le calcul de volume (`raw/L{n}_filled.bin`) ; le nuage
|
||||
BRUT (non comble) reste utilise pour l'export, les cartes de hauteur et le chainage de decimation,
|
||||
afin de montrer les vrais trous de scan et ne pas faire chainer l'interpolation d'un niveau vers
|
||||
le suivant (qui composerait l'erreur).
|
||||
points ajoutes. On le garde uniquement pour le calcul de volume et la carte de comparaison
|
||||
(`raw/L{n}_filled.xyz`, en **ASCII** — pas `.bin` : `-VOLUME` lit l'ASCII nativement, et ca evite
|
||||
un aller-retour BIN inutile puisqu'on a de toute facon besoin d'un export ASCII pour la carte de
|
||||
comparaison Python). Le nuage BRUT (non comble, en `.bin`) reste utilise pour l'assemblage final et
|
||||
le chainage de decimation, afin de ne pas faire chainer l'interpolation d'un niveau vers le suivant
|
||||
(qui composerait l'erreur).
|
||||
- **Toutes les strategies `-EMPTY_FILL` de CloudCompare sont exposees dans l'UI** (mirroir de la
|
||||
boite de dialogue "Compute Volume" du GUI), pas seulement INTERP : `LEAVE_EMPTY` (aucun flag),
|
||||
`MIN_H`/`MAX_H` (hauteur min/max des cellules voisines), `CUSTOM_H` (necessite `-CUSTOM_HEIGHT
|
||||
<valeur>`), `INTERP` (necessite `-MAX_EDGE_LENGTH <valeur>`), `KRIGING` (necessite `-KRIGING_KNN
|
||||
<n>`). Voir `emptyFillArgs()` dans `pipeline.service.ts`.
|
||||
- **`-PROJ <AVG|MIN|MAX|MED>`** (type de projection par cellule, "Hauteur de cellule" dans le GUI) et
|
||||
**`-VERT_DIR <0|1|2>`** (direction de projection X/Y/Z) sont egalement exposes. Contrairement a
|
||||
`-EMPTY_FILL`, `-VOLUME` accepte bien `-VERT_DIR` nativement (verifie dans les sources) — on le
|
||||
passe donc aux DEUX commandes (`-RASTERIZE` pour le remplissage, `-VOLUME` pour le calcul) afin
|
||||
qu'elles restent coherentes. `-PROJ` en revanche n'existe QUE pour `-RASTERIZE` (comme
|
||||
`-EMPTY_FILL` : `-VOLUME` a `PROJ_AVERAGE_VALUE` code en dur) — mais comme le nuage envoye a
|
||||
`-VOLUME` est deja notre grille-comble (1 point/cellule), le "moyennage" interne de `-VOLUME`
|
||||
devient une passe-plat : c'est bien NOTRE `-PROJ` (au moment du remplissage) qui determine le
|
||||
resultat final, pas celui hardcode dans `-VOLUME`. `INV_VAR` (variance inverse) n'est pas expose :
|
||||
necessite un champ scalaire dedie qu'on n'a pas dans ce pipeline.
|
||||
|
||||
- **Piege critique de parsing** : CloudCompare formate les **grands nombres avec une virgule comme
|
||||
separateur de milliers** dans le rapport texte (`Volume: 14,244.464657`, pas `14244.464657`). Une
|
||||
|
|
@ -191,10 +208,13 @@ Mais ce paquet est **allege par rapport au build officiel Windows** :
|
|||
plugin (verifie : `CloudCompare -O fichier.xyz` marche directement sur ce paquet). Un `.copc.laz`
|
||||
est lu par laspy comme un LAZ standard (l'indexation octree COPC est ignoree, seuls les points
|
||||
comptent ici).
|
||||
2. **Export GeoTIFF cassé** (voir `-RASTERIZE` ci-dessus). **Solution retenue** : les cartes de hauteur
|
||||
sont generees nous-memes (`python/render_images.py heightmap`) via export ASCII du nuage +
|
||||
binning `scipy.stats.binned_statistic_2d` + rendu `matplotlib`, sans passer par le rasterizer
|
||||
CloudCompare.
|
||||
2. **Export GeoTIFF cassé** (voir `-RASTERIZE` ci-dessus). **Solution retenue** : les images sont
|
||||
generees nous-memes (`python/render_images.py diffmap`) via export ASCII des nuages combles +
|
||||
binning `scipy.stats.binned_statistic_2d` sur une grille commune + rendu `matplotlib` (colormap
|
||||
`jet`, bleu->rouge façon CloudCompare), sans passer par le rasterizer CloudCompare. Une seule
|
||||
image de comparaison par niveau (ceil vs ground/plan constant), pas une carte par nuage —
|
||||
remplace l'ancienne approche (une heightmap par nuage) qui ne permettait pas de voir directement
|
||||
l'ecart entre les deux surfaces.
|
||||
3. **CloudCompare reste une appli Qt, meme en `-SILENT`** : plante avec `QXcbConnection: Could not
|
||||
connect to display` sans serveur X. Necessite `xvfb`. On demarre UN Xvfb persistant dans
|
||||
`docker/entrypoint.sh` (pas un `xvfb-run` par appel CLI, plus rapide et evite les conflits de
|
||||
|
|
@ -235,6 +255,17 @@ manquantes), mais pourraient etre simplifies.
|
|||
- `docker-compose.yml` utilise un **volume nomme** (`app-data:/data`), pas un bind mount vers `./data` :
|
||||
Coolify re-clone le repo a chaque deploiement, un bind mount relatif au checkout perdrait les
|
||||
donnees (uploads, resultats, base sqlite) a chaque redeploy.
|
||||
- **Mais** si la resource Coolify est de type "Application" (build pack Dockerfile, pas "Docker
|
||||
Compose"), `docker-compose.yml` est **completement ignore** par Coolify, y compris son `volumes:`.
|
||||
Le `VOLUME ["/data"]` du Dockerfile cree alors juste un volume anonyme a chaque nouveau conteneur,
|
||||
jamais reattache au precedent -> toutes les donnees disparaissent a chaque redeploy sans aucune
|
||||
erreur visible. Il faut explicitement ajouter un volume persistant sur `/data` dans l'onglet
|
||||
"Storages" de l'application Coolify. Observe reellement sur ce projet : Coolify auto-detecte par
|
||||
defaut un repo avec `package.json` comme un projet Node et choisit le build pack **Nixpacks**
|
||||
(qui ignore le `Dockerfile` et tente de build un projet Node generique — echoue forcement ici,
|
||||
aucune connaissance de CloudCompare/xvfb/Python). Il faut forcer manuellement le build pack sur
|
||||
"Dockerfile" dans les reglages de l'application — et donc, une fois ce changement fait, ne pas
|
||||
oublier le volume "Storages" puisqu'on est passe en mode "Application".
|
||||
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify
|
||||
l'utilise pour determiner si le deploiement a reussi.
|
||||
- Le port d'ecoute est configurable via `PORT` (`config.ts` lit `process.env.PORT`, defaut 3000).
|
||||
|
|
|
|||
407
CONCEPT.md
Normal file
407
CONCEPT.md
Normal 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) )
|
||||
```
|
||||
|
||||
où `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 `n−1`**,
|
||||
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 1–5 |
|
||||
|---|---|---|
|
||||
| `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 ≈ 100–200 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 96–99 % 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 n−1
|
||||
├─ 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.
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -75,13 +75,16 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui
|
|||
casse l'un de ces trois echoue le build, donc n'est jamais deploye.
|
||||
- `HEALTHCHECK` integre au Dockerfile (`curl` sur `/health`, route publique non authentifiee) :
|
||||
Coolify l'utilise pour verifier que le deploiement est reellement sain.
|
||||
- Persistance : le volume `/data` doit etre monte comme un **volume nomme/gere par Coolify**, pas un
|
||||
bind mount vers le checkout git (Coolify re-clone le repo a chaque deploiement — un bind mount
|
||||
relatif au checkout perdrait toutes les donnees a chaque redeploy). Deux options :
|
||||
- Resource **"Docker Compose"** dans Coolify pointant sur `docker-compose.yml` du repo : le volume
|
||||
nomme `app-data` declare dedans est deja correct.
|
||||
- Resource **"Application"** (Dockerfile) : configurer un volume persistant sur `/data` depuis
|
||||
l'onglet "Storage" de Coolify, et le port expose sur `3000` (variable `PORT`, lue par l'appli).
|
||||
- **Persistance — action requise cote Coolify, sinon les runs disparaissent a chaque redeploy** :
|
||||
- Si la resource est de type **"Docker Compose"** (pointant sur `docker-compose.yml` du repo) :
|
||||
le volume nomme `app-data:/data` declare dedans suffit, rien a faire de plus.
|
||||
- Si la resource est de type **"Application"** (build pack Dockerfile — c'est le cas typique quand
|
||||
on a du forcer le build pack sur "Dockerfile" pour eviter Nixpacks) : Coolify **ignore
|
||||
completement `docker-compose.yml`**, y compris son `volumes:`. Sans configuration explicite, le
|
||||
conteneur redemarre a chaque deploy avec un `/data` neuf (le `VOLUME ["/data"]` du Dockerfile cree
|
||||
juste un volume anonyme, jamais reattache au precedent). **Il faut aller dans l'onglet "Storages"
|
||||
de l'application Coolify et ajouter un volume persistant monte sur `/data`** — sans ca, tous les
|
||||
runs (nuages, resultats, base sqlite) sont perdus a chaque redeploiement.
|
||||
- Variables d'environnement a definir dans Coolify : `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`
|
||||
(obligatoire, voir "Authentification" plus haut), et optionnellement `MAX_UPLOAD_MB`,
|
||||
`STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS` si besoin de changer les defauts (voir tableau plus bas).
|
||||
|
|
|
|||
145
public/app.js
145
public/app.js
|
|
@ -13,6 +13,7 @@ async function api(path, opts) {
|
|||
try { const j = await res.json(); msg = j.message || j.error || msg; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,12 @@ function fmt(n, digits = 4) {
|
|||
return n.toLocaleString('fr-FR', { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function fmtPct(n, digits = 1) {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return '-';
|
||||
const sign = n > 0 ? '+' : '';
|
||||
return sign + n.toLocaleString('fr-FR', { maximumFractionDigits: digits }) + '%';
|
||||
}
|
||||
|
||||
function roleLabel(role) {
|
||||
return role === 'top' ? 'haut' : role === 'bottom' ? 'bas' : '';
|
||||
}
|
||||
|
|
@ -68,6 +75,9 @@ async function renderList() {
|
|||
<td class="py-2 px-3 text-sm text-slate-400">${new Date(r.createdAt).toLocaleString('fr-FR')}</td>
|
||||
<td class="py-2 px-3 text-sm">${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'}</td>
|
||||
<td class="py-2 px-3 text-sm">${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3 text-right">
|
||||
<button class="text-red-400 hover:text-red-300 text-xs" onclick="event.stopPropagation(); deleteRun('${r.id}')">Supprimer</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
app.innerHTML = `
|
||||
|
|
@ -81,6 +91,7 @@ async function renderList() {
|
|||
<tr>
|
||||
<th class="py-2 px-3">ID</th><th class="py-2 px-3">Fichier</th><th class="py-2 px-3">Statut</th>
|
||||
<th class="py-2 px-3">Cree le</th><th class="py-2 px-3">Niveaux OK</th><th class="py-2 px-3">Ecart volume (L0 -> dernier)</th>
|
||||
<th class="py-2 px-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
|
|
@ -88,6 +99,16 @@ async function renderList() {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
async function deleteRun(id) {
|
||||
if (!confirm('Supprimer definitivement ce run (nuages, images, rapports) ?')) return;
|
||||
try {
|
||||
await api(`${API}/${id}`, { method: 'DELETE' });
|
||||
renderList();
|
||||
} catch (err) {
|
||||
alert(`Echec de la suppression : ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Nouveau run ----------------
|
||||
function renderNew() {
|
||||
app.innerHTML = `
|
||||
|
|
@ -128,11 +149,11 @@ function renderNew() {
|
|||
<p class="text-xs text-slate-500">Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape.</p>
|
||||
</div>
|
||||
|
||||
<details class="border border-slate-800 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<details class="border border-slate-800 rounded-lg p-3" open>
|
||||
<summary class="cursor-pointer text-sm text-slate-400">Parametres CloudCompare (mirroir de la boite "Compute Volume")</summary>
|
||||
<div class="mt-3 space-y-4">
|
||||
<div id="zref-override-wrap">
|
||||
<label class="block text-sm mb-1">Plan de reference Z (override, mode Z constant uniquement)</label>
|
||||
<label class="block text-sm mb-1">Plan de reference Z / hauteur par defaut (override, mode Z constant uniquement)</label>
|
||||
<input type="number" step="any" name="zrefOverride" placeholder="auto = Zmin de l'echantillon"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
|
|
@ -142,11 +163,54 @@ function renderNew() {
|
|||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Distance max de remplissage des trous en metres (override)</label>
|
||||
<label class="block text-sm mb-1">Multiplicateur de grille (grid step = pas spatial x ce nombre)</label>
|
||||
<input type="number" step="any" min="0.1" name="gridStepMultiplierOverride" placeholder="auto = 2"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Cellules vides (Empty cells)</label>
|
||||
<select name="emptyCellStrategy" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="interpolate" selected>Interpoler (Delaunay, borne)</option>
|
||||
<option value="leave_empty">Laisser vide</option>
|
||||
<option value="min_height">Hauteur minimum</option>
|
||||
<option value="max_height">Hauteur maximum</option>
|
||||
<option value="custom_height">Hauteur personnalisee</option>
|
||||
<option value="kriging">Krigeage</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="max-edge-wrap">
|
||||
<label class="block text-sm mb-1">Distance max de remplissage / Delaunay (m)</label>
|
||||
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau x3"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
<p class="text-xs text-slate-500 mt-1">Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.</p>
|
||||
</div>
|
||||
<div id="custom-height-wrap" class="hidden">
|
||||
<label class="block text-sm mb-1">Hauteur personnalisee (m)</label>
|
||||
<input type="number" step="any" name="customHeightValue" placeholder="ex: 0"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div id="kriging-knn-wrap" class="hidden">
|
||||
<label class="block text-sm mb-1">Krigeage : nombre de voisins (KNN)</label>
|
||||
<input type="number" step="1" min="1" name="krigingKnn" placeholder="auto = 8"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Hauteur de cellule (projection)</label>
|
||||
<select name="projectionType" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="avg" selected>Hauteur moyenne</option>
|
||||
<option value="min">Hauteur minimum</option>
|
||||
<option value="max">Hauteur maximum</option>
|
||||
<option value="med">Hauteur mediane</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Direction de projection</label>
|
||||
<select name="vertDir" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm">
|
||||
<option value="2" selected>Z</option>
|
||||
<option value="0">X</option>
|
||||
<option value="1">Y</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button>
|
||||
|
|
@ -160,6 +224,10 @@ function renderNew() {
|
|||
const constFileInput = form.querySelector('input[name=file]');
|
||||
const topFileInput = form.querySelector('input[name=fileTop]');
|
||||
const bottomFileInput = form.querySelector('input[name=fileBottom]');
|
||||
const strategySelect = form.querySelector('select[name=emptyCellStrategy]');
|
||||
const maxEdgeWrap = document.getElementById('max-edge-wrap');
|
||||
const customHeightWrap = document.getElementById('custom-height-wrap');
|
||||
const krigingWrap = document.getElementById('kriging-knn-wrap');
|
||||
|
||||
form.querySelectorAll('input[name=mode]').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
|
|
@ -174,6 +242,12 @@ function renderNew() {
|
|||
});
|
||||
constFileInput.required = true;
|
||||
|
||||
strategySelect.addEventListener('change', () => {
|
||||
maxEdgeWrap.classList.toggle('hidden', strategySelect.value !== 'interpolate');
|
||||
customHeightWrap.classList.toggle('hidden', strategySelect.value !== 'custom_height');
|
||||
krigingWrap.classList.toggle('hidden', strategySelect.value !== 'kriging');
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
|
|
@ -230,7 +304,17 @@ function stepCell(l) {
|
|||
return fmt(c.spatialStep, 5) + ' m';
|
||||
}
|
||||
|
||||
function levelRow(l) {
|
||||
/** Cellule valeur + % absolu vs L0 + % relatif vs L0 (+ ou -). */
|
||||
function metricCell(value, l0value, unit, digits) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const main = `${fmt(value, digits)} ${unit}`;
|
||||
if (l0value === null || l0value === undefined || l0value === 0) return main;
|
||||
const abs = (value / l0value) * 100;
|
||||
const rel = ((value - l0value) / l0value) * 100;
|
||||
return `${main}<br/><span class="text-xs text-slate-400">${fmt(abs, 1)}% abs · ${fmtPct(rel, 1)}</span>`;
|
||||
}
|
||||
|
||||
function levelRow(l, l0) {
|
||||
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
|
||||
const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
|
||||
return `
|
||||
|
|
@ -238,18 +322,19 @@ function levelRow(l) {
|
|||
<td class="py-2 px-3 font-medium">${l.level}</td>
|
||||
<td class="py-2 px-3">${stepCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${pointsCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3">${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? '<span class="badge bg-amber-600/30 text-amber-300 ml-1">a verifier</span>' : ''}${statusTxt}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'}</td>
|
||||
<td class="py-2 px-3">${statusBadge(l.status)}${statusTxt}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function paintDetail(run) {
|
||||
const r = run.report;
|
||||
const isCompare = r && r.mode === 'cloud_compare';
|
||||
const l0 = r ? r.levels.find((l) => l.level === 0 && l.status === 'ok') : null;
|
||||
|
||||
const levelsTable = r ? `
|
||||
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
|
||||
|
|
@ -257,13 +342,14 @@ function paintDetail(run) {
|
|||
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th>
|
||||
<th class="py-2 px-3">Max edge (remplissage)</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
|
||||
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
|
||||
<th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Volume ajoute</th><th class="py-2 px-3">Volume retire</th>
|
||||
<th class="py-2 px-3">Surface</th><th class="py-2 px-3">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${r.levels.map((l) => levelRow(l)).join('')}</tbody>
|
||||
<tbody>${r.levels.map((l) => levelRow(l, l0)).join('')}</tbody>
|
||||
</table>
|
||||
<p class="text-xs text-slate-500 p-2">% abs = valeur / niveau 0 · % relatif = ecart vs niveau 0 (+ ou -)</p>
|
||||
</div>` : '';
|
||||
|
||||
const densityBlock = r && r.params.densityMatch ? `
|
||||
|
|
@ -274,20 +360,36 @@ function paintDetail(run) {
|
|||
(${r.params.densityMatch.preDecimatedRole === 'none' ? 'aucun nuage decime, deja comparables' : `nuage ${roleLabel(r.params.densityMatch.preDecimatedRole)} decime pour matcher`})</p>
|
||||
</div>` : '';
|
||||
|
||||
const strategyLabels = {
|
||||
leave_empty: 'Laisser vide',
|
||||
min_height: 'Hauteur minimum',
|
||||
max_height: 'Hauteur maximum',
|
||||
custom_height: 'Hauteur personnalisee',
|
||||
interpolate: 'Interpoler (Delaunay)',
|
||||
kriging: 'Krigeage',
|
||||
};
|
||||
const projLabels = { avg: 'Moyenne', min: 'Minimum', max: 'Maximum', med: 'Mediane' };
|
||||
const dirLabels = { 0: 'X', 1: 'Y', 2: 'Z' };
|
||||
|
||||
const params = r ? `
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
|
||||
${!isCompare ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>` : ''}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial (${r.params.gridStepMultiplierSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Cellules vides</dt><dd>${strategyLabels[r.params.emptyCellStrategy] || r.params.emptyCellStrategy}</dd></div>
|
||||
${r.params.emptyCellStrategy === 'interpolate' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>` : ''}
|
||||
${r.params.emptyCellStrategy === 'custom_height' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur personnalisee</dt><dd>${fmt(r.params.customHeightValue, 4)}</dd></div>` : ''}
|
||||
${r.params.emptyCellStrategy === 'kriging' ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Krigeage KNN</dt><dd>${r.params.krigingKnn}</dd></div>` : ''}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Hauteur de cellule</dt><dd>${projLabels[r.params.projectionType] || r.params.projectionType}</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Direction</dt><dd>${dirLabels[r.params.vertDir]}</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
|
||||
</dl>
|
||||
${densityBlock}` : '';
|
||||
|
||||
const heightmaps = r ? r.levels.flatMap((l) => l.clouds.filter((c) => c.imageFile).map((c) => ({ level: l.level, c }))).map(({ level, c }) => `
|
||||
const diffmaps = r ? r.levels.filter((l) => l.diffMapImage).map((l) => `
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
|
||||
<img src="${API}/${run.id}/images/${c.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}</p>
|
||||
<img src="${API}/${run.id}/images/${l.diffMapImage.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${l.level}</p>
|
||||
</div>`).join('') : '';
|
||||
|
||||
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
|
||||
|
|
@ -300,6 +402,7 @@ function paintDetail(run) {
|
|||
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuages assembles (.bin)</a>
|
||||
<a href="${API}/${run.id}/download/csv" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Rapport (.csv)</a>
|
||||
<a href="${API}/${run.id}/download/zip" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Dossier complet (.zip)</a>
|
||||
<button class="text-red-400 hover:text-red-300 text-sm ml-auto" onclick="deleteRun('${run.id}').then(() => location.hash = '#/')">Supprimer ce run</button>
|
||||
</div>` : '';
|
||||
|
||||
const errorBlock = run.error ? `<div class="bg-red-950/40 border border-red-900 rounded-lg p-3 text-sm text-red-300 mt-4">${escapeHtml(run.error)}</div>` : '';
|
||||
|
|
@ -317,7 +420,7 @@ function paintDetail(run) {
|
|||
${params}
|
||||
${levelsTable}
|
||||
${downloads}
|
||||
${heightmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de hauteur par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${heightmaps}</div>` : ''}
|
||||
${diffmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de comparaison par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${diffmaps}</div>` : ''}
|
||||
${summaryCharts ? `<h2 class="text-lg font-semibold mt-8 mb-3">Graphiques de synthese</h2><div class="grid grid-cols-1 md:grid-cols-3 gap-3">${summaryCharts}</div>` : ''}
|
||||
<h2 class="text-lg font-semibold mt-8 mb-2">Journal</h2>
|
||||
<pre class="bg-black/60 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 overflow-x-auto max-h-64 overflow-y-auto">${escapeHtml(run.log || '')}</pre>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue