Improved UX

Signed-off-by: James P. Ketrenos <james.p.ketrenos@intel.com>
This commit is contained in:
James P. Ketrenos 2023-01-17 14:12:48 -08:00
parent 75221f6cd9
commit a9549d29a9
8 changed files with 199 additions and 85 deletions

View File

@ -17,6 +17,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-resizable-panels": "^0.0.34",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
@ -14226,6 +14227,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-resizable-panels": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.34.tgz",
"integrity": "sha512-GGT69jbCiK5Fmw7p9mopb+quX63g+OA235bSHtj8TD3O+wsFNgrg9j5TaRI6auP1J10SBmR0OpJ7tX3K7MFxeg==",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-scripts": { "node_modules/react-scripts": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",

View File

@ -13,6 +13,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-resizable-panels": "^0.0.34",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"

View File

@ -19,6 +19,21 @@ div {
height: 100%; height: 100%;
} }
.Resizer {
width: 0.5rem;
border: 1px solid black;
}
.Explorer {
display: flex;
flex-direction: row;
justify-self: stretch;
align-self: stretch;
height: 100%;
width: auto !important;
flex-grow: 1;
}
.Identities { .Identities {
display: flex; display: flex;
overflow-y: scroll; overflow-y: scroll;
@ -71,11 +86,12 @@ div {
} }
.Cluster { .Cluster {
user-select: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: scroll; overflow-y: scroll;
flex-grow: 1;
padding: 0.5rem; padding: 0.5rem;
height: 100%;
} }
.Cluster .Face.Selected { .Cluster .Face.Selected {

View File

@ -1,8 +1,36 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { useApi } from './useApi'; import { useApi } from './useApi';
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import './App.css'; import './App.css';
const Photo = ({ photoId }: any) => {
const [image, setImage] = useState<any>(undefined);
useEffect(() => {
if (photoId === 0) {
return;
}
const fetchImageData = async (image: number) => {
console.log(`Loading photo ${image}`);
const res = await window.fetch(`../api/v1/photos/${image}`);
const data = await res.json();
setImage(data[0]);
};
fetchImageData(photoId);
}, [photoId, setImage]);
if (image === undefined) {
return <></>
}
return (<div className="Image"
style={{
background: `url(${image.path}thumbs/scaled/${image.filename})`
}}/>);
};
const Face = ({ faceId, onClick, title }: any) => { const Face = ({ faceId, onClick, title }: any) => {
const idPath = String(faceId % 100).padStart(2, '0'); const idPath = String(faceId % 100).padStart(2, '0');
return ( return (
@ -19,10 +47,11 @@ const Face = ({ faceId, onClick, title }: any) => {
}; };
type ClusterProps = { type ClusterProps = {
id: number id: number,
setImage(image: number): void
}; };
const Cluster = ({ id }: ClusterProps) => { const Cluster = ({ id, setImage }: ClusterProps) => {
const [identity, setIdentity] = useState<Identity | undefined>(undefined); const [identity, setIdentity] = useState<Identity | undefined>(undefined);
const { loading, data } = useApi( const { loading, data } = useApi(
`../api/v1/identities/${id}` `../api/v1/identities/${id}`
@ -38,15 +67,22 @@ const Cluster = ({ id }: ClusterProps) => {
} }
}, [data]); }, [data]);
const relatedFacesJSX = useMemo(() => { const relatedFacesJSX = useMemo(() => {
const faceClicked = (e: any, id: any) => { const faceClicked = async (e: any, id: any) => {
if (!identity) { if (!identity) {
return; return;
} }
const el = e.currentTarget; const el = e.currentTarget;
const face = identity.relatedFaces.find(item => item.faceId === id); const face = identity.relatedFaces.find(item => item.faceId === id);
if (!face) {
return;
}
if (e.shiftKey) {
e.stopPropagation();
e.preventDefault();
setImage(face.photoId);
return;
}
el.classList.toggle('Selected'); el.classList.toggle('Selected');
console.log(face); console.log(face);
} }
@ -59,7 +95,7 @@ const Cluster = ({ id }: ClusterProps) => {
onClick={faceClicked} onClick={faceClicked}
title={face.distance}/> title={face.distance}/>
); );
}, [identity]); }, [identity, setImage]);
const lastNameChanged = (e: any) => { const lastNameChanged = (e: any) => {
setIdentity(Object.assign( setIdentity(Object.assign(
@ -138,6 +174,7 @@ const Cluster = ({ id }: ClusterProps) => {
type FaceData = { type FaceData = {
faceId: number, faceId: number,
photoId: number,
lastName: string, lastName: string,
firstName: string, firstName: string,
middleName: string, middleName: string,
@ -190,6 +227,7 @@ const Identities = ({ identities, setIdentity } : IdentitiesProps) => {
const App = () => { const App = () => {
const [identities, setIdentities] = useState<Identity[]>([]); const [identities, setIdentities] = useState<Identity[]>([]);
const [identity, setIdentity] = useState<number>(0); const [identity, setIdentity] = useState<number>(0);
const [image, setImage] = useState<number>(0);
const { loading, data } = useApi( const { loading, data } = useApi(
'../api/v1/identities' '../api/v1/identities'
); );
@ -203,14 +241,22 @@ const App = () => {
return ( return (
<div className="App"> <div className="App">
<div className="Worksheet"> <div className="Worksheet">
<PanelGroup className="Explorer"
autoSaveId="persistence" direction="horizontal">
<Panel defaultSize={50}>
{loading && <div style={{ margin: '1rem' }}>Loading...</div>} {loading && <div style={{ margin: '1rem' }}>Loading...</div>}
{ !loading && identity !== 0 && <Cluster id={identity} />} {!loading && identity !== 0 && <Cluster id={identity} {...{ setImage, }} />}
{!loading && identity === 0 && <div className="Cluster"> {!loading && identity === 0 && <div className="Cluster">
Select identity to edit Select identity to edit
</div>} </div>}
{ !loading && <> </Panel>
<Identities {... {identities, setIdentity }}/> <PanelResizeHandle className="Resizer"/>
</> } <Panel>
{image === 0 && <div style={{ margin: '1rem' }}>Select image to view</div>}
{image !== 0 && <Photo photoId={image}/> }
</Panel>
</PanelGroup>
{ !loading && <Identities {... {identities, setIdentity }}/> }
</div> </div>
</div> </div>
); );

View File

@ -72,6 +72,8 @@ def update_cluster_averages(identities):
average = np.add(average, face['descriptors']) average = np.add(average, face['descriptors'])
average = np.divide(average, len(identity['faces'])) average = np.divide(average, len(identity['faces']))
identity['descriptors'] = average identity['descriptors'] = average
identity['sqrtsummul'] = np.sqrt(np.sum(np.multiply(
average, average)))
return identities return identities
def load_faces(db_path = db_path): def load_faces(db_path = db_path):
@ -102,6 +104,8 @@ def load_faces(db_path = db_path):
'focus': focus 'focus': focus
} }
face['faces'] = [ face ] face['faces'] = [ face ]
face['sqrtsummul'] = np.sqrt(np.sum(np.multiply(
face['descriptors'], face['descriptors'])))
faces.append(face) faces.append(face)
return faces return faces
@ -155,9 +159,10 @@ def build_straglers(faces):
print('Loading faces from database') print('Loading faces from database')
faces = load_faces() faces = load_faces()
print(f'{len(faces)} faces loaded') minPts = len(faces) / 100
print('Scanning for clusters') eps = 0.2
identities = DBSCAN(faces) # process_faces(faces) print(f'Scanning {len(faces)} faces for clusters (minPts: {minPts}, eps: {eps})')
identities = DBSCAN(faces, minPts = minPts, eps = eps)
print(f'{len(identities)} clusters grouped') print(f'{len(identities)} clusters grouped')
MAX_CLUSTER_DISTANCE = 0.15 # Used to merge clusters MAX_CLUSTER_DISTANCE = 0.15 # Used to merge clusters
@ -166,6 +171,7 @@ MAX_EPOCH_DISTANCE = 0.14 # Used to prune outliers
# Compute average center for all clusters # Compute average center for all clusters
identities = update_cluster_averages(identities) identities = update_cluster_averages(identities)
if False:
removed = -1 removed = -1
epoch = 1 epoch = 1
# Filter each cluster removing any face that is > cluster_max_distance # Filter each cluster removing any face that is > cluster_max_distance
@ -178,10 +184,12 @@ while removed != 0:
print(f'Excluded {removed} faces this epoch') print(f'Excluded {removed} faces this epoch')
print(f'{len(identities)} identities seeded.') print(f'{len(identities)} identities seeded.')
reduced = identities
if False:
# Cluster the clusters... # Cluster the clusters...
print('Reducing clusters via DBSCAN') print('Reducing clusters via DBSCAN')
reduced = DBSCAN(identities, eps = MAX_CLUSTER_DISTANCE, minPts = 2) reduced = DBSCAN(identities, eps = MAX_CLUSTER_DISTANCE, minPts = 3)
if len(reduced) == 0: if len(reduced) == 0:
reduced = identities reduced = identities
# For each cluster, merge the lists of faces referenced in the cluster's # For each cluster, merge the lists of faces referenced in the cluster's
@ -192,6 +200,7 @@ for cluster in reduced:
merged = merged + identity['faces'] merged = merged + identity['faces']
cluster['faces'] = merged cluster['faces'] = merged
if False:
# Creating a set containing those faces which have not been bound # Creating a set containing those faces which have not been bound
# to an identity to recluster them in isolation from the rest of # to an identity to recluster them in isolation from the rest of
# the faces # the faces
@ -225,6 +234,7 @@ update_distances(reduced)
sort_identities(reduced) sort_identities(reduced)
if False:
# This generates a set of differences between clusters and makes # This generates a set of differences between clusters and makes
# a recommendation to merge clusters (outside of DBSCAN) # a recommendation to merge clusters (outside of DBSCAN)
# #
@ -235,7 +245,7 @@ for i, A in enumerate(reduced):
continue continue
if A == B: if A == B:
continue continue
distance = findCosineDistance(A['descriptors'], B['descriptors']) distance = findCosineDistanceBaked(A, B)
if distance < MAX_CLUSTER_DISTANCE: if distance < MAX_CLUSTER_DISTANCE:
distance = "{:0.4f}".format(distance) distance = "{:0.4f}".format(distance)
print(f'{A["id"]} to {B["id"]} = {distance}: MERGE') print(f'{A["id"]} to {B["id"]} = {distance}: MERGE')

View File

@ -11,10 +11,13 @@ Noise = -2
# Union of two lists of dicts, adding unique elements of B to # Union of two lists of dicts, adding unique elements of B to
# end of A # end of A
def Union(A, B): def Union(A, B):
for key in B: # 5.012 of 100s sample
if key not in A: return A + [x for x in B if x not in A]
A.append(key) # 5.039 of 100s sample
return A # for key in B:
# if key not in A:
# A.append(key)
# return A
# https://en.wikipedia.org/wiki/DBSCAN # https://en.wikipedia.org/wiki/DBSCAN
# #
@ -24,19 +27,24 @@ def DBSCAN(points, eps = MAX_DISTANCE, minPts = MIN_PTS, verbose = True):
total = len(points) total = len(points)
last = 0 last = 0
start = time.time() start = time.time()
# NOTE: A point (P) is only scanned once by RangeQuery for cluster
# inclusion. The internal loop does not need to rescan those points
# as they would have already been in a cluster if minPts is reached.
for i, P in enumerate(points): for i, P in enumerate(points):
if verbose == True: if verbose == True:
new_perc = int(100 * (i+1) / total) new_perc = int(100 * (i+1) / total)
now = time.time() now = time.time()
if new_perc != perc or now - last > 5: if new_perc != perc or now - last > 5:
perc = new_perc perc = new_perc
print(f'Clustering points {perc}% ({i}/{total} processed) complete with {len(clusters)} identities ({now - start}s).')
last = now last = now
print(f'Clustering points {perc}% ({i}/{total} processed) complete with {len(clusters)} identities ({int(now - start)}s).')
if P['cluster'] != Undefined: # Previously processed in inner loop if P['cluster'] != Undefined: # Previously processed in inner loop
continue continue
N = RangeQuery(points, P, eps) # Find neighbors N = RangeQuery(points[i:], P, eps)# Find neighbors
if len(N) < minPts: # Density check if len(N) < minPts: # Density check
P['cluster'] = Noise # Label as Noise P['cluster'] = Noise # Label as Noise
continue continue
@ -49,8 +57,7 @@ def DBSCAN(points, eps = MAX_DISTANCE, minPts = MIN_PTS, verbose = True):
clusters.append(C) clusters.append(C)
P['cluster'] = C # Label initial point P['cluster'] = C # Label initial point
S = N # Neighbors to expand (exclude P) S = N # Neighbors to expand (excludes P)
S.remove(P)
sub_perc = -1 sub_perc = -1
sub_last = 0 sub_last = 0
@ -58,13 +65,11 @@ def DBSCAN(points, eps = MAX_DISTANCE, minPts = MIN_PTS, verbose = True):
for j, Q in enumerate(S): # Process every seed point for j, Q in enumerate(S): # Process every seed point
if verbose == True: if verbose == True:
sub_total = len(S)
sub_new_perc = int(100 * (j+1) / sub_total)
sub_now = time.time() sub_now = time.time()
if sub_new_perc != sub_perc or sub_now - sub_last > 5: if sub_now - sub_last > 5:
sub_perc = sub_new_perc sub_total = len(S)
print(f'... points {sub_perc}% ({j}/{sub_total} processed [{perc}% total]) complete with {len(clusters)} identities ({now - start}s).')
sub_last = sub_now sub_last = sub_now
print(f'... points {j}/{sub_total} processed [{perc}% total]). {len(C["faces"])} forming: {len(clusters)} identities ({int(sub_now - start)}s).')
if Q['cluster'] == Noise: # Change Noise to border point if Q['cluster'] == Noise: # Change Noise to border point
Q['cluster'] = C Q['cluster'] = C
@ -76,7 +81,7 @@ def DBSCAN(points, eps = MAX_DISTANCE, minPts = MIN_PTS, verbose = True):
Q['cluster'] = C # Label neighbor Q['cluster'] = C # Label neighbor
C['faces'].append(Q) C['faces'].append(Q)
N = RangeQuery(points, Q, eps) # Find neighbors N = RangeQuery(points[i:], Q, eps) # Find neighbors
if len(N) >= minPts: # Density check (if Q is a core point) if len(N) >= minPts: # Density check (if Q is a core point)
S = Union(S, N) # Add new neighbors to seed set S = Union(S, N) # Add new neighbors to seed set
return clusters return clusters
@ -86,12 +91,9 @@ def RangeQuery(points, Q, eps):
for P in points: # Scan all points in the database for P in points: # Scan all points in the database
if P == Q: if P == Q:
continue continue
if P in neighbors: distance = findCoseinDistanceBaked(# Compute distance
continue Q, P)
distance = findCosineDistance( # Compute distance and check epsilon if distance <= eps: # Check epsilon
Q['descriptors'], neighbors.append(P) # Add to result
P['descriptors'])
if distance <= eps:
neighbors += [ P ] # Add to result
return neighbors return neighbors

View File

@ -47,11 +47,15 @@ class NpEncoder(json.JSONEncoder):
if isinstance(obj, np.ndarray): if isinstance(obj, np.ndarray):
return obj.tolist() return obj.tolist()
def findCoseinDistanceBaked(src, dst):
a = np.matmul(np.transpose(src['descriptors']), dst['descriptors'])
return 1 - (a / (src['sqrtsummul'] * dst['sqrtsummul']))
def findCosineDistance(source_representation, test_representation): def findCosineDistance(source_representation, test_representation):
if type(source_representation) == list: # if type(source_representation) == list:
source_representation = np.array(source_representation) # source_representation = np.array(source_representation)
if type(test_representation) == list: # if type(test_representation) == list:
test_representation = np.array(test_representation) # test_representation = np.array(test_representation)
a = np.matmul(np.transpose(source_representation), test_representation) a = np.matmul(np.transpose(source_representation), test_representation)
b = np.sum(np.multiply(source_representation, source_representation)) b = np.sum(np.multiply(source_representation, source_representation))
c = np.sum(np.multiply(test_representation, test_representation)) c = np.sum(np.multiply(test_representation, test_representation))

View File

@ -1083,6 +1083,31 @@ console.log("Trying path as: " + path);
}); });
}); });
router.get("/:id", async (req, res) => {
const id = parseInt(req.params.id);
try {
const results = await photoDB.sequelize.query(
`
SELECT photos.*,albums.path AS path,
faces.identityId,faces.top,faces.left,faces.right,faces.bottom
FROM photos
INNER JOIN albums ON albums.id=photos.albumId
INNER JOIN faces ON faces.photoId=photos.id
WHERE photos.id=:id
`, {
replacements: { id }
}
);
if (results.length === 0) {
return res.status(404);
}
return res.status(200).json(results[0]);
} catch (error) {
console.error(error);
return res.status(404).json({message: `Error connecting to DB for ${id}.`})
}
});
router.get("/*", function(req, res/*, next*/) { router.get("/*", function(req, res/*, next*/) {
let limit = parseInt(req.query.limit) || 50, let limit = parseInt(req.query.limit) || 50,
id, cursor, index; id, cursor, index;