Identity averages are now working!

Signed-off-by: James Ketrenos <james_git@ketrenos.com>
This commit is contained in:
James Ketr 2023-01-24 14:15:17 -08:00
parent 58a2baddde
commit f8b33a65b8
6 changed files with 483 additions and 145 deletions

View File

@ -15,6 +15,7 @@
"@types/node": "^18.11.18",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^0.0.34",
@ -14346,6 +14347,14 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -11,6 +11,7 @@
"@types/node": "^18.11.18",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^0.0.34",

View File

@ -35,6 +35,15 @@ div {
flex-grow: 1;
}
.Actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 0.25rem;
padding: 0.25rem;
width: 100%;
}
.Identities {
display: grid;
user-select: none;
@ -64,27 +73,45 @@ div {
flex-direction: column;
}
.PhotoPanel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
justify-content: center;
}
.Image .FaceBox {
border: 1px solid red;
/* border-radius: 0.25rem;*/
position: absolute;
}
.Image .FaceBox:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0px 0px 5px black;
box-shadow: 0px 0px 10px black;
}
.Image {
display: flex;
width: 100%;
height: 100%;
position: relative;
background-size: contain !important;
background-repeat: no-repeat no-repeat !important;
background-position: 50% 50% !important;
}
.PhotoPanel .FaceInfo {
padding: 0.25rem;
background-color: #444;
color: white;
margin-top: 0.25rem;}
.PhotoPanel .ImageInfo {
padding: 0.25rem;
background-color: #222;
color: white;
margin-top: 0.25rem;
}
.UnknownFace {
display: flex;
align-items: center;

View File

@ -8,24 +8,32 @@ import {
Routes,
useParams
} from "react-router-dom";
import moment from 'moment';
import equal from "fast-deep-equal";
import './App.css';
const base = process.env.PUBLIC_URL; /* /identities -- set in .env */
const makeFaceBoxes = (photo: any, dimensions: any, onFaceClick: any): any => {
const makeFaceBoxes = (photo: any,
dimensions: any,
onFaceClick: any,
onFaceEnter: any,
onFaceLeave: any,
): any => {
const faces: FaceData[] = photo.faces;
let width: number, height: number, offsetLeft = 0, offsetTop = 0;
/* If photo is wider than viewport, it will be 100% width and < 100% height */
if (photo.width / photo.height > dimensions.width / dimensions.height) {
console.log('Landscape');
width = dimensions.width;
height = dimensions.height * photo.height / photo.width *
dimensions.width / dimensions.height;
offsetLeft = 0;
offsetTop = (dimensions.height - height) * 0.5;
} else {
console.log('Portrait');
width = dimensions.width * photo.width / photo.height *
dimensions.height / dimensions.width;
height = dimensions.height;
@ -48,8 +56,8 @@ const makeFaceBoxes = (photo: any, dimensions: any, onFaceClick: any): any => {
height: Math.floor((face.bottom - face.top) * height) + "px"
}}
onClick={(e) => { onFaceClick(e, face) }}
onMouseEnter={(e) => { onFaceMouseEnter(e, face) }}
onMouseLeave={(e) => { onFaceMouseLeave(e, face) }}
onMouseEnter={(e) => { onFaceEnter(e, face) }}
onMouseLeave={(e) => { onFaceLeave(e, face) }}
/>
)
});
@ -57,14 +65,26 @@ const makeFaceBoxes = (photo: any, dimensions: any, onFaceClick: any): any => {
const Photo = ({ photoId, onFaceClick }: any) => {
const [image, setImage] = useState<any>(undefined);
const [faceInfo, setFaceInfo] = useState<string>('');
const ref = useRef(null);
const [dimensions, setDimensions] = React.useState({width: 0, height: 0});
const onFaceEnter = (e: any, face: FaceData) => {
onFaceMouseEnter(e, face);
setFaceInfo(face.identity ? face.identity.displayName : 'Unknown');
}
const onFaceLeave = (e: any, face: FaceData) => {
setFaceInfo('');
onFaceMouseLeave(e, face);
}
const faces = useMemo(() => {
if (image === undefined || dimensions.height === 0) {
return <></>;
}
return makeFaceBoxes(image, dimensions, onFaceClick);
return makeFaceBoxes(image, dimensions,
onFaceClick, onFaceEnter, onFaceLeave);
}, [image, dimensions, onFaceClick]);
const checkResize = useCallback(() => {
@ -78,7 +98,7 @@ const Photo = ({ photoId, onFaceClick }: any) => {
setDimensions({
height: el.clientHeight,
width: el.clientWidth
})
});
}
}, [setDimensions, dimensions]);
@ -106,15 +126,25 @@ const Photo = ({ photoId, onFaceClick }: any) => {
return <></>
}
return (<div className="Image" ref={ref}>
<img
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}} />
{ faces }
return (<div className="PhotoPanel">
<div className="Image" ref={ref}>
<img
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}} />
{ faces }
</div>
<div className="ImageInfo">{
moment(image.taken)
.format('MMMM Do YYYY, h:mm:ss a')
}, {
moment(image.taken)
.fromNow()
}.</div>
<div className="FaceInfo">{ faceInfo ? faceInfo : 'Hover over face for information.'}</div>
</div>
);
};
@ -132,7 +162,10 @@ const onFaceMouseEnter = (e: any, face: FaceData) => {
els.forEach(el => {
el.classList.add('Active');
})
});
e.stopPropagation();
e.preventDefault();
};
const onFaceMouseLeave = (e: any, face: FaceData) => {
@ -145,7 +178,10 @@ const onFaceMouseLeave = (e: any, face: FaceData) => {
els.forEach(el => {
el.classList.remove('Active');
})
});
e.stopPropagation();
e.preventDefault();
};
const Face = ({ face, onFaceClick, title, ...rest }: any) => {
@ -180,10 +216,15 @@ type ClusterProps = {
identity: IdentityData,
setImage(image: number): void,
setSelected(selected: number[]): void,
setIdentity(identity: IdentityData): void
setIdentity(identity: IdentityData | undefined): void
identities: IdentityData[],
setIdentities(identiteis: IdentityData[]): void
};
const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps) => {
const Cluster = ({
identity, setIdentity,
identities, setIdentities,
setImage, setSelected }: ClusterProps) => {
const relatedFacesJSX = useMemo(() => {
const faceClicked = async (e: any, face: FaceData) => {
if (!identity) {
@ -218,7 +259,17 @@ const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps)
if (identity === undefined) {
return <></>;
}
return identity.relatedFaces.map((face: FaceData) => {
return identity.relatedFaces.map((face: FaceData, i: number) => {
if (i >= 1000) {
if (i === 1000) {
return <div key={face.faceId}>
too many faces (${identity.relatedFaces.length - 1000} remaining)
</div>;
} else {
return;
}
}
return (
<div
key={face.faceId}
@ -248,6 +299,27 @@ const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps)
setIdentity({...identity, displayName: e.currentTarget.value });
};
const deleteIdentity = async () => {
try {
const res = await window.fetch(
`${base}/api/v1/identities/${identity.identityId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
const updated = await res.json();
const index = identities
.findIndex((item: IdentityData) =>
item.identityId === identity.identityId);
if (index !== -1) {
identities.splice(index, 1);
}
setIdentity(undefined);
setIdentities([...identities]);
} catch (error) {
console.error(error);
}
};
const updateIdentity = async () => {
try {
const validFields = [
@ -289,6 +361,7 @@ const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps)
});
const created = await res.json();
setIdentity(created);
setIdentities([identity, ...identities]);
} catch (error) {
console.error(error);
}
@ -321,8 +394,11 @@ const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps)
value={identity.displayName}
onChange={displayNameChanged} />
</form>
<div className="Actions">
<Button onClick={createIdentity}>Create</Button>
<Button onClick={updateIdentity}>Update</Button>
<Button onClick={deleteIdentity}>Delete</Button>
</div>
</div>
<div>Faces: {identity.relatedFaces.length}</div>
<div className="Faces">
@ -356,7 +432,9 @@ type IdentityData = {
descriptors: number[],
identityId: number
displayName: string,
relatedFaces: FaceData[]
relatedFaces: FaceData[],
facesCount: number,
faceId: number
};
interface IdentitiesProps {
@ -379,7 +457,7 @@ const Identities = ({ identities, onFaceClick } : IdentitiesProps) => {
<Face
face={face}
onFaceClick={onFaceClick}
title={identity.displayName}/>
title={`${identity.displayName} (${identity.facesCount})`}/>
</div>
);
});
@ -406,7 +484,8 @@ const App = () => {
const [selectedIdentities, setSelectedIdentities] = useState<number[]>([]);
const [identity, setIdentity] = useState<IdentityData | undefined>(undefined);
const [image, setImage] = useState<number>(0);
const { loading, data } = useApi(
const [guess, setGuess] = useState<FaceData|undefined>(undefined);
const { loading, data } = useApi( /* TODO: Switch away from using useApi */
`${base}/api/v1/identities`
);
const [selected, setSelected] = useState<number[]>([]);
@ -467,6 +546,9 @@ const App = () => {
face.identity = identity;
});
});
data.sort((A: IdentityData, B: IdentityData) => {
return A.displayName.localeCompare(B.displayName);
});
setIdentities(data as IdentityData[]);
}
}, [data]);
@ -485,6 +567,42 @@ const App = () => {
}
}
const mergeIdentity = async () => {
if (selectedIdentities.length === 0) {
window.alert('You need to select an identity first (CTRL+CLICK)');
return;
}
if (!identity) {
return;
}
try {
let res = await window.fetch(
`${base}/api/v1/identities/faces/add/${selectedIdentities[0]}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ faces: identity.relatedFaces
.map(face => face.faceId) })
});
await res.json();
res = await window.fetch(
`${base}/api/v1/identities/${identity.identityId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
const updated = await res.json();
const index = identities
.findIndex((item: IdentityData) =>
item.identityId === identity.identityId);
if (index !== -1) {
identities.splice(index, 1);
}
setIdentity(undefined);
setIdentities([...identities]);
} catch (error) {
console.error(error);
}
};
const markSelectedIncorrectIdentity = async () => {
if (!identity) {
return;
@ -505,7 +623,6 @@ const App = () => {
};
const changeSelectedIdentity = async () => {
if (selectedIdentities.length === 0) {
window.alert('You need to select an identity first (CTRL+CLICK)');
return;
@ -525,6 +642,21 @@ const App = () => {
}
};
const guessIdentity = async () => {
try {
const res = await window.fetch(
`${base}/api/v1/identities/faces/guess/${selected[0]}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const faces = await res.json();
console.log(faces);
setGuess(faces[0]);
} catch (error) {
console.error(error);
}
};
const markSelectedNotFace = async () => {
try {
const res = await window.fetch(
@ -537,12 +669,23 @@ const App = () => {
})
});
const data = await res.json();
removeFacesFromIdentities(data);
removeFacesFromIdentities(selected);
} catch (error) {
console.error(error);
}
};
const deselectAll = () => {
const cluster = document.querySelector('.Cluster');
if (!cluster) {
return;
}
[...cluster.querySelectorAll('.Selected')].forEach(item => {
item.classList.remove('Selected')
});
setSelected([]);
};
const onFaceClick = (e: any, face: FaceData) => {
const identityId = face.identityId;
const faceId = face.faceId;
@ -555,6 +698,9 @@ const App = () => {
});
};
const guessOnFaceClick = (e: any, face: FaceData) => {
};
const identitiesOnFaceClick = (e: any, face: FaceData) => {
const identitiesEl = document.querySelector('.Identities');
if (!identitiesEl) {
@ -600,6 +746,8 @@ const App = () => {
<Cluster {...{
identity,
setIdentity,
identities,
setIdentities,
setImage,
setSelected
}} />}
@ -607,10 +755,17 @@ const App = () => {
Select identity to edit
</div>}
<div className="Actions">
{selected.length === 1 && <>
<Button onClick={guessIdentity}>Guess</Button>
</>}
{ selected.length !== 0 && <>
<Button onClick={markSelectedIncorrectIdentity}>Remove</Button>
<Button onClick={markSelectedNotFace}>Not a face</Button>
<Button onClick={changeSelectedIdentity}>Change Identity</Button>
<Button onClick={deselectAll}>Deselect All</Button>
</>}
{selectedIdentities.length !== 0 && <>
<Button onClick={mergeIdentity}>Merge</Button>
</>}
</div>
</Panel>
@ -618,6 +773,16 @@ const App = () => {
<Panel>
{image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>}
{image !== 0 && <Photo onFaceClick={onFaceClick} photoId={image}/> }
{guess !== undefined && guess.identity && <div
style={{
display: "flex",
justifyContent: 'center',
alignItems: 'center'}}>
<Face
face={guess.identity.relatedFaces[0]}
onFaceClick={guessOnFaceClick}
title={`${guess.identity.displayName} (${guess.distance})`}/>
</div> }
</Panel>
<PanelResizeHandle className="Resizer" />
<Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList">

View File

@ -119,6 +119,7 @@ function init() {
key: 'id',
}
},
facesCount: Sequelize.INTEGER,
descriptors: Sequelize.BLOB /* average of all faces mapped to this */
}, {
timestamps: false

View File

@ -31,7 +31,7 @@ const upsertIdentity = async(id, {
firstName,
lastName,
middleName,
id
identityId: id
};
if (id === -1 || !id) {
@ -41,7 +41,7 @@ const upsertIdentity = async(id, {
'VALUES(:displayName,:firstName,:lastName,:middleName)', {
replacements: identity
});
identity.id = lastId;
identity.identityId = lastId;
} else {
await photoDB.sequelize.query(
`UPDATE identities ` +
@ -50,7 +50,7 @@ const upsertIdentity = async(id, {
'firstName=:firstName, ' +
'lastName=:lastName, ' +
'middleName=:middleName ' +
'WHERE id=:id', {
'WHERE id=:identityId', {
replacements: identity
});
}
@ -74,7 +74,7 @@ const populateRelatedFaces = async (identity, count) => {
"FROM faces " +
"WHERE identityId=:identityId " +
limit, {
replacements: { identityId: identity.id },
replacements: { identityId: identity.identityId },
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
@ -92,13 +92,14 @@ const populateRelatedFaces = async (identity, count) => {
}
identity.relatedFaces.forEach(face => {
face.identityId = identity.id;
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) {
@ -110,10 +111,12 @@ router.post('/', async (req, res) => {
if (!identity) {
return;
}
populateRelatedFaces(identity, 1);
await 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) {
@ -130,7 +133,8 @@ router.put('/:id', async (req, res) => {
if (!identity) {
return;
}
populateRelatedFaces(identity);
await updateIdentityFaces(identity);
await populateRelatedFaces(identity);
return res.status(200).send(identity);
});
@ -156,7 +160,7 @@ router.delete('/:id', async (req, res) => {
await photoDB.sequelize.query(
'DELETE FROM identities ' +
'WHERE identityId=:id', {
'WHERE id=:id', {
replacements: { id }
});
@ -164,11 +168,6 @@ router.delete('/:id', async (req, res) => {
});
const addFaceToIdentityDescriptors = (identity, face) => {
};
const removeFaceToIdentityDescriptors = (identity, face) => {
};
const writeIdentityDescriptors = async (identity) => {
await photoDB.sequelize.query(
@ -180,6 +179,86 @@ const writeIdentityDescriptors = async (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 {
const faces = await photoDB.sequelize.query(
"SELECT faces.*,faceDescriptors.* " +
"FROM faces,faceDescriptors " +
"WHERE faces.id=:faceId " +
"AND faceDescriptors.id=faces.descriptorId", {
replacements: { faceId },
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
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) => {
face.faceId = face.id;
delete face.id;
face.identityId = -1;
face.distance = -1;
face.identity = null;
identities.forEach((identity) => {
if (!identity.descriptors) {
return;
}
const distance = euclideanDistance(
face.descriptors,
identity.descriptors
);
if (face.identityId === -1) {
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] */
const results = [];
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) {
@ -206,13 +285,12 @@ router.put("/faces/remove/:id", async (req, res) => {
}
});
const identity = {
id: id,
identityId: id,
faces: req.body.faces
};
identity.faces = identity.faces.map(id => +id);
updateIdentityDescriptors(identity);
await updateIdentityFaces(identity);
return res.status(200).json(identity);
} catch (error) {
console.error(error);
@ -245,13 +323,12 @@ router.put("/faces/add/:id", async (req, res) => {
}
});
const identity = {
id: id,
identityId: id,
faces: req.body.faces
};
identity.faces = identity.faces.map(id => +id);
updateIdentityDescriptors(identity);
await updateIdentityFaces(identity);
return res.status(200).json(identity);
} catch (error) {
console.error(error);
@ -259,49 +336,6 @@ router.put("/faces/add/:id", async (req, res) => {
};
});
router.post("/", (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 identity = {
lastName: req.body.lastName || "",
firstName: req.body.firstName || "",
middleName: req.body.middleName || ""
};
identity.name = req.body.name || (identity.firstName + " " + identity.lastName);
let fields = [];
for (let key in identity) {
fields.push(key);
}
if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) {
return res.status(400).send("No faces supplied.");
}
return photoDB.sequelize.query("INSERT INTO identities " +
"(" + fields.join(",") + ") " +
"VALUES(:" + fields.join(",:") + ")", {
replacements: identity,
}).then(([ results, metadata ]) => {
identity.id = metadata.lastID;
return photoDB.sequelize.query(
"UPDATE faces SET identityId=:identityId " +
"WHERE id IN (:faceIds)", {
replacements: {
identityId: identity.id,
faceIds: req.body.faces
}
}).then(() => {
identity.faces = req.body.faces;
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,
@ -309,19 +343,24 @@ function bufferToFloat32Array(buffer) {
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;
}
let A = bufferToFloat32Array(a);
let B = bufferToFloat32Array(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);
return euclideanDistanceArray(
bufferToFloat32Array(a),
bufferToFloat32Array(b)
);
}
const getUnknownIdentity = async (faceCount) => {
@ -332,10 +371,11 @@ const getUnknownIdentity = async (faceCount) => {
middleName: '',
displayName: 'Unknown',
descriptors: new Float32Array(0),
relatedFaces: []
relatedFaces: [],
facesCount: 0
};
const limit = faceCount
? ` LIMIT ${faceCount} `
? ` ORDER BY RANDOM() LIMIT ${faceCount} `
: ' ORDER BY faceConfidence DESC ';
unknownIdentity.relatedFaces = await photoDB.sequelize.query(
"SELECT id AS faceId,photoId,faceConfidence " +
@ -360,6 +400,13 @@ const getUnknownIdentity = async (faceCount) => {
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.
@ -369,8 +416,27 @@ const getUnknownIdentity = async (faceCount) => {
*/
const updateIdentityFaces = async (identity) => {
if (!identity.identityId) {
identity.identityId = identity.id;
throw Error(`identityId is not set.`);
}
if (!identity.descriptors) {
const results = await photoDB.sequelize.query(
"SELECT " +
"descriptors,facesCount,faceId " +
"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.* " +
@ -383,73 +449,132 @@ const updateIdentityFaces = async (identity) => {
raw: true
});
if (faces.length === 0) {
return;
}
let average = undefined,
closestId = -1,
closestDistance = -1,
count = 0;
closestDistance = -1;
/* First find the average centroid of all faces */
faces.forEach((face) => {
if (!identity.descriptors) {
/* 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;
}
if (!face.descriptors) {
return;
}
face.distance = euclideanDistance(
/* 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,
identity.descriptors
average
);
face.descriptors = bufferToFloat32Array(face.descriptors).map(x => x * x);
if (closestId === -1) {
closestId = face.id;
closestDistance = face.distance;
average = descriptors;
count = 1;
return;
}
descriptors.forEach((x, i) => {
average[i] += x;
});
count++;
if (face.distance < closestDistance) {
closestDistance = face.distance;
if (closestId === -1 || face.distance < closestDistance) {
closestDistance = distance;
closestId = face.id;
}
});
let same = true;
if (average) {
average = average.map(x => x / count);
same = bufferToFloat32Array(identity.descriptors)
.find((x, i) => average[i] === x) === undefined;
await Promise(faces, async (face) => {
const distance = euclideanDistanceArray(face.descriptors, average);
if (distance !== face.distance) {
await photoDB.sequelize.query(
'UPDATE faces SET distance=:distance WHERE id=:faceId', {
replacements: face
}
);
}
});
/* 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) > 0.0001) {
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: 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 (!same) {
/* If the centroid changed, update the identity descriptors to
* the new average */
if (Math.abs(moved) > 0.0001) {
console.log(
`Updating identity ${identity.identityId} centroid ` +
`(moved ${Number.parseFloat(moved).toFixed(4)}).`);
if (sql !== '') {
sql = `${sql}, `;
}
sql = `${sql} descriptors=:descriptors`;
identity.descriptors = average;
// 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 !== '') {
identity.faceId = closestId;
await photoDB.sequelize.query(
`UPDATE identities SET ${sql} ` +
`WHERE id=:identityId`, {
@ -487,7 +612,8 @@ router.get("/:id?", async (req, res) => {
"identities.lastName," +
"identities.middleName," +
"identities.displayName," +
"identities.faceId " +
"identities.faceId," +
"identities.facesCount " +
"FROM identities " +
filter, {
replacements: { id },
@ -496,6 +622,9 @@ router.get("/:id?", async (req, res) => {
});
await Promise.map(identities, async (identity) => {
console.log(`Updating ${identity.identityId}`);
await updateIdentityFaces(identity);
for (let field in identity) {
if (field.match(/.*Name/) && identity[field] === null) {
identity[field] = '';
@ -526,6 +655,10 @@ router.get("/:id?", async (req, res) => {
}
);
/* 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);
@ -541,6 +674,8 @@ router.get("/:id?", async (req, res) => {
faceConfidence: 0
});
}
}, {
concurrency: 1
});
/* If no ID was provided then this call is returning