519 lines
10 KiB
JavaScript
519 lines
10 KiB
JavaScript
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,
|
|
};
|