diff --git a/client/package-lock.json b/client/package-lock.json index f0949c9..c2cb21e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,6 +21,7 @@ "react-resizable-panels": "^0.0.34", "react-router-dom": "^6.6.2", "react-scripts": "5.0.1", + "react-virtuoso": "^4.0.4", "typescript": "^4.9.4", "web-vitals": "^2.1.4" }, @@ -16774,6 +16775,18 @@ } } }, + "node_modules/react-virtuoso": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.0.4.tgz", + "integrity": "sha512-X+qHVnDCNFrG4l7CZN7ai9U7ulVZsTKNLVSjOXmN7kVjYwiN2kJVRYXvPBYsRpZbzRPZLJe7/mZMQG0IBfYJbw==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16 || >=17 || >= 18", + "react-dom": ">=16 || >=17 || >= 18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/client/package.json b/client/package.json index cc51be0..a7f27e0 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "react-resizable-panels": "^0.0.34", "react-router-dom": "^6.6.2", "react-scripts": "5.0.1", + "react-virtuoso": "^4.0.4", "typescript": "^4.9.4", "web-vitals": "^2.1.4" }, diff --git a/client/src/App.css b/client/src/App.css index 4bb3688..6b38d0b 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -148,8 +148,8 @@ div { .Face .Image { position: relative; box-sizing: border-box; - width: 8rem; - height: 8rem; + /*width: 8rem; + height: 8rem;*/ display: flex; justify-content: center; } @@ -158,7 +158,7 @@ div { user-select: none; display: flex; flex-direction: column; - overflow-y: scroll; +/* overflow-y: scroll;*/ padding: 0.5rem; height: 100%; } @@ -177,4 +177,7 @@ div { display: grid; gap: 0.25rem; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + width: 100%; + height: 100%; + flex-wrap: wrap; } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index b847cb0..249128c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,8 +8,9 @@ import { Routes, useParams } from "react-router-dom"; +import { VirtuosoGrid } from 'react-virtuoso' import moment from 'moment'; -import equal from "fast-deep-equal"; +//import equal from "fast-deep-equal"; import './App.css'; const base = process.env.PUBLIC_URL; /* /identities -- set in .env */ @@ -191,7 +192,7 @@ const Face = ({ face, onFaceClick, title, ...rest }: any) => { ?
?
: ; @@ -225,67 +226,7 @@ const Cluster = ({ identity, setIdentity, identities, setIdentities, setImage, setSelected }: ClusterProps) => { - const relatedFacesJSX = useMemo(() => { - const faceClicked = async (e: any, face: FaceData) => { - if (!identity) { - return; - } - const el = e.currentTarget; - - /* Control -- select / deselect single item */ - if (e.ctrlKey) { - const cluster = document.querySelector('.Cluster'); - el.classList.toggle('Selected'); - if (!cluster) { - return; - } - - const selected = [...cluster.querySelectorAll('.Selected')] - .map((face: any) => face.getAttribute('data-face-id')); - setSelected(selected); - return; - } - - /* Shift -- select groups */ - if (e.shiftKey) { - return; - } - - /* Default to load image */ - e.stopPropagation(); - e.preventDefault(); - setImage(face.photoId); - } - if (identity === undefined) { - return <>; - } - - return identity.relatedFaces.map((face: FaceData, i: number) => { - if (i >= 1000) { - if (i === 1000) { - return
- too many faces (${identity.relatedFaces.length - 1000} remaining) -
; - } else { - return; - } - } - return ( -
- -
- ); - }); - }, [identity, setImage, setSelected]); - + const lastNameChanged = (e: any) => { setIdentity({...identity, lastName: e.currentTarget.value }); }; @@ -299,6 +240,37 @@ const Cluster = ({ setIdentity({...identity, displayName: e.currentTarget.value }); }; + const faceClicked = async (e: any, face: FaceData) => { + if (!identity) { + return; + } + const el = e.currentTarget; + + /* Control -- select / deselect single item */ + if (e.ctrlKey) { + const cluster = document.querySelector('.Cluster'); + el.classList.toggle('Selected'); + if (!cluster) { + return; + } + + const selected = [...cluster.querySelectorAll('.Selected')] + .map((face: any) => face.getAttribute('data-face-id')); + setSelected(selected); + return; + } + + /* Shift -- select groups */ + if (e.shiftKey) { + return; + } + + /* Default to load image */ + e.stopPropagation(); + e.preventDefault(); + setImage(face.photoId); + }; + const deleteIdentity = async () => { try { const res = await window.fetch( @@ -401,9 +373,17 @@ const Cluster = ({
Faces: {identity.relatedFaces.length}
-
- { relatedFacesJSX } -
+ ( + + )} + /> ); }; @@ -650,7 +630,6 @@ const App = () => { headers: { 'Content-Type': 'application/json' }, }); const faces = await res.json(); - console.log(faces); setGuess(faces[0]); } catch (error) { console.error(error); diff --git a/server/db/photos.js b/server/db/photos.js index c11e390..0658612 100755 --- a/server/db/photos.js +++ b/server/db/photos.js @@ -195,8 +195,8 @@ function init() { }, classifiedBy: { type: Sequelize.DataTypes.ENUM( - 'machine', - 'human', + 'machine', /* DBSCAN with VGG-Face */ + 'human', /* Human identified */ 'not-a-face'), /* implies "human"; identityId=NULL */ defaultValue: 'machine', }, diff --git a/server/routes/identities.js b/server/routes/identities.js index e34cc68..9f24839 100755 --- a/server/routes/identities.js +++ b/server/routes/identities.js @@ -9,6 +9,8 @@ require("../db/photos").then(function(db) { photoDB = db; }); +const MIN_DISTANCE_COMMIT = 0.0000001 + const router = express.Router(); const upsertIdentity = async(id, { @@ -68,13 +70,14 @@ const populateRelatedFaces = async (identity, count) => { * just return the empty 'unknown face'. * * Otherwise, query the DB for 'count' faces */ - if (count === undefined) { + if (count !== undefined) { identity.relatedFaces = await photoDB.sequelize.query( "SELECT id AS faceId,photoId,faceConfidence " + "FROM faces " + "WHERE identityId=:identityId " + + "ORDER BY distance ASC" + limit, { - replacements: { identityId: identity.identityId }, + replacements: identity, type: photoDB.Sequelize.QueryTypes.SELECT, raw: true }); @@ -111,7 +114,7 @@ router.post('/', async (req, res) => { if (!identity) { return; } - await updateIdentityFaces(identity); + updateIdentityFaces(identity); await populateRelatedFaces(identity, 1); return res.status(200).send(identity); }); @@ -189,8 +192,9 @@ router.get("/faces/guess/:faceId", async (req, res) => { } try { + /* Look up the requested face... */ const faces = await photoDB.sequelize.query( - "SELECT faces.*,faceDescriptors.* " + + "SELECT faces.*,faceDescriptors.descriptors " + "FROM faces,faceDescriptors " + "WHERE faces.id=:faceId " + "AND faceDescriptors.id=faces.descriptorId", { @@ -199,6 +203,7 @@ router.get("/faces/guess/:faceId", async (req, res) => { raw: true }); + /* Look up all the identities... */ const identities = await photoDB.sequelize.query( "SELECT * FROM identities", { type: photoDB.Sequelize.QueryTypes.SELECT, @@ -207,14 +212,15 @@ router.get("/faces/guess/:faceId", async (req, res) => { identities.forEach(x => { x.identityId = x.id; delete x.id;}); faces.forEach((face) => { + const currentIdentityId = face.identityId; face.faceId = face.id; delete face.id; - face.identityId = -1; - face.distance = -1; face.identity = null; + identities.forEach((identity) => { - if (!identity.descriptors) { + if (!identity.descriptors + || currentIdentityId === identity.identityId) { return; } @@ -223,7 +229,7 @@ router.get("/faces/guess/:faceId", async (req, res) => { identity.descriptors ); - if (face.identityId === -1) { + if (face.identity === null) { face.identityId = identity.identityId; face.identity = identity; face.distance = distance; @@ -239,7 +245,6 @@ router.get("/faces/guess/:faceId", async (req, res) => { }); /* Delete the VGG-Face descriptors and add relatedFaces[0] */ - const results = []; await Promise.map(faces, async (face) => { delete face.descriptors; if (face.identity.descriptors) { @@ -290,7 +295,10 @@ router.put("/faces/remove/:id", async (req, res) => { }; identity.faces = identity.faces.map(id => +id); - await updateIdentityFaces(identity); + /* Do not block on this call finishing -- update can occur + * in the background */ + updateIdentityFaces(identity); + return res.status(200).json(identity); } catch (error) { console.error(error); @@ -328,7 +336,9 @@ router.put("/faces/add/:id", async (req, res) => { }; identity.faces = identity.faces.map(id => +id); - await updateIdentityFaces(identity); + /* Do not block on this call finishing -- update can occur + * in the background */ + updateIdentityFaces(identity); return res.status(200).json(identity); } catch (error) { console.error(error); @@ -422,7 +432,7 @@ const updateIdentityFaces = async (identity) => { if (!identity.descriptors) { const results = await photoDB.sequelize.query( "SELECT " + - "descriptors,facesCount,faceId " + + "descriptors,facesCount,faceId,displayName " + "FROM identities " + "WHERE id=:identityId", { replacements: identity, @@ -513,7 +523,7 @@ const updateIdentityFaces = async (identity) => { .parseFloat(euclideanDistanceArray(face.descriptors, average)) .toFixed(4); - if (Math.abs(distance - face.distance) > 0.0001) { + if (Math.abs(distance - face.distance) > MIN_DISTANCE_COMMIT) { console.log( `Updating face ${face.id} to ${round(distance, 2)} ` + `(${distance - face.distance}) ` + @@ -526,7 +536,7 @@ const updateIdentityFaces = async (identity) => { ); } }, { - concurrency: 1 + concurrency: 5 }); let sql = ''; @@ -540,7 +550,7 @@ const updateIdentityFaces = async (identity) => { /* If the centroid changed, update the identity descriptors to * the new average */ - if (Math.abs(moved) > 0.0001) { + if (Math.abs(moved) > MIN_DISTANCE_COMMIT) { console.log( `Updating identity ${identity.identityId} centroid ` + `(moved ${Number.parseFloat(moved).toFixed(4)}).`); @@ -622,9 +632,6 @@ router.get("/:id?", async (req, res) => { }); await Promise.map(identities, async (identity) => { - console.log(`Updating ${identity.identityId}`); - await updateIdentityFaces(identity); - for (let field in identity) { if (field.match(/.*Name/) && identity[field] === null) { identity[field] = '';