const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); const { promisify } = require('util'); const db = require('../../db/knex'); const { resolveFilePathForEntryId, } = require('../../utils/resolveFilePath'); const execFileAsync = promisify(execFile); const EMBEDDING_DIM = 128; const EMBEDDING_BYTES = EMBEDDING_DIM * 4; const PROJECT_ROOT = path.resolve(__dirname, '../..'); function getFaceScanBinary() { if (process.env.FACE_SCAN_BINARY) { return path.resolve( process.env.FACE_SCAN_BINARY, ); } return path.join( PROJECT_ROOT, 'bin', 'face_scan', ); } function isFiniteNumber(value) { return ( typeof value === 'number' && Number.isFinite(value) ); } function validateImage(image) { if (!image || typeof image !== 'object') { throw new Error( 'Risultato face_scan non valido: image mancante', ); } if ( !Number.isInteger(image.width) || image.width <= 0 ) { throw new Error( `Larghezza immagine non valida: ${image.width}`, ); } if ( !Number.isInteger(image.height) || image.height <= 0 ) { throw new Error( `Altezza immagine non valida: ${image.height}`, ); } if (typeof image.path !== 'string') { throw new Error( 'Percorso immagine non valido nel risultato face_scan', ); } } function validateBoundingBox(bbox, faceIndex) { if (!bbox || typeof bbox !== 'object') { throw new Error( `Bounding box mancante per face_index=${faceIndex}`, ); } for (const key of ['x', 'y', 'width', 'height']) { if (!isFiniteNumber(bbox[key])) { throw new Error( `bbox.${key} non valido per face_index=${faceIndex}`, ); } } if (bbox.width < 0 || bbox.height < 0) { throw new Error( `Bounding box negativa per face_index=${faceIndex}`, ); } } function validateLandmarks(landmarks, faceIndex) { if (!Array.isArray(landmarks)) { throw new Error( `Landmark non validi per face_index=${faceIndex}`, ); } if (landmarks.length !== 5) { throw new Error( `Landmark non validi per face_index=${faceIndex}: ` + `attesi 5, ricevuti ${landmarks.length}`, ); } landmarks.forEach((point, landmarkIndex) => { if ( !Array.isArray(point) || point.length !== 2 || !isFiniteNumber(point[0]) || !isFiniteNumber(point[1]) ) { throw new Error( `Landmark ${landmarkIndex} non valido ` + `per face_index=${faceIndex}`, ); } }); } function validateEmbedding(embedding, faceIndex) { if (!Array.isArray(embedding)) { throw new Error( `Embedding non valido per face_index=${faceIndex}`, ); } if (embedding.length !== EMBEDDING_DIM) { throw new Error( `Embedding non valido per face_index=${faceIndex}: ` + `attesi ${EMBEDDING_DIM}, ` + `ricevuti ${embedding.length}`, ); } for ( let embeddingIndex = 0; embeddingIndex < embedding.length; embeddingIndex += 1 ) { if (!isFiniteNumber(embedding[embeddingIndex])) { throw new Error( `Embedding non finito per face_index=${faceIndex}, ` + `indice=${embeddingIndex}`, ); } } const normSquared = embedding.reduce( (sum, value) => sum + (value * value), 0, ); const norm = Math.sqrt(normSquared); if ( !Number.isFinite(norm) || Math.abs(norm - 1) > 0.001 ) { throw new Error( `Embedding non normalizzato per face_index=${faceIndex}: ` + `norm=${norm}`, ); } } function validateFace(face, expectedIndex) { if (!face || typeof face !== 'object') { throw new Error( `Volto non valido all'indice ${expectedIndex}`, ); } if ( !Number.isInteger(face.face_index) || face.face_index !== expectedIndex ) { throw new Error( `face_index non valido: atteso ${expectedIndex}, ` + `ricevuto ${face.face_index}`, ); } if (!isFiniteNumber(face.score)) { throw new Error( `Score non valido per face_index=${expectedIndex}`, ); } validateBoundingBox(face.bbox, expectedIndex); validateLandmarks(face.landmarks, expectedIndex); validateEmbedding(face.embedding, expectedIndex); } function validateFaceScanResult(result) { if (!result || typeof result !== 'object') { throw new Error( 'Risultato face_scan non valido', ); } validateImage(result.image); if (!Array.isArray(result.faces)) { throw new Error( 'Risultato face_scan non valido: faces non è un array', ); } result.faces.forEach((face, faceIndex) => { validateFace(face, faceIndex); }); return result; } function embeddingToBlob(embedding) { if ( !Array.isArray(embedding) || embedding.length !== EMBEDDING_DIM ) { throw new Error( `Embedding non valido: attesi ${EMBEDDING_DIM} valori`, ); } const blob = Buffer.allocUnsafe(EMBEDDING_BYTES); embedding.forEach((value, index) => { if (!isFiniteNumber(value)) { throw new Error( `Valore embedding non valido all'indice ${index}`, ); } blob.writeFloatLE(value, index * 4); }); if (blob.length !== EMBEDDING_BYTES) { throw new Error( `Dimensione BLOB non valida: ${blob.length}`, ); } return blob; } async function verifyFaceScanBinary() { const binaryPath = getFaceScanBinary(); try { await fs.promises.access( binaryPath, fs.constants.X_OK, ); } catch { throw new Error( `face_scan non trovato o non eseguibile: ${binaryPath}`, ); } return binaryPath; } async function runFaceScan( imagePath, rotationDegrees = 0, ) { const binaryPath = await verifyFaceScanBinary(); const normalizedRotation = Number(rotationDegrees); if ( !Number.isInteger(normalizedRotation) || ![0, 90, 180, 270].includes(normalizedRotation) ) { throw new Error( `Rotazione foto non valida: ${rotationDegrees}`, ); } const startedAt = Date.now(); let stdout; let stderr; try { const result = await execFileAsync( binaryPath, [ '--rotation', String(normalizedRotation), imagePath, ], { encoding: 'utf8', timeout: Number( process.env.FACE_SCAN_TIMEOUT_MS || 300000, ), maxBuffer: Number( process.env.FACE_SCAN_MAX_BUFFER || 64 * 1024 * 1024, ), windowsHide: true, }, ); stdout = result.stdout; stderr = result.stderr; } catch (error) { const diagnostics = [ error.message, error.stderr, ] .filter(Boolean) .join('\n') .trim(); throw new Error( `face_scan fallito per ${imagePath}` + (diagnostics ? `\n${diagnostics}` : ''), ); } if (stderr && stderr.trim()) { console.warn( '[faceScanner] face_scan stderr:', stderr.trim(), ); } if (!stdout || !stdout.trim()) { throw new Error( `face_scan non ha prodotto JSON per ${imagePath}`, ); } let parsed; try { parsed = JSON.parse(stdout); } catch (error) { throw new Error( `JSON face_scan non valido per ${imagePath}: ` + error.message, ); } validateFaceScanResult(parsed); if ( parsed.image.rotation !== normalizedRotation ) { throw new Error( `Rotazione face_scan inattesa: richiesta ` + `${normalizedRotation}, ricevuta ` + `${parsed.image.rotation}`, ); } return { ...parsed, execution_ms: Date.now() - startedAt, }; } function faceToDatabaseRow(photoId, face) { const landmarks = face.landmarks; return { id: `face:${photoId}:${face.face_index}`, photo_id: photoId, face_index: face.face_index, x: face.bbox.x, y: face.bbox.y, width: face.bbox.width, height: face.bbox.height, score: face.score, landmark_0_x: landmarks[0][0], landmark_0_y: landmarks[0][1], landmark_1_x: landmarks[1][0], landmark_1_y: landmarks[1][1], landmark_2_x: landmarks[2][0], landmark_2_y: landmarks[2][1], landmark_3_x: landmarks[3][0], landmark_3_y: landmarks[3][1], landmark_4_x: landmarks[4][0], landmark_4_y: landmarks[4][1], embedding: embeddingToBlob(face.embedding), cluster_id: null, person_id: null, status: 'detected', created_at: db.fn.now(), updated_at: db.fn.now(), }; } async function replacePhotoFaces(photoId, faces) { const rows = faces.map((face) => ( faceToDatabaseRow(photoId, face) )); await db.transaction(async (trx) => { await trx('faces') .where({ photo_id: photoId }) .del(); if (rows.length > 0) { await trx('faces').insert(rows); } }); return rows.length; } async function scanPhotoFacesById(photoId) { const cleanPhotoId = String(photoId || '').trim(); if (!cleanPhotoId) { throw new Error('photoId mancante'); } const photo = await db('photos') .where({ id: cleanPhotoId }) .whereNull('deleted_at') .first(); if (!photo) { throw new Error( `Foto non trovata o eliminata: ${cleanPhotoId}`, ); } if ( photo.mime_type && !String(photo.mime_type).startsWith('image/') ) { throw new Error( `Il record non è un'immagine: ` + `${photo.mime_type}`, ); } const imagePath = await resolveFilePathForEntryId(cleanPhotoId); if (!imagePath) { throw new Error( `Percorso fisico non risolto per photo_id=${cleanPhotoId}`, ); } let fileStat; try { fileStat = await fs.promises.stat(imagePath); } catch (error) { throw new Error( `File non accessibile: ${imagePath}: ${error.message}`, ); } if (!fileStat.isFile()) { throw new Error( `Il percorso non è un file: ${imagePath}`, ); } const rotationDegrees = photo.rotation == null ? 0 : Number(photo.rotation); const result = await runFaceScan( imagePath, rotationDegrees, ); const insertedFaces = await replacePhotoFaces( cleanPhotoId, result.faces, ); return { photo_id: cleanPhotoId, image_path: imagePath, image_width: result.image.width, image_height: result.image.height, photo_rotation: photo.rotation ?? null, applied_rotation: result.image.rotation, faces_detected: result.faces.length, faces_inserted: insertedFaces, execution_ms: result.execution_ms, }; } module.exports = { EMBEDDING_DIM, EMBEDDING_BYTES, embeddingToBlob, validateFaceScanResult, runFaceScan, replacePhotoFaces, scanPhotoFacesById, };