Add/Remove while updating faces is *mostly* working -- it doesn't update the faceId in Identities though.

Signed-off-by: James Ketrenos <james_git@ketrenos.com>
This commit is contained in:
James Ketr 2023-01-25 19:55:59 -08:00
parent b86c12fc92
commit c4a6b6dad4
2 changed files with 421 additions and 150 deletions

View File

@ -1,6 +1,5 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useApi } from './useApi';
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { import {
BrowserRouter as Router, BrowserRouter as Router,
@ -224,20 +223,42 @@ const Cluster = ({
setSelected, setSelected,
setImage, setImage,
}: ClusterProps) => { }: ClusterProps) => {
const [lastName, setLastName] = useState<string>(identity.lastName);
const [firstName, setFirstName] = useState<string>(identity.firstName);
const [middleName, setMiddleName] = useState<string>(identity.middleName);
const [displayName, setDisplayName] = useState<string>(identity.displayName);
const [updated, setUpdated] = useState<boolean>(false);
const lastNameChanged = (e: any) => { const lastNameChanged = (e: any) => {
setIdentity({...identity, lastName: e.currentTarget.value }); setLastName(e.currentTarget.value);
}; };
const firstNameChanged = (e: any) => { const firstNameChanged = (e: any) => {
setIdentity({...identity, firstName: e.currentTarget.value }); setFirstName(e.currentTarget.value);
}; };
const middleNameChanged = (e: any) => { const middleNameChanged = (e: any) => {
setIdentity({...identity, middleName: e.currentTarget.value }); setMiddleName(e.currentTarget.value);
}; };
const displayNameChanged = (e: any) => { const displayNameChanged = (e: any) => {
setIdentity({...identity, displayName: e.currentTarget.value }); setDisplayName(e.currentTarget.value);
}; };
/* If the user edits the identity, set the "updated" flag */
useEffect(() => {
setUpdated(lastName !== identity.lastName
|| firstName !== identity.firstName
|| middleName !== identity.middleName
|| displayName !== identity.displayName
);
}, [setUpdated, identity, lastName, firstName, middleName, displayName]);
/* If the identity changes, update all the fields */
useEffect(() => {
setLastName(identity.lastName);
setFirstName(identity.firstName);
setMiddleName(identity.middleName);
setDisplayName(identity.displayName);
}, [identity]);
const faceClicked = useCallback((e: any, face: FaceData) => { const faceClicked = useCallback((e: any, face: FaceData) => {
const el = e.currentTarget; const el = e.currentTarget;
@ -262,6 +283,9 @@ const Cluster = ({
}, [setSelected, setImage]); }, [setSelected, setImage]);
const deleteIdentity = async () => { const deleteIdentity = async () => {
if (!identity || identity.identityId === -1) {
return;
}
try { try {
const res = await window.fetch( const res = await window.fetch(
`${base}/api/v1/identities/${identity.identityId}`, { `${base}/api/v1/identities/${identity.identityId}`, {
@ -283,23 +307,39 @@ const Cluster = ({
}; };
const updateIdentity = async () => { const updateIdentity = async () => {
if (!identity || identity.identityId === -1) {
return;
}
try { try {
const validFields = [ const values = {
'id', 'displayName', 'firstName', 'lastName', 'middleName']; lastName,
const filtered: any = Object.assign({}, identity); firstName,
for (let key in filtered) { middleName,
if (validFields.indexOf(key) === -1) { displayName
delete filtered[key] };
}
}
const res = await window.fetch( const res = await window.fetch(
`${base}/api/v1/identities/${identity.identityId}`, { `${base}/api/v1/identities/${identity.identityId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filtered) body: JSON.stringify(values)
}); });
await res.json(); await res.json();
setIdentity({ ...identity }); setIdentity({ ...identity, ...values });
setIdentities(
[...identities]
.sort((A: IdentityData, B: IdentityData) => {
/* Sort the Unknown (-1) identity to the end */
if (A.identityId === -1) {
return +1;
}
if (B.identityId === -1) {
return -1;
}
/* Otherwise sort alphabetically by displayName */
return A.displayName.localeCompare(B.displayName);
})
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -307,23 +347,34 @@ const Cluster = ({
const createIdentity = async () => { const createIdentity = async () => {
try { try {
const validFields = [ const values = {
'id', 'displayName', 'firstName', 'lastName', 'middleName']; lastName,
const filtered: any = Object.assign({}, identity); firstName,
for (let key in filtered) { middleName,
if (validFields.indexOf(key) === -1) { displayName
delete filtered[key] };
}
}
const res = await window.fetch( const res = await window.fetch(
`${base}/api/v1/identities/`, { `${base}/api/v1/identities/`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filtered) body: JSON.stringify(values)
}); });
const created = await res.json(); const created = await res.json();
setIdentity(created); setIdentity(created);
setIdentities([identity, ...identities]); setIdentities(
[created, ...identities]
.sort((A: IdentityData, B: IdentityData) => {
/* Sort the Unknown (-1) identity to the end */
if (A.identityId === -1) {
return +1;
}
if (B.identityId === -1) {
return -1;
}
/* Otherwise sort alphabetically by displayName */
return A.displayName.localeCompare(B.displayName);
})
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -342,31 +393,32 @@ const Cluster = ({
<form className="IdentityForm"> <form className="IdentityForm">
<div>Last name:</div> <div>Last name:</div>
<input type="text" <input type="text"
value={identity.lastName} value={lastName}
onChange={lastNameChanged}/> onChange={lastNameChanged}/>
<div>First name:</div> <div>First name:</div>
<input type="text" <input type="text"
value={identity.firstName} value={firstName}
onChange={firstNameChanged} /> onChange={firstNameChanged} />
<div>Middle name:</div><input type="text" <div>Middle name:</div><input type="text"
value={identity.middleName} value={middleName}
onChange={middleNameChanged} /> onChange={middleNameChanged} />
<div>Display name:</div> <div>Display name:</div>
<input type="text" <input type="text"
value={identity.displayName} value={displayName}
onChange={displayNameChanged} /> onChange={displayNameChanged} />
</form> </form>
<Face <Face
face={identity.relatedFaces.length face={{
? identity.relatedFaces[0] identityId: identity.identityId,
: UnknownFace} faceId: identity.faceId
}}
onFaceClick={() => {}} onFaceClick={() => {}}
title={`${identity.displayName} (${identity.facesCount})`} /> title={`${displayName} (${identity.facesCount})`} />
</div> </div>
<div className="Actions"> <div className="Actions">
<Button onClick={createIdentity}>Create</Button> <Button onClick={createIdentity}>Create</Button>
{ identity.identityId !== -1 && <> { identity.identityId !== -1 && <>
<Button onClick={updateIdentity}>Update</Button> { updated && <Button onClick={updateIdentity}>Update</Button> }
<Button onClick={deleteIdentity}>Delete</Button> <Button onClick={deleteIdentity}>Delete</Button>
</> } </> }
</div> </div>
@ -447,27 +499,33 @@ interface IdentitiesProps {
}; };
const Identities = ({ identities, onFaceClick } : IdentitiesProps) => { const Identities = ({ identities, onFaceClick } : IdentitiesProps) => {
const identitiesJSX = identities.map((identity) => { const [jsx, setJsx] = useState<any[]>([]);
const face = identity.relatedFaces[0]; useEffect(() => {
return ( setJsx(identities.map((identity) => {
<div const face = {
key={face.faceId} faceId: identity.faceId,
style={{ identityId: identity.identityId
display: "flex", };
justifyContent: 'center', return (
alignItems: 'center' <div
}}> key={identity.identityId}
<Face style={{
face={face} display: "flex",
onFaceClick={onFaceClick} justifyContent: 'center',
title={`${identity.displayName} (${identity.facesCount})`}/> alignItems: 'center'
</div> }}>
); <Face
}); face={face}
onFaceClick={onFaceClick}
title={`${identity.displayName} (${identity.facesCount})`}/>
</div>
);
}));
}, [identities, onFaceClick]);
return ( return (
<div className='Identities'> <div className='Identities'>
{ identitiesJSX } { jsx }
</div> </div>
); );
}; };
@ -481,15 +539,13 @@ const Button = ({ onClick, children }: any) => {
}; };
const App = () => { const App = () => {
const [identities, setIdentities] = useState<IdentityData[]>([]);
const { identityId, faceId } = useParams(); const { identityId, faceId } = useParams();
const [identities, setIdentities] = useState<IdentityData[]>([]);
const [selectedIdentities, setSelectedIdentities] = useState<number[]>([]); const [selectedIdentities, setSelectedIdentities] = useState<number[]>([]);
const [identity, setIdentity] = useState<IdentityData>(EmptyIdentity); const [identity, setIdentity] = useState<IdentityData>(EmptyIdentity);
const [image, setImage] = useState<number>(0); const [image, setImage] = useState<number>(0);
const [guess, setGuess] = useState<FaceData|undefined>(undefined); const [guess, setGuess] = useState<FaceData|undefined>(undefined);
const { loading, data } = useApi( /* TODO: Switch away from using useApi */ const [loaded, setLoaded] = useState<boolean>(false);
`${base}/api/v1/identities`
);
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
/* If 'selected' changes, clear any selected face which is not in the /* If 'selected' changes, clear any selected face which is not in the
@ -515,9 +571,10 @@ const App = () => {
} }
}; };
/* If the identity changes, update its entry in the identities list */ /* If the identity changes, update its entry in the identities list
* NOTE: Blocks update to 'Unknown' (-1) fake identity */
useEffect(() => { useEffect(() => {
if (!identity || identities.length === 0) { if (!identity || identities.length === 0 || identity.identityId === -1) {
return; return;
} }
for (let key in identities) { for (let key in identities) {
@ -525,9 +582,11 @@ const App = () => {
let same = true; let same = true;
[ 'displayName', 'firstName', 'lastName', 'middleName' ] [ 'displayName', 'firstName', 'lastName', 'middleName' ]
.forEach((field: string) => { .forEach((field: string) => {
same = same && (identities[key] as any)[field] === (identity as any)[field]; same = same
&& (identities[key] as any)[field] === (identity as any)[field];
}); });
if (!same) { if (!same) {
console.log(`Updating `, identity, identities[key]);
identities[key] = { identities[key] = {
...identity, ...identity,
relatedFaces: identities[key].relatedFaces relatedFaces: identities[key].relatedFaces
@ -543,6 +602,22 @@ const App = () => {
} }
}, [identity, setIdentities, identities]); }, [identity, setIdentities, identities]);
/* If the identity changes, scroll it into view in the Identities list */
useEffect(() => {
if (selectedIdentities.length !== 0) {
return;
}
const el = document.querySelector(
`.Identities [data-identity-id="${identity.identityId}"]`);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest"
});
}
}, [identity, selectedIdentities]);
useEffect(() => { useEffect(() => {
if (identityId !== undefined && !isNaN(+identityId)) { if (identityId !== undefined && !isNaN(+identityId)) {
loadIdentity(+identityId); loadIdentity(+identityId);
@ -555,20 +630,38 @@ const App = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (data && data.length) { if (identities.length !== 0 || loaded) {
return;
}
const loadIdentities = async () => {
const res = await window.fetch(`${base}/api/v1/identities`, {
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
data.forEach((identity: IdentityData) => { data.forEach((identity: IdentityData) => {
identity.relatedFaces.forEach(face => { identity.relatedFaces.forEach(face => {
face.identity = identity; face.identity = identity;
}); });
}); });
data.sort((A: IdentityData, B: IdentityData) => { data.sort((A: IdentityData, B: IdentityData) => {
/* Sort the Unknown (-1) identity to the end */
if (A.identityId === -1) {
return +1;
}
if (B.identityId === -1) {
return -1;
}
/* Otherwise sort alphabetically by displayName */
return A.displayName.localeCompare(B.displayName); return A.displayName.localeCompare(B.displayName);
}); });
setLoaded(true);
setIdentities(data as IdentityData[]); setIdentities(data as IdentityData[]);
} }
}, [data]); loadIdentities();
}, [identities, setIdentities, setLoaded, loaded]);
const removeFacesFromIdentities = (faceIds: number[]) => { const removeFacesFromIdentity = (faceIds: number[]) => {
if (!identity) { if (!identity) {
return; return;
} }
@ -578,6 +671,7 @@ const App = () => {
identity.relatedFaces = identity.relatedFaces.filter( identity.relatedFaces = identity.relatedFaces.filter(
(face: FaceData) => faceIds.indexOf(face.faceId) === -1); (face: FaceData) => faceIds.indexOf(face.faceId) === -1);
if (pre !== identity.relatedFaces.length) { if (pre !== identity.relatedFaces.length) {
identity.facesCount = identity.relatedFaces.length;
setIdentity({ ...identity }) setIdentity({ ...identity })
} }
} }
@ -587,7 +681,7 @@ const App = () => {
window.alert('You need to select an identity first (CTRL+CLICK)'); window.alert('You need to select an identity first (CTRL+CLICK)');
return; return;
} }
if (!identity) { if (!identity || identity.identityId === -1) {
return; return;
} }
try { try {
@ -598,18 +692,26 @@ const App = () => {
body: JSON.stringify({ faces: identity.relatedFaces body: JSON.stringify({ faces: identity.relatedFaces
.map(face => face.faceId) }) .map(face => face.faceId) })
}); });
await res.json(); const result = await res.json();
res = await window.fetch( res = await window.fetch(
`${base}/api/v1/identities/${identity.identityId}`, { `${base}/api/v1/identities/${identity.identityId}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
await res.json(); await res.json();
const index = identities /* Delete the identity from the list of identities */
const deleted = identities
.findIndex((item: IdentityData) => .findIndex((item: IdentityData) =>
item.identityId === identity.identityId); item.identityId === identity.identityId);
if (index !== -1) { if (deleted !== -1) {
identities.splice(index, 1); identities.splice(deleted, 1);
}
/* Update the faces count on the target identity */
const target = identities
.find((item: IdentityData) =>
item.identityId === selectedIdentities[0]);
if (target) {
target.facesCount += result.added.length;
} }
setIdentity(EmptyIdentity); setIdentity(EmptyIdentity);
setIdentities([...identities]); setIdentities([...identities]);
@ -629,10 +731,14 @@ const App = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ faces: selected }) body: JSON.stringify({ faces: selected })
}); });
const data = await res.json(); const results = await res.json();
removeFacesFromIdentities(data.faces); removeFacesFromIdentity(results.removed);
deselectAll(); deselectAll();
if (identity.faceId !== results.faceId) {
setIdentity({...identity, ...{ faceId: results.faceId }});
setIdentities([...identities]);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -650,10 +756,29 @@ const App = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ faces: selected }) body: JSON.stringify({ faces: selected })
}); });
const data = await res.json(); const results = await res.json();
removeFacesFromIdentities(data.faces); removeFacesFromIdentity(results.added);
/* If the identity faceId was removed from the identity, select
* the next relatedFace */
if (results.added.indexOf(identity.faceId) !== -1) {
if (identity.relatedFaces.length === 0) {
identity.faceId = -1;
} else {
identity.faceId = identity.relatedFaces[0].faceId;
}
setIdentity({...identity});
}
/* Update the faces count on the target identity */
const target = identities
.find((item: IdentityData) =>
item.identityId === selectedIdentities[0]);
if (target) {
target.facesCount += results.added.length;
target.faceId = results.faceId;
}
deselectAll(); deselectAll();
setIdentities([...identities]);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -685,7 +810,7 @@ const App = () => {
}) })
}); });
await res.json(); await res.json();
removeFacesFromIdentities(selected); removeFacesFromIdentity(selected);
deselectAll(); deselectAll();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -707,7 +832,11 @@ const App = () => {
...document.querySelectorAll(`.Identities [data-identity-id="${identityId}"]`), ...document.querySelectorAll(`.Identities [data-identity-id="${identityId}"]`),
...document.querySelectorAll(`.Cluster [data-face-id="${faceId}"]`)]; ...document.querySelectorAll(`.Cluster [data-face-id="${faceId}"]`)];
faces.forEach((el: any) => { faces.forEach((el: any) => {
el.scrollIntoView(); el.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest"
});
}); });
}; };
@ -742,12 +871,14 @@ const App = () => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
[...document.querySelectorAll('.Cluster .Faces img')] if (identity.identityId !== identityId) {
.forEach((img: any) => { [...document.querySelectorAll('.Cluster .Faces img')]
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; .forEach((img: any) => {
}); img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
});
loadIdentity(identityId);
loadIdentity(identityId);
}
} }
return ( return (
@ -756,19 +887,15 @@ const App = () => {
<PanelGroup className="Explorer" <PanelGroup className="Explorer"
autoSaveId="persistence" direction="horizontal"> autoSaveId="persistence" direction="horizontal">
<Panel defaultSize={50} className="ClusterEditor"> <Panel defaultSize={50} className="ClusterEditor">
{loading && <div style={{ margin: '1rem' }}>Loading...</div>} <Cluster {...{
<Cluster {...{ identity,
identity, setIdentity,
setIdentity, identities,
identities, setIdentities,
setIdentities, setImage,
setImage, selected,
selected, setSelected,
setSelected, }} />
}} />
{!loading && identity === undefined && <div className="Cluster">
Select identity to edit
</div>}
<div className="Actions"> <div className="Actions">
{ selected.length === 1 && <> { selected.length === 1 && <>
<Button onClick={guessIdentity}>Guess</Button> <Button onClick={guessIdentity}>Guess</Button>
@ -801,7 +928,10 @@ const App = () => {
</Panel> </Panel>
<PanelResizeHandle className="Resizer" /> <PanelResizeHandle className="Resizer" />
<Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList"> <Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList">
{ !loading && <Identities { !loaded && <div style={{ margin: '1rem' }}>
Loading...
</div> }
{ loaded && <Identities
{... { onFaceClick: identitiesOnFaceClick, identities }}/> {... { onFaceClick: identitiesOnFaceClick, identities }}/>
} }
</Panel> </Panel>

View File

@ -28,22 +28,27 @@ const upsertIdentity = async(id, {
return undefined; return undefined;
} }
const identity = { /* Create identity structure based on UnknownIdentity to set
displayName, * default values for new identities */
firstName, const identity = {
lastName, ...UnknownIdentity,
middleName, ...{
identityId: id displayName,
firstName,
lastName,
middleName,
identityId: id
}
}; };
if (id === -1 || !id) { if (id === -1 || !id) {
const [results, { lastId }] = await photoDB.sequelize.query( const [, { lastID }] = await photoDB.sequelize.query(
`INSERT INTO identities ` + `INSERT INTO identities ` +
'(displayName,firstName,lastName,middleName)' + '(displayName,firstName,lastName,middleName)' +
'VALUES(:displayName,:firstName,:lastName,:middleName)', { 'VALUES(:displayName,:firstName,:lastName,:middleName)', {
replacements: identity replacements: identity
}); });
identity.identityId = lastId; identity.identityId = lastID;
console.log('Created identity: ', identity) console.log('Created identity: ', identity)
} else { } else {
await photoDB.sequelize.query( await photoDB.sequelize.query(
@ -282,8 +287,39 @@ router.put("/faces/remove/:id", async (req, res) => {
identityId: id, identityId: id,
faces: req.body.faces faces: req.body.faces
}; };
identity.faces = identity.faces.map(id => +id); identity.removed = identity.faces.map(id => +id);
/* If the primary faceId was removed, update the identity's faceId
* to a new faceId */
const faceIds = await photoDB.sequelize.query(`
SELECT faceId FROM identities WHERE id=:identityId`, {
replacements: identity,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
if (identity.removed.indexOf(faceIds[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 /* Do not block on this call finishing -- update can occur
* in the background */ * in the background */
updateIdentityFaces(identity); updateIdentityFaces(identity);
@ -301,8 +337,8 @@ router.put("/faces/add/:id", async (req, res) => {
return res.status(401).send("Unauthorized to modify photos."); return res.status(401).send("Unauthorized to modify photos.");
} }
const id = parseInt(req.params.id); const identityId = parseInt(req.params.id);
if (id != req.params.id) { if (identityId != req.params.id) {
return res.status(400).send("Invalid identity id."); return res.status(400).send("Invalid identity id.");
} }
@ -310,24 +346,107 @@ router.put("/faces/add/:id", async (req, res) => {
return res.status(400).send("No faces supplied."); return res.status(400).send("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 { try {
await photoDB.sequelize.query( await photoDB.sequelize.query(
"UPDATE faces SET identityId=:identityId,classifiedBy='human' " + "UPDATE faces SET identityId=:identityId,classifiedBy='human' " +
"WHERE id IN (:faceIds)", { "WHERE id IN (:faceIds)", {
replacements: { replacements: {
identityId: id, identityId,
faceIds: req.body.faces faceIds
} }
}); });
const identity = {
identityId: id, identity.added = faceIds;
faces: req.body.faces identity.faceId = identity.identityFaceId;
}; delete identity.identityFaceId;
identity.faces = identity.faces.map(id => +id);
if (identity.faceId === -1 || identity.faceId === null) {
identity.faceId = faceIds[0];
}
/* Do not block on this call finishing -- update can occur /* Do not block on this call finishing -- update can occur
* in the background */ * in the background */
updateIdentityFaces(identity); Promise.map([identity, ...tuples], identity => {
updateIdentityFaces(identity);
}, {
concurrency: 1
});
return res.status(200).json(identity); return res.status(200).json(identity);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -335,7 +454,6 @@ router.put("/faces/add/:id", async (req, res) => {
}; };
}); });
function bufferToFloat32Array(buffer) { function bufferToFloat32Array(buffer) {
return new Float64Array(buffer.buffer, return new Float64Array(buffer.buffer,
buffer.byteOffset, buffer.byteOffset,
@ -362,39 +480,63 @@ function euclideanDistance(a, 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 getUnknownIdentity = async (faceCount) => {
const unknownIdentity = { const unknownIdentity = { ...UnknownIdentity };
identityId: -1,
lastName: '',
firstName: '',
middleName: '',
displayName: 'Unknown',
descriptors: new Float32Array(0),
relatedFaces: [],
facesCount: 0
};
const limit = faceCount const limit = faceCount
? ` ORDER BY RANDOM() LIMIT ${faceCount} ` ? ` ORDER BY RANDOM() LIMIT ${faceCount} `
: ' ORDER BY faceConfidence DESC '; : ' ORDER BY faceConfidence DESC ';
unknownIdentity.relatedFaces = await photoDB.sequelize.query( const sql = `
"SELECT id AS faceId,photoId,faceConfidence " + SELECT
"FROM faces WHERE identityId IS NULL AND classifiedBy != 'not-a-face' " + faces.id AS faceId,
limit, { 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') AS total
WHERE
faces.identityId IS NULL
AND faces.classifiedBy != 'not-a-face'
${ limit }`;
unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, {
type: photoDB.Sequelize.QueryTypes.SELECT, type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true raw: true
}); });
if (unknownIdentity.relatedFaces.length === 0) { if (unknownIdentity.relatedFaces.length !== 0) {
unknownIdentity.relatedFaces.push({ unknownIdentity.facesCount = unknownIdentity.relatedFaces[0].facesCount;
faceId: -1,
photoId: -1,
faceConfidence: 0
});
} }
unknownIdentity.relatedFaces.forEach(face => { unknownIdentity.relatedFaces.forEach(face => {
face.identityId = -1; face.identityId = -1;
face.distance = face.faceConfidence; face.distance = face.faceConfidence;
face.descriptors = new Float32Array(0); face.descriptors = new Float32Array(0);
delete face.faceConfidence; delete face.faceConfidence;
delete face.facesCount;
}); });
return unknownIdentity; return unknownIdentity;
} }
@ -452,9 +594,7 @@ const updateIdentityFaces = async (identity) => {
return; return;
} }
let average = undefined, let average = undefined;
closestId = -1,
closestDistance = -1;
/* First find the average centroid of all faces */ /* First find the average centroid of all faces */
faces.forEach((face) => { faces.forEach((face) => {
@ -479,17 +619,18 @@ const updateIdentityFaces = async (identity) => {
average[i] = average[i] / faces.length; average[i] = average[i] / faces.length;
} }
let closestId = -1,
closestDistance = -1;
/* Now compute the distance from each face to the new centroid */ /* Now compute the distance from each face to the new centroid */
faces.forEach((face) => { faces.forEach((face) => {
let distance; face.updatedDistance = euclideanDistanceArray(
distance = euclideanDistanceArray(
face.descriptors, face.descriptors,
average average
); );
if (closestId === -1 || face.distance < closestDistance) { if (closestId === -1 || face.updatedDistance < closestDistance) {
closestDistance = distance; closestDistance = face.updatedDistance;
closestId = face.id; closestId = face.id;
} }
}); });
@ -499,6 +640,7 @@ const updateIdentityFaces = async (identity) => {
if (!identity.descriptors) { if (!identity.descriptors) {
console.log(`Identity ${identity.identityId} has no descriptors`); console.log(`Identity ${identity.identityId} has no descriptors`);
} }
let moved = (identity.descriptors === null ? 1 : 0) let moved = (identity.descriptors === null ? 1 : 0)
|| Number || Number
.parseFloat(euclideanDistanceArray(identity.descriptors, average)) .parseFloat(euclideanDistanceArray(identity.descriptors, average))
@ -511,15 +653,17 @@ const updateIdentityFaces = async (identity) => {
await Promise.map(faces, async (face) => { await Promise.map(faces, async (face) => {
/* All the buffer are already arrays, so use the short-cut version */ /* All the buffer are already arrays, so use the short-cut version */
const distance = Number const distance = Number
.parseFloat(euclideanDistanceArray(face.descriptors, average)) .parseFloat(face.updatedDistance)
.toFixed(4); .toFixed(4);
if (Math.abs(distance - face.distance) > MIN_DISTANCE_COMMIT) { if (Math.abs(face.updatedDistance - face.distance)
> MIN_DISTANCE_COMMIT) {
console.log( console.log(
`Updating face ${face.id} to ${round(distance, 2)} ` + `Updating face ${face.id} to ${round(distance, 2)} ` +
`(${distance - face.distance}) ` + `(${distance - face.distance}) ` +
`from identity ${identity.identityId} (${identity.displayName})`); `from identity ${identity.identityId} (${identity.displayName})`);
face.distance = distance; face.distance = face.updatedDistance;
delete face.updatedDistance;
await photoDB.sequelize.query( await photoDB.sequelize.query(
'UPDATE faces SET distance=:distance WHERE id=:id', { 'UPDATE faces SET distance=:distance WHERE id=:id', {
replacements: face, replacements: face,
@ -677,11 +821,8 @@ router.get("/:id?", async (req, res) => {
/* If there were no faces, then add a 'Unknown' face */ /* If there were no faces, then add a 'Unknown' face */
if (identity.relatedFaces.length === 0) { if (identity.relatedFaces.length === 0) {
identity.relatedFaces.push({ identity.relatedFaces.push({
faceId: -1, ...UnknownFace,
photoId: -1, ...{ identityId: identity.identityId }
identityId: identity.identityId,
distance: 0,
faceConfidence: 0
}); });
} }
}, { }, {