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;