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>
679 lines
27 KiB
TypeScript
679 lines
27 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import * as fs from 'fs/promises';
|
|
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, EmptyCellStrategy, LevelCloudInfo, LevelResult, ProjectionType, RunParamsInput, RunReport, VertDir } from './types';
|
|
|
|
export function sanitizeBaseName(filename: string): string {
|
|
const base = filename.replace(/\.[^./\\]+$/, '');
|
|
return base.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80) || 'nuage';
|
|
}
|
|
|
|
export interface RunInputs {
|
|
/** Fichier unique (mode const_height) ou nuage du HAUT / surface superieure (mode cloud_compare) */
|
|
primaryPath: string;
|
|
primaryFilename: string;
|
|
/** Nuage du BAS / limite inferieure (mode cloud_compare uniquement) */
|
|
secondaryPath?: string;
|
|
secondaryFilename?: string;
|
|
}
|
|
|
|
interface CloudPrepResult {
|
|
status: 'ok' | 'skipped' | 'error';
|
|
message?: string;
|
|
info: LevelCloudInfo;
|
|
/** Chemin absolu du nuage brut (non rempli), utilise pour chainer la decimation suivante */
|
|
rawBinAbs: 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.
|
|
* - 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 (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 {
|
|
private readonly logger = new Logger(PipelineService.name);
|
|
|
|
constructor(
|
|
private readonly cc: CcRunnerService,
|
|
private readonly py: PyHelperService,
|
|
) {}
|
|
|
|
async execute(
|
|
runId: string,
|
|
inputs: RunInputs,
|
|
workDir: string,
|
|
params: RunParamsInput,
|
|
onProgress: (msg: string) => void,
|
|
): Promise<RunReport> {
|
|
const levelsDir = path.join(workDir, 'levels');
|
|
const imagesDir = path.join(workDir, 'images');
|
|
const rawDir = path.join(workDir, 'raw');
|
|
await fs.mkdir(levelsDir, { recursive: true });
|
|
await fs.mkdir(imagesDir, { recursive: true });
|
|
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);
|
|
const secondaryNorm = inputs.secondaryPath ? await this.normalizeInput(inputs.secondaryPath, workDir, 'secondary', onProgress) : null;
|
|
|
|
// ---------- 1) Statistiques ----------
|
|
onProgress('Estimation du pas spatial (echantillonnage + KD-tree)...');
|
|
const t0 = Date.now();
|
|
const primaryStats = await this.computeStatsFor(primaryNorm.path, workDir, 'sample_primary.xyz');
|
|
|
|
let initialStep: number;
|
|
let zref: number | null = null;
|
|
let zrefSource: 'override' | 'auto' | null = null;
|
|
let densityMatch: RunReport['params']['densityMatch'] = null;
|
|
|
|
if (!isCompare) {
|
|
initialStep = params.initialStepOverride ?? primaryStats.stats.median_nn;
|
|
// 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}, percentile 1%). [${Date.now() - t0}ms]`,
|
|
);
|
|
} else {
|
|
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);
|
|
initialStep = params.initialStepOverride ?? matchedSpacing;
|
|
const eps = matchedSpacing * 0.01;
|
|
densityMatch = {
|
|
topSpacing,
|
|
bottomSpacing,
|
|
matchedSpacing: initialStep,
|
|
preDecimatedRole: topSpacing < initialStep - eps ? 'top' : bottomSpacing < initialStep - eps ? 'bottom' : 'none',
|
|
};
|
|
onProgress(
|
|
`Pas top=${topSpacing.toFixed(5)}m, pas bas=${bottomSpacing.toFixed(5)}m -> pas apparie=${initialStep.toFixed(5)}m ` +
|
|
`(${params.initialStepOverride ? 'override' : 'auto, max des deux medianes NN'}). [${Date.now() - t0}ms]`,
|
|
);
|
|
}
|
|
|
|
const maxEdgeLengthSource: 'override' | 'auto' = params.maxEdgeLengthOverride !== undefined ? 'override' : 'auto';
|
|
|
|
const levels: LevelResult[] = [];
|
|
let stopped = false;
|
|
let stopReason = '';
|
|
|
|
// Etat courant par role : chemin du nuage BRUT (non rempli) du niveau precedent
|
|
const prevRaw: Partial<Record<CloudRole, string>> = {};
|
|
const refPointCount: Partial<Record<CloudRole, number>> = {};
|
|
|
|
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));
|
|
continue;
|
|
}
|
|
|
|
// 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) * 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 (${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!.path : primaryNorm.path) : prevRaw[role]!;
|
|
const isFirstConversion = !isCompare && i === 0;
|
|
const res = await this.prepareCloudAtLevel({
|
|
level: i,
|
|
role,
|
|
spatialStep,
|
|
sourcePath,
|
|
isFirstConversion,
|
|
levelsDir,
|
|
rawDir,
|
|
workDir,
|
|
gridStep,
|
|
maxEdgeLength,
|
|
emptyCellStrategy,
|
|
customHeightValue,
|
|
krigingKnn,
|
|
projectionType,
|
|
vertDir,
|
|
refPointCount: refPointCount[role] ?? 0,
|
|
});
|
|
cloudResults.push(res);
|
|
}
|
|
|
|
const anyBad = cloudResults.find((r) => r.status !== 'ok');
|
|
if (anyBad) {
|
|
levels.push({
|
|
level: i,
|
|
gridStep,
|
|
maxEdgeLength,
|
|
clouds: cloudResults.map((r) => r.info),
|
|
volume: null,
|
|
surface: null,
|
|
addedVolume: null,
|
|
removedVolume: null,
|
|
matchingCellsPct: null,
|
|
groundNonMatchingPct: null,
|
|
ceilNonMatchingPct: null,
|
|
diffMapImage: null,
|
|
warn: true,
|
|
status: anyBad.status,
|
|
message: anyBad.message,
|
|
computeTimeMs: 0,
|
|
});
|
|
stopped = true;
|
|
stopReason = anyBad.message ?? `echec au niveau ${i}`;
|
|
continue;
|
|
}
|
|
|
|
// ---------- Volume (sur les nuages COMBLES) ----------
|
|
const beforeVolume = Date.now();
|
|
let volRes;
|
|
if (!isCompare) {
|
|
volRes = await this.cc.run(
|
|
[
|
|
'-O',
|
|
this.relFrom(workDir, cloudResults[0].filledXyzAbs!),
|
|
'-VOLUME',
|
|
'-GRID_STEP',
|
|
String(gridStep),
|
|
'-CONST_HEIGHT',
|
|
String(zref),
|
|
'-VERT_DIR',
|
|
String(vertDir),
|
|
],
|
|
workDir,
|
|
{ autoSave: true },
|
|
);
|
|
} else {
|
|
volRes = await this.cc.run(
|
|
[
|
|
'-O',
|
|
this.relFrom(workDir, cloudResults[0].filledXyzAbs!), // top = ceil
|
|
'-O',
|
|
this.relFrom(workDir, cloudResults[1].filledXyzAbs!), // bottom = ground
|
|
'-VOLUME',
|
|
'-GRID_STEP',
|
|
String(gridStep),
|
|
'-VERT_DIR',
|
|
String(vertDir),
|
|
],
|
|
workDir,
|
|
{ autoSave: true },
|
|
);
|
|
}
|
|
const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume });
|
|
if (!reportFile) {
|
|
throw new Error(`Rapport de volume introuvable au niveau ${i}. Sortie:\n${volRes.stdout.slice(-1500)}`);
|
|
}
|
|
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. 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({
|
|
level: i,
|
|
gridStep,
|
|
maxEdgeLength,
|
|
clouds: cloudResults.map((r) => r.info),
|
|
volume: vol.volume,
|
|
surface: vol.surface,
|
|
addedVolume: vol.addedVolume,
|
|
removedVolume: vol.removedVolume,
|
|
matchingCellsPct: vol.matchingCellsPct,
|
|
groundNonMatchingPct: vol.groundNonMatchingPct,
|
|
ceilNonMatchingPct: vol.ceilNonMatchingPct,
|
|
diffMapImage,
|
|
warn,
|
|
status: 'ok',
|
|
computeTimeMs: Date.now() - beforeVolume,
|
|
});
|
|
|
|
for (const r of cloudResults) {
|
|
prevRaw[r.info.role] = r.rawBinAbs!;
|
|
if (i === 0) refPointCount[r.info.role] = r.info.pointCount;
|
|
}
|
|
|
|
const tooSparse = cloudResults.some((r) => r.info.pointCount < config.minPointsToContinue);
|
|
if (tooSparse) {
|
|
stopped = true;
|
|
stopReason = `nuage trop clairseme apres le niveau ${i} (seuil ${config.minPointsToContinue} points)`;
|
|
}
|
|
} catch (err: any) {
|
|
this.logger.error(`Niveau ${i} en erreur: ${err.message}`);
|
|
levels.push({
|
|
level: i,
|
|
gridStep,
|
|
maxEdgeLength,
|
|
clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null })),
|
|
volume: null,
|
|
surface: null,
|
|
addedVolume: null,
|
|
removedVolume: null,
|
|
matchingCellsPct: null,
|
|
groundNonMatchingPct: null,
|
|
ceilNonMatchingPct: null,
|
|
diffMapImage: null,
|
|
warn: true,
|
|
status: 'error',
|
|
message: err.message,
|
|
computeTimeMs: 0,
|
|
});
|
|
stopped = true;
|
|
stopReason = err.message;
|
|
}
|
|
}
|
|
|
|
// ---------- Assemblage ----------
|
|
onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...');
|
|
const okBinFiles: string[] = [];
|
|
for (const lvl of levels) {
|
|
if (lvl.status !== 'ok') continue;
|
|
for (const c of lvl.clouds) {
|
|
if (c.binFile) okBinFiles.push(c.binFile);
|
|
}
|
|
}
|
|
let mergedBinFile: string | null = null;
|
|
if (okBinFiles.length > 0) {
|
|
const baseName = sanitizeBaseName(inputs.primaryFilename);
|
|
const mergedName = `${baseName}_all_levels.bin`;
|
|
const args: string[] = [];
|
|
for (const f of okBinFiles) args.push('-O', f);
|
|
args.push('-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'ALL_AT_ONCE', 'FILE', mergedName);
|
|
const mergeRes = await this.cc.run(args, workDir);
|
|
const mergedAbs = path.join(workDir, mergedName);
|
|
await this.assertExists(mergedAbs, 'fusion des nuages', mergeRes.stdout);
|
|
mergedBinFile = mergedName;
|
|
}
|
|
|
|
const report: RunReport = {
|
|
runId,
|
|
mode: params.mode,
|
|
originalFilename: inputs.primaryFilename,
|
|
originalFilenameBottom: inputs.secondaryFilename ?? null,
|
|
createdAt: new Date().toISOString(),
|
|
params: {
|
|
zref,
|
|
zrefSource,
|
|
initialStep,
|
|
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
|
|
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,
|
|
sampleStats: {
|
|
sampleCount: primaryStats.stats.count,
|
|
meanNn: primaryStats.stats.mean_nn,
|
|
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,
|
|
};
|
|
|
|
await fs.writeFile(path.join(workDir, 'report.json'), JSON.stringify(report, null, 2), 'utf-8');
|
|
await fs.writeFile(path.join(workDir, 'report.csv'), reportToCsv(report), 'utf-8');
|
|
|
|
onProgress('Generation des graphiques de synthese...');
|
|
try {
|
|
await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir);
|
|
} catch (err: any) {
|
|
this.logger.warn(`Graphiques de synthese non generes: ${err.message}`);
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* 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 { path: inputPath, offsetX: null, offsetY: null };
|
|
onProgress(`Conversion du nuage (${tag}) LAS/LAZ/COPC en ASCII...`);
|
|
const xyzPath = path.join(workDir, `input_${tag}.xyz`);
|
|
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 }> {
|
|
const res = await this.cc.run(
|
|
['-O', normalizedPath, '-SS', 'RANDOM', String(config.statsSampleCap), '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', sampleFileName],
|
|
workDir,
|
|
);
|
|
const totalPointCount = this.cc.parseLoadedPointCount(res.stdout);
|
|
if (totalPointCount === null) {
|
|
throw new Error(`Impossible de lire le nuage d'entree. Sortie CloudCompare:\n${res.stdout.slice(-2000)}`);
|
|
}
|
|
const samplePath = path.join(workDir, sampleFileName);
|
|
await this.assertExists(samplePath, 'echantillonnage du nuage', res.stdout);
|
|
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 };
|
|
}
|
|
|
|
private relFrom(workDir: string, absPath: string): string {
|
|
return path.relative(workDir, absPath).split(path.sep).join('/');
|
|
}
|
|
|
|
private skippedLevel(level: number, reason: string): LevelResult {
|
|
return {
|
|
level,
|
|
gridStep: null,
|
|
maxEdgeLength: null,
|
|
clouds: [],
|
|
volume: null,
|
|
surface: null,
|
|
addedVolume: null,
|
|
removedVolume: null,
|
|
matchingCellsPct: null,
|
|
groundNonMatchingPct: null,
|
|
ceilNonMatchingPct: null,
|
|
diffMapImage: null,
|
|
warn: true,
|
|
status: 'skipped',
|
|
message: reason,
|
|
computeTimeMs: 0,
|
|
};
|
|
}
|
|
|
|
private async assertExists(p: string, what: string, ccLog: string) {
|
|
try {
|
|
await fs.access(p);
|
|
} catch {
|
|
throw new Error(`Etape "${what}" : fichier attendu introuvable (${p}). Sortie CloudCompare:\n${ccLog.slice(-2000)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
role: CloudRole;
|
|
spatialStep: number | null;
|
|
sourcePath: string;
|
|
isFirstConversion: boolean;
|
|
levelsDir: 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;
|
|
const suffix = role === 'unique' ? '' : `_${role}`;
|
|
let binRelPath: string;
|
|
let pointCount: number;
|
|
|
|
if (opts.isFirstConversion) {
|
|
binRelPath = path.posix.join('levels', `L${level}${suffix}_full.bin`);
|
|
const res = await this.cc.run(['-O', opts.sourcePath, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
|
|
const n = this.cc.parseLoadedPointCount(res.stdout);
|
|
if (n === null) {
|
|
return this.badResult(level, role, spatialStep, `Conversion niveau ${level}${suffix} echouee. Sortie:\n${res.stdout.slice(-1500)}`, 'error');
|
|
}
|
|
pointCount = n;
|
|
await this.assertExists(path.join(workDir, binRelPath), `conversion niveau ${level}${suffix}`, res.stdout);
|
|
} else {
|
|
const stepStr = spatialStep!.toFixed(6);
|
|
binRelPath = path.posix.join('levels', `L${level}${suffix}_step${stepStr}.bin`);
|
|
const res = await this.cc.run(
|
|
['-O', opts.sourcePath, '-SS', 'SPATIAL', stepStr, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath],
|
|
workDir,
|
|
);
|
|
const n = this.cc.parseSubsampleResult(res.stdout);
|
|
if (n === null || n === 0) {
|
|
return this.badResult(
|
|
level,
|
|
role,
|
|
spatialStep,
|
|
`decimation spatiale (${role}) n'a produit aucun point exploitable (pas=${stepStr}m)`,
|
|
'skipped',
|
|
);
|
|
}
|
|
pointCount = n;
|
|
await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}${suffix}`, res.stdout);
|
|
}
|
|
|
|
const rawBinAbs = path.join(workDir, binRelPath);
|
|
|
|
// 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 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),
|
|
'-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}`, '');
|
|
filledXyzAbs = filledAbs;
|
|
} catch (err: any) {
|
|
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 {
|
|
status: 'ok',
|
|
info: {
|
|
role,
|
|
spatialStep,
|
|
pointCount,
|
|
pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0,
|
|
binFile: binRelPath,
|
|
},
|
|
rawBinAbs,
|
|
filledXyzAbs,
|
|
};
|
|
}
|
|
|
|
private badResult(level: number, role: CloudRole, spatialStep: number | null, message: string, status: 'skipped' | 'error'): CloudPrepResult {
|
|
return {
|
|
status,
|
|
message,
|
|
info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null },
|
|
rawBinAbs: null,
|
|
filledXyzAbs: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function reportToCsv(report: RunReport): string {
|
|
const headers = [
|
|
'level',
|
|
'role',
|
|
'spatialStep',
|
|
'gridStep',
|
|
'maxEdgeLength',
|
|
'pointCount',
|
|
'pointRatio',
|
|
'volume',
|
|
'surface',
|
|
'addedVolume',
|
|
'removedVolume',
|
|
'matchingCellsPct',
|
|
'groundNonMatchingPct',
|
|
'ceilNonMatchingPct',
|
|
'status',
|
|
'warn',
|
|
'computeTimeMs',
|
|
'message',
|
|
];
|
|
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 }];
|
|
for (const c of clouds) {
|
|
rows.push(
|
|
[
|
|
l.level,
|
|
c.role,
|
|
c.spatialStep ?? '',
|
|
l.gridStep ?? '',
|
|
l.maxEdgeLength ?? '',
|
|
c.pointCount,
|
|
c.pointRatio,
|
|
l.volume ?? '',
|
|
l.surface ?? '',
|
|
l.addedVolume ?? '',
|
|
l.removedVolume ?? '',
|
|
l.matchingCellsPct ?? '',
|
|
l.groundNonMatchingPct ?? '',
|
|
l.ceilNonMatchingPct ?? '',
|
|
l.status,
|
|
l.warn,
|
|
l.computeTimeMs,
|
|
(l.message ?? '').replace(/[\r\n,]+/g, ' '),
|
|
].join(','),
|
|
);
|
|
}
|
|
}
|
|
return [headers.join(','), ...rows].join('\n') + '\n';
|
|
}
|