Identity averages are now working!
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
This commit is contained in:
parent
58a2baddde
commit
f8b33a65b8
9
client/package-lock.json
generated
9
client/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -119,6 +119,7 @@ function init() {
|
||||
key: 'id',
|
||||
}
|
||||
},
|
||||
facesCount: Sequelize.INTEGER,
|
||||
descriptors: Sequelize.BLOB /* average of all faces mapped to this */
|
||||
}, {
|
||||
timestamps: false
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user