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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 18:13:27 +02:00

24 KiB
Raw Permalink Blame History

Estimation de volume 2.5D par nuage de points et sensibilité à la décimation spatiale

Document de référence méthodologique, destiné à être adapté en section « Matériel et méthodes » d'une publication. Il décrit le protocole implémenté par ce dépôt, les grandeurs mesurées, et — section 7 — les limites et biais connus qui doivent être déclarés ou corrigés avant publication.


1. Question de recherche

L'estimation du volume d'un amas (stock de matériaux, tas, dépôt) à partir d'un nuage de points aéroporté est une opération courante mais dont la robustesse à la densité de points est rarement quantifiée. La question posée ici est double :

  1. Le volume estimé à partir d'un nuage LiDAR HD (IGN) est-il comparable à une mesure de référence ?
  2. Dans quelle mesure varie-t-il lorsque la densité de points est dégradée de façon contrôlée ?

Le second point conditionne le premier : si le volume est stable sur deux ordres de grandeur de densité, l'estimation ne dépend pas de la campagne d'acquisition ; s'il dérive, tout chiffre publié doit être accompagné de la densité à laquelle il a été obtenu.


2. Grandeur mesurée

Le volume est calculé selon la méthode 2.5D par différence de surfaces rasterisées, telle qu'implémentée par CloudCompare 2.13.2 (commande -VOLUME).

Soit une direction de projection verticale d (par défaut Z), et une grille régulière de pas g dans le plan orthogonal à d. Pour chaque cellule c de la grille, on définit :

  • z_ceil(c) : hauteur de la surface supérieure (le « plafond ») ;
  • z_ground(c) : hauteur de la surface inférieure (le « sol ») ;

et le volume est la somme de Riemann

V = g² · Σ_{c ∈ C_match} ( z_ceil(c)  z_ground(c) )

C_match est l'ensemble des cellules pour lesquelles les deux hauteurs sont définies. La méthode décompose V en un volume ajouté (cellules où z_ceil > z_ground) et un volume retiré (cellules où z_ceil < z_ground) ; la grandeur V rapportée est le volume net, c'est-à-dire leur différence signée. Cette distinction importe dès que les deux surfaces s'intersectent.

Il s'agit donc d'une quadrature par la règle du point milieu sur la fonction de hauteur h(x,y) = z_ceil z_ground. L'erreur de quadrature est en O(g²·‖∇²h‖) : elle est nulle pour une surface plane, et croît avec la courbure. Cette méthode suppose que la surface est un graphe de (x,y) : elle ne peut représenter ni surplomb, ni cavité fermée. Cette hypothèse est admissible pour un amas de matériaux, et constitue une limite explicite du protocole.

2.1. Deux modes de définition du sol

Mode « plan de référence constant » (const_height). Un seul nuage est fourni ; il tient lieu de surface supérieure. La surface inférieure est le plan z = z_ref, avec z_ref par défaut égal à l'altitude minimale du nuage.

Mode « comparaison de deux nuages » (cloud_compare). Deux nuages sont fournis : la surface supérieure de l'amas, et une surface de base (socle, plan d'ajustement, terrain naturel avant dépôt). La surface inférieure est la seconde.

Résultat expérimental déterminant. Le mode const_height n'est valide que si le nuage ne contient que l'objet mesuré. Dès que le fichier inclut du terrain environnant, z_ref (altitude minimale globale) n'a plus aucun rapport avec la base réelle de l'amas, et le volume obtenu est arbitraire. Sur un amas industriel de ~150 m d'emprise, le mode const_height a donné ≈ 50 m³, contre ≈ 14 244 m³ en mode cloud_compare avec une base ajustée sous l'amas — soit un facteur 285. Une inspection de la carte de hauteurs a confirmé que les deux nuages ont bien la même emprise au sol et que le second chiffre est le bon. Toute publication utilisant le mode const_height doit justifier que le nuage a été préalablement segmenté.


3. Protocole de dégradation contrôlée

3.1. Estimation de la résolution native

La résolution spatiale native s₀ du nuage est estimée par la médiane des distances au plus proche voisin (structure KD-tree), calculée sur un échantillon de points du nuage. Pour un semis approximativement poissonnien de densité surfacique ρ (points·m⁻²), cette médiane vaut

median_NN = sqrt( ln 2 / (π ρ) ) ≈ 0.4697 / sqrt(ρ)

Elle constitue donc un estimateur direct de l'espacement inter-points.

Voir §7.1 : l'implémentation actuelle calcule cette médiane sur un sous-échantillon aléatoire de taille fixe, ce qui introduit un biais multiplicatif connu qu'il faut corriger.

3.2. Chaîne de décimation

On construit six niveaux de densité, indexés n = 0…5. Le pas spatial cible du niveau n est

s(n) = s₀ · 2ⁿ

Le niveau n est obtenu en appliquant un sous-échantillonnage spatial (contrainte de distance minimale s(n) entre points conservés ; -SS SPATIAL de CloudCompare) au nuage du niveau n1, et non au nuage original. Ce chaînage garantit que chaque niveau est un sous-ensemble strict du précédent, donc que la comparaison isole bien l'effet d'une perte d'information monotone.

Point à ne pas mal interpréter. Le pas spatial double à chaque niveau. Comme les points échantillonnent une surface (dimension 2), le nombre de points est divisé par ≈ 4 par niveau. Au niveau 5 le pas est ×32 mais le nombre de points est de l'ordre de 1/1000 du niveau 0. Le nombre de points effectivement obtenu n'est jamais garanti a priori : -SS SPATIAL impose une distance minimale, pas un taux de décimation.

Le niveau 0 diffère selon le mode :

Mode Niveau 0 Niveaux 15
const_height nuage intégral, non décimé s(n) = s₀·2ⁿ
cloud_compare nuages appariés en densité au pas s₀ s(n) = s₀·2ⁿ, appliqué aux deux nuages

Appariement de densité (mode cloud_compare). Comparer une surface supérieure à 10 pts·m⁻² avec une base à 0.5 pt·m⁻² fait porter au résultat la résolution de la plus grossière, sans que cela soit visible. On calcule donc median_NN séparément pour chaque nuage, on retient s₀ = max(median_NN_top, median_NN_bottom), et on ramène le nuage le plus dense à ce pas par un sous-échantillonnage spatial unique. L'opération est un no-op sur le nuage le plus clairsemé (un filtre de distance minimale ne peut pas densifier). Le niveau 0 du mode cloud_compare est donc lui-même déjà décimé — les écarts relatifs sont exprimés par rapport à ce niveau.

La décimation des deux nuages est ensuite synchronisée (même s(n)). Si l'un des deux passe sous un seuil de 25 points, la progression s'arrête pour les deux : les deux surfaces sont nécessaires à une comparaison valide.

3.3. Grille de calcul adaptative

Le pas de la grille de quadrature du niveau n est

g(n) = s(n) · k,    k = 2 par défaut

Ce choix est essentiel et contre-intuitif. Une première version du protocole figeait g à la résolution du niveau 0 « pour garder les niveaux comparables ». C'est exactement l'inverse de ce qu'il faut faire : dès que la décimation devient plus grossière que cette grille fixe, chaque cellule cesse de contenir un point, et l'indicateur de couverture (§4) s'effondre mécaniquement (99 % → 58 % → 13 % → 3 % → 0.9 % → 0.2 % sur les cinq niveaux). Cet effondrement mesure un désalignement grille/densité, pas une perte d'information : il ne dit rien sur la robustesse du volume.

En liant g(n) à s(n), chaque niveau est intégré sur une grille adaptée à sa propre densité. La contrepartie, qui doit être déclarée, est que le volume V(n) varie alors sous l'effet de deux causes confondues : la perte de points, et le changement de pas de quadrature (erreur en O(g²)). Le protocole ne les sépare pas. Une expérience de contrôle à g fixé permet de borner la seconde.

3.4. Traitement des occlusions (remplissage de cellules vides)

Un nuage aéroporté comporte des cellules vides (occlusions, zones d'ombre). Laissées vides, elles sont exclues de C_match et le volume est sous-estimé. La commande -VOLUME de CloudCompare n'expose aucune option de remplissage : la vérification du code source (qCC/ccCommandRaster.cpp, CommandVolume25D::process) montre que la stratégie LEAVE_EMPTY y est codée en dur pour les deux surfaces, alors même que les constantes des autres stratégies existent dans le fichier et sont consommées par CommandRasterize::process.

Le protocole contourne cette limite en rasterisant et comblant chaque nuage séparément avant le calcul de volume (-RASTERIZE -GRID_STEP g -EMPTY_FILL … -OUTPUT_CLOUD), puis en soumettant à -VOLUME la grille comblée plutôt que le nuage original. Le nuage brut, non comblé, reste utilisé pour le chaînage de la décimation, afin que l'interpolation d'un niveau ne se propage pas au suivant.

Six stratégies sont exposées, reproduisant la boîte de dialogue « Compute Volume » de l'interface graphique : LEAVE_EMPTY, MIN_H, MAX_H, CUSTOM_H, INTERP (interpolation de Delaunay bornée par une longueur d'arête maximale L), KRIGING. La stratégie retenue par défaut est INTERP.

Rôle de L (longueur d'arête maximale). L borne la portée du remplissage : un trou de diamètre supérieur à L n'est pas comblé. C'est ce qui empêche l'interpolation de « recoudre » les concavités réelles du contour de l'amas, qui doivent rester vides. Vérification sur un nuage synthétique comportant une occlusion circulaire de rayon 0.6 m :

Configuration Cellules appariées Trou comblé
Sans remplissage 96.4 % non (volume sous-estimé)
INTERP, L = 0.30 m (> diamètre du trou) 98.9 % oui
INTERP, L = 0.08 m (< diamètre du trou) 96.4 % non (identique au cas sans remplissage)

Le paramètre se comporte donc bien comme une borne de portée. Sa valeur par défaut est L(n) = 3·g(n).

Voir §7.3 : L(n) croissant avec n introduit un biais systématique qui doit être neutralisé pour l'étude de sensibilité (L fixé en absolu).

3.5. Paramètres exposés

Paramètre Rôle Défaut
s₀ (initialStep) résolution native médiane NN
k (gridStepMultiplier) g(n) = s(n)·k 2
L (maxEdgeLength) portée du remplissage INTERP 3·g(n)
z_ref plan de référence (mode const_height) altitude min du nuage
stratégie de remplissage LEAVE_EMPTYKRIGING INTERP
projection par cellule AVG / MIN / MAX / MED AVG
direction verticale X / Y / Z Z
facteur, nombre de niveaux fixés par le protocole 2, 5

Note d'implémentation : -PROJ (hauteur de cellule) n'existe que pour -RASTERIZE-VOLUME a PROJ_AVERAGE_VALUE codé en dur. Comme le nuage soumis à -VOLUME est déjà une grille comblée à un point par cellule, le moyennage interne de -VOLUME est une passe-plat : c'est bien le -PROJ du remplissage qui détermine le résultat. -VERT_DIR en revanche est accepté par les deux commandes et leur est passé de façon cohérente.


4. Indicateurs rapportés

Pour chaque niveau n :

  • V(n) : volume net (m³), et sa décomposition en volumes ajouté / retiré ;
  • S(n) : surface projetée des cellules appariées (m²) ;
  • N(n) : nombre de points de chaque nuage, et N(n)/N(0) ;
  • g(n), s(n), L(n) ;
  • matching cells % : proportion de cellules de la grille où les deux surfaces sont simultanément définies, avec sa décomposition en cellules « ground » et « ceil » manquantes ;
  • l'écart relatif 100·(V(n) V(0))/V(0), indicateur principal de robustesse.

Une carte de comparaison (différence de hauteur z_ceil z_ground, dégradé bleu→rouge, cellules non appariées en gris) est produite par niveau. Elle est reconstruite en Python (binned_statistic_2d + matplotlib) à partir d'exports ASCII des grilles comblées, plutôt que par l'export GeoTIFF de CloudCompare (voir §6).

Tous les nuages exploitables des six niveaux sont assemblés dans un unique fichier .bin, comme entités distinctes (le format .bin de CloudCompare supporte nativement une hiérarchie de nuages ; il ne faut surtout pas fusionner les géométries), pour inspection visuelle a posteriori.

Voir §7.4 : matching cells % est, dans l'implémentation actuelle, largement neutralisé par l'étape de remplissage. Il ne doit pas être présenté comme l'indicateur de robustesse principal.


5. Résultat de référence

Sur l'amas industriel de ~150 m d'emprise (Amas 1 Nuage.las vs Plan d'ajustement NUAGE.las), en mode cloud_compare : V ≈ 14 244 m³, stable à ± 4 % jusqu'au niveau 4 (facteur 16 sur le pas spatial, nuage réduit à 129 points).

Ce résultat, s'il se confirme sur un échantillon d'amas plus large et avec les corrections de §7, est le message principal : le volume 2.5D d'un amas est une grandeur intégrale, donc peu sensible à la densité de points tant que la géométrie d'ensemble reste échantillonnée. Les erreurs locales de hauteur, positives et négatives, se compensent dans la somme. La densité conditionne la précision du contour et des détails de surface, pas celle du volume.


6. Contraintes d'implémentation (reproductibilité)

Ces points ne relèvent pas de la méthode mais conditionnent la reproductibilité de l'exécution. Ils ont été vérifiés empiriquement sur CloudCompare 2.13.2 (build officiel Windows et paquet apt Debian trixie) et contredisent parfois la documentation.

  1. -VOLUME ne produit son rapport texte que si -AUTO_SAVE ON. Avec -AUTO_SAVE OFF, la commande s'exécute, journalise [2.5D VOLUME CALCULATION] finished, et n'écrit rien — sans erreur. Il faut réactiver l'auto-sauvegarde pour ce seul appel, et supprimer ensuite la grille *_HEIGHT_DIFFERENCE_*.bin produite au passage.
  2. CloudCompare écrit ses sorties automatiques relativement au dossier du fichier chargé par -O, pas au répertoire de travail du processus. La recherche du rapport doit être récursive.
  3. Les grands nombres sont formatés avec une virgule comme séparateur de milliers dans le rapport (Volume: 14,244.464657). Un parsing naïf tronque silencieusement à la virgule : 14 244 devient 14. Ce bug a réellement produit un volume erroné avant d'être identifié.
  4. Le paquet Debian de CloudCompare n'embarque pas le plugin LAS/LAZ. Les fichiers LAS/LAZ/COPC sont convertis en ASCII XYZ via laspy en amont ; l'import ASCII fonctionne sans plugin.
  5. L'export GeoTIFF (-RASTERIZE -OUTPUT_RASTER_Z) provoque une assertion et un core dump sur ce paquet (GDAL non compilé). Les cartes sont donc rendues en Python.
  6. CloudCompare reste une application Qt même en -SILENT et exige un serveur X (Xvfb).
  7. Le CLI est une machine à états séquentielle sur un seul processus : chaque étape logique est un appel indépendant avec son propre rechargement.
  8. Chaque appel -VOLUME du protocole est journalisé, et le rapport texte brut de CloudCompare est conservé (raw/L{n}_volume_report.txt), ce qui permet de rejouer le parsing indépendamment.

7. Limites, biais connus et corrections requises

Cette section doit être lue avant toute publication de chiffres issus de ce pipeline. Les points 7.1 et 7.2 sont des défauts d'implémentation à corriger ; 7.3 à 7.6 sont des limites de protocole à déclarer ou à traiter.

7.1. Biais de sous-échantillonnage sur l'estimation de s₀ (à corriger)

median_NN est actuellement calculé sur un sous-échantillon aléatoire de taille fixe (50 000 points) tiré du nuage complet. La distance au plus proche voisin n'est pas invariante par sous-échantillonnage : prélever une fraction f divise la densité par f et multiplie median_NN par 1/√f.

Sur une dalle LiDAR HD de 20 M de points (~10 pts·m⁻², s₀ réel ≈ 0.15 m), f = 2.5·10⁻³ et l'estimation obtenue est ≈ 3 m, soit un facteur 20. Tout le protocole en hérite : g(0), L(0) et l'ensemble des s(n). Le biais est nul pour les nuages de moins de 50 000 points et croît en √N au-delà — il est donc invisible sur les petits jeux de test.

Correction. Soit corriger analytiquement, s₀ = median_NN_échantillon · √(N_échantillon / N_total) (le nombre total de points est déjà lu par le pipeline), soit — préférable car exact plutôt qu'asymptotique — calculer median_NN sur une emprise spatiale restreinte (un pavé de la bbox contenant ≤ 50 000 points), qui préserve la densité locale.

7.2. Précision numérique en coordonnées projetées (à corriger)

CloudCompare stocke les coordonnées en simple précision (float32). En Lambert-93 (EPSG:2154), les ordonnées valent ≈ 6.8·10⁶ m, où l'ULP de float32 est de 0.5 m. Sans décalage global (-GLOBAL_SHIFT, absent du pipeline), les coordonnées X/Y sont quantifiées sur un réseau de 0.5 m, ce qui vide de son sens toute grille de pas inférieur au mètre. CloudCompare n'émet qu'un avertissement de journal (Entity has very big coordinates: original accuracy may be lost!).

Le biais est passé inaperçu sur les jeux de test, dont les coordonnées semblent exprimées dans un repère local. Il est rédhibitoire sur du LiDAR HD IGN brut. Noter que seules X et Y sont affectées : Z ≈ 100200 m reste représentable au micromètre.

Correction. Le volume 2.5D étant invariant par translation, soustraire un décalage fixe (coin de la bbox, arrondi) lors de la conversion LAS→ASCII, et consigner ce décalage dans le rapport.

7.3. Le remplissage devient plus agressif à chaque niveau (à traiter)

Avec le défaut L(n) = 3·g(n) = 6·s(n), la portée du remplissage double à chaque niveau. Au niveau 5, l'interpolation comble des lacunes 32 fois plus larges qu'au niveau 0, y compris des concavités réelles du contour — précisément ce que L était censé empêcher. Le volume dérive donc vers le haut avec n pour une raison étrangère à la décimation.

Pour l'étude de sensibilité, L doit être fixé en valeur absolue, identique sur les six niveaux (le paramètre existe déjà en surcharge). Toute courbe V(n) produite avec L(n) proportionnel à g(n) mélange deux effets et ne peut être publiée telle quelle.

7.4. matching cells % n'est plus un indicateur de couverture

Depuis que les nuages sont rasterisés et comblés au pas g(n) avant d'être soumis à -VOLUME, qui les rasterise à nouveau au même pas g(n), chaque cellule reçoit environ un point par construction. En mode const_height, le sol est un plan constant, défini partout. matching cells % ne mesure alors plus la couverture des données, mais essentiellement le désalignement d'une demi-cellule entre la grille du -RASTERIZE (ancrée sur la bbox du nuage brut) et celle du -VOLUME (ancrée sur la bbox de la grille comblée). Les valeurs de 9699 % observées sont cohérentes avec cette interprétation.

L'indicateur reste utile avec la stratégie LEAVE_EMPTY, où il retrouve son sens originel. Il ne doit pas être présenté comme le critère de robustesse quand le remplissage est actif. L'indicateur de robustesse à publier est l'écart relatif V(n)/V(0) 1, accompagné de sa dispersion (§7.6).

7.5. Co-registration des grilles en mode cloud_compare

Les deux nuages sont rasterisés indépendamment, donc chacun sur une grille ancrée sur sa propre bbox. Les centres de cellules des deux surfaces sont ainsi décalés d'une fraction de cellule non contrôlée. -VOLUME les reprojette ensuite sur une grille commune : deux points verticalement superposés peuvent tomber dans des cellules distinctes. L'erreur induite sur z_ceil z_ground est d'ordre pente · g(n) et croît donc avec le niveau.

CloudCompare n'expose pas l'origine de la grille de rasterisation. La correction propre consiste à effectuer la rasterisation des deux nuages en Python sur une grille commune explicitement ancrée. À défaut, l'amplitude de l'effet doit être bornée expérimentalement (par exemple en translatant l'un des nuages d'une fraction de g et en observant la dispersion de V).

7.6. Absence de quantification d'incertitude (à traiter)

Le protocole produit une seule valeur de V par niveau, sans barre d'erreur. Le sous-échantillonnage spatial étant quasi déterministe (il dépend de l'ordre de parcours de l'octree), une simple répétition ne produirait aucune variance.

Deux sources de dispersion doivent être échantillonnées pour publier un intervalle :

  • la réalisation de la décimation : répéter chaque niveau avec un sous-échantillonnage aléatoire à nombre de points équivalent, sur M tirages ;
  • la phase de la grille : translater le nuage d'un décalage uniforme dans [0, g)² avant rasterisation, sur M tirages, ce qui échantillonne directement l'erreur de quadrature.

V(n) doit alors être rapporté comme moyenne ± écart-type sur ces M réalisations.

Par ailleurs, le pipeline n'est pas reproductible en l'état : le sous-échantillonnage aléatoire servant à estimer s₀ et z_ref n'est pas graîné, ces deux quantités varient donc d'une exécution à l'autre sur un même fichier. Une graine explicite doit être fixée et consignée.

7.7. Sensibilité de z_ref aux points aberrants

En mode const_height, z_ref est l'altitude minimale du nuage. Un unique point aberrant bas (bruit, écho multiple) abaisse z_ref de Δz et gonfle le volume de Δz · S. Sur une emprise de 10 000 m², un aberrant à 10 cm sous le sol ajoute 1 000 m³. Un percentile robuste (p₀.₁ ou p₁) doit être substitué au minimum, et la valeur retenue doit figurer dans le rapport. (Défaut supplémentaire de l'implémentation actuelle : z_ref est tiré de la bbox du sous-échantillon, ce qui le rend en outre biaisé vers le haut et aléatoire.)

7.8. Hypothèses structurelles du modèle 2.5D

  • La surface est un graphe de (x,y) : ni surplomb, ni cavité fermée n'est représentable.
  • L'emprise diagonale d'un amas dans sa bounding box (observée : ~150 × 113 m) n'invalide pas le calcul mais gonfle le nombre de cellules vides, donc l'influence de la stratégie de remplissage.
  • La grandeur rapportée est le volume net. Dès que les deux surfaces s'intersectent, il faut présenter aussi les volumes ajouté et retiré ; le net seul est trompeur.
  • Le format E57 est hors périmètre.

8. Synthèse du pipeline

LAS / LAZ / COPC.LAZ / BIN
  └─ conversion ASCII XYZ (laspy)                    [+ décalage global — §7.2]
  └─ estimation de s₀ = médiane NN (KD-tree)         [+ correction de biais — §7.1]
  └─ [cloud_compare] appariement de densité : s₀ = max(NN_top, NN_bottom)
  │
  └─ pour n = 0…5 :
       ├─ décimation spatiale au pas s(n) = s₀·2ⁿ, chaînée depuis le niveau n1
       ├─ rasterisation + remplissage des occlusions au pas g(n) = 2·s(n)   [L fixe — §7.3]
       ├─ calcul de volume 2.5D sur les grilles comblées (CloudCompare -VOLUME)
       ├─ carte de comparaison z_ceil  z_ground (matplotlib)
       └─ arrêt si un nuage passe sous 25 points
  │
  └─ assemblage des 6 niveaux dans un .bin unique (entités distinctes)
  └─ rapport CSV / JSON + graphiques de synthèse

Résultat attendu : la courbe V(n) et son écart relatif à V(0), qui quantifient la robustesse de l'estimation de volume à la dégradation de densité — sous réserve des corrections de la section 7.