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

View File

@ -129,12 +129,7 @@ const Photo = ({ photoId, onFaceClick }: any) => {
<div className="Image" ref={ref}>
<img
alt={image.filename}
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}
style={{
objectFit: 'contain',
width: '100%',
height: '100%'
}} />
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}/>
{ faces }
</div>
<div className="ImageInfo">{
@ -326,20 +321,6 @@ const Cluster = ({
});
await res.json();
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) {
console.error(error);
}
@ -361,20 +342,6 @@ const Cluster = ({
});
const created = await res.json();
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) {
console.error(error);
}
@ -537,6 +504,62 @@ const Button = ({ onClick, children }: any) => {
</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 { identityId, faceId } = useParams();
@ -574,32 +597,28 @@ const App = () => {
/* If the identity changes, update its entry in the identities list
* NOTE: Blocks update to 'Unknown' (-1) fake identity */
useEffect(() => {
if (!identity || identities.length === 0 || identity.identityId === -1) {
if (identity.identityId === -1) {
return;
}
for (let key in identities) {
if (identities[key].identityId === identity.identityId) {
let same = true;
[ '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]);
}
if (!updateIdentityReferences(identities, identity)) {
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]);
/* 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) {
return;
}
@ -735,9 +754,9 @@ const App = () => {
removeFacesFromIdentity(results.removed);
deselectAll();
if (identity.faceId !== results.faceId) {
setIdentity({...identity, ...{ faceId: results.faceId }});
setIdentities([...identities]);
if (results.faceId !== undefined
&& identity.faceId !== results.faceId) {
setIdentity({...identity, faceId: results.faceId });
}
} catch (error) {
console.error(error);
@ -778,7 +797,6 @@ const App = () => {
target.faceId = results.faceId;
}
deselectAll();
setIdentities([...identities]);
} catch (error) {
console.error(error);
}
@ -798,7 +816,7 @@ const App = () => {
}
};
const markSelectedNotFace = async () => {
const updateFasAsNotFace = async () => {
try {
const res = await window.fetch(
`${base}/api/v1/faces`, {
@ -871,7 +889,8 @@ const App = () => {
e.stopPropagation();
e.preventDefault();
if (identity.identityId !== identityId) {
if (identity.identityId !== identityId
|| identity.facesCount === 0) {
[...document.querySelectorAll('.Cluster .Faces img')]
.forEach((img: any) => {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
@ -901,8 +920,8 @@ const App = () => {
<Button onClick={guessIdentity}>Guess</Button>
</>}
{ selected.length !== 0 && <>
<Button onClick={markSelectedIncorrectIdentity}>Remove</Button>
<Button onClick={markSelectedNotFace}>Not a face</Button>
<Button onClick={removeFaceFromIdentity}>Remove</Button>
<Button onClick={updateFasAsNotFace}>Not a face</Button>
<Button onClick={changeSelectedIdentity}>Change Identity</Button>
<Button onClick={deselectAll}>Deselect All</Button>
</>}
@ -913,18 +932,17 @@ const App = () => {
</Panel>
<PanelResizeHandle className="Resizer"/>
<Panel>
<div className="Viewer">
{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'}}>
className="Guess">
<Face
face={guess.identity.relatedFaces[0]}
onFaceClick={guessOnFaceClick}
title={`${guess.identity.displayName} (${guess.distance})`}/>
</div> }
</div>
</Panel>
<PanelResizeHandle className="Resizer" />
<Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList">

View File

@ -9,7 +9,7 @@ require("../db/photos").then(function(db) {
photoDB = db;
});
const MIN_DISTANCE_COMMIT = 0.0000001
const MIN_DISTANCE_COMMIT = 0.0001
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}`)
if (!req.user.maintainer) {
console.warn(`${req.user.name} attempted to modify photos.`);
return res.status(401).send({ message: "Unauthorized to modify photos." });
}
const id = parseInt(req.params.id);
if (id != req.params.id) {
const identityId = parseInt(req.params.identityId);
if (identityId != req.params.identityId) {
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." });
}
/* Convert faces array to numbers and filter any non-numbers */
let faceIds = req.body.faces
.map(faceId => +faceId)
.filter(faceId => !isNaN(faceId));
try {
await photoDB.sequelize.query(
"UPDATE faces SET identityId=null " +
"WHERE id IN (:faceIds)", {
replacements: {
identityId: id,
faceIds: req.body.faces
identityId,
faceIds
}
});
const identity = {
identityId: id,
faces: req.body.faces
identityId,
removed: faceIds
};
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`, {
const identityFaceIds = await photoDB.sequelize.query(`
SELECT faceId AS identityFaceId FROM identities WHERE id=:identityId`, {
replacements: identity,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
if (identity.removed.indexOf(faceIds[0]) !== -1) {
if (identity.removed.indexOf(identityFaceIds[0]) !== -1) {
const newFaceId = await photoDB.sequelize.query(`
SELECT faceId FROM faces WHERE identityId=:identityId
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) {
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 */
@ -442,8 +447,13 @@ router.put("/faces/add/:id", async (req, res) => {
/* Do not block on this call finishing -- update can occur
* in the background */
Promise.map([identity, ...tuples], identity => {
updateIdentityFaces(identity);
Promise.map([identity, ...tuples], (x, i) => {
try {
updateIdentityFaces(x);
} catch (error) {
console.log(i, x);
throw error;
}
}, {
concurrency: 1
});
@ -649,7 +659,11 @@ const updateIdentityFaces = async (identity) => {
const t = await photoDB.sequelize.transaction();
try {
/* If the average position has not changed, then face distances should
* not change either! */
* not change either!
*
* Do not update all the faces unless the centroid has moved a fair
* amount */
if (Math.abs(moved) > MIN_DISTANCE_COMMIT) {
await Promise.map(faces, async (face) => {
/* All the buffer are already arrays, so use the short-cut version */
const distance = Number
@ -672,8 +686,9 @@ const updateIdentityFaces = async (identity) => {
);
}
}, {
concurrency: 5
concurrency: 1
});
}
let sql = '';
/* If there is a new closestId, then set the faceId field */