"use strict"; const express = require("express"), Promise = require("bluebird"); let photoDB; require("../db/photos").then(function(db) { photoDB = db; }); const MIN_DISTANCE_COMMIT = 0.0001 const router = express.Router(); const upsertIdentity = async(id, { displayName, firstName, lastName, middleName }, res) => { if (displayName === undefined || firstName === undefined || lastName === undefined || middleName === undefined) { res.status(400).send({ message: `Missing fields` }); return undefined; } /* Create identity structure based on UnknownIdentity to set * default values for new identities */ const identity = { ...UnknownIdentity, ...{ displayName, firstName, lastName, middleName, identityId: id } }; if (id === -1 || !id) { const [, { lastID }] = await photoDB.sequelize.query( `INSERT INTO identities ` + '(displayName,firstName,lastName,middleName)' + 'VALUES(:displayName,:firstName,:lastName,:middleName)', { replacements: identity }); identity.identityId = lastID; console.log('Created identity: ', identity) } else { await photoDB.sequelize.query( `UPDATE identities ` + 'SET ' + 'displayName=:displayName, ' + 'firstName=:firstName, ' + 'lastName=:lastName, ' + 'middleName=:middleName ' + 'WHERE id=:identityId', { replacements: identity }); console.log('Updated identity: ', identity) } return identity; }; const populateRelatedFaces = async (identity, count) => { let limit = ''; if (count) { limit = ` LIMIT ${count} `; } /* If this is a new identity, no faces are being requested -- * just return the empty 'unknown face'. * * Otherwise, query the DB for 'count' faces */ if (count !== undefined) { identity.relatedFaces = await photoDB.sequelize.query( "SELECT id AS faceId,photoId,faceConfidence " + "FROM faces " + "WHERE identityId=:identityId " + "ORDER BY distance ASC" + limit, { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); } else { identity.relatedFaces = []; } /* If there are no faces, return the 'unknown face' */ if (identity.relatedFaces.length === 0) { identity.relatedFaces.push({ faceId: -1, photoId: -1, faceConfidence: 0 }); } identity.relatedFaces.forEach(face => { face.identityId = identity.identityId; face.distance = face.faceConfidence; face.descriptors = []; delete face.faceConfidence; }); } /* Create new identity */ router.post('/', async (req, res) => { console.log(`POST ${req.url}`) if (!req.user.maintainer) { console.warn(`${req.user.name} attempted to modify photos.`); return res.status(401).send({ message: "Unauthorized to modify photos." }); } const identity = await upsertIdentity(-1, req.body, res); if (!identity) { return; } updateIdentityFaces(identity); await populateRelatedFaces(identity, 1); return res.status(200).send(identity); }); /* Update identity */ router.put('/:id', async (req, res) => { console.log(`PUT ${req.url}`) if (!req.user.maintainer) { console.warn(`${req.user.name} attempted to modify photos.`); return res.status(401).send({ message: "Unauthorized to modify photos." }); } const { id } = req.params; if (!id || isNaN(+id)) { return res.status(400).send({ message: `Invalid identity id ${id}` }); } const identity = await upsertIdentity(id, req.body, res); if (!identity) { return; } await updateIdentityFaces(identity); await populateRelatedFaces(identity); return res.status(200).send(identity); }); router.delete('/:id', async (req, res) => { console.log(`DELETE ${req.url}`) if (!req.user.maintainer) { console.warn(`${req.user.name} attempted to modify photos.`); return res.status(401).send({ message: "Unauthorized to modify photos." }); } const { id } = req.params; if (!id || isNaN(+id)) { return res.status(400).send({ message: `Invalid identity id ${id}` }); } await photoDB.sequelize.query( 'UPDATE faces SET distance=0,identityId=NULL ' + 'WHERE identityId=:id', { replacements: { id } } ); await photoDB.sequelize.query( 'DELETE FROM identities ' + 'WHERE id=:id', { replacements: { id } }); return res.status(200).send({}); }); /* Given a faceId, find the closest defined identity and return * it as a guess -- does not modify the DB */ router.get("/faces/guess/:faceId", async (req, res) => { const faceId = parseInt(req.params.faceId); if (faceId != req.params.faceId) { return res.status(400).send({ message: "Invalid identity id." }); } try { /* Look up the requested face... */ const faces = await photoDB.sequelize.query( "SELECT faces.*,faceDescriptors.descriptors " + "FROM faces,faceDescriptors " + "WHERE faces.id=:faceId " + "AND faceDescriptors.id=faces.descriptorId", { replacements: { faceId }, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); /* Look up all the identities... */ const identities = await photoDB.sequelize.query( "SELECT * FROM identities", { type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); identities.forEach(x => { x.identityId = x.id; delete x.id;}); faces.forEach((face) => { const currentIdentityId = face.identityId; face.faceId = face.id; delete face.id; face.identity = null; identities.forEach((identity) => { if (!identity.descriptors || currentIdentityId === identity.identityId) { return; } const distance = euclideanDistance( face.descriptors, identity.descriptors ); if (face.identity === null) { face.identityId = identity.identityId; face.identity = identity; face.distance = distance; return; } if (distance < face.distance) { face.identityId = identity.identityId; face.identity = identity; face.distance = distance; } }); }); /* Delete the VGG-Face descriptors and add relatedFaces[0] */ await Promise.map(faces, async (face) => { delete face.descriptors; if (face.identity.descriptors) { delete face.identity.descriptors; } if (!face.identity.relatedFaces) { await populateRelatedFaces(face.identity, 1); } }, { concurrency: 1 }); return res.status(200).json(faces); } catch (error) { console.error(error); return res.status(500).send("Error processing request."); }; }); router.put("/faces/remove/:identityId", async (req, res) => { console.log(`PUT ${req.url}`) if (!req.user.maintainer) { console.warn(`${req.user.name} attempted to modify photos.`); return res.status(401).send({ message: "Unauthorized to modify photos." }); } const identityId = parseInt(req.params.identityId); if (identityId != req.params.identityId) { return res.status(400).send({ message: "Invalid identity id." }); } if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) { return res.status(400).send({ message: "No faces supplied." }); } /* Convert faces array to numbers and filter any non-numbers */ let faceIds = req.body.faces .map(faceId => +faceId) .filter(faceId => !isNaN(faceId)); try { await photoDB.sequelize.query( "UPDATE faces SET identityId=null " + "WHERE id IN (:faceIds)", { replacements: { identityId, faceIds } }); const identity = { identityId, removed: faceIds }; /* If the primary faceId was removed, update the identity's faceId * to a new faceId */ const identityFaceIds = await photoDB.sequelize.query(` SELECT faceId AS identityFaceId FROM identities WHERE id=:identityId`, { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); if (identity.removed.indexOf(identityFaceIds[0]) !== -1) { const newFaceId = await photoDB.sequelize.query(` SELECT faceId FROM faces WHERE identityId=:identityId ORDER BY distance ASC LIMIT 1`, { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true } ); if (newFaceId.length === 0) { identity.faceId = -1; } else { identity.faceId = newFaceId[0]; } await photoDB.sequelize.query(` UPDATE identities SET faceId=${identity.faceId} WHERE id=${identity.identityId} `); console.log( `New faceId for ${identity.identityId} set to ${identity.faceId}.`); } /* Do not block on this call finishing -- update can occur * in the background */ updateIdentityFaces(identity); return res.status(200).json(identity); } catch (error) { console.error(error); return res.status(500).send({message: "Error processing request." }); }; }); router.put("/faces/add/:id", async (req, res) => { if (!req.user.maintainer) { console.warn(`${req.user.name} attempted to modify photos.`); return res.status(401).send("Unauthorized to modify photos."); } const identityId = parseInt(req.params.id); if (identityId != req.params.id) { return res.status(400).send("Invalid identity id."); } if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) { return res.status(400).send({ message: "No faces supplied." }); } /* Convert faces array to numbers and filter any non-numbers */ let faceIds = req.body.faces .map(faceId => +faceId) .filter(faceId => !isNaN(faceId)); /* See which identities currently have these faces (if any) */ let tuples = await photoDB.sequelize.query(` SELECT faces.identityId, faces.id AS faceId FROM faces WHERE faces.id IN (:faceIds)`, { replacements: { identityId, faceIds }, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true } ); /* Filter out any faces which are already owned by this identity */ tuples = tuples .filter(tuple => tuple.identityId !== identityId); if (tuples.length === 0) { return res.status(400).json({ message: 'No faceIds provided not owned by identity.' }); } /* Obtain faceId from all referenced identities (src and dsts) */ const identityIds = [ identityId, ...tuples .filter(tuple => tuple.identityId !== null && tuple.identityId != -1) .map(tuple => tuple.identityId) ]; let identities = await photoDB.sequelize.query(` SELECT id AS identityId, faceId AS identityFaceId FROM identities WHERE id IN (:identityIds)`, { replacements: { identityIds }, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true } ); /* Find the src identity */ const identity = identities.find(tuple => tuple.identityId === identityId); /* Merge the identities and the tuples, first filtering out the src * identity */ tuples = tuples .filter(tuple => tuple.identityId !== identityId) .map(tuple => { return { faceId: tuple.faceId, identityId: tuple.identityId, identityFaceId: (tuple.identityId === null || tuple.identityId === -1) ? -1 : identities .find(identity => tuple.identityId === identity.identityId) .identityFaceId } }); console.log({ dst: identity, src: tuples }); console.log(`Need new faceId: `, tuples.filter(tuple => tuple.faceId === tuple.identityFaceId)); try { await photoDB.sequelize.query( "UPDATE faces SET identityId=:identityId,classifiedBy='human' " + "WHERE id IN (:faceIds)", { replacements: { identityId, faceIds } }); identity.added = faceIds; identity.faceId = identity.identityFaceId; delete identity.identityFaceId; if (identity.faceId === -1 || identity.faceId === null) { identity.faceId = faceIds[0]; } /* Do not block on this call finishing -- update can occur * in the background */ Promise.map([identity, ...tuples], (x, i) => { try { updateIdentityFaces(x); } catch (error) { console.log(i, x); throw error; } }, { concurrency: 1 }); return res.status(200).json(identity); } catch (error) { console.error(error); return res.status(500).send("Error processing request."); }; }); function bufferToFloat32Array(buffer) { return new Float64Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / Float64Array.BYTES_PER_ELEMENT); } function euclideanDistanceArray(a, b) { let sum = 0; for (let i = 0; i < a.length; i++) { let delta = a[i] - b[i]; sum += delta * delta; } return Math.sqrt(sum); } function euclideanDistance(a, b) { if (!a.buffer || !b.buffer) { return -1; } return euclideanDistanceArray( bufferToFloat32Array(a), bufferToFloat32Array(b) ); } const UnknownFace = { faceId: -1, identityId: -1, photoId: -1, distance: 0, faceConfidence: 0 }; const UnknownIdentity = { identityId: -1, lastName: '', firstName: '', middleName: '', displayName: 'Unknown', descriptors: new Float32Array(0), relatedFaces: [ UnknownFace ], facesCount: 0, faceId: -1 }; const getUnknownIdentity = async (faceCount) => { const unknownIdentity = { ...UnknownIdentity }; const limit = faceCount ? ` ORDER BY RANDOM() LIMIT ${faceCount} ` : ' ORDER BY faceConfidence DESC '; const sql = ` SELECT faces.id AS faceId, faces.photoId, faces.faceConfidence, total.facesCount FROM faces, (SELECT COUNT(total.id) AS facesCount FROM faces AS total WHERE total.identityId IS NULL AND total.classifiedBy != 'not-a-face' AND total.classifiedBy != 'forget') AS total WHERE faces.identityId IS NULL AND faces.classifiedBy != 'not-a-face' AND total.classifiedBy != 'forget' ${ limit }`; unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, { type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); if (unknownIdentity.relatedFaces.length !== 0) { unknownIdentity.facesCount = unknownIdentity.relatedFaces[0].facesCount; } unknownIdentity.relatedFaces.forEach(face => { face.identityId = -1; face.distance = face.faceConfidence; face.descriptors = new Float32Array(0); delete face.faceConfidence; delete face.facesCount; }); return unknownIdentity; } const round = (x, precision) => { if (precision === undefined) { precision = 2; } return Number.parseFloat(x).toFixed(precision); }; /* Compute the identity's centroid descriptor from all faces * and determine closest face to that centroid. If either of * those values have changed, update the identity. * * Also updates the 'distance' on each face to the identity * centroid. */ const updateIdentityFaces = async (identity) => { if (!identity.identityId) { throw Error(`identityId is not set.`); } if (!identity.descriptors) { const results = await photoDB.sequelize.query( "SELECT " + "descriptors,facesCount,faceId,displayName " + "FROM identities " + "WHERE id=:identityId", { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); Object.assign(identity, results[0]); /* New identities do not have descriptors set */ } if (identity.descriptors) { identity.descriptors = bufferToFloat32Array(identity.descriptors); } const faces = await photoDB.sequelize.query( "SELECT " + "faces.*,faceDescriptors.* " + "FROM faces,faceDescriptors " + "WHERE " + "faces.identityId=:identityId " + "AND faceDescriptors.id=faces.descriptorId", { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); if (faces.length === 0) { return; } let average = undefined; /* First find the average centroid of all faces */ faces.forEach((face) => { /* Convert the descriptors from buffer to array so they can be * modified */ face.descriptors = bufferToFloat32Array(face.descriptors); /* First face starts the average sum */ if (average === undefined) { average = face.descriptors.slice(); return; } /* Add this face descriptor into the average descriptor */ for (let i = 0; i < face.descriptors.length; i++) { average[i] = average[i] + face.descriptors[i]; }; }); /* Divide sum of descriptors to create average centroid */ for (let i = 0; i < average.length; i++) { average[i] = average[i] / faces.length; } let closestId = -1, closestDistance = -1; /* Now compute the distance from each face to the new centroid */ faces.forEach((face) => { face.updatedDistance = euclideanDistanceArray( face.descriptors, average ); if (closestId === -1 || face.updatedDistance < closestDistance) { closestDistance = face.updatedDistance; closestId = face.id; } }); /* Determine if the centroid for this identity has moved * and for each relatedFace, update its distance to the centroid */ if (!identity.descriptors) { console.log(`Identity ${identity.identityId} has no descriptors`); } let moved = (identity.descriptors === null ? 1 : 0) || Number .parseFloat(euclideanDistanceArray(identity.descriptors, average)) .toFixed(4); const t = await photoDB.sequelize.transaction(); try { /* If the average position has not changed, then face distances should * not change either! * * Do not update all the faces unless the centroid has moved a fair * amount */ if (Math.abs(moved) > MIN_DISTANCE_COMMIT) { await Promise.map(faces, async (face) => { /* All the buffer are already arrays, so use the short-cut version */ const distance = Number .parseFloat(face.updatedDistance) .toFixed(4); if (Math.abs(face.updatedDistance - face.distance) > MIN_DISTANCE_COMMIT) { console.log( `Updating face ${face.id} to ${round(distance, 2)} ` + `(${distance - face.distance}) ` + `from identity ${identity.identityId} (${identity.displayName})`); face.distance = face.updatedDistance; delete face.updatedDistance; await photoDB.sequelize.query( 'UPDATE faces SET distance=:distance WHERE id=:id', { replacements: face, transaction: t } ); } }, { concurrency: 1 }); } let sql = ''; /* If there is a new closestId, then set the faceId field */ if (closestId !== -1 && closestId !== identity.faceId) { console.log( `Updating identity ${identity.identityId} closest face to ${closestId}`); sql = `${sql} faceId=:faceId`; identity.faceId = closestId; } /* If the centroid changed, update the identity descriptors to * the new average */ if (Math.abs(moved) > MIN_DISTANCE_COMMIT) { console.log( `Updating identity ${identity.identityId} centroid ` + `(moved ${Number.parseFloat(moved).toFixed(4)}).`); if (sql !== '') { sql = `${sql}, `; } sql = `${sql} descriptors=:descriptors`; // this: identity.descriptors = average; // gives: Invalid value Float64Array(2622) // // this: identity.descriptors = new Blob(average); // gives: Invalid value Blob { size: 54008, type: '' } // // this: identity.descriptors = Buffer.from(average); // gives: all zeroes // // this: identity.descriptors = Buffer.from(average.buffer); // gives: IT WORKS!!! identity.descriptors = Buffer.from(average.buffer); } /* If the number of faces changed, update the facesCount */ if (identity.facesCount !== faces.length) { if (sql !== '') { sql = `${sql}, `; } console.log( `Updating identity ${identity.identityId} face count to ${faces.length}`); identity.facesCount = faces.length; sql = `${sql} facesCount=${faces.length}`; } /* If any of the above required changes, actually commit to the DB */ if (sql !== '') { await photoDB.sequelize.query( `UPDATE identities SET ${sql} ` + `WHERE id=:identityId`, { replacements: identity, transaction: t } ); } t.commit(); } catch (error) { console.error(error); t.rollback(); return; } }; router.get("/:id?", async (req, res) => { console.log(`GET ${req.url}`); let id; if (req.params.id) { id = parseInt(req.params.id); if (id != req.params.id) { return res.status(400).send({ message: "Usage /[id]"}); } } /* If identityId requested is -1, this is the "Unknown" identity * where all unmapped faces live. */ if (id === -1) { const unknownIdentity = await getUnknownIdentity() return res.status(200).json([ unknownIdentity ]); } const filter = id ? "WHERE identities.id=:id " : ""; const identities = await photoDB.sequelize.query( "SELECT " + "identities.id AS identityId," + "identities.firstName," + "identities.lastName," + "identities.middleName," + "identities.displayName," + "identities.faceId," + "identities.facesCount " + "FROM identities " + filter, { replacements: { id }, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); await Promise.map(identities, async (identity) => { for (let field in identity) { if (field.match(/.*Name/) && identity[field] === null) { identity[field] = ''; } } let where, limit = ''; /* If id was not set, only return a single face */ if (id === undefined) { if (identity.faceId !== -1 && identity.faceId !== null) { /* Return the identity faceId, and make sure the face * is associated with that id -- they can get out of sync * when faces are added/removed from an identity */ where = 'faceId=:faceId AND identityId=:identityId'; } else { where = 'identityId=:identityId'; limit = 'LIMIT 1'; } } else { where = 'identityId=:identityId' } identity.relatedFaces = await photoDB.sequelize.query( 'SELECT id as faceId,identityId,photoId,distance ' + 'FROM faces ' + `WHERE ${where} ` + 'ORDER BY distance ASC ' + limit, { replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true } ); /* If this identity has at least one face associated with it, * and it does not yet have the 'closest' face assigned, update * the identity statistics. */ if (identity.relatedFaces.length !== 0 && (!identity.faceId || identity.faceId === -1)) { await updateIdentityFaces(identity); } /* If there were no faces, then add a 'Unknown' face */ if (identity.relatedFaces.length === 0) { identity.relatedFaces.push({ ...UnknownFace, ...{ identityId: identity.identityId } }); } }, { concurrency: 1 }); /* If no ID was provided then this call is returning * a list of all identities -- we create a fake identity for all * unlabeled faces */ if (!id) { const unknownIdentity = await getUnknownIdentity(1) identities.push(unknownIdentity); } return res.status(200).json(identities); }); module.exports = router;