galleria_photo_face_rknn_op.../api_v1/scanner/faceScanner.js.ok
2026-10-03 12:16:58 +02:00

486 lines
9.9 KiB
Text

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 DEFAULT_FACE_CLUSTERING_ROOT =
'/home/nvme/dockerdata/prove/face-clustering-rk3588';
function getFaceClusteringRoot() {
return path.resolve(
process.env.FACE_CLUSTERING_ROOT
|| DEFAULT_FACE_CLUSTERING_ROOT,
);
}
function getFaceScanBinary() {
if (process.env.FACE_SCAN_BINARY) {
return path.resolve(process.env.FACE_SCAN_BINARY);
}
return path.join(
getFaceClusteringRoot(),
'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) {
const binaryPath = await verifyFaceScanBinary();
const startedAt = Date.now();
let stdout;
let stderr;
try {
const result = await execFileAsync(
binaryPath,
[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);
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 result = await runFaceScan(imagePath);
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,
faces_detected: result.faces.length,
faces_inserted: insertedFaces,
execution_ms: result.execution_ms,
};
}
module.exports = {
EMBEDDING_DIM,
EMBEDDING_BYTES,
embeddingToBlob,
validateFaceScanResult,
runFaceScan,
replacePhotoFaces,
scanPhotoFacesById,
};