From e5a55de73ceac63b2694b418df5093a226add0cc Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 28 Jan 2023 20:19:52 -0800 Subject: [PATCH] Improved face dropping from photopanel Signed-off-by: James Ketrenos --- client/src/App.css | 35 ++++++++-- client/src/App.tsx | 126 ++++++++++++++++++++++++++++++------ server/db/MODIFY.md | 52 +++++++++++++++ server/routes/faces.js | 2 +- server/routes/identities.js | 2 +- 5 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 server/db/MODIFY.md diff --git a/client/src/App.css b/client/src/App.css index 49cfaec..7eaa53a 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -44,6 +44,7 @@ div { width: 100%; } +.PhotoFaces, .Identities { display: grid; user-select: none; @@ -52,6 +53,13 @@ div { 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)); } @@ -77,12 +85,14 @@ div { display: flex; flex-direction: column; position: relative; + height: 100%; } .PhotoPanel { display: flex; flex-direction: column; justify-content: center; + max-height: 100%; } .Guess { @@ -96,17 +106,28 @@ 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; } @@ -124,6 +145,7 @@ button { margin-top: 0.25rem; } +.PhotoFaces .UnknownFace, .Identities .UnknownFace { font-size: 2rem; } @@ -174,6 +196,11 @@ button { height: 10rem; } +.PhotoFaces .Face .Image { + min-width: 6rem; + min-height: 6rem; +} + .Identities .Face .Image { min-width: 4rem; min-height: 4rem; @@ -210,7 +237,7 @@ button { } -.Viewer .PhotoPanel img { +.Photo img { object-fit: contain; max-width: 100%; max-height: 100%; diff --git a/client/src/App.tsx b/client/src/App.tsx index 7e4d0a3..88e48da 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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(undefined); + const [photo, setPhoto] = useState(EmptyPhoto); const [faceInfo, setFaceInfo] = useState(''); const ref = useRef(null); + const [photoSelected, setPhotoSelected] = useState([]); 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) { @@ -111,35 +128,104 @@ 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([]); + 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 (
-
+
{image.filename} + alt={photo.filename} + src={`${base}/../${photo.path}thumbs/scaled/${photo.filename}`.replace(/ /g, '%20')}/> { faces }
-
{ - moment(image.taken) - .format('MMMM Do YYYY, h:mm:ss a') +
{ + moment(photo.taken).format('MMMM Do YYYY, h:mm:ss a') }, { - moment(image.taken) - .fromNow() + moment(photo.taken).fromNow() }.
-
{ faceInfo ? faceInfo : 'Hover over face for information.'}
+
+ { faceInfo ? faceInfo : 'Hover over face for information.'} +
+
+ { photoSelected.length !== 0 && } + + +
+ { photoSelected.length !== 0 &&
Selected faces (CTRL-CLICK to remove):
} +
+ { photoSelected.map(face => + faceClick(e, face)}/> + ) } +
); }; diff --git a/server/db/MODIFY.md b/server/db/MODIFY.md new file mode 100644 index 0000000..5118368 --- /dev/null +++ b/server/db/MODIFY.md @@ -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 +``` diff --git a/server/routes/faces.js b/server/routes/faces.js index 8c20b4e..5085644 100755 --- a/server/routes/faces.js +++ b/server/routes/faces.js @@ -38,7 +38,7 @@ router.put("/:id?", async (req, res/*, next*/) => { console.log(`${action}: ${faces}`); if ([ 'not-a-face', 'forget' ].indexOf(action) !== -1) { await photoDB.sequelize.query( - `UPDATE faces SET classifiedBy=':action',identityId=NULL ` + + `UPDATE faces SET classifiedBy=:action,identityId=NULL ` + `WHERE id IN (:faces)`, { replacements: { action, diff --git a/server/routes/identities.js b/server/routes/identities.js index 751596f..458e7a0 100755 --- a/server/routes/identities.js +++ b/server/routes/identities.js @@ -533,7 +533,7 @@ const getUnknownIdentity = async (faceCount) => { WHERE faces.identityId IS NULL AND faces.classifiedBy != 'not-a-face' - AND total.classifiedBy != 'forget' + AND faces.classifiedBy != 'forget' ${ limit }`; unknownIdentity.relatedFaces = await photoDB.sequelize.query(sql, {