Skip to content

feat(faces): dissolve a contaminated person from the face cleanup console - #1080

Open
Deeds67 wants to merge 19 commits into
mainfrom
feat/dissolve-person
Open

Deeds67 wants to merge 19 commits into
mainfrom
feat/dissolve-person

Conversation

@Deeds67

@Deeds67 Deeds67 commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Adds a way to dissolve a person from the admin Face Cleanup console, and to undo it. Revised for issue #1228: "junk persons" built from small, blurry or non-face detections that face recognition chains together, and that came back after any cleanup.

What a dissolve does

The admin picks which faces: all, imported from metadata, detected, or without an embedding. Each face then gets the one treatment that keeps it from coming back:

Face Treatment
Detected (machine-learning) Hidden: asset_face.deletedAt set, person and identity links cleared. Recorded for restore.
Imported from metadata (exif) Deleted, and its photo queued for the non-forced "Detect faces (missing)" pass.
Hand-drawn or manual Unassigned and left on the photo.

The person is removed once no face carries it. In Spaces, the dissolved faces are unlinked, Space people left empty are removed, survivors are recounted, and a survivor whose representative face was hidden gets a visible one.

Why hidden rather than unassigned or deleted: the non-forced recognition pass picks up every unassigned detected face and clusters it again with the same rules, and re-detecting the same photo finds the same boxes. A soft-deleted face is skipped by getAllFaces, searchFaces, suggestions and Space matching, and a re-scan matches the tombstone instead of adding the box again. deletedAt is upstream's own soft-delete column, so stock Immich hides these faces too.

Restore

Administration → Face cleanup → Dissolved lists every dissolve with how many hidden faces can still come back. Restore un-hides them as unnamed faces and queues a non-forced recognition pass. Deleted imported faces cannot be restored, and a forced "Detect faces (All)" removes hidden faces too.

Discovery

Unchanged from the first version: the Health tab ranks people by imported, manual and embedding-less faces. Junk clusters made of ordinary detected faces do not show there; the admin names the person on the People page and searches for it in the console.

Schema

Fork migration 1799100000000-AddFaceDissolveTables adds face_dissolve and face_dissolve_face. Three new FKs onto upstream keys are in gallery-upstream-fks.json; revert-to-immich.sql drops both tables. down() drops them: the record has no upstream home, and the hidden faces stay hidden in upstream's column.

Upstream files: registration lines only, +17 lines across base.service.ts, test/utils.ts, test/medium.factory.ts, schema/gallery.ts and web/src/lib/route.ts (grew). New server code lives under server/src/gallery/.

Testing

  • Server unit suite and the dissolve medium specs (53) pass.
  • face-dissolve.sticks.spec.ts runs real recognition and detection after a dissolve: the faces stay unassigned, no person re-forms, and a neighbouring real person gains nothing. With the hide step reverted to a plain unassign, four of its five tests fail. The Space-matching test passes either way and only guards the hidden case.
  • Web unit tests cover the dialog and the Dissolved page. The e2e spec dissolves an imported-face person and a detected-face person, then restores the latter. It was not run locally; CI runs it.
  • Mobile: synced faces carry deletedAt and the people strip filters it, so no mobile change.

Known limits

  • New uploads can still chain into a new junk person. Preventing that (a minimum face size for clustering, a check against the whole person) is a separate change.
  • On "All faces", an imported face on a photo whose detected face was just hidden is re-scanned without effect: the tombstone absorbs the detection.
  • Space face matching is not part of the "detection or recognition is running" refusal, so a match job already in flight could re-link a face hidden at the same moment.

Design: specs/2026-09-06-dissolve-person-design.md (Revision 2 at the top).

@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Sep 6, 2026
@Deeds67 Deeds67 changed the title feat(server,web): dissolve a contaminated person from the face cleanup console Sep 6, 2026
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 48c4688 to a004c51 Compare September 11, 2026 19:28
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch 2 times, most recently from ec3186f to 5ed8aa0 Compare September 19, 2026 21:56
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 5ed8aa0 to ddc2a92 Compare September 27, 2026 20:00
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from ddc2a92 to 6faa909 Compare October 8, 2026 20:54
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 64bd6ad to e9ecc0e Compare October 9, 2026 17:06
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from e9ecc0e to 053af70 Compare October 15, 2026 06:57
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 5b0e81f to be3b327 Compare October 28, 2026 13:42
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Oct 28, 2026
…sole

Squashed from 32 commits when rebasing onto the v3.3.3 main (originals on backup/pr-1080-2026-10-15):

- docs: design for dissolving a contaminated person
- docs: close three gaps found reviewing the dissolve-person spec
- feat(server): add the dissolve scope predicate
- feat(server): clear the recognition watermark for a person's assets
- test(server): prove clearFacesRecognizedAt honors the dissolve scope predicate
- feat(server): add the dissolve write path, watermark before delete
- test(server): pin the dissolve blast radius against a shared fixture
- fix(server): capture the inserted shared_space id instead of reading it off the factory
- fix(server): make the L6 pet-exclusion and L2 faceless-person guarantees actually break-tested
- feat(server): count what a dissolve would touch, including what it cannot repair
- fix(server): exclude pet siblings from sharedAssets and close counts test-discrimination gaps
- feat(server): guard and orchestrate a person dissolve
- test(server): close two dissolve-warning coverage gaps
- test(server): make the metadata-import-on warning test discriminate on outcome
- feat(server): expose the dissolve preview and apply endpoints
- feat(web): surface per-person face contamination in the cleanup console
- fix(server): make getPeopleHealth pagination stable under ties, fix i18n terms
- feat(web): add the dissolve person dialog
- fix(web): reach dissolve from the page discovery lands on
- test(e2e): dissolve a metadata-contaminated person end to end
- fix(server): require ownerId on people health, share the dissolve scope predicate
- fix(server): make the dissolve preview's two dishonest warnings honest
- test(server): pin the dissolve rollback, and reconcile the spec with what shipped
- fix(faces): name the dissolve outcome schema so the Dart client compiles
- fix(faces): never show one scope's counts under another, and align discovery with the dissolve
- fix(faces): point the dissolve at person_group after the upstream repointing
- chore: regenerate API clients and SQL queries for the v3.2.0 base
- fix(rebase): carry the person-dissolve work onto the v3.2.2 server
- fix(server): never delete a hand-drawn face box when dissolving a person
- chore(open-api): regenerate for the dissolve handDrawn count
- feat(web): say which hand-drawn faces a dissolve keeps and unassigns
- docs: record that a dissolve never deletes hand-drawn face boxes
… dissolve

person.type now lives in person_pet and asset_face.createdBy in
asset_face_creator; FaceDissolve joins the gallery registry.
Detected faces are hidden instead of unassigned or deleted, hiding is undoable,
Space copies are cleaned up, and the outcome picker goes. Driven by issue #1228.
The service and DTO are updated in the next commit; this one does not typecheck on its own.
…atment

The outcome and re-detect choices are gone. The service runs the write and the
suggestion drain in one transaction, refuses while detection or recognition
runs, and re-scans only photos whose imported faces were deleted.

The web dialog still sends the old body until the next web commit.
…rs, and make restore honest

- A face hidden before the dissolve keeps its Space links; unlink those too, so a
  Space person the preview said would go does go.
- Recount surviving Space persons in the same transaction, and mark a repointed
  representative face as the system's choice.
- The preview's hide count only includes faces a restore can bring back.
- A restore reports faces that were hard-deleted since, and a second restore is
  refused under the record lock.
The dialog now lists hidden faces under "Can be undone" and, separately, the
deleted imported faces, the removed person (with its name and birthday) and
removed Space people under "Cannot be undone". The real-faces note only shows
when faces will be hidden.
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 938b58d to d773c36 Compare October 28, 2026 20:40

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation 🗄️server 🖥️web

1 participant