Merge pull request 'Person merge API + Modify People UI' (#95) from feature/person-merge into master
CI / skip-ci-check (push) Successful in 31s
CI / docker-ci (push) Successful in 32s
CI / python-lint (push) Successful in 32s
CI / secret-scan (push) Successful in 39s
CI / viewer-unit (push) Successful in 1m44s
CI / e2e (push) Successful in 1m53s
CI / admin-unit (push) Successful in 1m58s

This commit was merged in pull request #95.
This commit is contained in:
2026-08-05 11:41:20 -05:00
7 changed files with 426 additions and 4 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ Living plan for product quality, auth/email reliability, and automation.
- [x] **Phase 4 — multi-ref matching** — up to 3 trusted refs per person; best (min) distance wins; rolling-mean calibration fitter; status in `docs/FACE_ACCURACY_STATUS.md`
- [x] **Immich precision gates** — max recognition distance, next-best person margin, detection floor 0.55, auto-accept distance cap; admin Chabad Blue theme (shell + login)
- [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)** — partial via detection floor; blur/pose still open
- [ ] **Phase 6 — multi-embedding / person merge / cluster-name Identify**
- [ ] **Phase 6 — multi-embedding / cluster-name Identify** (person merge shipped in #95)
## Later
+15
View File
@@ -46,6 +46,14 @@ export interface PersonUpdateRequest {
phone?: string | null
}
export interface PersonMergeResponse {
person: Person
merged_ids: number[]
faces_moved: number
videos_moved: number
encodings_rebuilt: number
}
export const peopleApi = {
list: async (lastName?: string): Promise<PeopleListResponse> => {
const params = lastName ? { last_name: lastName } : {}
@@ -98,6 +106,13 @@ export const peopleApi = {
)
return res.data
},
merge: async (keepId: number, mergeIds: number[]): Promise<PersonMergeResponse> => {
const res = await apiClient.post<PersonMergeResponse>(
`/api/v1/people/${keepId}/merge`,
{ merge_ids: mergeIds },
)
return res.data
},
delete: async (personId: number): Promise<void> => {
await apiClient.delete(`/api/v1/people/${personId}`)
},
+114 -1
View File
@@ -192,6 +192,8 @@ export default function Modify() {
const [selectedVideos, setSelectedVideos] = useState<Set<number>>(new Set())
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
const [deleteDialogPerson, setDeleteDialogPerson] = useState<PersonWithFaces | null>(null)
const [mergeSourceIds, setMergeSourceIds] = useState<Set<number>>(new Set())
const [mergeConfirmOpen, setMergeConfirmOpen] = useState(false)
const [facesExpanded, setFacesExpanded] = useState(true)
const [videosExpanded, setVideosExpanded] = useState(false)
const [peoplePanelWidth, setPeoplePanelWidth] = useState(450) // Default width in pixels (will be set to 50% on mount)
@@ -502,10 +504,50 @@ export default function Modify() {
const handlePersonClick = (person: PersonWithFaces) => {
setSelectedPersonId(person.id)
setSelectedPersonName(formatPersonName(person))
setMergeSourceIds((prev) => {
const next = new Set(prev)
next.delete(person.id)
return next
})
loadPersonFaces(person.id)
loadPersonVideos(person.id)
}
const toggleMergeSource = (personId: number) => {
setMergeSourceIds((prev) => {
const next = new Set(prev)
if (next.has(personId)) {
next.delete(personId)
} else {
next.add(personId)
}
return next
})
}
const handleMergePeople = async () => {
if (!selectedPersonId || mergeSourceIds.size === 0) return
try {
setBusy(true)
setError(null)
const result = await peopleApi.merge(selectedPersonId, Array.from(mergeSourceIds))
setMergeConfirmOpen(false)
setMergeSourceIds(new Set())
await loadPeople()
await loadPersonFaces(selectedPersonId)
await loadPersonVideos(selectedPersonId)
setSuccess(
`Merged ${result.merged_ids.length} person(s): ${result.faces_moved} face(s), ${result.videos_moved} video(s)`,
)
setTimeout(() => setSuccess(null), 4000)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to merge people')
} finally {
setBusy(false)
}
}
const handleEditPerson = (person: PersonWithFaces) => {
setEditDialogPerson(person)
}
@@ -537,6 +579,13 @@ export default function Modify() {
setSelectedPersonName('')
setFaces([])
setVideos([])
setMergeSourceIds(new Set())
} else {
setMergeSourceIds((prev) => {
const next = new Set(prev)
next.delete(deleteDialogPerson.id)
return next
})
}
// Reload people list
@@ -772,6 +821,20 @@ export default function Modify() {
</button>
</div>
<p className="text-xs text-gray-500">Search by First, Middle, Last, or Maiden Name</p>
{selectedPersonId && mergeSourceIds.size > 0 && (
<button
onClick={() => setMergeConfirmOpen(true)}
disabled={busy}
className="mt-2 w-full px-3 py-2 text-sm bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
Merge {mergeSourceIds.size} into selected
</button>
)}
{selectedPersonId && mergeSourceIds.size === 0 && (
<p className="mt-2 text-xs text-gray-500">
Check boxes next to other people, then merge them into the selected person.
</p>
)}
</div>
{/* People list */}
@@ -785,13 +848,29 @@ export default function Modify() {
{people.map((person) => {
const isSelected = selectedPersonId === person.id
const name = formatPersonName(person)
const isMergeSource = mergeSourceIds.has(person.id)
return (
<div
key={person.id}
className={`flex items-center gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer ${
isSelected ? 'bg-blue-50 font-semibold' : ''
}`}
} ${isMergeSource ? 'ring-1 ring-indigo-300' : ''}`}
>
<input
type="checkbox"
checked={isMergeSource}
disabled={!selectedPersonId || isSelected || busy}
onChange={() => toggleMergeSource(person.id)}
onClick={(e) => e.stopPropagation()}
title={
isSelected
? 'Selected person is the merge target'
: selectedPersonId
? 'Mark to merge into selected'
: 'Select a keep person first'
}
className="rounded border-gray-300"
/>
<button
onClick={(e) => {
e.stopPropagation()
@@ -1113,6 +1192,40 @@ export default function Modify() {
</div>
)}
{/* Merge confirmation dialog */}
{mergeConfirmOpen && selectedPersonId && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 className="text-xl font-bold mb-4 text-indigo-700">Merge People</h2>
<p className="mb-4">
Merge {mergeSourceIds.size} person(s) into{' '}
<strong>{selectedPersonName || 'the selected person'}</strong>?
</p>
<p className="mb-4 text-sm text-gray-600">
Faces, video links, and match history move to the keep person. Source people are
deleted. Duplicate video links on the same file are dropped.
</p>
<p className="mb-6 text-sm font-semibold text-red-600">This action cannot be undone.</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setMergeConfirmOpen(false)}
disabled={busy}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleMergePeople}
disabled={busy}
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{busy ? 'Merging...' : 'Merge'}
</button>
</div>
</div>
</div>
)}
{/* Unmatch confirmation dialog */}
{unmatchConfirmDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+128 -1
View File
@@ -10,13 +10,15 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.api.auth import get_current_user_with_id
from backend.db.models import Face, Person, PersonEncoding, Photo, PhotoPersonLinkage
from backend.db.models import Face, MatchDecision, Person, PersonEncoding, Photo, PhotoPersonLinkage
from backend.db.session import get_db
from backend.schemas.faces import AcceptMatchesRequest, IdentifyFaceResponse, PersonFaceItem, PersonFacesResponse
from backend.schemas.people import (
PeopleListResponse,
PeopleWithFacesListResponse,
PersonCreateRequest,
PersonMergeRequest,
PersonMergeResponse,
PersonResponse,
PersonUpdateRequest,
PersonWithFacesResponse,
@@ -350,6 +352,131 @@ def accept_matches(
)
@router.post("/{person_id}/merge", response_model=PersonMergeResponse)
def merge_people(
person_id: int,
request: PersonMergeRequest,
db: Session = Depends(get_db),
) -> PersonMergeResponse:
"""Merge other people into this person (keep).
Reassigns faces, person encodings, video linkages, and match decisions
from each source onto keep, then deletes the source people.
"""
keep = db.query(Person).filter(Person.id == person_id).first()
if not keep:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person {person_id} not found",
)
merge_ids = list(dict.fromkeys(request.merge_ids)) # preserve order, dedupe
if person_id in merge_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot merge a person into themselves",
)
if not merge_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="merge_ids must not be empty",
)
sources = db.query(Person).filter(Person.id.in_(merge_ids)).all()
found_ids = {p.id for p in sources}
missing = [mid for mid in merge_ids if mid not in found_ids]
if missing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person(s) not found: {missing}",
)
try:
faces_moved = 0
videos_moved = 0
keep_video_photo_ids = {
row.photo_id
for row in db.query(PhotoPersonLinkage.photo_id)
.filter(PhotoPersonLinkage.person_id == person_id)
.all()
}
for source_id in merge_ids:
moved = (
db.query(Face)
.filter(Face.person_id == source_id)
.update({"person_id": person_id}, synchronize_session=False)
)
faces_moved += moved or 0
# Drop source encodings; rebuild keep refs after all moves
db.query(PersonEncoding).filter(
PersonEncoding.person_id == source_id
).delete(synchronize_session=False)
source_links = (
db.query(PhotoPersonLinkage)
.filter(PhotoPersonLinkage.person_id == source_id)
.all()
)
for link in source_links:
if link.photo_id in keep_video_photo_ids:
db.delete(link)
else:
link.person_id = person_id
keep_video_photo_ids.add(link.photo_id)
videos_moved += 1
db.add(link)
db.query(MatchDecision).filter(
MatchDecision.person_id == source_id
).update({"person_id": person_id}, synchronize_session=False)
source_person = next(p for p in sources if p.id == source_id)
db.delete(source_person)
# Rebuild trusted encodings for keep (same quality gate as Auto-Match accept)
db.query(PersonEncoding).filter(
PersonEncoding.person_id == person_id
).delete(synchronize_session=False)
current_faces = (
db.query(Face)
.filter(Face.person_id == person_id)
.filter(Face.quality_score >= 0.3)
.all()
)
for face in current_faces:
db.add(
PersonEncoding(
person_id=person_id,
face_id=face.id,
encoding=face.encoding,
quality_score=face.quality_score,
detector_backend=face.detector_backend,
model_name=face.model_name,
)
)
db.commit()
db.refresh(keep)
return PersonMergeResponse(
person=PersonResponse.model_validate(keep),
merged_ids=merge_ids,
faces_moved=faces_moved,
videos_moved=videos_moved,
encodings_rebuilt=len(current_faces),
)
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to merge people: {str(e)}",
)
@router.delete("/{person_id}")
def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response:
"""Delete a person and all their linkages.
+20
View File
@@ -86,4 +86,24 @@ class PeopleWithFacesListResponse(BaseModel):
total: int
class PersonMergeRequest(BaseModel):
"""Merge other people into the keep person (Immich-style)."""
model_config = ConfigDict(protected_namespaces=())
merge_ids: list[int] = Field(..., min_length=1, description="Person IDs to merge into keep")
class PersonMergeResponse(BaseModel):
"""Result of merging people into keep."""
model_config = ConfigDict(protected_namespaces=())
person: PersonResponse
merged_ids: list[int]
faces_moved: int
videos_moved: int
encodings_rebuilt: int
+1 -1
View File
@@ -32,7 +32,7 @@ plus Immich-inspired precision gates.
| Under-merge + margin | **Done** |
| Min detection score | **Done** (new Process jobs) |
| Cluster-first / name cluster | Later |
| Person merge UI | Later |
| Person merge UI | Done (`POST /people/{id}/merge` + Modify People checkboxes) |
| InsightFace buffalo | Skip — ArcFace OK |
Immich source is public (`github.com/immich-app/immich`); no local clone required. You already run Immich at `photos.levkin.ca`.
+147
View File
@@ -263,3 +263,150 @@ class TestPeopleFaces:
assert response.status_code == 404
class TestPeopleMerge:
"""Test Immich-style person merge."""
def test_merge_people_moves_faces_and_deletes_source(
self,
test_client: TestClient,
test_db_session: "Session",
test_person: "Person",
test_photo,
):
"""Faces move to keep; source person is deleted; encodings rebuild."""
from datetime import datetime
from backend.db.models import Face, Person, PersonEncoding
import numpy as np
source = Person(
first_name="Jane",
last_name="Duplicate",
created_date=datetime.utcnow(),
)
test_db_session.add(source)
test_db_session.commit()
test_db_session.refresh(source)
encoding = np.random.rand(128).astype(np.float32).tobytes()
source_face = Face(
photo_id=test_photo.id,
person_id=source.id,
encoding=encoding,
location='{"x": 10, "y": 10, "w": 50, "h": 50}',
quality_score=0.85,
face_confidence=0.9,
detector_backend="retinaface",
model_name="ArcFace",
)
test_db_session.add(source_face)
test_db_session.flush()
test_db_session.add(
PersonEncoding(
person_id=source.id,
face_id=source_face.id,
encoding=encoding,
quality_score=0.85,
detector_backend="retinaface",
model_name="ArcFace",
)
)
test_db_session.commit()
source_id = source.id
source_face_id = source_face.id
response = test_client.post(
f"/api/v1/people/{test_person.id}/merge",
json={"merge_ids": [source_id]},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["merged_ids"] == [source_id]
assert data["faces_moved"] >= 1
assert data["person"]["id"] == test_person.id
test_db_session.expire_all()
assert test_db_session.get(Person, source_id) is None
moved = (
test_db_session.query(Face)
.filter(Face.id == source_face_id)
.one()
)
assert moved.person_id == test_person.id
enc_count = (
test_db_session.query(PersonEncoding)
.filter(PersonEncoding.person_id == test_person.id)
.count()
)
assert enc_count >= 1
def test_merge_rejects_self(
self,
test_client: TestClient,
test_person: "Person",
):
response = test_client.post(
f"/api/v1/people/{test_person.id}/merge",
json={"merge_ids": [test_person.id]},
)
assert response.status_code == 400
def test_merge_source_not_found(
self,
test_client: TestClient,
test_person: "Person",
):
response = test_client.post(
f"/api/v1/people/{test_person.id}/merge",
json={"merge_ids": [99999]},
)
assert response.status_code == 404
def test_merge_video_unique_conflict_drops_duplicate(
self,
test_client: TestClient,
test_db_session: "Session",
test_person: "Person",
test_photo,
):
"""If keep and source both link the same video photo, drop source link."""
from datetime import datetime
from backend.db.models import Person, PhotoPersonLinkage
source = Person(
first_name="Vid",
last_name="Dup",
created_date=datetime.utcnow(),
)
test_db_session.add(source)
test_db_session.commit()
test_db_session.refresh(source)
test_db_session.add(
PhotoPersonLinkage(photo_id=test_photo.id, person_id=test_person.id)
)
test_db_session.add(
PhotoPersonLinkage(photo_id=test_photo.id, person_id=source.id)
)
test_db_session.commit()
source_id = source.id
response = test_client.post(
f"/api/v1/people/{test_person.id}/merge",
json={"merge_ids": [source_id]},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["videos_moved"] == 0
links = (
test_db_session.query(PhotoPersonLinkage)
.filter(PhotoPersonLinkage.photo_id == test_photo.id)
.all()
)
assert len(links) == 1
assert links[0].person_id == test_person.id
assert test_db_session.get(Person, source_id) is None