700 lines
19 KiB
JavaScript
Executable File
700 lines
19 KiB
JavaScript
Executable File
"use strict";
|
|
|
|
const express = require("express"),
|
|
Promise = require("bluebird");
|
|
|
|
let photoDB;
|
|
|
|
require("../db/photos").then(function(db) {
|
|
photoDB = db;
|
|
});
|
|
|
|
const MIN_DISTANCE_COMMIT = 0.0000001
|
|
|
|
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;
|
|
}
|
|
|
|
const identity = {
|
|
displayName,
|
|
firstName,
|
|
lastName,
|
|
middleName,
|
|
identityId: id
|
|
};
|
|
|
|
if (id === -1 || !id) {
|
|
const [results, { lastId }] = await photoDB.sequelize.query(
|
|
`INSERT INTO identities ` +
|
|
'(displayName,firstName,lastName,middleName)' +
|
|
'VALUES(:displayName,:firstName,:lastName,:middleName)', {
|
|
replacements: identity
|
|
});
|
|
identity.identityId = lastId;
|
|
} else {
|
|
await photoDB.sequelize.query(
|
|
`UPDATE identities ` +
|
|
'SET ' +
|
|
'displayName=:displayName, ' +
|
|
'firstName=:firstName, ' +
|
|
'lastName=:lastName, ' +
|
|
'middleName=:middleName ' +
|
|
'WHERE id=:identityId', {
|
|
replacements: 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({});
|
|
});
|
|
|
|
|
|
|
|
const writeIdentityDescriptors = async (identity) => {
|
|
await photoDB.sequelize.query(
|
|
'UPDATE identities ' +
|
|
'SET descriptors=:descriptors' +
|
|
'WHERE id=:identityId', {
|
|
replacements: identity
|
|
}
|
|
);
|
|
};
|
|
|
|
|
|
/* 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/: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 = parseInt(req.params.id);
|
|
if (id != req.params.id) {
|
|
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." });
|
|
}
|
|
|
|
try {
|
|
await photoDB.sequelize.query(
|
|
"UPDATE faces SET identityId=null " +
|
|
"WHERE id IN (:faceIds)", {
|
|
replacements: {
|
|
identityId: id,
|
|
faceIds: req.body.faces
|
|
}
|
|
});
|
|
const identity = {
|
|
identityId: id,
|
|
faces: req.body.faces
|
|
};
|
|
identity.faces = identity.faces.map(id => +id);
|
|
|
|
/* 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 id = parseInt(req.params.id);
|
|
if (id != 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("No faces supplied.");
|
|
}
|
|
|
|
try {
|
|
await photoDB.sequelize.query(
|
|
"UPDATE faces SET identityId=:identityId,classifiedBy='human' " +
|
|
"WHERE id IN (:faceIds)", {
|
|
replacements: {
|
|
identityId: id,
|
|
faceIds: req.body.faces
|
|
}
|
|
});
|
|
const identity = {
|
|
identityId: id,
|
|
faces: req.body.faces
|
|
};
|
|
identity.faces = identity.faces.map(id => +id);
|
|
|
|
/* 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("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 getUnknownIdentity = async (faceCount) => {
|
|
const unknownIdentity = {
|
|
identityId: -1,
|
|
lastName: '',
|
|
firstName: '',
|
|
middleName: '',
|
|
displayName: 'Unknown',
|
|
descriptors: new Float32Array(0),
|
|
relatedFaces: [],
|
|
facesCount: 0
|
|
};
|
|
const limit = faceCount
|
|
? ` ORDER BY RANDOM() LIMIT ${faceCount} `
|
|
: ' ORDER BY faceConfidence DESC ';
|
|
unknownIdentity.relatedFaces = await photoDB.sequelize.query(
|
|
"SELECT id AS faceId,photoId,faceConfidence " +
|
|
"FROM faces WHERE identityId IS NULL AND classifiedBy != 'not-a-face' " +
|
|
limit, {
|
|
type: photoDB.Sequelize.QueryTypes.SELECT,
|
|
raw: true
|
|
});
|
|
if (unknownIdentity.relatedFaces.length === 0) {
|
|
unknownIdentity.relatedFaces.push({
|
|
faceId: -1,
|
|
photoId: -1,
|
|
faceConfidence: 0
|
|
});
|
|
}
|
|
unknownIdentity.relatedFaces.forEach(face => {
|
|
face.identityId = -1;
|
|
face.distance = face.faceConfidence;
|
|
face.descriptors = new Float32Array(0);
|
|
delete face.faceConfidence;
|
|
});
|
|
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,
|
|
closestId = -1,
|
|
closestDistance = -1;
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Now compute the distance from each face to the new centroid */
|
|
faces.forEach((face) => {
|
|
let distance;
|
|
|
|
distance = euclideanDistanceArray(
|
|
face.descriptors,
|
|
average
|
|
);
|
|
|
|
if (closestId === -1 || face.distance < closestDistance) {
|
|
closestDistance = distance;
|
|
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);
|
|
|
|
/* If the average position has not changed, then face distances should
|
|
* not change either! */
|
|
await Promise.map(faces, async (face) => {
|
|
/* All the buffer are already arrays, so use the short-cut version */
|
|
const distance = Number
|
|
.parseFloat(euclideanDistanceArray(face.descriptors, average))
|
|
.toFixed(4);
|
|
|
|
if (Math.abs(distance - 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 = distance;
|
|
await photoDB.sequelize.query(
|
|
'UPDATE faces SET distance=:distance WHERE id=:id', {
|
|
replacements: face
|
|
}
|
|
);
|
|
}
|
|
}, {
|
|
concurrency: 5
|
|
});
|
|
|
|
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
|
|
}
|
|
);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
where = 'faceId=:faceId';
|
|
} 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({
|
|
faceId: -1,
|
|
photoId: -1,
|
|
identityId: identity.identityId,
|
|
distance: 0,
|
|
faceConfidence: 0
|
|
});
|
|
}
|
|
}, {
|
|
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;
|