Compare commits

...

3 Commits

Author SHA1 Message Date
a71fb177e9 Working fairly well
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-28 21:33:49 -08:00
e5a55de73c Improved face dropping from photopanel
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-28 20:19:52 -08:00
f6685e78e1 Add "forget" as face type
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-28 18:56:32 -08:00
8 changed files with 283 additions and 79 deletions

View File

@ -19,12 +19,19 @@ div {
height: 100%; height: 100%;
} }
.Resizer { .Explorer .Resizer {
width: 0.5rem; width: 0.5rem;
background-color: #ccc; background-color: #ccc;
border: 1px solid black; border: 1px solid black;
} }
.ExplorerVertical .Resizer {
height: 0.5rem;
width: 100%;
background-color: #ccc;
border: 1px solid black;
}
.Explorer { .Explorer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -35,6 +42,16 @@ div {
flex-grow: 1; flex-grow: 1;
} }
.ExplorerVertical {
display: flex;
flex-direction: column;
justify-self: stretch;
align-self: stretch;
width: 100%;
height: auto !important;
flex-grow: 1;
}
.Actions { .Actions {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -44,15 +61,23 @@ div {
width: 100%; width: 100%;
} }
.PhotoFaces,
.Identities { .Identities {
display: grid; display: grid;
user-select: none; user-select: none;
overflow-y: scroll;
overflow-x: clip; overflow-x: clip;
width: 100%; width: 100%;
max-height: 100%; /* scroll if too large */ max-height: 100%; /* scroll if too large */
gap: 0.25rem; gap: 0.25rem;
}
.PhotoFaces {
grid-template-columns: repeat(auto-fill, minmax(6.25rem, auto));
}
.Identities {
grid-template-columns: repeat(auto-fill, minmax(4.25rem, auto)); grid-template-columns: repeat(auto-fill, minmax(4.25rem, auto));
overflow-y: scroll;
} }
.Face { .Face {
@ -73,16 +98,26 @@ 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;
justify-content: center; justify-content: center;
max-height: 100%;
overflow-y: auto;
}
.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;
} }
.Guess { .Guess {
@ -96,34 +131,34 @@ button {
min-width: 4rem; min-width: 4rem;
} }
.Image .FaceBox { .PhotoInfo,
.FaceInfo {
display: flex;
align-content: center;
align-items: center;
align-self: center;
text-align: center;
width: 100%;
flex-grow: 1;
}
.Photo .FaceBox {
border: 1px solid red; border: 1px solid red;
position: absolute; position: absolute;
} }
.Image .FaceBox:hover { .Photo .FaceBox:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0px 0px 10px black; box-shadow: 0px 0px 10px black;
} }
.Image { .Photo {
display: flex; display: flex;
position: relative; position: relative;
flex-direction: column;
} }
.PhotoPanel .FaceInfo { .PhotoFaces .UnknownFace,
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;
}
.Identities .UnknownFace { .Identities .UnknownFace {
font-size: 2rem; font-size: 2rem;
} }
@ -174,6 +209,11 @@ button {
height: 10rem; height: 10rem;
} }
.PhotoFaces .Face .Image {
min-width: 6rem;
min-height: 6rem;
}
.Identities .Face .Image { .Identities .Face .Image {
min-width: 4rem; min-width: 4rem;
min-height: 4rem; min-height: 4rem;
@ -210,7 +250,7 @@ button {
} }
.Viewer .PhotoPanel img { .Photo img {
object-fit: contain; object-fit: contain;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;

View File

@ -62,10 +62,27 @@ const makeFaceBoxes = (photo: any,
}); });
}; };
type PhotoData = {
photoId: number,
faces: FaceData[],
filename: string,
path: string,
taken: number
};
const EmptyPhoto = {
photoId: -1,
faces: [],
filename: '',
path: '',
taken: Date.now()
};
const Photo = ({ photoId, onFaceClick }: any) => { const Photo = ({ photoId, onFaceClick }: any) => {
const [image, setImage] = useState<any>(undefined); const [photo, setPhoto] = useState<PhotoData>(EmptyPhoto);
const [faceInfo, setFaceInfo] = useState<string>(''); const [faceInfo, setFaceInfo] = useState<string>('');
const ref = useRef(null); const ref = useRef(null);
const [photoSelected, setPhotoSelected] = useState<FaceData[]>([]);
const [dimensions, setDimensions] = React.useState({width: 0, height: 0}); const [dimensions, setDimensions] = React.useState({width: 0, height: 0});
const onFaceEnter = (e: any, face: FaceData) => { const onFaceEnter = (e: any, face: FaceData) => {
@ -79,12 +96,12 @@ const Photo = ({ photoId, onFaceClick }: any) => {
} }
const faces = useMemo(() => { const faces = useMemo(() => {
if (image === undefined || dimensions.height === 0) { if (photo === undefined || dimensions.height === 0) {
return <></>; return <></>;
} }
return makeFaceBoxes(image, dimensions, return makeFaceBoxes(photo, dimensions,
onFaceClick, onFaceEnter, onFaceLeave); onFaceClick, onFaceEnter, onFaceLeave);
}, [image, dimensions, onFaceClick]); }, [photo, dimensions, onFaceClick]);
const checkResize = useCallback(() => { const checkResize = useCallback(() => {
if (!ref.current) { if (!ref.current) {
@ -101,7 +118,6 @@ const Photo = ({ photoId, onFaceClick }: any) => {
} }
}, [setDimensions, dimensions]); }, [setDimensions, dimensions]);
useEffect(() => { useEffect(() => {
let timer = setInterval(() => checkResize(), 250); let timer = setInterval(() => checkResize(), 250);
return () => { clearInterval(timer); } return () => { clearInterval(timer); }
@ -111,35 +127,110 @@ const Photo = ({ photoId, onFaceClick }: any) => {
if (photoId === 0) { if (photoId === 0) {
return; return;
} }
const fetchImageData = async (image: number) => { const fetchPhotoData = async (photoId: number) => {
console.log(`Loading photo ${image}`); console.log(`Loading photo ${photoId}`);
const res = await window.fetch(`${base}/api/v1/photos/${image}`); const res = await window.fetch(`${base}/api/v1/photos/${photoId}`);
const photo = await res.json(); const photo = await res.json();
setImage(photo); setPhoto(photo);
}; };
fetchImageData(photoId); setPhotoSelected([]);
}, [photoId, setImage]); fetchPhotoData(photoId);
}, [photoId, setPhoto, setPhotoSelected]);
if (image === undefined) { const forget = async () => {
try {
const res = await window.fetch(
`${base}/api/v1/faces`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'forget',
faces: photoSelected.map(face => face.faceId)
})
});
await res.json();
setPhotoSelected([]);
photo.faces = photo.faces.filter(
face => photoSelected.findIndex(x => x.faceId === face.faceId) === -1
);
setPhoto({...photo});
} catch (error) {
console.error(error);
}
};
const selectAll = async () => {
setPhotoSelected([...photo.faces]);
};
const clearSelection = async () => {
setPhotoSelected([]);
};
const faceClick = useCallback((e: any, face: FaceData) => {
const el = e.currentTarget;
/* Control -- select / deselect single item */
if (e.ctrlKey) {
const index = photoSelected.findIndex(x => x.faceId === face.faceId);
if (index !== -1) {
el.classList.remove('Selected');
photoSelected.splice(index, 1);
} else {
el.classList.add('Selected');
photoSelected.push(face);
}
setPhotoSelected([...photoSelected]);
return;
}
/* Shift -- select groups */
if (e.shiftKey) {
return;
}
/* Default to load image */
e.stopPropagation();
e.preventDefault();
}, [photoSelected, setPhotoSelected]);
if (photo.photoId === -1) {
return <></> return <></>
} }
return (<div className="PhotoPanel"> return (
<div className="Image" ref={ref}> <div className="PhotoPanel">
<div className="Photo" ref={ref}>
<img <img
alt={image.filename} alt={photo.filename}
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}/> src={`${base}/../${photo.path}thumbs/scaled/${photo.filename}`.replace(/ /g, '%20')}/>
{ faces } { faces }
</div> </div>
<div className="ImageInfo">{ <div className="PhotoInfo">{
moment(image.taken) moment(photo.taken).format('MMMM Do YYYY, h:mm:ss a')
.format('MMMM Do YYYY, h:mm:ss a')
}, { }, {
moment(image.taken) moment(photo.taken).fromNow()
.fromNow()
}.</div> }.</div>
<div className="FaceInfo">{ faceInfo ? faceInfo : 'Hover over face for information.'}</div> <div className="FaceInfo">
{ faceInfo ? faceInfo : 'Hover over face for information.'}
</div>
<div className="Actions">
{ photoSelected.length !== 0 && <Button onClick={forget}>
Forget Selected</Button> }
<Button onClick={selectAll}>Select All</Button>
<Button onClick={clearSelection}>Select None</Button>
</div>
{ photoSelected.length !== 0 && <div style={{ display: 'flex' }}>
Selected faces (CTRL-CLICK to remove):
</div> }
<div className="PhotoFaces">
{ photoSelected.map(face =>
<Face
key={face.faceId}
face={face}
onFaceClick={(e: any) => faceClick(e, face)}/>
) }
</div>
</div> </div>
); );
}; };
@ -738,6 +829,26 @@ const App = () => {
} }
}; };
const forgetFace = async () => {
try {
const res = await window.fetch(
`${base}/api/v1/faces`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'forget',
faces: selected
})
});
await res.json();
removeFacesFromIdentity(selected);
deselectAll();
} catch (error) {
console.error(error);
}
};
const changeSelectedIdentity = async () => { const changeSelectedIdentity = async () => {
if (selectedIdentities.length === 0) { if (selectedIdentities.length === 0) {
window.alert('You need to select an identity first (CTRL+CLICK)'); window.alert('You need to select an identity first (CTRL+CLICK)');
@ -928,6 +1039,7 @@ const App = () => {
<Button onClick={guessIdentity}>Guess</Button> <Button onClick={guessIdentity}>Guess</Button>
</>} </>}
{ selected.length !== 0 && <> { selected.length !== 0 && <>
<Button onClick={forgetFace}>Forget</Button>
<Button onClick={removeFaceFromIdentity}>Remove</Button> <Button onClick={removeFaceFromIdentity}>Remove</Button>
<Button onClick={updateFasAsNotFace}>Not a face</Button> <Button onClick={updateFasAsNotFace}>Not a face</Button>
<Button onClick={changeSelectedIdentity}>Change Identity</Button> <Button onClick={changeSelectedIdentity}>Change Identity</Button>
@ -939,19 +1051,21 @@ const App = () => {
</div> </div>
</Panel> </Panel>
<PanelResizeHandle className="Resizer"/> <PanelResizeHandle className="Resizer"/>
<Panel> <Panel><>
<div className="Viewer"> { image === 0 &&
{image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>} <div style={{ margin: '1rem' }}>Select image to view</div>
{image !== 0 && <Photo onFaceClick={onFaceClick} photoId={image}/> } }
{guess !== undefined && guess.identity && <div { image !== 0 &&
className="Guess"> <Photo onFaceClick={onFaceClick} photoId={image}/>
<Face }
face={guess.identity.relatedFaces[0]} { guess !== undefined && guess.identity && <div
onFaceClick={guessOnFaceClick} className="Guess">
title={`${guess.identity.displayName} (${guess.distance})`}/> <Face
</div> } face={guess.identity.relatedFaces[0]}
</div> onFaceClick={guessOnFaceClick}
</Panel> title={`${guess.identity.displayName} (${guess.distance})`}/>
</div> }
</></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">
{ !loaded && <div style={{ margin: '1rem' }}> { !loaded && <div style={{ margin: '1rem' }}>

View File

@ -101,6 +101,7 @@ def load_faces(db_path ):
JOIN facedescriptors ON (faces.descriptorId=facedescriptors.id) JOIN facedescriptors ON (faces.descriptorId=facedescriptors.id)
WHERE faces.identityId IS null WHERE faces.identityId IS null
AND faces.classifiedBy != 'not-a-face' AND faces.classifiedBy != 'not-a-face'
AND faces.classifiedBy != 'forget'
AND faces.photoId=photos.id AND faces.photoId=photos.id
''') ''')
for row in res.fetchall(): for row in res.fetchall():

52
server/db/MODIFY.md Normal file
View File

@ -0,0 +1,52 @@
# Dump schema
```bash
cat << EOF | sqlite3 db/photos.db > photos.schema
.schema
EOF
```
# Edit schema
```bash
nano photos.schema
```
# Backup database
```bash
cp db/photos.db photos.db.bk
```
# For the table you want to modify
## Backup the table
```bash
cat << EOF | sqlite3 db/photos.db
.output faces.dump
.dump faces
.quit
EOF
```
## Drop the table
```bash
cat << EOF | sqlite3 db/photos.db
drop table faces
EOF
```
## Create the table with the modified schema
```bash
cat photos.schema | sqlite3 db/photos.db
```
## Re-populate the table
```bash
cat faces.dump | sqlite3 db/photos.db
```

View File

@ -197,6 +197,7 @@ function init() {
type: Sequelize.DataTypes.ENUM( type: Sequelize.DataTypes.ENUM(
'machine', /* DBSCAN with VGG-Face */ 'machine', /* DBSCAN with VGG-Face */
'human', /* Human identified */ 'human', /* Human identified */
'forget', /* implies "human"; identityId=NULL */
'not-a-face'), /* implies "human"; identityId=NULL */ 'not-a-face'), /* implies "human"; identityId=NULL */
defaultValue: 'machine', defaultValue: 'machine',
}, },

View File

@ -36,27 +36,16 @@ router.put("/:id?", async (req, res/*, next*/) => {
} }
const { action } = req.body; const { action } = req.body;
console.log(`${action}: ${faces}`); console.log(`${action}: ${faces}`);
switch (action) { if ([ 'not-a-face', 'forget' ].indexOf(action) !== -1) {
case 'not-a-face':
await photoDB.sequelize.query( await photoDB.sequelize.query(
`UPDATE faces SET classifiedBy='not-a-face',identityId=NULL ` + `UPDATE faces SET classifiedBy=:action,identityId=NULL ` +
`WHERE id IN (:faces)`, { `WHERE id IN (:faces)`, {
replacements: { faces } replacements: {
action,
faces
}
} }
); );
/*
faces = await photoDB.sequelize.query(
'SELECT * FROM faces WHERE id IN (:faces)', {
replacements: { faces },
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}
);
faces.forEach(face => {
face.faceId = face.id;
delete face.id;
});
*/
return res.status(200).json(faces); return res.status(200).json(faces);
} }
return res.status(400).json({ message: "Invalid request" }); return res.status(400).json({ message: "Invalid request" });

View File

@ -448,8 +448,12 @@ 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], (x, i) => { Promise.map([identity, ...tuples], (x, i) => {
if (x.identityId === null) {
/* Moving from the Unknown group */
return;
}
try { try {
updateIdentityFaces(x); updateIdentityFaces(x);
} catch (error) { } catch (error) {
console.log(i, x); console.log(i, x);
throw error; throw error;
@ -528,10 +532,12 @@ const getUnknownIdentity = async (faceCount) => {
faces AS total faces AS total
WHERE WHERE
total.identityId IS NULL total.identityId IS NULL
AND total.classifiedBy != 'not-a-face') AS total AND total.classifiedBy != 'not-a-face'
AND total.classifiedBy != 'forget') AS total
WHERE WHERE
faces.identityId IS NULL faces.identityId IS NULL
AND faces.classifiedBy != 'not-a-face' AND faces.classifiedBy != 'not-a-face'
AND faces.classifiedBy != 'forget'
${ limit }`; ${ limit }`;
unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, { unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, {

View File

@ -1109,6 +1109,7 @@ router.get("/:id", async (req, res) => {
` `
SELECT faces.* FROM faces SELECT faces.* FROM faces
WHERE faces.photoId=:id WHERE faces.photoId=:id
AND faces.classifiedBy NOT IN ('not-a-face', 'forget' )
`, { `, {
replacements: { id }, replacements: { id },
type: photoDB.Sequelize.QueryTypes.SELECT type: photoDB.Sequelize.QueryTypes.SELECT