galleria_photo_face_rknn_op.../tools/runFaceClustering.js
2026-10-03 12:16:58 +02:00

954 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const path = require('path');
const { spawn } = require('child_process');
const { randomUUID } = require('crypto');
const db = require('../db/knex');
const PROJECT_ROOT = path.resolve(
__dirname,
'..',
);
const DEFAULT_DATABASE_PATH = path.join(
PROJECT_ROOT,
'api_v1',
'database.sqlite',
);
const DEFAULT_PYTHON_PATH = path.join(
PROJECT_ROOT,
'.venv-clustering',
'bin',
'python',
);
const HDBSCAN_SCRIPT_PATH = path.join(
PROJECT_ROOT,
'tools',
'clustering',
'face_hdbscan.py',
);
function parsePositiveInteger(
value,
optionName,
) {
const number = Number(value);
if (
!Number.isInteger(number)
|| number <= 0
) {
throw new Error(
`${optionName} deve essere un intero positivo`,
);
}
return number;
}
function parseArguments(argv) {
const options = {
user: '',
databasePath: DEFAULT_DATABASE_PATH,
pythonPath: DEFAULT_PYTHON_PATH,
minClusterSize: 2,
minSamples: 2,
metric: 'euclidean',
selectionMethod: 'leaf',
scope: 'all',
persist: false,
};
for (
let index = 0;
index < argv.length;
index += 1
) {
const argument = argv[index];
const value = argv[index + 1];
if (argument === '--user') {
if (!value) {
throw new Error(
'Valore mancante dopo --user',
);
}
options.user = String(value).trim();
index += 1;
continue;
}
if (argument === '--database') {
if (!value) {
throw new Error(
'Valore mancante dopo --database',
);
}
options.databasePath = path.resolve(value);
index += 1;
continue;
}
if (argument === '--python') {
if (!value) {
throw new Error(
'Valore mancante dopo --python',
);
}
options.pythonPath = path.resolve(value);
index += 1;
continue;
}
if (
argument === '--min-cluster-size'
) {
if (!value) {
throw new Error(
'Valore mancante dopo '
+ '--min-cluster-size',
);
}
options.minClusterSize =
parsePositiveInteger(
value,
'--min-cluster-size',
);
index += 1;
continue;
}
if (argument === '--min-samples') {
if (!value) {
throw new Error(
'Valore mancante dopo '
+ '--min-samples',
);
}
options.minSamples =
parsePositiveInteger(
value,
'--min-samples',
);
index += 1;
continue;
}
if (argument === '--metric') {
if (!value) {
throw new Error(
'Valore mancante dopo --metric',
);
}
options.metric = String(value).trim();
index += 1;
continue;
}
if (
argument === '--selection-method'
) {
if (
value !== 'eom'
&& value !== 'leaf'
) {
throw new Error(
'--selection-method deve essere '
+ 'eom oppure leaf',
);
}
options.selectionMethod = value;
index += 1;
continue;
}
if (argument === '--scope') {
if (
value !== 'all'
&& value !== 'unclustered'
) {
throw new Error(
'--scope deve essere '
+ 'all oppure unclustered',
);
}
options.scope = value;
index += 1;
continue;
}
if (argument === '--persist') {
options.persist = true;
continue;
}
if (
argument === '--help'
|| argument === '-h'
) {
options.help = true;
continue;
}
throw new Error(
`Argomento sconosciuto: ${argument}`,
);
}
if (!options.help && !options.user) {
throw new Error(
'--user è obbligatorio',
);
}
if (options.minClusterSize < 2) {
throw new Error(
'--min-cluster-size deve essere '
+ 'almeno 2',
);
}
if (
options.persist
&& options.scope !== 'all'
) {
throw new Error(
'La persistenza iniziale supporta '
+ 'soltanto --scope all',
);
}
return options;
}
function printHelp() {
console.log(`
Uso:
node tools/runFaceClustering.js \\
--user Fabio \\
[--min-cluster-size 2] \\
[--min-samples 2] \\
[--selection-method leaf] \\
[--metric euclidean] \\
[--scope all] \\
[--persist]
Comportamento:
Senza --persist:
esegue HDBSCAN e mostra il riepilogo,
senza modificare il database.
Con --persist:
salva run, cluster e membership
in una singola transazione SQLite.
`.trim());
}
function runPythonClustering(options) {
return new Promise((resolve, reject) => {
const argumentsList = [
HDBSCAN_SCRIPT_PATH,
'--database',
options.databasePath,
'--user',
options.user,
'--min-cluster-size',
String(options.minClusterSize),
'--min-samples',
String(options.minSamples),
'--metric',
options.metric,
'--selection-method',
options.selectionMethod,
'--scope',
options.scope,
];
const child = spawn(
options.pythonPath,
argumentsList,
{
cwd: PROJECT_ROOT,
stdio: [
'ignore',
'pipe',
'pipe',
],
},
);
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
reject(
new Error(
'Impossibile avviare Python: '
+ error.message,
),
);
});
child.on('close', (code, signal) => {
if (code !== 0) {
reject(
new Error(
'Clustering Python fallito'
+ ` (code=${code}, signal=${signal}):\n`
+ stderr.trim(),
),
);
return;
}
let result;
try {
result = JSON.parse(stdout);
} catch (error) {
reject(
new Error(
'Output JSON Python non valido: '
+ error.message
+ '\nOutput:\n'
+ stdout.slice(0, 2000),
),
);
return;
}
resolve(result);
});
});
}
function collectAssignments(result) {
const assignments = [];
for (
const cluster
of result.clusters || []
) {
for (
const member
of cluster.members || []
) {
assignments.push({
faceId: member.id,
label: Number(cluster.label),
probability:
Number(member.probability),
noiseFlag: 0,
});
}
}
for (
const member
of result.noise || []
) {
assignments.push({
faceId: member.id,
label: -1,
probability:
Number(member.probability || 0),
noiseFlag: 1,
});
}
return assignments;
}
function validateResult(
result,
options,
) {
if (
!result
|| typeof result !== 'object'
) {
throw new Error(
'Risultato clustering mancante',
);
}
const parameters =
result.parameters || {};
const summary =
result.summary || {};
if (parameters.user !== options.user) {
throw new Error(
'Utente risultato non coerente: '
+ `${parameters.user}`,
);
}
if (
Number(parameters.min_cluster_size)
!== options.minClusterSize
) {
throw new Error(
'min_cluster_size non coerente',
);
}
if (
Number(parameters.min_samples)
!== options.minSamples
) {
throw new Error(
'min_samples non coerente',
);
}
if (
parameters.cluster_selection_method
!== options.selectionMethod
) {
throw new Error(
'selection_method non coerente',
);
}
const totalFaces =
Number(summary.total_faces);
const clusteredFaces =
Number(summary.clustered_faces);
const noiseFaces =
Number(summary.noise_faces);
const clusterCount =
Number(summary.cluster_count);
if (
!Number.isInteger(totalFaces)
|| totalFaces < 2
) {
throw new Error(
'Numero totale volti non valido',
);
}
if (
clusteredFaces + noiseFaces
!== totalFaces
) {
throw new Error(
'Riepilogo incoerente: '
+ 'clustered + noise != total',
);
}
if (
!Array.isArray(result.clusters)
|| result.clusters.length
!== clusterCount
) {
throw new Error(
'Numero cluster non coerente',
);
}
const assignments =
collectAssignments(result);
if (
assignments.length !== totalFaces
) {
throw new Error(
'Numero membership non coerente',
);
}
const faceIds = new Set();
for (const assignment of assignments) {
if (!assignment.faceId) {
throw new Error(
'Membership senza face_id',
);
}
if (faceIds.has(assignment.faceId)) {
throw new Error(
'Volto duplicato nel risultato: '
+ assignment.faceId,
);
}
faceIds.add(assignment.faceId);
if (
!Number.isFinite(
assignment.probability,
)
|| assignment.probability < 0
|| assignment.probability > 1
) {
throw new Error(
'Probabilità non valida per '
+ assignment.faceId,
);
}
if (
assignment.noiseFlag === 1
&& assignment.label !== -1
) {
throw new Error(
'Noise con label differente da -1',
);
}
if (
assignment.noiseFlag === 0
&& assignment.label < 0
) {
throw new Error(
'Cluster con label negativa',
);
}
}
for (const cluster of result.clusters) {
const members =
cluster.members || [];
if (
Number(cluster.size)
!== members.length
) {
throw new Error(
'Dimensione cluster incoerente: '
+ cluster.label,
);
}
}
return {
assignments,
totalFaces,
clusteredFaces,
noiseFaces,
clusterCount,
};
}
async function validateDatabaseFaces(
trx,
options,
assignments,
) {
const faceIds = assignments.map(
(assignment) => assignment.faceId,
);
const rows = await trx('faces as f')
.join(
'photos as p',
'p.id',
'f.photo_id',
)
.where(
'p.user',
options.user,
)
.whereIn(
'f.id',
faceIds,
)
.select(
'f.id',
'f.embedding',
);
if (rows.length !== faceIds.length) {
const foundIds = new Set(
rows.map((row) => row.id),
);
const missingIds = faceIds.filter(
(faceId) => !foundIds.has(faceId),
);
throw new Error(
'Volti mancanti o appartenenti '
+ 'a un altro utente: '
+ missingIds.join(', '),
);
}
for (const row of rows) {
if (
!Buffer.isBuffer(row.embedding)
|| row.embedding.length !== 512
) {
throw new Error(
'Embedding non valido per '
+ row.id,
);
}
}
const allUserFaces = await trx(
'faces as f',
)
.join(
'photos as p',
'p.id',
'f.photo_id',
)
.where(
'p.user',
options.user,
)
.count({
count: 'f.id',
})
.first();
const totalUserFaces = Number(
allUserFaces?.count || 0,
);
if (
options.scope === 'all'
&& totalUserFaces !== faceIds.length
) {
throw new Error(
'La run scope=all non contiene '
+ 'tutti i volti dell’utente: '
+ `DB=${totalUserFaces}, `
+ `risultato=${faceIds.length}`,
);
}
}
async function persistResult(
result,
validation,
options,
) {
const runId = randomUUID();
const now = new Date().toISOString();
const clusterIdByLabel = new Map();
for (const cluster of result.clusters) {
clusterIdByLabel.set(
Number(cluster.label),
randomUUID(),
);
}
await db.transaction(async (trx) => {
await validateDatabaseFaces(
trx,
options,
validation.assignments,
);
await trx('face_cluster_runs')
.insert({
id: runId,
user: options.user,
status: 'processing',
scope: options.scope,
embedding_count:
validation.totalFaces,
min_cluster_size:
options.minClusterSize,
min_samples:
options.minSamples,
metric: options.metric,
selection_method:
options.selectionMethod,
engine: 'sklearn',
engine_version: null,
cluster_count: null,
clustered_faces: null,
noise_faces: null,
noise_percent: null,
last_error: null,
started_at: now,
completed_at: null,
created_at: now,
updated_at: now,
});
const clusterRows =
result.clusters.map(
(cluster) => ({
id: clusterIdByLabel.get(
Number(cluster.label),
),
run_id: runId,
label: Number(cluster.label),
size: Number(cluster.size),
min_samples:
options.minSamples,
min_cluster_size:
options.minClusterSize,
person_id: null,
created_at: now,
updated_at: now,
}),
);
if (clusterRows.length > 0) {
await trx('face_clusters')
.insert(clusterRows);
}
const membershipRows =
validation.assignments.map(
(assignment) => ({
id: randomUUID(),
run_id: runId,
cluster_id:
assignment.noiseFlag === 1
? null
: clusterIdByLabel.get(
assignment.label,
),
face_id: assignment.faceId,
label: assignment.label,
probability:
assignment.probability,
noise_flag:
assignment.noiseFlag,
review_status: 'suggested',
reviewed_by: null,
reviewed_at: null,
created_at: now,
updated_at: now,
}),
);
await trx(
'face_cluster_memberships',
).insert(membershipRows);
if (options.scope === 'all') {
await trx('faces as f')
.whereIn(
'f.id',
function selectUserFaceIds() {
this.select('f2.id')
.from('faces as f2')
.join(
'photos as p2',
'p2.id',
'f2.photo_id',
)
.where(
'p2.user',
options.user,
);
},
)
.update({
cluster_id: null,
status: 'detected',
updated_at: now,
});
}
for (
const assignment
of validation.assignments
) {
if (assignment.noiseFlag === 1) {
continue;
}
const clusterId =
clusterIdByLabel.get(
assignment.label,
);
if (!clusterId) {
throw new Error(
'Cluster ID mancante per label '
+ assignment.label,
);
}
const updated = await trx('faces')
.where({
id: assignment.faceId,
})
.update({
cluster_id: clusterId,
status: 'clustered',
updated_at: now,
});
if (updated !== 1) {
throw new Error(
'Impossibile aggiornare il volto '
+ assignment.faceId,
);
}
}
await trx('face_cluster_runs')
.where({
id: runId,
})
.update({
status: 'done',
cluster_count:
validation.clusterCount,
clustered_faces:
validation.clusteredFaces,
noise_faces:
validation.noiseFaces,
noise_percent:
Number(
result.summary.noise_percent,
),
completed_at: now,
updated_at: now,
});
});
return {
run_id: runId,
cluster_count:
validation.clusterCount,
clustered_faces:
validation.clusteredFaces,
noise_faces:
validation.noiseFaces,
};
}
function printSummary(
result,
validation,
options,
) {
console.log({
mode: options.persist
? 'persist'
: 'dry-run',
user: options.user,
parameters: result.parameters,
summary: result.summary,
validated: {
total_faces:
validation.totalFaces,
cluster_count:
validation.clusterCount,
clustered_faces:
validation.clusteredFaces,
noise_faces:
validation.noiseFaces,
unique_memberships:
validation.assignments.length,
},
});
}
async function main() {
const options = parseArguments(
process.argv.slice(2),
);
if (options.help) {
printHelp();
return;
}
const result =
await runPythonClustering(options);
const validation =
validateResult(
result,
options,
);
printSummary(
result,
validation,
options,
);
if (!options.persist) {
console.log(
'[faceClustering] dry-run completato. '
+ 'Database non modificato.',
);
return;
}
const persisted = await persistResult(
result,
validation,
options,
);
console.log(
'[faceClustering] run persistita:',
persisted,
);
}
main()
.catch((error) => {
console.error(
'[faceClustering] errore:',
error.stack
|| error.message
|| error,
);
process.exitCode = 1;
})
.finally(async () => {
await db.destroy();
});