Face selection is working pretty well

Signed-off-by: James Ketrenos <james_git@ketrenos.com>
This commit is contained in:
James Ketr 2023-01-25 22:57:42 -08:00
parent c4a6b6dad4
commit f1c1b79672
3 changed files with 188 additions and 128 deletions

View File

@ -49,10 +49,9 @@ div {
user-select: none; user-select: none;
overflow-y: scroll; overflow-y: scroll;
overflow-x: clip; overflow-x: clip;
height: 100%;
width: 100%; width: 100%;
gap: 0.25rem; gap: 0.25rem;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, auto)); grid-template-columns: repeat(auto-fill, minmax(4.25rem, auto));
} }
.Face { .Face {
@ -73,14 +72,29 @@ div {
flex-direction: column; flex-direction: column;
} }
.Viewer {
display: flex;
flex-direction: column;
position: relative;
}
.PhotoPanel { .PhotoPanel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
width: 100%;
justify-content: center; justify-content: center;
} }
.Guess {
display: flex;
justify-content: center;
align-items: center;
}
button {
padding: 0.5rem;
min-width: 4rem;
}
.Image .FaceBox { .Image .FaceBox {
border: 1px solid red; border: 1px solid red;
position: absolute; position: absolute;
@ -94,9 +108,6 @@ div {
.Image { .Image {
display: flex; display: flex;
position: relative; position: relative;
background-size: contain !important;
background-repeat: no-repeat no-repeat !important;
background-position: 50% 50% !important;
} }
.PhotoPanel .FaceInfo { .PhotoPanel .FaceInfo {
@ -112,6 +123,10 @@ div {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.Identities .UnknownFace {
font-size: 2rem;
}
.UnknownFace { .UnknownFace {
display: flex; display: flex;
align-items: center; align-items: center;
@ -158,6 +173,11 @@ div {
height: 10rem; height: 10rem;
} }
.Identities .Face .Image {
min-width: 4rem;
min-height: 4rem;
}
.Face .Image { .Face .Image {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@ -186,6 +206,13 @@ div {
align-items: flex-start; align-items: flex-start;
} }
.Viewer .PhotoPanel img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
.Image img { .Image img {
object-fit: cover; /* contain */ object-fit: cover; /* contain */
width: 100%; width: 100%;
@ -195,7 +222,7 @@ div {
.Cluster .Faces { .Cluster .Faces {
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
width: 100%; width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
} }

View File

@ -129,12 +129,7 @@ const Photo = ({ photoId, onFaceClick }: any) => {
<div className="Image" ref={ref}> <div className="Image" ref={ref}>
<img <img
alt={image.filename} alt={image.filename}
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')} src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}/>
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}} />
{ faces } { faces }
</div> </div>
<div className="ImageInfo">{ <div className="ImageInfo">{
@ -326,20 +321,6 @@ const Cluster = ({
}); });
await res.json(); await res.json();
setIdentity({ ...identity, ...values }); 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);
} }
@ -361,20 +342,6 @@ const Cluster = ({
}); });
const created = await res.json(); const created = await res.json();
setIdentity(created); setIdentity(created);
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);
} }
@ -537,6 +504,62 @@ const Button = ({ onClick, children }: any) => {
</button> </button>
); );
}; };
/* returns true if update to identities array occurred */
const updateIdentityReferences = (
identities: IdentityData[],
identity: IdentityData) : boolean => {
if (identity.identityId === -1) {
console.warn('Identity Unknown (-1) attempting to be updated');
return false;
}
const targetIndex = identities.findIndex(
x => x.identityId === identity.identityId);
if (targetIndex === -1) {
identities.push(identity);
return true;
}
const target = identities[targetIndex];
/*
IdentityData fields we check to make sure they are the same:
lastName: string,
middleName: string,
firstName: string,
displayName: string,
facesCount: number,
faceId: number
!identityId: number
!relatedFaces: FaceData[],
!descriptors: number[],
*/
let same = true;
[ 'lastName', 'firstName', 'middleName',
'displayName', 'faceId', 'facesCount', 'faceId' ]
.forEach((field: string) => {
same = same && (target as any)[field] === (identity as any)[field];
});
if (same) {
return false;
}
identities[targetIndex] = {
...identity,
relatedFaces: target.relatedFaces
};
/* relatedFaces is a list of references to identity */
identity.relatedFaces.forEach(face => {
face.identity = identity;
});
return true;
};
const App = () => { const App = () => {
const { identityId, faceId } = useParams(); const { identityId, faceId } = useParams();
@ -574,32 +597,28 @@ 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 */ * NOTE: Blocks update to 'Unknown' (-1) fake identity */
useEffect(() => { useEffect(() => {
if (!identity || identities.length === 0 || identity.identityId === -1) { if (identity.identityId === -1) {
return; return;
} }
for (let key in identities) {
if (identities[key].identityId === identity.identityId) { if (!updateIdentityReferences(identities, identity)) {
let same = true; return;
[ 'displayName', 'firstName', 'lastName', 'middleName' ]
.forEach((field: string) => {
same = same
&& (identities[key] as any)[field] === (identity as any)[field];
});
if (!same) {
console.log(`Updating `, identity, identities[key]);
identities[key] = {
...identity,
relatedFaces: identities[key].relatedFaces
};
/* relatedFaces is a list of references to identity */
identity.relatedFaces.forEach(face => {
face.identity = identity;
});
setIdentities([...identities]);
}
return;
}
} }
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);
})
);
}, [identity, setIdentities, identities]); }, [identity, setIdentities, identities]);
/* If the identity changes, scroll it into view in the Identities list */ /* If the identity changes, scroll it into view in the Identities list */
@ -720,7 +739,7 @@ const App = () => {
} }
}; };
const markSelectedIncorrectIdentity = async () => { const removeFaceFromIdentity = async () => {
if (!identity) { if (!identity) {
return; return;
} }
@ -735,9 +754,9 @@ const App = () => {
removeFacesFromIdentity(results.removed); removeFacesFromIdentity(results.removed);
deselectAll(); deselectAll();
if (identity.faceId !== results.faceId) { if (results.faceId !== undefined
setIdentity({...identity, ...{ faceId: results.faceId }}); && identity.faceId !== results.faceId) {
setIdentities([...identities]); setIdentity({...identity, faceId: results.faceId });
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -778,7 +797,6 @@ const App = () => {
target.faceId = results.faceId; target.faceId = results.faceId;
} }
deselectAll(); deselectAll();
setIdentities([...identities]);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -798,7 +816,7 @@ const App = () => {
} }
}; };
const markSelectedNotFace = async () => { const updateFasAsNotFace = async () => {
try { try {
const res = await window.fetch( const res = await window.fetch(
`${base}/api/v1/faces`, { `${base}/api/v1/faces`, {
@ -871,7 +889,8 @@ const App = () => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (identity.identityId !== identityId) { if (identity.identityId !== identityId
|| identity.facesCount === 0) {
[...document.querySelectorAll('.Cluster .Faces img')] [...document.querySelectorAll('.Cluster .Faces img')]
.forEach((img: any) => { .forEach((img: any) => {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
@ -901,8 +920,8 @@ const App = () => {
<Button onClick={guessIdentity}>Guess</Button> <Button onClick={guessIdentity}>Guess</Button>
</>} </>}
{ selected.length !== 0 && <> { selected.length !== 0 && <>
<Button onClick={markSelectedIncorrectIdentity}>Remove</Button> <Button onClick={removeFaceFromIdentity}>Remove</Button>
<Button onClick={markSelectedNotFace}>Not a face</Button> <Button onClick={updateFasAsNotFace}>Not a face</Button>
<Button onClick={changeSelectedIdentity}>Change Identity</Button> <Button onClick={changeSelectedIdentity}>Change Identity</Button>
<Button onClick={deselectAll}>Deselect All</Button> <Button onClick={deselectAll}>Deselect All</Button>
</>} </>}
@ -913,18 +932,17 @@ const App = () => {
</Panel> </Panel>
<PanelResizeHandle className="Resizer"/> <PanelResizeHandle className="Resizer"/>
<Panel> <Panel>
{image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>} <div className="Viewer">
{image !== 0 && <Photo onFaceClick={onFaceClick} photoId={image}/> } {image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>}
{guess !== undefined && guess.identity && <div {image !== 0 && <Photo onFaceClick={onFaceClick} photoId={image}/> }
style={{ {guess !== undefined && guess.identity && <div
display: "flex", className="Guess">
justifyContent: 'center', <Face
alignItems: 'center'}}> face={guess.identity.relatedFaces[0]}
<Face onFaceClick={guessOnFaceClick}
face={guess.identity.relatedFaces[0]} title={`${guess.identity.displayName} (${guess.distance})`}/>
onFaceClick={guessOnFaceClick} </div> }
title={`${guess.identity.displayName} (${guess.distance})`}/> </div>
</div> }
</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">
@ -932,7 +950,7 @@ const App = () => {
Loading... Loading...
</div> } </div> }
{ loaded && <Identities { loaded && <Identities
{... { onFaceClick: identitiesOnFaceClick, identities }}/> {...{ onFaceClick: identitiesOnFaceClick, identities }}/>
} }
</Panel> </Panel>
</PanelGroup> </PanelGroup>

View File

@ -9,7 +9,7 @@ require("../db/photos").then(function(db) {
photoDB = db; photoDB = db;
}); });
const MIN_DISTANCE_COMMIT = 0.0000001 const MIN_DISTANCE_COMMIT = 0.0001
const router = express.Router(); const router = express.Router();
@ -258,15 +258,15 @@ router.get("/faces/guess/:faceId", async (req, res) => {
}; };
}); });
router.put("/faces/remove/:id", async (req, res) => { router.put("/faces/remove/:identityId", async (req, res) => {
console.log(`PUT ${req.url}`) console.log(`PUT ${req.url}`)
if (!req.user.maintainer) { if (!req.user.maintainer) {
console.warn(`${req.user.name} attempted to modify photos.`); console.warn(`${req.user.name} attempted to modify photos.`);
return res.status(401).send({ message: "Unauthorized to modify photos." }); return res.status(401).send({ message: "Unauthorized to modify photos." });
} }
const id = parseInt(req.params.id); const identityId = parseInt(req.params.identityId);
if (id != req.params.id) { if (identityId != req.params.identityId) {
return res.status(400).send({ message: "Invalid identity id." }); return res.status(400).send({ message: "Invalid identity id." });
} }
@ -274,30 +274,35 @@ router.put("/faces/remove/:id", async (req, res) => {
return res.status(400).send({ message: "No faces supplied." }); return res.status(400).send({ message: "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));
try { try {
await photoDB.sequelize.query( await photoDB.sequelize.query(
"UPDATE faces SET identityId=null " + "UPDATE faces SET identityId=null " +
"WHERE id IN (:faceIds)", { "WHERE id IN (:faceIds)", {
replacements: { replacements: {
identityId: id, identityId,
faceIds: req.body.faces faceIds
} }
}); });
const identity = { const identity = {
identityId: id, identityId,
faces: req.body.faces removed: faceIds
}; };
identity.removed = identity.faces.map(id => +id);
/* If the primary faceId was removed, update the identity's faceId /* If the primary faceId was removed, update the identity's faceId
* to a new faceId */ * to a new faceId */
const faceIds = await photoDB.sequelize.query(` const identityFaceIds = await photoDB.sequelize.query(`
SELECT faceId FROM identities WHERE id=:identityId`, { SELECT faceId AS identityFaceId FROM identities WHERE id=:identityId`, {
replacements: identity, replacements: identity,
type: photoDB.Sequelize.QueryTypes.SELECT, type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true raw: true
}); });
if (identity.removed.indexOf(faceIds[0]) !== -1) { if (identity.removed.indexOf(identityFaceIds[0]) !== -1) {
const newFaceId = await photoDB.sequelize.query(` const newFaceId = await photoDB.sequelize.query(`
SELECT faceId FROM faces WHERE identityId=:identityId SELECT faceId FROM faces WHERE identityId=:identityId
ORDER BY distance ASC ORDER BY distance ASC
@ -343,7 +348,7 @@ router.put("/faces/add/:id", async (req, res) => {
} }
if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) { if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) {
return res.status(400).send("No faces supplied."); return res.status(400).send({ message: "No faces supplied." });
} }
/* Convert faces array to numbers and filter any non-numbers */ /* Convert faces array to numbers and filter any non-numbers */
@ -442,8 +447,13 @@ router.put("/faces/add/:id", async (req, res) => {
/* 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 */
Promise.map([identity, ...tuples], identity => { Promise.map([identity, ...tuples], (x, i) => {
updateIdentityFaces(identity); try {
updateIdentityFaces(x);
} catch (error) {
console.log(i, x);
throw error;
}
}, { }, {
concurrency: 1 concurrency: 1
}); });
@ -649,32 +659,37 @@ const updateIdentityFaces = async (identity) => {
const t = await photoDB.sequelize.transaction(); const t = await photoDB.sequelize.transaction();
try { try {
/* If the average position has not changed, then face distances should /* If the average position has not changed, then face distances should
* not change either! */ * not change either!
await Promise.map(faces, async (face) => { *
/* All the buffer are already arrays, so use the short-cut version */ * Do not update all the faces unless the centroid has moved a fair
const distance = Number * amount */
.parseFloat(face.updatedDistance) if (Math.abs(moved) > MIN_DISTANCE_COMMIT) {
.toFixed(4); await Promise.map(faces, async (face) => {
/* All the buffer are already arrays, so use the short-cut version */
if (Math.abs(face.updatedDistance - face.distance) const distance = Number
> MIN_DISTANCE_COMMIT) { .parseFloat(face.updatedDistance)
console.log( .toFixed(4);
`Updating face ${face.id} to ${round(distance, 2)} ` +
`(${distance - face.distance}) ` + if (Math.abs(face.updatedDistance - face.distance)
`from identity ${identity.identityId} (${identity.displayName})`); > MIN_DISTANCE_COMMIT) {
face.distance = face.updatedDistance; console.log(
delete face.updatedDistance; `Updating face ${face.id} to ${round(distance, 2)} ` +
await photoDB.sequelize.query( `(${distance - face.distance}) ` +
'UPDATE faces SET distance=:distance WHERE id=:id', { `from identity ${identity.identityId} (${identity.displayName})`);
replacements: face, face.distance = face.updatedDistance;
transaction: t delete face.updatedDistance;
} await photoDB.sequelize.query(
); 'UPDATE faces SET distance=:distance WHERE id=:id', {
} replacements: face,
}, { transaction: t
concurrency: 5 }
}); );
}
}, {
concurrency: 1
});
}
let sql = ''; let sql = '';
/* If there is a new closestId, then set the faceId field */ /* If there is a new closestId, then set the faceId field */
if (closestId !== -1 && closestId !== identity.faceId) { if (closestId !== -1 && closestId !== identity.faceId) {