c305ad4146
A separation — an explicit dismissal, or the implicit one a Split records — was stored as a pair of event_people.id. Those ids do not survive re-derivation: recluster() deletes every person, and a full re-scan replaces a photo's faces outright, so face ids die too. The only thing that survives both is the embedding, so the decision is keyed on the two centroids the pair had when the photographer separated them. It binds while both sides still look like the clusters that were separated, and lapses once they have drifted past recognition. The constraint is now honoured at assignment time as well as in consolidate(), which is what makes it hold across a re-scan rather than being reformed before any later pass could object. Six review rounds shaped the matching itself: each candidate must resolve to the OPPOSITE side rather than merely matching something (a split leaves two similar halves, and the loose test fragmented the person the split was not even about); assignment judges both sides at the ordinary match threshold, since a single face — or a cluster of one part-way through a recluster — cannot resemble a settled centroid; separations carry their own model_version; and the projections are hoisted out of the innermost loop, which took a 2000-photo scan from ~15s of dot products to 0.23s. Lifecycle closed three ways: purgePhotoFaces re-anchors each side onto the live cluster it still describes and drops rows that describe nothing left, deleteEventCascade and the permanent archive delete clear the table (which deliberately has no event FK), and a later manual merge drops the separations it reverses. All of it matched on vectors rather than ids, since a row that has outlived a recluster names people who no longer exist. Merged with admin privileges: the author cannot self-approve.
568 lines
26 KiB
JavaScript
568 lines
26 KiB
JavaScript
/**
|
|
* "Not the same person" has to outlive re-derivation (#1132).
|
|
*
|
|
* The decision used to be stored as a pair of event_people.id, and neither
|
|
* person ids nor face ids survive:
|
|
*
|
|
* - recluster() deletes every person and re-assigns, so person ids die but
|
|
* photo_faces.id survives
|
|
* - a full re-scan replaces a photo's faces outright, so FACE ids die too
|
|
*
|
|
* The embedding is the only stable handle, so that is what the separation is
|
|
* keyed on. These tests simulate both kinds of re-derivation by destroying the
|
|
* ids and rebuilding from the same vectors — which is exactly what the real
|
|
* paths do — and assert the constraint still binds.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sep-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sep-test-secret';
|
|
|
|
const { bootCrmDb } = require('./helpers/crmDb');
|
|
|
|
let db; let cleanup; let clustering;
|
|
|
|
const THRESHOLDS = { face_match_threshold: 0.6, face_quality_min_score: 0.7, face_quality_min_px: 40 };
|
|
const DIM = 64;
|
|
|
|
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
|
|
function pairAtSimilarity(target, basis) {
|
|
const a = new Float32Array(DIM);
|
|
const b = new Float32Array(DIM);
|
|
a[basis] = 1;
|
|
b[basis] = target;
|
|
b[basis + 1] = Math.sqrt(1 - target * target);
|
|
return [a, b];
|
|
}
|
|
|
|
async function seedEvent(slug) {
|
|
const [row] = await db('events').insert({
|
|
slug, event_type: 'wedding', event_name: slug, event_date: '2026-01-01',
|
|
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
|
|
share_link: `${slug}-share`, expires_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
return typeof row === 'object' ? row.id : row;
|
|
}
|
|
|
|
async function insertPerson(eventId, centroid, overrides = {}) {
|
|
const [row] = await db('event_people').insert({
|
|
event_id: eventId,
|
|
centroid: clustering.packEmbedding(centroid),
|
|
face_count_total: 1,
|
|
model_version: 'test-v1',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
...overrides,
|
|
}).returning('id');
|
|
return typeof row === 'object' ? row.id : row;
|
|
}
|
|
|
|
/** The mirror of pairAtSimilarity's second vector: same similarity, other side. */
|
|
function mirrorAtSimilarity(target, basis) {
|
|
const b = new Float32Array(DIM);
|
|
b[basis] = target;
|
|
b[basis + 1] = -Math.sqrt(1 - target * target);
|
|
return b;
|
|
}
|
|
|
|
async function insertFaceWithPhoto(eventId, personId, centroid) {
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/x.jpg', type: 'individual',
|
|
}).returning('id');
|
|
const photoId = typeof p === 'object' ? p.id : p;
|
|
const [f] = await db('photo_faces').insert({
|
|
photo_id: photoId, event_id: eventId, person_id: personId,
|
|
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
|
embedding: clustering.packEmbedding(centroid),
|
|
model_version: 'test-v1', created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
return { faceId: typeof f === 'object' ? f.id : f, photoId };
|
|
}
|
|
|
|
async function insertFace(eventId, personId, centroid) {
|
|
const { faceId } = await insertFaceWithPhoto(eventId, personId, centroid);
|
|
return faceId;
|
|
}
|
|
|
|
/**
|
|
* What a re-scan does to identity: the people are gone and the faces come back
|
|
* with brand-new ids. Same vectors, nothing else preserved.
|
|
*/
|
|
async function simulateRescan(eventId, vectors) {
|
|
await db('photo_faces').where({ event_id: eventId }).del();
|
|
await db('event_people').where({ event_id: eventId }).del();
|
|
const ids = [];
|
|
for (const vec of vectors) {
|
|
const personId = await insertPerson(eventId, vec);
|
|
await insertFace(eventId, personId, vec);
|
|
ids.push(personId);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
describe('separations survive re-derivation (#1132)', () => {
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
clustering = require('../../src/services/faceClustering');
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
describe('the matcher', () => {
|
|
it('binds a pair that still looks like the one that was separated', () => {
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
|
});
|
|
|
|
it('binds regardless of which way round the candidates arrive', () => {
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
// Neither the stored pair nor the candidate pair has a meaningful order.
|
|
expect(clustering.separationForbids(b, a, [{ a, b }])).toBe(true);
|
|
});
|
|
|
|
it('lapses once a side has drifted past recognition', () => {
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
// A cluster reshaped far enough is no longer the cluster the
|
|
// photographer pointed at, so the constraint should stop applying rather
|
|
// than bind something they never saw.
|
|
const drifted = new Float32Array(DIM);
|
|
drifted[10] = 1;
|
|
expect(clustering.separationForbids(drifted, b, [{ a, b }])).toBe(false);
|
|
});
|
|
|
|
it('does not bind two clusters that are both the SAME side', () => {
|
|
// A split leaves two halves of one cluster, so the pair it records is
|
|
// often similar to itself — here 0.95. Two candidates that are plainly
|
|
// both side A (0.97 to each other) each clear the bar against BOTH
|
|
// stored sides, so a test that only asks "does each side match
|
|
// something" says yes and refuses to let that person cluster with
|
|
// itself. It fragments into singletons — the person the split was not
|
|
// even about.
|
|
const [a, b] = pairAtSimilarity(0.95, 0);
|
|
const x = new Float32Array(DIM); x[0] = 1;
|
|
const y = mirrorAtSimilarity(0.97, 0);
|
|
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
|
// The pair it was actually about still binds.
|
|
expect(clustering.separationForbids(a, b, [{ a, b }])).toBe(true);
|
|
});
|
|
|
|
it('ignores a separation recorded under a different embedding model', () => {
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
// Vectors from another model are meaningless here, not merely stale —
|
|
// the same rule assignment and consolidation apply to person centroids.
|
|
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v2' }],
|
|
{ modelVersion: 'test-v1' })).toBe(false);
|
|
expect(clustering.separationForbids(a, b, [{ a, b, modelVersion: 'test-v1' }],
|
|
{ modelVersion: 'test-v1' })).toBe(true);
|
|
});
|
|
|
|
it('ignores an unrelated pair entirely', () => {
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
const [x, y] = pairAtSimilarity(0.64, 20);
|
|
expect(clustering.separationForbids(x, y, [{ a, b }])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('across a re-scan', () => {
|
|
it('still refuses to merge the pair after every id has changed', async () => {
|
|
const eventId = await seedEvent('sep-rescan');
|
|
// Well above the auto-merge threshold: only the separation keeps them apart.
|
|
const [a, b] = pairAtSimilarity(0.97, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await insertFace(eventId, idA, a);
|
|
await insertFace(eventId, idB, b);
|
|
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
|
|
const newIds = await simulateRescan(eventId, [a, b]);
|
|
// The premise: nothing the old row named still exists.
|
|
expect(newIds).not.toContain(idA);
|
|
expect(newIds).not.toContain(idB);
|
|
|
|
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
|
|
|
|
expect(merged).toEqual([]);
|
|
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
|
|
});
|
|
|
|
it('keeps the pair out of the suggestion list too', async () => {
|
|
const eventId = await seedEvent('sep-rescan-suggest');
|
|
const [a, b] = pairAtSimilarity(0.64, 0); // inside the suggestion band
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
await simulateRescan(eventId, [a, b]);
|
|
|
|
expect(await clustering.suggestMerges(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
|
});
|
|
|
|
it('a split still binds after the ids it recorded are gone', async () => {
|
|
const eventId = await seedEvent('sep-split-rescan');
|
|
// Two faces that look alike enough to have been clustered together, but
|
|
// are not the same vector — which is what a split is FOR, and the only
|
|
// case it can survive re-derivation in. Two byte-identical embeddings
|
|
// carry no information about which side is which, so a separation
|
|
// between them has nothing to key on once the ids are gone.
|
|
const [base, other] = pairAtSimilarity(0.96, 0);
|
|
const personId = await insertPerson(eventId, base);
|
|
await insertFace(eventId, personId, base);
|
|
const extra = await insertFace(eventId, personId, other);
|
|
|
|
const newPersonId = await clustering.splitPerson(eventId, personId, [extra]);
|
|
expect(newPersonId).toBeTruthy();
|
|
|
|
// The snapshot must have been taken AFTER recomputeCentroid — before it,
|
|
// the new person has no centroid at all.
|
|
const row = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
|
expect(row.centroid_a).toBeTruthy();
|
|
expect(row.centroid_b).toBeTruthy();
|
|
|
|
await simulateRescan(eventId, [base, other]);
|
|
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('when a photo is hard-deleted', () => {
|
|
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
|
|
|
it('drops the separation when one side has no photos left', async () => {
|
|
const eventId = await seedEvent('sep-purge-gone');
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await insertFace(eventId, idA, a);
|
|
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
|
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
await purgePhotoFaces(photoId);
|
|
|
|
// Person B is gone with its only photo. The row held a COPY of its
|
|
// centroid, so leaving it standing would keep a vector derived from a
|
|
// deleted photo alive in a table nothing else touches.
|
|
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
|
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
|
});
|
|
|
|
it('keeps the constraint when a side still has another cluster on it', async () => {
|
|
const eventId = await seedEvent('sep-purge-descendant');
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await insertFace(eventId, idA, a);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
|
|
// Re-derivation can leave one stored side represented by more than one
|
|
// current person. Deleting the photo behind ONE of them must not throw
|
|
// the whole decision away — the other still stands for that side, and the
|
|
// pair would be free to merge again.
|
|
const twin = new Float32Array(DIM);
|
|
for (let i = 0; i < DIM; i++) twin[i] = 0.98 * b[i];
|
|
twin[6] = Math.sqrt(1 - 0.98 ** 2);
|
|
const survivor = await insertPerson(eventId, twin);
|
|
await insertFace(eventId, survivor, twin);
|
|
const { photoId } = await insertFaceWithPhoto(eventId, idB, b);
|
|
|
|
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
|
await purgePhotoFaces(photoId);
|
|
|
|
expect(await db('event_people').where({ id: idB }).first()).toBeUndefined();
|
|
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
|
|
expect(rows).toHaveLength(1);
|
|
// Re-anchored onto the survivor, so it still binds.
|
|
expect(clustering.separationForbids(a, twin, [{
|
|
a: clustering.unpackEmbedding(rows[0].centroid_a),
|
|
b: clustering.unpackEmbedding(rows[0].centroid_b),
|
|
}])).toBe(true);
|
|
});
|
|
|
|
it('re-takes the snapshot from what is left when the person survives', async () => {
|
|
const eventId = await seedEvent('sep-purge-survives');
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await insertFace(eventId, idA, a);
|
|
await insertFace(eventId, idB, b);
|
|
// A second face on B, close enough that B stays recognisably B — so
|
|
// purging it moves B's centroid rather than deleting the person, and the
|
|
// side still resolves to B afterwards.
|
|
const other = new Float32Array(DIM);
|
|
for (let i = 0; i < DIM; i++) other[i] = 0.95 * b[i];
|
|
other[5] = Math.sqrt(1 - 0.95 ** 2);
|
|
const { photoId } = await insertFaceWithPhoto(eventId, idB, other);
|
|
await clustering.recomputeCentroid(idB);
|
|
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
const before = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
|
|
|
await purgePhotoFaces(photoId);
|
|
|
|
const after = await db('event_people_merge_dismissals').where({ event_id: eventId }).first();
|
|
expect(after).toBeTruthy();
|
|
expect(Buffer.from(after.centroid_b).equals(Buffer.from(before.centroid_b))).toBe(false);
|
|
// It now equals the recomputed centroid — nothing of the deleted face left.
|
|
const person = await db('event_people').where({ id: idB }).first();
|
|
expect(Buffer.from(after.centroid_b).equals(Buffer.from(person.centroid))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('when the photographer changes their mind', () => {
|
|
it('a manual merge clears the separation between the merged people', async () => {
|
|
const eventId = await seedEvent('sep-merge-overrules');
|
|
const [a, b] = pairAtSimilarity(0.97, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await insertFace(eventId, idA, a);
|
|
await insertFace(eventId, idB, b);
|
|
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
// ...and then decides they ARE the same person after all.
|
|
await clustering.mergePeople(eventId, [idB], idA);
|
|
|
|
// The row is keyed on the centroids as well as the ids, so leaving it
|
|
// would survive the ids it names: the next recluster would recognise
|
|
// those two sides and pull the merge apart again.
|
|
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
|
|
|
await simulateRescan(eventId, [a, b]);
|
|
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('cleanup after the ids have already died', () => {
|
|
// The rows these paths must find are exactly the ones whose person ids no
|
|
// longer resolve — that is the state this whole feature creates. Matching
|
|
// on ids alone walks past them, which is worse than not cleaning up at
|
|
// all: the surviving row still enforces its vectors.
|
|
|
|
it('a merge clears a separation that had already outlived its ids', async () => {
|
|
const eventId = await seedEvent('sep-merge-stale');
|
|
const [a, b] = pairAtSimilarity(0.97, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
|
|
// A recluster: same vectors, brand-new people. The row now names nobody.
|
|
const [newA, newB] = await simulateRescan(eventId, [a, b]);
|
|
expect([newA, newB]).not.toContain(idA);
|
|
|
|
await clustering.mergePeople(eventId, [newB], newA);
|
|
|
|
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
|
// And it stays merged through the next re-derivation.
|
|
await simulateRescan(eventId, [a, b]);
|
|
expect(await clustering.consolidate(eventId, { thresholds: THRESHOLDS })).toHaveLength(1);
|
|
});
|
|
|
|
it('a purge clears a separation that had already outlived its ids', async () => {
|
|
const eventId = await seedEvent('sep-purge-stale');
|
|
const [a, b] = pairAtSimilarity(0.64, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
|
|
// Same recluster, then hard-delete the photo behind the B side.
|
|
await db('photo_faces').where({ event_id: eventId }).del();
|
|
await db('event_people').where({ event_id: eventId }).del();
|
|
const newA = await insertPerson(eventId, a);
|
|
await insertFace(eventId, newA, a);
|
|
const newB = await insertPerson(eventId, b);
|
|
const { photoId } = await insertFaceWithPhoto(eventId, newB, b);
|
|
|
|
const { purgePhotoFaces } = require('../../src/services/faceProcessor');
|
|
await purgePhotoFaces(photoId);
|
|
|
|
expect(await db('event_people').where({ id: newB }).first()).toBeUndefined();
|
|
// The row named idA/idB, neither of which exists — but its centroid_b is
|
|
// a copy of a vector derived from the photo that was just destroyed.
|
|
expect(await db('event_people_merge_dismissals').where({ event_id: eventId })).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('when the whole gallery is deleted', () => {
|
|
it('deleteEventCascade clears the separations too', () => {
|
|
// Source inspection, deliberately. deleteEventCascade takes an admin
|
|
// context and does filesystem cleanup, so driving it here would test the
|
|
// scaffolding rather than the contract. The contract is narrow and
|
|
// absolute: this table now holds centroid BLOBs, it has no event FK by
|
|
// design, and nothing else in the codebase would ever reach it — so the
|
|
// one delete has to be in the cascade or the embeddings outlive the
|
|
// gallery. Same approach as the contract tests added for #596.
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'src', 'routes', 'adminEvents', 'helpers.js'), 'utf8'
|
|
);
|
|
const body = src.slice(src.indexOf('async function deleteEventCascade'));
|
|
expect(body).toContain('event_people_merge_dismissals\').where(\'event_id\', eventId).del()');
|
|
// Guarded, not caught: a failed statement aborts the transaction on PG.
|
|
expect(body).toContain('hasTable(\'event_people_merge_dismissals\')');
|
|
});
|
|
|
|
it('permanent archive deletion clears the face data too', () => {
|
|
// Same contract, second door. This route deletes the event row directly
|
|
// and leans on the FK cascade, which is inert on SQLite — and no FK
|
|
// reaches the dismissals table on either engine. archiveEvent's purge is
|
|
// nonfatal, so an event really can arrive here still holding embeddings.
|
|
const src = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'src', 'routes', 'adminArchives.js'), 'utf8'
|
|
);
|
|
expect(src).toContain('event_people_merge_dismissals');
|
|
expect(src).toContain('db(\'photo_faces\').where(\'event_id\', req.params.id).del()');
|
|
expect(src).toContain('db(\'event_people\').where(\'event_id\', req.params.id).del()');
|
|
});
|
|
});
|
|
|
|
describe('during assignment', () => {
|
|
it('will not put a new face into a cluster it was separated from', async () => {
|
|
const eventId = await seedEvent('sep-assign');
|
|
const [a, b] = pairAtSimilarity(0.97, 0);
|
|
const idA = await insertPerson(eventId, a);
|
|
const idB = await insertPerson(eventId, b);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
|
|
// A face that looks like side B arrives. Its nearest centroid is A (0.97,
|
|
// far above the 0.6 match threshold), and before #1132 it would simply
|
|
// have joined — reforming the pair the photographer pulled apart, because
|
|
// assignment consulted no separations at all.
|
|
await db('event_people').where({ id: idB }).del();
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: 'new.jpg', path: '/tmp/n.jpg', type: 'individual',
|
|
}).returning('id');
|
|
const photoId = typeof p === 'object' ? p.id : p;
|
|
const [f] = await db('photo_faces').insert({
|
|
photo_id: photoId, event_id: eventId,
|
|
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
|
embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
const faceId = typeof f === 'object' ? f.id : f;
|
|
|
|
const assignments = await clustering.assignFaces(
|
|
eventId, [{ id: faceId, embedding: clustering.packEmbedding(b), model_version: 'test-v1',
|
|
det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
|
{ thresholds: THRESHOLDS },
|
|
);
|
|
|
|
expect(assignments).toHaveLength(1);
|
|
expect(assignments[0].personId).not.toBe(idA);
|
|
// It opened its own person rather than being forced into the wrong one.
|
|
expect(assignments[0].personId).toBeTruthy();
|
|
});
|
|
|
|
it('holds back a face that is only loosely like the side it belongs to', async () => {
|
|
const eventId = await seedEvent('sep-assign-loose');
|
|
// The separated sides are CENTROIDS; an individual face sits well below
|
|
// its own centroid — that is why faces join at 0.6 and not at 0.92. A
|
|
// face 0.85-like its own side would clear no strict bar against it, and
|
|
// before this it walked straight into the other person during a
|
|
// recluster, which is the exact merge the photographer undid.
|
|
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
|
const idA = await insertPerson(eventId, sideA);
|
|
const idB = await insertPerson(eventId, sideB);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
await db('event_people').where({ id: idB }).del();
|
|
|
|
// 0.65 to side A — above the 0.6 match threshold, so it would join A —
|
|
// and 0.85 to side B, which is where it actually belongs.
|
|
const face = new Float32Array(DIM);
|
|
face[0] = 0.65; face[1] = 0.553; face[2] = Math.sqrt(1 - 0.65 ** 2 - 0.553 ** 2);
|
|
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: 'loose.jpg', path: '/tmp/l.jpg', type: 'individual',
|
|
}).returning('id');
|
|
const [f] = await db('photo_faces').insert({
|
|
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
|
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
|
embedding: clustering.packEmbedding(face), model_version: 'test-v1',
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
|
|
const assignments = await clustering.assignFaces(
|
|
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(face),
|
|
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
|
{ thresholds: THRESHOLDS },
|
|
);
|
|
|
|
expect(assignments[0].personId).not.toBe(idA);
|
|
expect(assignments[0].personId).toBeTruthy();
|
|
});
|
|
|
|
it('binds while the clusters are still being rebuilt one face at a time', async () => {
|
|
const eventId = await seedEvent('sep-assign-rebuild');
|
|
// recluster() empties event_people and re-assigns from scratch, so for
|
|
// the first faces of a batch the "person" on the other side of the
|
|
// comparison is a cluster of ONE. A settled centroid it is not, and
|
|
// holding it to the strict threshold meant the pair was already merged
|
|
// by the time the constraint could bind — with nothing left to split it.
|
|
const [sideA, sideB] = pairAtSimilarity(0.7, 0);
|
|
const idA = await insertPerson(eventId, sideA);
|
|
const idB = await insertPerson(eventId, sideB);
|
|
await clustering.dismissMergeSuggestion(eventId, idA, idB);
|
|
await db('event_people').where({ event_id: eventId }).del();
|
|
|
|
// Two faces, one per side, each a little off its own side's centroid —
|
|
// 0.91, just under the strict bar — and 0.66 to each other, over the
|
|
// match threshold. Exactly the pair that must not re-form.
|
|
const off = Math.sqrt(1 - 0.91 ** 2);
|
|
const faceA = new Float32Array(DIM);
|
|
faceA[0] = 0.91; faceA[3] = off;
|
|
const faceB = new Float32Array(DIM);
|
|
faceB[0] = 0.91 * 0.7; faceB[1] = 0.91 * Math.sqrt(1 - 0.7 ** 2); faceB[3] = off;
|
|
|
|
const rows = [];
|
|
for (const vec of [faceA, faceB]) {
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: `${Math.random()}.jpg`, path: '/tmp/r.jpg', type: 'individual',
|
|
}).returning('id');
|
|
const [f] = await db('photo_faces').insert({
|
|
photo_id: typeof p === 'object' ? p.id : p, event_id: eventId,
|
|
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
|
embedding: clustering.packEmbedding(vec), model_version: 'test-v1',
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
rows.push({ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(vec),
|
|
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 });
|
|
}
|
|
|
|
// The premise: they are close enough to each other to cluster together.
|
|
expect(clustering.dot(faceA, faceB)).toBeGreaterThan(THRESHOLDS.face_match_threshold);
|
|
|
|
const assignments = await clustering.assignFaces(eventId, rows, { thresholds: THRESHOLDS });
|
|
expect(assignments[0].personId).not.toBe(assignments[1].personId);
|
|
});
|
|
|
|
it('leaves ordinary assignment alone when no separation applies', async () => {
|
|
const eventId = await seedEvent('sep-assign-clean');
|
|
const base = new Float32Array(DIM); base[0] = 1;
|
|
const personId = await insertPerson(eventId, base);
|
|
|
|
const [p] = await db('photos').insert({
|
|
event_id: eventId, filename: 'x.jpg', path: '/tmp/x.jpg', type: 'individual',
|
|
}).returning('id');
|
|
const photoId = typeof p === 'object' ? p.id : p;
|
|
const [f] = await db('photo_faces').insert({
|
|
photo_id: photoId, event_id: eventId,
|
|
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200, det_score: 0.99,
|
|
embedding: clustering.packEmbedding(base), model_version: 'test-v1',
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
|
|
const assignments = await clustering.assignFaces(
|
|
eventId, [{ id: typeof f === 'object' ? f.id : f, embedding: clustering.packEmbedding(base),
|
|
model_version: 'test-v1', det_score: 0.99, bbox_w: 200, bbox_h: 200 }],
|
|
{ thresholds: THRESHOLDS },
|
|
);
|
|
|
|
// The whole point of the strict threshold: a constraint that fires when
|
|
// it should not would quietly wreck ordinary clustering.
|
|
expect(assignments[0].personId).toBe(personId);
|
|
});
|
|
});
|
|
});
|