Compare commits

...

2 Commits

Author SHA1 Message Date
0043480ff8 Face removal working
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-17 20:58:12 -08:00
1ead088eb8 Clustering worked well enough; need to add face column indicating manual (expert) assignment
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-17 19:23:48 -08:00
8 changed files with 159 additions and 101 deletions

View File

@ -56,9 +56,14 @@ div {
border: 0.25rem solid transparent; border: 0.25rem solid transparent;
} }
.ClusterEditor {
display: flex;
flex-direction: column;
}
.Image .FaceBox { .Image .FaceBox {
border: 1px solid red; border: 1px solid red;
border-radius: 0.25rem; /* border-radius: 0.25rem;*/
position: absolute; position: absolute;
} }

View File

@ -37,55 +37,31 @@ const makeFaceBoxes = (photo: any, dimensions: any): any => {
)); ));
}; };
/*
function debounce(fn: any, ms: number) {
let timer: any;
return () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null
fn.apply(this as typeof Photo, arguments)
}, ms)
};
};
*/
const Photo = ({ photoId }: any) => { const Photo = ({ photoId }: any) => {
const [image, setImage] = useState<any>(undefined); const [image, setImage] = useState<any>(undefined);
const ref = useRef(null); const ref = useRef(null);
const [dimensions, setDimensions] = React.useState({ const [dimensions, setDimensions] = React.useState({width: 0, height: 0});
height: window.innerHeight,
width: window.innerWidth
})
const faces = useMemo(() => { const faces = useMemo(() => {
if (image === undefined) { if (image === undefined || dimensions.height === 0) {
return <></>; return <></>;
} }
return makeFaceBoxes(image, dimensions); return makeFaceBoxes(image, dimensions);
}, [image, dimensions]); }, [image, dimensions]);
useEffect(() : any => { useEffect(() => {
if (!ref || !ref.current) { if (!ref.current) {
return; return;
} }
const el: Element = ref.current as Element; const el: Element = ref.current as Element;
if (dimensions.height !== el.clientHeight
const handleResize = () => { || dimensions.width !== el.clientWidth) {
setDimensions({ setDimensions({
height: el.clientHeight, height: el.clientHeight,
width: el.clientWidth width: el.clientWidth
}) })
}; }
}/*, [dimensions.height, dimensions.width]*/);
const debouncedHandleResize = handleResize;//debounce(handleResize, 250);
debouncedHandleResize();
window.addEventListener('resize', debouncedHandleResize);
return () => {
window.removeEventListener('resize', debouncedHandleResize)
};
});
useEffect(() => { useEffect(() => {
if (photoId === 0) { if (photoId === 0) {
@ -108,15 +84,15 @@ const Photo = ({ photoId }: any) => {
return (<div className="Image" return (<div className="Image"
ref={ref} ref={ref}
style={{ style={{
background: `url(../${image.path}thumbs/scaled/${image.filename})` background: `url(../${image.path}thumbs/scaled/${image.filename})`.replace(/ /g, '%20')
}}>{ faces }</div> }}>{ faces }</div>
); );
}; };
const Face = ({ faceId, onClick, title }: any) => { const Face = ({ faceId, onClick, title, ...rest }: any) => {
const idPath = String(faceId % 100).padStart(2, '0'); const idPath = String(faceId % 100).padStart(2, '0');
return ( return (
<div onClick={(e) => { onClick(e, faceId) }} <div {...rest} onClick={(e) => { onClick(e, faceId) }}
className='Face'> className='Face'>
<div className='Image' <div className='Image'
style={{ style={{
@ -129,26 +105,13 @@ const Face = ({ faceId, onClick, title }: any) => {
}; };
type ClusterProps = { type ClusterProps = {
id: number, identity: Identity,
setImage(image: number): void setImage(image: number): void,
setSelected(selected: number[]): void,
setIdentity(identity: Identity): void
}; };
const Cluster = ({ id, setImage }: ClusterProps) => { const Cluster = ({ identity, setIdentity, setImage, setSelected }: ClusterProps) => {
const [identity, setIdentity] = useState<Identity | undefined>(undefined);
const { loading, data } = useApi(
`../api/v1/identities/${id}`
);
useEffect(() => {
if (data) {
if (Array.isArray(data) && data.length > 0) {
setIdentity(data[0] as Identity);
} else {
setIdentity(data as Identity);
}
}
}, [data]);
const relatedFacesJSX = useMemo(() => { const relatedFacesJSX = useMemo(() => {
const faceClicked = async (e: any, id: any) => { const faceClicked = async (e: any, id: any) => {
if (!identity) { if (!identity) {
@ -166,18 +129,25 @@ const Cluster = ({ id, setImage }: ClusterProps) => {
return; return;
} }
el.classList.toggle('Selected'); el.classList.toggle('Selected');
const selected = [...el.parentElement
.querySelectorAll('.Selected')]
.map((face: any) => face.getAttribute('data-face-id'));
setSelected(selected);
console.log(face); console.log(face);
} }
if (identity === undefined) { if (identity === undefined) {
return <></>; return <></>;
} }
return identity.relatedFaces.map(face => return identity.relatedFaces.map(face =>
<Face key={face.faceId} <Face
data-face-id={face.faceId}
key={face.faceId}
faceId={face.faceId} faceId={face.faceId}
onClick={faceClicked} onClick={faceClicked}
title={face.distance}/> title={face.distance}/>
); );
}, [identity, setImage]); }, [identity, setImage, setSelected]);
const lastNameChanged = (e: any) => { const lastNameChanged = (e: any) => {
setIdentity(Object.assign( setIdentity(Object.assign(
@ -212,12 +182,6 @@ const Cluster = ({ id, setImage }: ClusterProps) => {
)); ));
}; };
if (loading) {
return (<div className='Cluster'>
{loading && `Loading ${id}...`}
</div>);
}
if (identity === undefined) { if (identity === undefined) {
return (<div className='Cluster'> return (<div className='Cluster'>
Select identity to load. Select identity to load.
@ -281,21 +245,23 @@ type Identity = {
}; };
interface IdentitiesProps { interface IdentitiesProps {
setIdentity?(id: number): void, setIdentity(identity: Identity): void,
identities: Identity[] identities: Identity[]
}; };
const Identities = ({ identities, setIdentity } : IdentitiesProps) => { const Identities = ({ identities, setIdentity } : IdentitiesProps) => {
const identitiesJSX = useMemo(() => { const identitiesJSX = useMemo(() => {
const loadIdentity = (id: number): void => { const loadIdentity = async (id: number) => {
if (setIdentity) { const res = await window.fetch(`../api/v1/identities/${id}`);
setIdentity(id) const data = await res.json();
} setIdentity(data[0]);
}; };
return identities.map((identity) => { return identities.map((identity) => {
const face = identity.relatedFaces[0]; const face = identity.relatedFaces[0];
return ( return (
<Face key={face.faceId} <Face key={face.faceId}
data-face-id={face.faceId}
faceId={face.faceId} faceId={face.faceId}
onClick={() => loadIdentity(identity.id)} onClick={() => loadIdentity(identity.id)}
title={identity.displayName}/> title={identity.displayName}/>
@ -310,13 +276,22 @@ const Identities = ({ identities, setIdentity } : IdentitiesProps) => {
); );
}; };
const Button = ({ onClick, children }: any) => {
return (
<button type="button" onClick={onClick}>
{children}
</button>
);
};
const App = () => { const App = () => {
const [identities, setIdentities] = useState<Identity[]>([]); const [identities, setIdentities] = useState<Identity[]>([]);
const [identity, setIdentity] = useState<number>(0); const [identity, setIdentity] = useState<any>(undefined);
const [image, setImage] = useState<number>(0); const [image, setImage] = useState<number>(0);
const { loading, data } = useApi( const { loading, data } = useApi(
'../api/v1/identities' '../api/v1/identities'
); );
const [selected, setSelected] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
if (data && data.length) { if (data && data.length) {
@ -324,17 +299,49 @@ const App = () => {
} }
}, [data]); }, [data]);
const removeSelected = async () => {
try {
const res = await window.fetch(
`../api/v1/identities/faces/remove/${identity.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ faces: selected })
});
const data = await res.json();
const pre = identity.relatedFaces.length;
/* Remove all relatedFaces which are part of the set of removed
* faces */
identity.relatedFaces = identity.relatedFaces.filter(
(face: FaceData) => data.faces.indexOf(face.faceId) === -1);
if (pre !== identity.relatedFaces.length) {
setIdentity({...identity})
}
} catch (error) {
console.error(error);
}
};
return ( return (
<div className="App"> <div className="App">
<div className="Worksheet"> <div className="Worksheet">
<PanelGroup className="Explorer" <PanelGroup className="Explorer"
autoSaveId="persistence" direction="horizontal"> autoSaveId="persistence" direction="horizontal">
<Panel defaultSize={50}> <Panel defaultSize={50} className="ClusterEditor">
{loading && <div style={{ margin: '1rem' }}>Loading...</div>} {loading && <div style={{ margin: '1rem' }}>Loading...</div>}
{!loading && identity !== 0 && <Cluster id={identity} {...{ setImage, }} />} {!loading && identity !== 0 &&
<Cluster {...{
identity,
setIdentity,
setImage,
setSelected
}} />}
{!loading && identity === 0 && <div className="Cluster"> {!loading && identity === 0 && <div className="Cluster">
Select identity to edit Select identity to edit
</div>} </div>}
<div className="Actions">
{ selected.length !== 0 && <Button onClick={removeSelected}>Remove</Button> }
</div>
</Panel> </Panel>
<PanelResizeHandle className="Resizer"/> <PanelResizeHandle className="Resizer"/>
<Panel> <Panel>

View File

@ -24,6 +24,9 @@ html_base = config['basePath']
if html_base == "/": if html_base == "/":
html_base = "." html_base = "."
MAX_CLUSTER_DISTANCE = 0.14 # Used to merge clusters
MAX_DISTANCE_FROM_CENTROID = 0.14 # Used to prune outliers
# TODO # TODO
# Switch to using DBSCAN # Switch to using DBSCAN
# #
@ -86,8 +89,10 @@ def load_faces(db_path = db_path):
res = cur.execute(''' res = cur.execute('''
SELECT faces.id,facedescriptors.descriptors,faces.faceConfidence,faces.photoId,faces.focus SELECT faces.id,facedescriptors.descriptors,faces.faceConfidence,faces.photoId,faces.focus
FROM faces FROM faces
INNER JOIN photos ON (photos.duplicate == 0 OR photos.duplicate IS NULL)
JOIN facedescriptors ON (faces.descriptorId=facedescriptors.id) JOIN facedescriptors ON (faces.descriptorId=facedescriptors.id)
WHERE faces.identityId IS null AND faces.faceConfidence>0.99 WHERE faces.identityId IS null AND faces.faceConfidence>0.99
AND faces.photoId=photos.id
''') ''')
for row in res.fetchall(): for row in res.fetchall():
id, descriptors, confidence, photoId, focus = row id, descriptors, confidence, photoId, focus = row
@ -109,13 +114,15 @@ def load_faces(db_path = db_path):
faces.append(face) faces.append(face)
return faces return faces
def update_distances(identities, prune = False): def update_distances(identities,
prune = False,
maxDistance = MAX_DISTANCE_FROM_CENTROID):
removed = 0 removed = 0
for identity in identities: for identity in identities:
for face in identity['faces']: for face in identity['faces']:
average = identity['descriptors'] average = identity['descriptors']
distance = findCosineDistance(average, face['descriptors']) distance = findCosineDistanceBaked(identity, face)
if prune and distance > MAX_EPOCH_DISTANCE: if prune and distance > maxDistance:
average = np.dot(average, len(identity['faces'])) average = np.dot(average, len(identity['faces']))
average = np.subtract(average, face['descriptors']) average = np.subtract(average, face['descriptors'])
@ -123,7 +130,11 @@ def update_distances(identities, prune = False):
face['distance'] = 0 face['distance'] = 0
identity['faces'].remove(face) identity['faces'].remove(face)
identity['descriptors'] = np.divide(average, len(identity['faces'])) average = np.divide(average, len(identity['faces']))
identity['descriptors'] = average
identity['sqrtsummul'] = np.sqrt(np.sum(np.multiply(
average, average)))
removed += 1 removed += 1
else: else:
face['distance'] = distance face['distance'] = distance
@ -159,19 +170,19 @@ def build_straglers(faces):
print('Loading faces from database') print('Loading faces from database')
faces = load_faces() faces = load_faces()
minPts = len(faces) / 100
eps = 0.2 minPts = max(len(faces) / 500, 5)
eps = 0.185
print(f'Scanning {len(faces)} faces for clusters (minPts: {minPts}, eps: {eps})') print(f'Scanning {len(faces)} faces for clusters (minPts: {minPts}, eps: {eps})')
identities = DBSCAN(faces, 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_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)
epoch_prune = True
merge_identities = True
if False: if epoch_prune:
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
@ -179,14 +190,17 @@ if False:
while removed != 0: while removed != 0:
print(f'Epoch {epoch}...') print(f'Epoch {epoch}...')
epoch += 1 epoch += 1
removed = update_distances(identities, prune = True) removed = update_distances(
identities,
prune = True,
maxDistance = MAX_DISTANCE_FROM_CENTROID)
if removed > 0: if 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 reduced = identities
if False: if merge_identities:
# Cluster the clusters... # Cluster the clusters...
print('Reducing clusters via DBSCAN') print('Reducing clusters via DBSCAN')
reduced = DBSCAN(identities, eps = MAX_CLUSTER_DISTANCE, minPts = 3) reduced = DBSCAN(identities, eps = MAX_CLUSTER_DISTANCE, minPts = 3)
@ -229,9 +243,7 @@ for id, identity in enumerate(reduced):
face['cluster'] = identity face['cluster'] = identity
reduced = update_cluster_averages(reduced) reduced = update_cluster_averages(reduced)
update_distances(reduced) update_distances(reduced)
sort_identities(reduced) sort_identities(reduced)
if False: if False:
@ -294,6 +306,7 @@ print(f'Connecting to database: {db_path}')
conn = create_connection(db_path) conn = create_connection(db_path)
with conn: with conn:
for identity in reduced: for identity in reduced:
print(f'Writing identity {identity["id"]} to DB')
id = create_identity(conn, identity) id = create_identity(conn, identity)
for face in identity['faces']: for face in identity['faces']:
update_face_identity(conn, face['id'], id) update_face_identity(conn, face['id'], id)

View File

@ -197,9 +197,10 @@ conn = create_connection('../db/photos.db')
with conn: with conn:
cur = conn.cursor() cur = conn.cursor()
res = cur.execute(''' res = cur.execute('''
SELECT photos.id,photos.faces,albums.path,photos.filename FROM photos SELECT photos.id,photos.faces,albums.path,photos.filename
FROM photos
LEFT JOIN albums ON (albums.id=photos.albumId) LEFT JOIN albums ON (albums.id=photos.albumId)
WHERE photos.faces=-1 WHERE photos.faces=-1 AND photos.duplicate=0
''') ''')
rows = res.fetchall() rows = res.fetchall()
count = len(rows) count = len(rows)

View File

@ -11,13 +11,8 @@ 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):
# 5.012 of 100s sample
return A + [x for x in B if x not in A] return A + [x for x in B if x not in A]
# 5.039 of 100s sample
# 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
# #
@ -91,7 +86,7 @@ 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
distance = findCoseinDistanceBaked(# Compute distance distance = findCosineDistanceBaked(# Compute distance
Q, P) Q, P)
if distance <= eps: # Check epsilon if distance <= eps: # Check epsilon
neighbors.append(P) # Add to result neighbors.append(P) # Add to result

View File

@ -23,7 +23,6 @@ def redirect_off():
sys.stdout = original sys.stdout = original
original = None original = None
def zlib_uuencode(databytes, name='<data>'): def zlib_uuencode(databytes, name='<data>'):
''' Compress databytes with zlib & uuencode the result ''' ''' Compress databytes with zlib & uuencode the result '''
inbuff = BytesIO(zlib.compress(databytes, 9)) inbuff = BytesIO(zlib.compress(databytes, 9))
@ -47,15 +46,11 @@ class NpEncoder(json.JSONEncoder):
if isinstance(obj, np.ndarray): if isinstance(obj, np.ndarray):
return obj.tolist() return obj.tolist()
def findCoseinDistanceBaked(src, dst): def findCosineDistanceBaked(src, dst):
a = np.matmul(np.transpose(src['descriptors']), dst['descriptors']) a = np.matmul(np.transpose(src['descriptors']), dst['descriptors'])
return 1 - (a / (src['sqrtsummul'] * dst['sqrtsummul'])) return 1 - (a / (src['sqrtsummul'] * dst['sqrtsummul']))
def findCosineDistance(source_representation, test_representation): def findCosineDistance(source_representation, test_representation):
# if type(source_representation) == list:
# source_representation = np.array(source_representation)
# if type(test_representation) == list:
# 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

@ -173,6 +173,11 @@ function init() {
} }
}, },
expertAssignment: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
lastComparedId: { lastComparedId: {
type: Sequelize.INTEGER, type: Sequelize.INTEGER,
allowNull: true, allowNull: true,

View File

@ -11,6 +11,42 @@ require("../db/photos").then(function(db) {
const router = express.Router(); const router = express.Router();
router.put("/faces/remove/:id", (req, res) => {
console.log(`PUT ${req.url}`)
if (!req.user.maintainer) {
console.warn(`${req.user.name} attempted to modify photos.`);
return res.status(401).send({ message: "Unauthorized to modify photos." });
}
const id = parseInt(req.params.id);
if (id != req.params.id) {
return res.status(400).send({ message: "Invalid identity id." });
}
if (!Array.isArray(req.body.faces) || req.body.faces.length == 0) {
return res.status(400).send({ message: "No faces supplied." });
}
return photoDB.sequelize.query(
"UPDATE faces SET identityId=null " +
"WHERE id IN (:faceIds)", {
replacements: {
identityId: id,
faceIds: req.body.faces
}
}).then(() => {
const identity = {
id: id,
faces: req.body.faces
};
return res.status(200).json(identity);
}).catch((error) => {
console.error(error);
return res.status(500).send({message: "Error processing request." });
});
});
router.put("/faces/add/:id", (req, res) => { router.put("/faces/add/:id", (req, res) => {
if (!req.user.maintainer) { if (!req.user.maintainer) {
console.warn(`${req.user.name} attempted to modify photos.`); console.warn(`${req.user.name} attempted to modify photos.`);
@ -182,6 +218,7 @@ router.get("/:id?", async (req, res) => {
delete identity.descriptors; delete identity.descriptors;
delete identity.relatedFaceIds; delete identity.relatedFaceIds;
delete identity.relatedFacePhotoIds; delete identity.relatedFacePhotoIds;
delete identity.relatedFaceDescriptorIds;
delete identity.relatedIdentityDescriptors; delete identity.relatedIdentityDescriptors;
}); });