feat(faces): consolidate look-alike clusters after a scan, and suggest the rest (#1107)

consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.

It now runs when a scan drains. There is no scan-finished event to hook, so an
idle worker asks whether the events it touched have actually drained — 'a worker
went idle' is deliberately not treated as sufficient, because with concurrency
above one the others may still be working.

The uncertain band asks instead of acting: pairs between the assignment
threshold and the stricter auto-merge one surface as accept/dismiss suggestions,
with sticky dismissals. Nothing merges silently — a pass that merged anything
reports it and points at Split.

Review rounds hardened it against overruling explicit decisions: it no longer
absorbs ignored clusters (mergePeople ORs is_ignored onto the survivor, which
would have hidden a real person), no longer merges dismissed pairs, no longer
undoes a manual Split (which now records a separation), and no longer runs after
detection is switched off. The dismissal read fails closed, a failed pass is
retried with backoff rather than lost or hot-looped, and the new table follows
event_people out of exports and backups.

Name autocomplete needs no endpoint — the people list already open is the source,
and it is event-scoped on purpose.

Known limitation, tracked in #1132: separations are keyed on person ids, so a
full re-scan loses them.

Reported by @BraynArts.
This commit is contained in:
Paul Nothaft
2026-08-22 21:39:15 +02:00
committed by GitHub
parent 25fbefc703
commit 3583c924da
14 changed files with 1486 additions and 24 deletions
@@ -0,0 +1,73 @@
/**
* Automatic consolidation after a scan, and the suggestion band below it
* (#1107).
*
* `faceClustering.consolidate()` has existed since #1074 but only ever ran from
* `recluster()`, i.e. when an admin pressed "Re-group people". After a normal
* background scan nobody looked, so a gallery settled with "Anna in daylight"
* and "Anna at the party" as separate people even though their centroids had
* long since converged.
*
* Two things get stored here.
*
* 1. `events.faces_last_consolidated_*` — merging biometric clusters silently
* is the wrong default even at high confidence, so a pass that merged
* anything has to be able to say so afterwards. The count is per-scan: the
* next consolidation overwrites it, which is the intended lifetime.
*
* 2. `event_people_merge_dismissals` — the band BELOW the auto-merge threshold
* is surfaced as a suggestion rather than merged, and a suggestion the
* photographer has rejected must stay rejected. Without this the same "are
* these the same person?" pair returns after every scan.
*
* The dismissal rows reference people that merge, split and recluster all
* delete. That is deliberately NOT enforced with a foreign key: a dangling
* dismissal simply stops matching anything, which is the correct outcome, and
* an FK would either block those operations or need cascade rules on a table
* whose whole purpose is to be advisory. `pruneDismissals` is not needed for
* correctness — the rows are tiny and harmless.
*/
exports.up = async function up(knex) {
if (await knex.schema.hasTable('events')) {
const hasCount = await knex.schema.hasColumn('events', 'faces_last_consolidated_count');
const hasAt = await knex.schema.hasColumn('events', 'faces_last_consolidated_at');
if (!hasCount || !hasAt) {
await knex.schema.alterTable('events', (table) => {
if (!hasCount) table.integer('faces_last_consolidated_count').defaultTo(0);
if (!hasAt) table.timestamp('faces_last_consolidated_at').nullable();
});
}
}
if (!(await knex.schema.hasTable('event_people_merge_dismissals'))) {
await knex.schema.createTable('event_people_merge_dismissals', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable();
// Stored with person_a_id < person_b_id so a pair has exactly one row
// regardless of which order the comparison produced it in.
table.integer('person_a_id').notNullable();
table.integer('person_b_id').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['event_id', 'person_a_id', 'person_b_id']);
table.index(['event_id']);
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasTable('event_people_merge_dismissals')) {
await knex.schema.dropTable('event_people_merge_dismissals');
}
if (await knex.schema.hasTable('events')) {
const hasCount = await knex.schema.hasColumn('events', 'faces_last_consolidated_count');
const hasAt = await knex.schema.hasColumn('events', 'faces_last_consolidated_at');
if (hasCount || hasAt) {
await knex.schema.alterTable('events', (table) => {
if (hasCount) table.dropColumn('faces_last_consolidated_count');
if (hasAt) table.dropColumn('faces_last_consolidated_at');
});
}
}
};