Compare commits
3 Commits
34960de283
...
a71fb177e9
Author | SHA1 | Date | |
---|---|---|---|
a71fb177e9 | |||
e5a55de73c | |||
f6685e78e1 |
@ -19,12 +19,19 @@ div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Resizer {
|
||||
.Explorer .Resizer {
|
||||
width: 0.5rem;
|
||||
background-color: #ccc;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.ExplorerVertical .Resizer {
|
||||
height: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: #ccc;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.Explorer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -35,6 +42,16 @@ div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ExplorerVertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -44,15 +61,23 @@ div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.PhotoFaces,
|
||||
.Identities {
|
||||
display: grid;
|
||||
user-select: none;
|
||||
overflow-y: scroll;
|
||||
overflow-x: clip;
|
||||
width: 100%;
|
||||
max-height: 100%; /* scroll if too large */
|
||||
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));
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.Face {
|
||||
@ -73,16 +98,26 @@ div {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.PhotoPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@ -96,34 +131,34 @@ button {
|
||||
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;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.Image .FaceBox:hover {
|
||||
.Photo .FaceBox:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0px 0px 10px black;
|
||||
}
|
||||
|
||||
.Image {
|
||||
.Photo {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.PhotoFaces .UnknownFace,
|
||||
.Identities .UnknownFace {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@ -174,6 +209,11 @@ button {
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.PhotoFaces .Face .Image {
|
||||
min-width: 6rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.Identities .Face .Image {
|
||||
min-width: 4rem;
|
||||
min-height: 4rem;
|
||||
@ -210,7 +250,7 @@ button {
|
||||
}
|
||||
|
||||
|
||||
.Viewer .PhotoPanel img {
|
||||
.Photo img {
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
|
@ -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 [image, setImage] = useState<any>(undefined);
|
||||
const [photo, setPhoto] = useState<PhotoData>(EmptyPhoto);
|
||||
const [faceInfo, setFaceInfo] = useState<string>('');
|
||||
const ref = useRef(null);
|
||||
const [photoSelected, setPhotoSelected] = useState<FaceData[]>([]);
|
||||
const [dimensions, setDimensions] = React.useState({width: 0, height: 0});
|
||||
|
||||
const onFaceEnter = (e: any, face: FaceData) => {
|
||||
@ -79,12 +96,12 @@ const Photo = ({ photoId, onFaceClick }: any) => {
|
||||
}
|
||||
|
||||
const faces = useMemo(() => {
|
||||
if (image === undefined || dimensions.height === 0) {
|
||||
if (photo === undefined || dimensions.height === 0) {
|
||||
return <></>;
|
||||
}
|
||||
return makeFaceBoxes(image, dimensions,
|
||||
return makeFaceBoxes(photo, dimensions,
|
||||
onFaceClick, onFaceEnter, onFaceLeave);
|
||||
}, [image, dimensions, onFaceClick]);
|
||||
}, [photo, dimensions, onFaceClick]);
|
||||
|
||||
const checkResize = useCallback(() => {
|
||||
if (!ref.current) {
|
||||
@ -101,7 +118,6 @@ const Photo = ({ photoId, onFaceClick }: any) => {
|
||||
}
|
||||
}, [setDimensions, dimensions]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let timer = setInterval(() => checkResize(), 250);
|
||||
return () => { clearInterval(timer); }
|
||||
@ -111,35 +127,110 @@ const Photo = ({ photoId, onFaceClick }: any) => {
|
||||
if (photoId === 0) {
|
||||
return;
|
||||
}
|
||||
const fetchImageData = async (image: number) => {
|
||||
console.log(`Loading photo ${image}`);
|
||||
const res = await window.fetch(`${base}/api/v1/photos/${image}`);
|
||||
const fetchPhotoData = async (photoId: number) => {
|
||||
console.log(`Loading photo ${photoId}`);
|
||||
const res = await window.fetch(`${base}/api/v1/photos/${photoId}`);
|
||||
const photo = await res.json();
|
||||
setImage(photo);
|
||||
setPhoto(photo);
|
||||
};
|
||||
|
||||
fetchImageData(photoId);
|
||||
}, [photoId, setImage]);
|
||||
setPhotoSelected([]);
|
||||
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 (<div className="PhotoPanel">
|
||||
<div className="Image" ref={ref}>
|
||||
return (
|
||||
<div className="PhotoPanel">
|
||||
<div className="Photo" ref={ref}>
|
||||
<img
|
||||
alt={image.filename}
|
||||
src={`${base}/../${image.path}thumbs/scaled/${image.filename}`.replace(/ /g, '%20')}/>
|
||||
alt={photo.filename}
|
||||
src={`${base}/../${photo.path}thumbs/scaled/${photo.filename}`.replace(/ /g, '%20')}/>
|
||||
{ faces }
|
||||
</div>
|
||||
<div className="ImageInfo">{
|
||||
moment(image.taken)
|
||||
.format('MMMM Do YYYY, h:mm:ss a')
|
||||
<div className="PhotoInfo">{
|
||||
moment(photo.taken).format('MMMM Do YYYY, h:mm:ss a')
|
||||
}, {
|
||||
moment(image.taken)
|
||||
.fromNow()
|
||||
moment(photo.taken).fromNow()
|
||||
}.</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>
|
||||
);
|
||||
};
|
||||
@ -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 () => {
|
||||
if (selectedIdentities.length === 0) {
|
||||
window.alert('You need to select an identity first (CTRL+CLICK)');
|
||||
@ -928,6 +1039,7 @@ const App = () => {
|
||||
<Button onClick={guessIdentity}>Guess</Button>
|
||||
</>}
|
||||
{ selected.length !== 0 && <>
|
||||
<Button onClick={forgetFace}>Forget</Button>
|
||||
<Button onClick={removeFaceFromIdentity}>Remove</Button>
|
||||
<Button onClick={updateFasAsNotFace}>Not a face</Button>
|
||||
<Button onClick={changeSelectedIdentity}>Change Identity</Button>
|
||||
@ -939,10 +1051,13 @@ const App = () => {
|
||||
</div>
|
||||
</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}/> }
|
||||
<Panel><>
|
||||
{ image === 0 &&
|
||||
<div style={{ margin: '1rem' }}>Select image to view</div>
|
||||
}
|
||||
{ image !== 0 &&
|
||||
<Photo onFaceClick={onFaceClick} photoId={image}/>
|
||||
}
|
||||
{ guess !== undefined && guess.identity && <div
|
||||
className="Guess">
|
||||
<Face
|
||||
@ -950,8 +1065,7 @@ const App = () => {
|
||||
onFaceClick={guessOnFaceClick}
|
||||
title={`${guess.identity.displayName} (${guess.distance})`}/>
|
||||
</div> }
|
||||
</div>
|
||||
</Panel>
|
||||
</></Panel>
|
||||
<PanelResizeHandle className="Resizer" />
|
||||
<Panel defaultSize={8.5} minSize={8.5} className="IdentitiesList">
|
||||
{ !loaded && <div style={{ margin: '1rem' }}>
|
||||
|
@ -101,6 +101,7 @@ def load_faces(db_path ):
|
||||
JOIN facedescriptors ON (faces.descriptorId=facedescriptors.id)
|
||||
WHERE faces.identityId IS null
|
||||
AND faces.classifiedBy != 'not-a-face'
|
||||
AND faces.classifiedBy != 'forget'
|
||||
AND faces.photoId=photos.id
|
||||
''')
|
||||
for row in res.fetchall():
|
||||
|
52
server/db/MODIFY.md
Normal file
52
server/db/MODIFY.md
Normal 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
|
||||
```
|
@ -197,6 +197,7 @@ function init() {
|
||||
type: Sequelize.DataTypes.ENUM(
|
||||
'machine', /* DBSCAN with VGG-Face */
|
||||
'human', /* Human identified */
|
||||
'forget', /* implies "human"; identityId=NULL */
|
||||
'not-a-face'), /* implies "human"; identityId=NULL */
|
||||
defaultValue: 'machine',
|
||||
},
|
||||
|
@ -36,27 +36,16 @@ router.put("/:id?", async (req, res/*, next*/) => {
|
||||
}
|
||||
const { action } = req.body;
|
||||
console.log(`${action}: ${faces}`);
|
||||
switch (action) {
|
||||
case 'not-a-face':
|
||||
if ([ 'not-a-face', 'forget' ].indexOf(action) !== -1) {
|
||||
await photoDB.sequelize.query(
|
||||
`UPDATE faces SET classifiedBy='not-a-face',identityId=NULL ` +
|
||||
`UPDATE faces SET classifiedBy=:action,identityId=NULL ` +
|
||||
`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(400).json({ message: "Invalid request" });
|
||||
|
@ -448,6 +448,10 @@ 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], (x, i) => {
|
||||
if (x.identityId === null) {
|
||||
/* Moving from the Unknown group */
|
||||
return;
|
||||
}
|
||||
try {
|
||||
updateIdentityFaces(x);
|
||||
} catch (error) {
|
||||
@ -528,10 +532,12 @@ const getUnknownIdentity = async (faceCount) => {
|
||||
faces AS total
|
||||
WHERE
|
||||
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
|
||||
faces.identityId IS NULL
|
||||
AND faces.classifiedBy != 'not-a-face'
|
||||
AND faces.classifiedBy != 'forget'
|
||||
${ limit }`;
|
||||
|
||||
unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, {
|
||||
|
@ -1109,6 +1109,7 @@ router.get("/:id", async (req, res) => {
|
||||
`
|
||||
SELECT faces.* FROM faces
|
||||
WHERE faces.photoId=:id
|
||||
AND faces.classifiedBy NOT IN ('not-a-face', 'forget' )
|
||||
`, {
|
||||
replacements: { id },
|
||||
type: photoDB.Sequelize.QueryTypes.SELECT
|
||||
|
Loading…
x
Reference in New Issue
Block a user