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 { 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 { 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 { 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 { 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]); } }