galleria_photo_face_rknn_op.../routes/faceReview.js.before_person_assignment
2026-10-03 12:16:58 +02:00

794 lines
14 KiB
Text

const express = require('express');
const sharp = require('sharp');
const db = require('../db/knex');
const {
resolveFilePathForEntryId,
} = require('../utils/resolveFilePath');
const {
createHttpError,
getAuthenticatedUser,
isFaceReviewAdmin,
resolveFaceReviewUser,
} = require('../middleware/faceReviewUser');
const router = express.Router();
const DEFAULT_THUMBNAIL_SIZE = 192;
const MIN_THUMBNAIL_SIZE = 64;
const MAX_THUMBNAIL_SIZE = 512;
const FACE_MARGIN_FACTOR = 0.25;
function parseThumbnailSize(value) {
if (value == null || value === '') {
return DEFAULT_THUMBNAIL_SIZE;
}
const size = Number(value);
if (
!Number.isInteger(size)
|| size < MIN_THUMBNAIL_SIZE
|| size > MAX_THUMBNAIL_SIZE
) {
throw createHttpError(
400,
`size deve essere compreso tra `
+ `${MIN_THUMBNAIL_SIZE} e `
+ `${MAX_THUMBNAIL_SIZE}`,
);
}
return size;
}
function normalizeRotation(value) {
let rotation = Number(value || 0);
if (!Number.isInteger(rotation)) {
throw createHttpError(
500,
`Rotazione foto non valida: ${value}`,
);
}
rotation %= 360;
if (rotation < 0) {
rotation += 360;
}
if (
rotation !== 0
&& rotation !== 90
&& rotation !== 180
&& rotation !== 270
) {
throw createHttpError(
500,
`Rotazione foto non supportata: ${value}`,
);
}
return rotation;
}
function calculateSquareCrop(
face,
imageWidth,
imageHeight,
) {
const faceX = Number(face.x);
const faceY = Number(face.y);
const faceWidth = Number(face.width);
const faceHeight = Number(face.height);
if (
!Number.isFinite(faceX)
|| !Number.isFinite(faceY)
|| !Number.isFinite(faceWidth)
|| !Number.isFinite(faceHeight)
|| faceWidth <= 0
|| faceHeight <= 0
) {
throw createHttpError(
500,
'Bounding box del volto non valida',
);
}
const centreX =
faceX + faceWidth / 2;
const centreY =
faceY + faceHeight / 2;
const largestSide = Math.max(
faceWidth,
faceHeight,
);
let cropSize = Math.ceil(
largestSide
* (1 + FACE_MARGIN_FACTOR * 2),
);
cropSize = Math.max(
1,
Math.min(
cropSize,
imageWidth,
imageHeight,
),
);
let left = Math.round(
centreX - cropSize / 2,
);
let top = Math.round(
centreY - cropSize / 2,
);
left = Math.max(
0,
Math.min(
left,
imageWidth - cropSize,
),
);
top = Math.max(
0,
Math.min(
top,
imageHeight - cropSize,
),
);
return {
left,
top,
width: cropSize,
height: cropSize,
};
}
async function ensureTargetUserExists(user) {
const run = await db('face_cluster_runs')
.where({ user })
.first('id');
if (run) {
return;
}
const photo = await db('photos')
.where({ user })
.first('id');
if (!photo) {
throw createHttpError(
404,
`Utente non trovato: ${user}`,
);
}
}
async function getLatestCompletedRun(user) {
return db('face_cluster_runs')
.where({
user,
status: 'done',
})
.orderBy('completed_at', 'desc')
.orderBy('created_at', 'desc')
.first();
}
function buildFaceResponse(
face,
targetUser,
) {
const faceId = encodeURIComponent(
face.face_id,
);
return {
id: face.face_id,
photo_id: face.photo_id,
photo_name: face.photo_name,
photo_path: face.photo_path,
face_index: Number(face.face_index),
detector_score:
Number(face.detector_score),
probability:
face.probability == null
? null
: Number(face.probability),
review_status:
face.review_status ?? null,
person_id:
face.person_id ?? null,
rotation:
Number(face.rotation || 0),
thumbnail_url:
`/face-review/faces/${faceId}`
+ '/thumbnail',
};
}
async function loadClusterFaces(
runId,
clusterId,
targetUser,
) {
const rows = await db(
'face_cluster_memberships as m',
)
.join(
'faces as f',
'f.id',
'm.face_id',
)
.join(
'photos as p',
'p.id',
'f.photo_id',
)
.join(
'face_cluster_runs as r',
'r.id',
'm.run_id',
)
.where({
'm.run_id': runId,
'm.cluster_id': clusterId,
'r.user': targetUser,
'p.user': targetUser,
})
.select(
'f.id as face_id',
'f.photo_id',
'f.face_index',
'f.score as detector_score',
'f.person_id',
'p.name as photo_name',
'p.path as photo_path',
'p.rotation',
'm.probability',
'm.review_status',
)
.orderBy('m.probability', 'desc')
.orderBy('p.name', 'asc')
.orderBy('f.face_index', 'asc');
return rows.map(
(row) => buildFaceResponse(
row,
targetUser,
),
);
}
router.get('/context', async (req, res) => {
try {
const authenticatedUser =
getAuthenticatedUser(req);
const targetUser =
resolveFaceReviewUser(req);
await ensureTargetUserExists(
targetUser,
);
const latestRun =
await getLatestCompletedRun(
targetUser,
);
res.json({
authenticated_user:
authenticatedUser,
target_user:
targetUser,
is_admin:
isFaceReviewAdmin(req),
can_select_user:
isFaceReviewAdmin(req),
latest_run_id:
latestRun?.id ?? null,
});
} catch (error) {
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
});
router.get('/runs', async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
await ensureTargetUserExists(
targetUser,
);
const rows = await db(
'face_cluster_runs',
)
.where({ user: targetUser })
.orderBy('created_at', 'desc')
.select(
'id',
'user',
'status',
'scope',
'embedding_count',
'min_cluster_size',
'min_samples',
'metric',
'selection_method',
'engine',
'engine_version',
'cluster_count',
'clustered_faces',
'noise_faces',
'noise_percent',
'started_at',
'completed_at',
'created_at',
);
res.json({
user: targetUser,
runs: rows,
});
} catch (error) {
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
});
router.get('/clusters', async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
await ensureTargetUserExists(
targetUser,
);
const requestedRunId = String(
req.query.run_id || '',
).trim();
const run = requestedRunId
? await db('face_cluster_runs')
.where({
id: requestedRunId,
user: targetUser,
})
.first()
: await getLatestCompletedRun(
targetUser,
);
if (!run) {
return res.json({
user: targetUser,
run: null,
clusters: [],
});
}
const clusterRows = await db(
'face_clusters as c',
)
.leftJoin(
'persons as person',
'person.id',
'c.person_id',
)
.where({
'c.run_id': run.id,
})
.select(
'c.id',
'c.run_id',
'c.label',
'c.size',
'c.min_samples',
'c.min_cluster_size',
'c.person_id',
'person.name as person_name',
'c.created_at',
'c.updated_at',
)
.orderBy('c.label', 'asc');
const clusters = [];
for (const cluster of clusterRows) {
const faces = await loadClusterFaces(
run.id,
cluster.id,
targetUser,
);
clusters.push({
...cluster,
label: Number(cluster.label),
size: Number(cluster.size),
faces,
});
}
res.json({
user: targetUser,
run,
clusters,
});
} catch (error) {
console.error(
'[faceReview] GET /clusters:',
error,
);
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
});
router.get(
'/clusters/:clusterId',
async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
const clusterId = String(
req.params.clusterId || '',
).trim();
const cluster = await db(
'face_clusters as c',
)
.join(
'face_cluster_runs as r',
'r.id',
'c.run_id',
)
.leftJoin(
'persons as person',
'person.id',
'c.person_id',
)
.where({
'c.id': clusterId,
'r.user': targetUser,
})
.first(
'c.id',
'c.run_id',
'c.label',
'c.size',
'c.min_samples',
'c.min_cluster_size',
'c.person_id',
'person.name as person_name',
'c.created_at',
'c.updated_at',
);
if (!cluster) {
throw createHttpError(
404,
'Cluster non trovato',
);
}
const faces = await loadClusterFaces(
cluster.run_id,
cluster.id,
targetUser,
);
res.json({
user: targetUser,
cluster: {
...cluster,
label: Number(cluster.label),
size: Number(cluster.size),
faces,
},
});
} catch (error) {
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
},
);
router.get(
'/unclustered',
async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
const run =
await getLatestCompletedRun(
targetUser,
);
if (!run) {
return res.json({
user: targetUser,
run: null,
faces: [],
});
}
const rows = await db(
'face_cluster_memberships as m',
)
.join(
'faces as f',
'f.id',
'm.face_id',
)
.join(
'photos as p',
'p.id',
'f.photo_id',
)
.join(
'face_cluster_runs as r',
'r.id',
'm.run_id',
)
.where({
'm.run_id': run.id,
'm.noise_flag': 1,
'r.user': targetUser,
'p.user': targetUser,
})
.select(
'f.id as face_id',
'f.photo_id',
'f.face_index',
'f.score as detector_score',
'f.person_id',
'p.name as photo_name',
'p.path as photo_path',
'p.rotation',
'm.probability',
'm.review_status',
)
.orderBy('p.name', 'asc')
.orderBy('f.face_index', 'asc');
res.json({
user: targetUser,
run,
faces: rows.map(
(row) => buildFaceResponse(
row,
targetUser,
),
),
});
} catch (error) {
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
},
);
router.get('/persons', async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
const persons = await db('persons')
.where({ user: targetUser })
.orderBy('name', 'asc')
.select(
'id',
'user',
'name',
'created_at',
'updated_at',
);
res.json({
user: targetUser,
persons,
});
} catch (error) {
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
});
router.get(
'/faces/:faceId/thumbnail',
async (req, res) => {
try {
const targetUser =
resolveFaceReviewUser(req);
const faceId = String(
req.params.faceId || '',
).trim();
const requestedSize =
parseThumbnailSize(
req.query.size,
);
const face = await db('faces as f')
.join(
'photos as p',
'p.id',
'f.photo_id',
)
.where({
'f.id': faceId,
'p.user': targetUser,
})
.whereNull('p.deleted_at')
.first(
'f.id',
'f.photo_id',
'f.x',
'f.y',
'f.width',
'f.height',
'p.rotation',
'p.name',
);
if (!face) {
throw createHttpError(
404,
'Volto non trovato',
);
}
const filePath =
await resolveFilePathForEntryId(
face.photo_id,
);
if (!filePath) {
throw createHttpError(
404,
'File originale non trovato',
);
}
const rotation =
normalizeRotation(
face.rotation,
);
const sourceMetadata =
await sharp(filePath)
.metadata();
const sourceWidth =
Number(sourceMetadata.width);
const sourceHeight =
Number(sourceMetadata.height);
if (
!Number.isInteger(sourceWidth)
|| sourceWidth <= 0
|| !Number.isInteger(sourceHeight)
|| sourceHeight <= 0
) {
throw createHttpError(
500,
'Dimensioni immagine non valide',
);
}
const rotatedWidth =
rotation === 90
|| rotation === 270
? sourceHeight
: sourceWidth;
const rotatedHeight =
rotation === 90
|| rotation === 270
? sourceWidth
: sourceHeight;
const crop =
calculateSquareCrop(
face,
rotatedWidth,
rotatedHeight,
);
const buffer = await sharp(
filePath,
{
failOn: 'none',
},
)
.rotate(rotation)
.extract(crop)
.resize({
width: requestedSize,
height: requestedSize,
fit: 'cover',
position: 'centre',
withoutEnlargement: false,
})
.webp({
quality: 82,
effort: 4,
})
.toBuffer();
res.set({
'Content-Type': 'image/webp',
'Content-Length':
String(buffer.length),
'Cache-Control':
'private, max-age=3600',
'X-Content-Type-Options':
'nosniff',
});
res.send(buffer);
} catch (error) {
console.error(
'[faceReview] thumbnail error:',
error,
);
res.status(error.status || 500).json({
ok: false,
error: error.message,
});
}
},
);
module.exports = router;