import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom"; import { VirtuosoGrid } from 'react-virtuoso' import moment from 'moment'; import './App.css'; const base = process.env.PUBLIC_URL; /* /identities -- set in .env */ const makeFaceBoxes = (photo: any, dimensions: any, onFaceClick: any, onFaceEnter: any, onFaceLeave: any, ): any => { const faces: FaceData[] = photo.faces; let width: number, height: number, offsetLeft = 0, offsetTop = 0; /* If photo is wider than viewport, it will be 100% width and < 100% height */ if (photo.width / photo.height > dimensions.width / dimensions.height) { console.log('Landscape'); width = dimensions.width; height = dimensions.height * photo.height / photo.width * dimensions.width / dimensions.height; offsetLeft = 0; offsetTop = (dimensions.height - height) * 0.5; } else { console.log('Portrait'); width = dimensions.width * photo.width / photo.height * dimensions.height / dimensions.width; height = dimensions.height; offsetLeft = (dimensions.width - width) * 0.5; offsetTop = 0; } return faces.map((face: FaceData) => { const faceId = face.faceId; const identityId = face.identityId; return (
{ onFaceClick(e, face) }} onMouseEnter={(e) => { onFaceEnter(e, face) }} onMouseLeave={(e) => { onFaceLeave(e, face) }} /> ) }); }; const Photo = ({ photoId, onFaceClick }: any) => { const [image, setImage] = useState(undefined); const [faceInfo, setFaceInfo] = useState(''); const ref = useRef(null); const [dimensions, setDimensions] = React.useState({width: 0, height: 0}); const onFaceEnter = (e: any, face: FaceData) => { onFaceMouseEnter(e, face); setFaceInfo(face.identity ? face.identity.displayName : 'Unknown'); } const onFaceLeave = (e: any, face: FaceData) => { setFaceInfo(''); onFaceMouseLeave(e, face); } const faces = useMemo(() => { if (image === undefined || dimensions.height === 0) { return <>; } return makeFaceBoxes(image, dimensions, onFaceClick, onFaceEnter, onFaceLeave); }, [image, dimensions, onFaceClick]); const checkResize = useCallback(() => { if (!ref.current) { return; } const el: Element = ref.current as Element; if (dimensions.height !== el.clientHeight || dimensions.width !== el.clientWidth) { setDimensions({ height: el.clientHeight, width: el.clientWidth }); } }, [setDimensions, dimensions]); useEffect(() => { let timer = setInterval(() => checkResize(), 250); return () => { clearInterval(timer); } }, [checkResize]); useEffect(() => { 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 photo = await res.json(); setImage(photo); }; fetchImageData(photoId); }, [photoId, setImage]); if (image === undefined) { return <> } return (
{image.filename} { faces }
{ moment(image.taken) .format('MMMM Do YYYY, h:mm:ss a') }, { moment(image.taken) .fromNow() }.
{ faceInfo ? faceInfo : 'Hover over face for information.'}
); }; const onFaceMouseEnter = (e: any, face: FaceData) => { const faceId = face.faceId; const els = [...document.querySelectorAll(`[data-face-id="${faceId}"]`)]; const identityId = face.identityId; els.splice(0, 0, ...document.querySelectorAll( `.Identities [data-identity-id="${identityId}"]`), ...document.querySelectorAll( `.Photo [data-identity-id="${identityId}"]`)); els.forEach(el => { el.classList.add('Active'); }); e.stopPropagation(); e.preventDefault(); }; const onFaceMouseLeave = (e: any, face: FaceData) => { const faceId = face.faceId; const els = [...document.querySelectorAll(`[data-face-id="${faceId}"]`)]; const identityId = face.identityId; els.splice(0, 0, ...document.querySelectorAll(`[data-identity-id="${identityId}"]`)); els.forEach(el => { el.classList.remove('Active'); }); e.stopPropagation(); e.preventDefault(); }; const Face = ({ face, onFaceClick, title, isSelected }: any) => { const faceId = face.faceId; const idPath = String(faceId % 100).padStart(2, '0'); const img = faceId === -1 ?
?
: {faceId}; return (
{ onFaceClick(e, face) }} onMouseEnter={(e) => { onFaceMouseEnter(e, face) }} onMouseLeave={(e) => { onFaceMouseLeave(e, face) }} className={`Face ${isSelected ? 'Selected' : ''}`}>
{ img }
{title}
); }; type ClusterProps = { identity: IdentityData, setIdentity(identity: IdentityData): void, identities: IdentityData[], setIdentities(identiteis: IdentityData[]): void, setImage(image: number): void, selected: number[], setSelected(selected: number[]): void, }; const Cluster = ({ identity, setIdentity, identities, setIdentities, selected, setSelected, setImage, }: ClusterProps) => { const [lastName, setLastName] = useState(identity.lastName); const [firstName, setFirstName] = useState(identity.firstName); const [middleName, setMiddleName] = useState(identity.middleName); const [displayName, setDisplayName] = useState(identity.displayName); const [updated, setUpdated] = useState(false); const lastNameChanged = (e: any) => { setLastName(e.currentTarget.value); }; const firstNameChanged = (e: any) => { setFirstName(e.currentTarget.value); }; const middleNameChanged = (e: any) => { setMiddleName(e.currentTarget.value); }; const displayNameChanged = (e: any) => { setDisplayName(e.currentTarget.value); }; /* If the user edits the identity, set the "updated" flag */ useEffect(() => { setUpdated(lastName !== identity.lastName || firstName !== identity.firstName || middleName !== identity.middleName || displayName !== identity.displayName ); }, [setUpdated, identity, lastName, firstName, middleName, displayName]); /* If the identity changes, update all the fields */ useEffect(() => { setLastName(identity.lastName); setFirstName(identity.firstName); setMiddleName(identity.middleName); setDisplayName(identity.displayName); }, [identity]); const faceClicked = useCallback((e: any, face: FaceData) => { const el = e.currentTarget; /* Control -- select / deselect single item */ if (e.ctrlKey) { el.classList.toggle('Selected'); const tmp = [...document.querySelectorAll('.Cluster .Selected')] .map((face: any) => +face.getAttribute('data-face-id')); setSelected(tmp); return; } /* Shift -- select groups */ if (e.shiftKey) { return; } /* Default to load image */ e.stopPropagation(); e.preventDefault(); setImage(face.photoId); }, [setSelected, setImage]); const deleteIdentity = async () => { if (!identity || identity.identityId === -1) { return; } try { const res = await window.fetch( `${base}/api/v1/identities/${identity.identityId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); await res.json(); const index = identities .findIndex((item: IdentityData) => item.identityId === identity.identityId); if (index !== -1) { identities.splice(index, 1); } setIdentity(EmptyIdentity); setIdentities([...identities]); } catch (error) { console.error(error); } }; const updateIdentity = async () => { if (!identity || identity.identityId === -1) { return; } try { const values = { lastName, firstName, middleName, displayName }; const res = await window.fetch( `${base}/api/v1/identities/${identity.identityId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }); await res.json(); setIdentity({ ...identity, ...values }); } catch (error) { console.error(error); } }; const createIdentity = async () => { try { const values = { lastName, firstName, middleName, displayName }; const res = await window.fetch( `${base}/api/v1/identities/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }); const created = await res.json(); setIdentity(created); } catch (error) { console.error(error); } }; if (identity === undefined) { return (
Select identity to load.
); } return (
Last name:
First name:
Middle name:
Display name:
{}} title={`${displayName} (${identity.facesCount})`} />
{ identity.identityId !== -1 && <> { updated && } }
Faces: {identity.relatedFaces.length}
( x === face.faceId) !== -1 } face={face} onFaceClick={faceClicked} title={face.distance} /> )} />
); }; type FaceData = { faceId: number, photoId: number, identity: IdentityData, identityId: number, distance: number, descriptors: any[], top: number right: number, bottom: number, left: number }; type IdentityData = { lastName: string, middleName: string, firstName: string, descriptors: number[], identityId: number displayName: string, relatedFaces: FaceData[], facesCount: number, faceId: number }; const EmptyIdentity: IdentityData = { lastName: '', middleName: '', firstName: '', descriptors: [], identityId: -1, displayName: '', relatedFaces: [], facesCount: 0, faceId: -1 }; const UnknownFace = { faceId: -1, photoId: -1, identityId: -1, distance: 0, descriptors: [], top: 0, left: 0, bottom: 0, right: 0, identity: EmptyIdentity }; interface IdentitiesProps { identities: IdentityData[], onFaceClick(e: any, face: FaceData): void }; const Identities = ({ identities, onFaceClick } : IdentitiesProps) => { const [jsx, setJsx] = useState([]); useEffect(() => { setJsx(identities.map((identity) => { const face = { faceId: identity.faceId, identityId: identity.identityId }; return (
); })); }, [identities, onFaceClick]); return (
{ jsx }
); }; const Button = ({ onClick, children }: any) => { return ( ); }; /* 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(); const [identities, setIdentities] = useState([]); const [selectedIdentities, setSelectedIdentities] = useState([]); const [identity, setIdentity] = useState(EmptyIdentity); const [image, setImage] = useState(0); const [guess, setGuess] = useState(undefined); const [loaded, setLoaded] = useState(false); const [selected, setSelected] = useState([]); /* If 'selected' changes, clear any selected face which is not in the * selected array. */ useEffect(() => { [...document.querySelectorAll('.Cluster .Selected')].forEach(el => { const faceId = el.getAttribute('data-face-id'); if (faceId) { if (selected.findIndex(item => item === +faceId) === -1) { el.classList.remove('Selected'); } } }); }, [selected]); const loadIdentity = async (identityId: number) => { try { const res = await window.fetch(`${base}/api/v1/identities/${identityId}`); const data = await res.json(); setIdentity(data[0]); } catch (error) { console.error(error); } }; /* If the identity changes, update its entry in the identities list * NOTE: Blocks update to 'Unknown' (-1) fake identity */ useEffect(() => { if (identity.identityId === -1) { return; } 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 */ useEffect(() => { if (selectedIdentities.length !== 0) { return; } const el = document.querySelector( `.Identities [data-identity-id="${identity.identityId}"]`); if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); } }, [identity, selectedIdentities]); useEffect(() => { if (identityId !== undefined && !isNaN(+identityId)) { loadIdentity(+identityId); } if (faceId !== undefined && !isNaN(+faceId)) { setImage(+faceId); } // eslint-disable-next-line }, []); useEffect(() => { if (identities.length !== 0 || loaded) { return; } const loadIdentities = async () => { const res = await window.fetch(`${base}/api/v1/identities`, { headers: { 'Content-Type': 'application/json' }, }); const data = await res.json(); data.forEach((identity: IdentityData) => { identity.relatedFaces.forEach(face => { face.identity = identity; }); }); data.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); }); setLoaded(true); setIdentities(data as IdentityData[]); } loadIdentities(); }, [identities, setIdentities, setLoaded, loaded]); const removeFacesFromIdentity = (faceIds: number[]) => { if (!identity) { return; } const pre = identity.relatedFaces.length; /* Remove all relatedFaces which are part of the set of removed * faces */ identity.relatedFaces = identity.relatedFaces.filter( (face: FaceData) => faceIds.indexOf(face.faceId) === -1); if (pre !== identity.relatedFaces.length) { identity.facesCount = identity.relatedFaces.length; setIdentity({ ...identity }) } } const mergeIdentity = async () => { if (selectedIdentities.length === 0) { window.alert('You need to select an identity first (CTRL+CLICK)'); return; } if (!identity || identity.identityId === -1) { return; } try { let res = await window.fetch( `${base}/api/v1/identities/faces/add/${selectedIdentities[0]}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faces: identity.relatedFaces .map(face => face.faceId) }) }); const result = await res.json(); res = await window.fetch( `${base}/api/v1/identities/${identity.identityId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); await res.json(); /* Delete the identity from the list of identities */ const deleted = identities .findIndex((item: IdentityData) => item.identityId === identity.identityId); if (deleted !== -1) { identities.splice(deleted, 1); } /* Update the faces count on the target identity */ const target = identities .find((item: IdentityData) => item.identityId === selectedIdentities[0]); if (target) { target.facesCount += result.added.length; } setIdentity(EmptyIdentity); setIdentities([...identities]); } catch (error) { console.error(error); } }; const removeFaceFromIdentity = async () => { if (!identity) { return; } try { const res = await window.fetch( `${base}/api/v1/identities/faces/remove/${identity.identityId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faces: selected }) }); const results = await res.json(); removeFacesFromIdentity(results.removed); deselectAll(); if (results.faceId !== undefined && identity.faceId !== results.faceId) { setIdentity({...identity, faceId: results.faceId }); } } catch (error) { console.error(error); } }; const changeSelectedIdentity = async () => { if (selectedIdentities.length === 0) { window.alert('You need to select an identity first (CTRL+CLICK)'); return; } try { const res = await window.fetch( `${base}/api/v1/identities/faces/add/${selectedIdentities[0]}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faces: selected }) }); const results = await res.json(); removeFacesFromIdentity(results.added); /* If the identity faceId was removed from the identity, select * the next relatedFace */ if (results.added.indexOf(identity.faceId) !== -1) { if (identity.relatedFaces.length === 0) { identity.faceId = -1; } else { identity.faceId = identity.relatedFaces[0].faceId; } setIdentity({...identity}); } /* Update the faces count on the target identity */ const target = identities .find((item: IdentityData) => item.identityId === selectedIdentities[0]); if (target) { target.facesCount += results.added.length; target.faceId = results.faceId; } deselectAll(); } catch (error) { console.error(error); } }; const guessIdentity = async () => { try { const res = await window.fetch( `${base}/api/v1/identities/faces/guess/${selected[0]}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); const faces = await res.json(); setGuess(faces[0]); } catch (error) { console.error(error); } }; const updateFasAsNotFace = async () => { try { const res = await window.fetch( `${base}/api/v1/faces`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'not-a-face', faces: selected }) }); await res.json(); removeFacesFromIdentity(selected); deselectAll(); } catch (error) { console.error(error); } }; const deselectAll = () => { [...document.querySelectorAll('.Cluster .Selected')].forEach(item => { item.classList.remove('Selected'); }); setSelected([]); }; const onFaceClick = (e: any, face: FaceData) => { const identityId = face.identityId; const faceId = face.faceId; console.log(`onFaceClick`, { faceId, identityId}); const faces = [ ...document.querySelectorAll(`.Identities [data-identity-id="${identityId}"]`), ...document.querySelectorAll(`.Cluster [data-face-id="${faceId}"]`)]; faces.forEach((el: any) => { el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); }); }; const guessOnFaceClick = (e: any, face: FaceData) => { }; const identitiesOnFaceClick = (e: any, face: FaceData) => { const identityId = face.identityId; const el = e.currentTarget; /* Control -- select / deselect single item */ if (e.ctrlKey) { let set = !el.classList.contains('Selected'); [...document.querySelectorAll('.Identities .Selected')].forEach(item => { item.classList.remove('Selected') }); if (set) { el.classList.add('Selected'); } const tmp = [...document.querySelectorAll('.Identities .Selected')] .map((face: any) => +face.getAttribute('data-identity-id')); setSelectedIdentities(tmp); return; } /* Shift -- select groups */ if (e.shiftKey) { return; } /* Default to load image */ e.stopPropagation(); e.preventDefault(); if (identity.identityId !== identityId || identity.facesCount === 0) { [...document.querySelectorAll('.Cluster .Faces img')] .forEach((img: any) => { img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; }); loadIdentity(identityId); } } return (
{ selected.length === 1 && <> } { selected.length !== 0 && <> } { selectedIdentities.length !== 0 && <> }
{image === 0 &&
Select image to view
} {image !== 0 && } {guess !== undefined && guess.identity &&
}
{ !loaded &&
Loading...
} { loaded && }
); } const AppRouter = () => { return ( } path={`${base}/:identityId?/:faceId?`} /> ); } export default AppRouter;