Skip to content

Commit 66d418e

Browse files
davidhooclaude
andcommitted
fix: use top-K quality faces for fallback attach scoring to prevent person splits
When a face component's average score falls below the attach threshold due to low-quality faces (blurry, side-angle) dragging down the average, re-score using only the top-5 highest-quality faces. This prevents creating duplicate persons for the same individual when high-quality faces clearly match an existing person. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f1c49bc commit 66d418e

2 files changed

Lines changed: 63 additions & 0 deletions

File tree

backend/internal/service/people_service.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ const (
4141
// making it easier for new faces to join user-confirmed identities (e.g., family members).
4242
confirmedPersonDiscount = 0.05
4343

44+
// attachTopK is the number of highest-quality faces used for fallback scoring
45+
// when the full-component average falls below the attach threshold.
46+
attachTopK = 5
47+
4448
// Adaptive threshold decay for long-pending faces
4549
// retry_count 0-1: full threshold (no discount)
4650
// retry_count 2-4: linear decay to floor
@@ -2109,6 +2113,27 @@ func (s *peopleService) scoreComponentAgainstPersonWithEmbeddings(component []fa
21092113
return total / float64(scored)
21102114
}
21112115

2116+
// topKFacesByQuality returns the k faces with the highest QualityScore from the component.
2117+
func topKFacesByQuality(component []faceWithEmbedding, k int) []faceWithEmbedding {
2118+
if len(component) <= k {
2119+
return component
2120+
}
2121+
sorted := make([]faceWithEmbedding, len(component))
2122+
copy(sorted, component)
2123+
sort.Slice(sorted, func(i, j int) bool {
2124+
qi := 0.0
2125+
if sorted[i].face != nil {
2126+
qi = sorted[i].face.QualityScore
2127+
}
2128+
qj := 0.0
2129+
if sorted[j].face != nil {
2130+
qj = sorted[j].face.QualityScore
2131+
}
2132+
return qi > qj
2133+
})
2134+
return sorted[:k]
2135+
}
2136+
21122137
func (s *peopleService) attachComponentToExistingPerson(component []*model.Face, prototypes map[uint][]*model.Face, attachThreshold float64) (uint, float64, bool) {
21132138
if len(component) == 0 || len(prototypes) == 0 {
21142139
return 0, -1, false
@@ -2195,6 +2220,18 @@ func (s *peopleService) attachComponentToExistingPersonWithEmbeddings(
21952220
return bestPersonID, bestScore, true
21962221
}
21972222

2223+
// Fallback: re-score using only the top-K highest-quality faces in the component.
2224+
// Low-quality faces (blurry, side-angle) drag down the average score, causing
2225+
// splits where a component should have attached to an existing person.
2226+
// If the top-K faces score above the threshold, attach anyway.
2227+
if bestPersonID != 0 && len(component) > attachTopK {
2228+
topK := topKFacesByQuality(component, attachTopK)
2229+
topKScore := s.scoreComponentAgainstPersonWithEmbeddings(topK, prototypesWithEmb[bestPersonID])
2230+
if topKScore >= attachThreshold {
2231+
return bestPersonID, topKScore, true
2232+
}
2233+
}
2234+
21982235
// Apply discount for confirmed persons (have manual-locked faces)
21992236
if bestPersonID != 0 && bestScore >= attachThreshold-confirmedPersonDiscount {
22002237
for _, proto := range prototypesOriginal[bestPersonID] {

backend/internal/service/people_service_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,32 @@ func TestPeopleService_AttachComponentToExistingPerson(t *testing.T) {
369369
assert.Zero(t, personID)
370370
assert.Less(t, attachScore, 0.70) // defaultAttachThreshold
371371
})
372+
373+
t.Run("component attaches via top-K fallback when low-quality faces drag down average", func(t *testing.T) {
374+
// 5 high-quality faces close to personOne's prototypes, plus 5 low-quality orthogonal faces.
375+
// The full-component average falls below threshold, but top-5 quality faces should pass.
376+
component := []*model.Face{
377+
{ID: 100, QualityScore: 0.9, Embedding: encodeEmbedding(t, []float32{1, 0, 0})},
378+
{ID: 101, QualityScore: 0.85, Embedding: encodeEmbedding(t, []float32{0.98, 0.199, 0})},
379+
{ID: 102, QualityScore: 0.80, Embedding: encodeEmbedding(t, []float32{0.97, 0.243, 0})},
380+
{ID: 103, QualityScore: 0.75, Embedding: encodeEmbedding(t, []float32{0.99, 0.1, 0})},
381+
{ID: 104, QualityScore: 0.70, Embedding: encodeEmbedding(t, []float32{0.96, 0.28, 0})},
382+
// Low-quality orthogonal faces (drag down average)
383+
{ID: 200, QualityScore: 0.1, Embedding: encodeEmbedding(t, []float32{0, 0, 1})},
384+
{ID: 201, QualityScore: 0.1, Embedding: encodeEmbedding(t, []float32{0.05, 0.05, 0.99})},
385+
{ID: 202, QualityScore: 0.1, Embedding: encodeEmbedding(t, []float32{0.02, 0.02, 0.999})},
386+
{ID: 203, QualityScore: 0.1, Embedding: encodeEmbedding(t, []float32{0, 0.1, 0.995})},
387+
{ID: 204, QualityScore: 0.1, Embedding: encodeEmbedding(t, []float32{0.03, 0, 0.999})},
388+
}
389+
390+
fullScore := attacher.scoreComponentAgainstPerson(component, prototypes[personOneID])
391+
assert.Less(t, fullScore, 0.70, "full component score should be below threshold due to low-quality faces")
392+
393+
personID, attachScore, attached := attacher.attachComponentToExistingPerson(component, prototypes, 0.70)
394+
assert.True(t, attached, "should attach via top-K fallback")
395+
assert.Equal(t, personOneID, personID)
396+
assert.GreaterOrEqual(t, attachScore, 0.70)
397+
})
372398
}
373399

374400
func TestPeopleService_PendingComponent(t *testing.T) {

0 commit comments

Comments
 (0)