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>
99 lines
4.1 KiB
TypeScript
99 lines
4.1 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { spawn } from 'child_process';
|
|
import * as path from 'path';
|
|
import { config } from '../config';
|
|
|
|
const PYTHON_DIR = path.resolve(__dirname, '..', '..', 'python');
|
|
|
|
export interface CloudStats {
|
|
count: number;
|
|
mean_nn: number;
|
|
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;
|
|
}
|
|
|
|
/** Wrapper autour des scripts python/*.py (statistiques + generation d'images). */
|
|
@Injectable()
|
|
export class PyHelperService {
|
|
private readonly logger = new Logger(PyHelperService.name);
|
|
|
|
private async runJson(scriptRelPath: string, args: string[]): Promise<any> {
|
|
const scriptPath = path.join(PYTHON_DIR, scriptRelPath);
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(config.pythonBin, [scriptPath, ...args], { windowsHide: true });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
child.on('error', (err) => reject(new Error(`Impossible de lancer python (${config.pythonBin}): ${err.message}`)));
|
|
child.on('close', (code) => {
|
|
if (stderr) this.logger.warn(`${scriptRelPath} stderr: ${stderr.trim()}`);
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
reject(new Error(`${scriptRelPath} n'a produit aucune sortie (code ${code}). stderr: ${stderr}`));
|
|
return;
|
|
}
|
|
let parsed: any;
|
|
try {
|
|
parsed = JSON.parse(trimmed.split('\n').pop()!);
|
|
} catch {
|
|
reject(new Error(`Sortie JSON invalide de ${scriptRelPath}: ${trimmed}`));
|
|
return;
|
|
}
|
|
if (parsed.error) {
|
|
reject(new Error(`${scriptRelPath}: ${parsed.error}`));
|
|
return;
|
|
}
|
|
resolve(parsed);
|
|
});
|
|
});
|
|
}
|
|
|
|
computeStats(xyzSamplePath: string): Promise<CloudStats> {
|
|
return this.runJson('stats_helper.py', ['--input', xyzSamplePath]);
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
return this.runJson('render_images.py', ['summary', '--report', reportJsonPath, '--outdir', outDir]);
|
|
}
|
|
|
|
/**
|
|
* Le paquet apt CloudCompare (Debian) ne fournit pas le plugin LAS/LAZ (seulement "Core I/O").
|
|
* 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.
|
|
*/
|
|
/**
|
|
* 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]);
|
|
}
|
|
}
|