ketr.photos/server/routes/identities.js
James Ketrenos b86c12fc92 Can now create blank identities
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-25 13:43:43 -08:00

703 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;
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/: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);
const t = await photoDB.sequelize.transaction();
try {
/* 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,
transaction: t
}
);
}
}, {
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,
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({
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;