Skip to content

Commit 72f2b58

Browse files
committed
Faces: Carry the detector provenance when upgrading a marker
1 parent f4a6372 commit 72f2b58

8 files changed

Lines changed: 90 additions & 38 deletions

File tree

internal/entity/face.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
)
2020

2121
var faceMutex = sync.Mutex{}
22+
23+
// UpdateFaces reports whether a matching pass changed a cluster, so callers know to refresh.
2224
var UpdateFaces = atomic.Bool{}
2325

2426
// Face represents the face of a Subject.

internal/entity/file.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -745,11 +745,9 @@ func (m *File) SetInstanceID(id string) {
745745
}
746746
}
747747

748-
// RedactForSession removes identifying per-file metadata a shared-only session must not see when it
749-
// accesses a file through sharing: the XMP InstanceID (a content-provenance identifier) is cleared and
750-
// markers are omitted. Sessions with full library or admin access (and nil sessions) are unchanged.
751-
// This is the per-file counterpart of Photo.RedactForSession, so single-file reads (GetFile) and the
752-
// picture read (GetPhoto) strip the same fields.
748+
// RedactForSession removes identifying per-file metadata a shared-only session must not see: the
749+
// XMP InstanceID is cleared and markers are omitted. Full-library, admin and nil sessions are
750+
// unchanged. Counterpart of Photo.RedactForSession, so GetFile and GetPhoto strip the same fields.
753751
func (m *File) RedactForSession(sess *Session) *File {
754752
if m == nil || sess == nil {
755753
return m
@@ -896,16 +894,28 @@ func (m *File) AddFace(f face.Face, subjUid string) {
896894
if existing.Embeddings().Empty() {
897895
landmarks := f.RelativeLandmarksJSON()
898896

897+
// Unmeasured stays -1 rather than keeping a value recorded for another sampling.
898+
thumbSize := -1
899+
900+
if f.ThumbSize > 0 {
901+
thumbSize = f.ThumbSize
902+
}
903+
899904
// For an already-saved marker, persist first and mutate in-memory
900905
// only on success: a failed write must not leave an unpersisted
901906
// embedding (Markers.Save does not re-write existing markers), so the
902907
// marker stays embedding-less and is retried on the next pass.
903908
if existing.MarkerUID != "" {
909+
// thumb_size and score travel with the detector that produced them, or both gates
910+
// read the half this upgrade did not replace: the size bar would fall back to an
911+
// XMP-declared box extent, and the score bar is looked up by detect_model.
904912
values := Values{
905913
"embeddings_json": f.Embeddings.JSON(),
906914
"embed_model": f.EmbedModel,
907915
"detect_model": f.DetectModel,
908916
"landmarks_json": landmarks,
917+
"thumb_size": thumbSize,
918+
"score": f.Score,
909919
}
910920

911921
if err := existing.Updates(values); err != nil {
@@ -916,6 +926,8 @@ func (m *File) AddFace(f face.Face, subjUid string) {
916926

917927
existing.SetEmbeddings(f.Embeddings, f.EmbedModel, f.DetectModel)
918928
existing.LandmarksJSON = landmarks
929+
existing.ThumbSize = thumbSize
930+
existing.Score = f.Score
919931
}
920932

921933
return

internal/entity/file_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/photoprism/photoprism/internal/ai/face"
1414
"github.com/photoprism/photoprism/internal/config/customize"
15+
"github.com/photoprism/photoprism/internal/thumb/crop"
1516
"github.com/photoprism/photoprism/pkg/clean"
1617
"github.com/photoprism/photoprism/pkg/fs"
1718
"github.com/photoprism/photoprism/pkg/http/header"
@@ -689,6 +690,47 @@ func TestFile_AddFaces(t *testing.T) {
689690
})
690691
}
691692

693+
// TestFile_AddFaceUpgradesProvenance pins that upgrading an embedding-less marker carries the
694+
// detector's own numbers with the vector. Left behind, the size bar falls back to an XMP box extent
695+
// and the score bar is looked up by the newly written detect_model.
696+
func TestFile_AddFaceUpgradesProvenance(t *testing.T) {
697+
file := &File{
698+
FileUID: "fs6sg6bw45bnlqdw",
699+
FileHash: "0e3d3e2e5b2f4b1a9a3d7c1e5f6a8b9c0d1e2f30",
700+
FileWidth: 3000,
701+
FileHeight: 2000,
702+
}
703+
704+
// A sidecar region: a generous box, no embedding, and a score from no detector.
705+
area := crop.Area{Name: "face", X: 0.4, Y: 0.4, W: 0.2, H: 0.2}
706+
existing := NewMarker(*file, area, "", SrcXmp, MarkerFace, MarkerSize(area, *file), 10)
707+
require.NotNil(t, existing)
708+
require.NoError(t, existing.Create())
709+
t.Cleanup(func() { UnscopedDb().Delete(existing) })
710+
711+
markers := Markers{*existing}
712+
file.markers = &markers
713+
714+
f := face.Face{
715+
Rows: 2000,
716+
Cols: 3000,
717+
Score: 88,
718+
Area: face.NewArea("face", 1000, 1500, 300),
719+
Embeddings: face.Embeddings{face.RandomEmbedding()},
720+
EmbedModel: face.EmbeddingModelName(),
721+
ThumbSize: 97,
722+
}
723+
724+
file.AddFace(f, "")
725+
726+
stored := &Marker{}
727+
require.NoError(t, UnscopedDb().First(stored, "marker_uid = ?", existing.MarkerUID).Error)
728+
729+
assert.Equal(t, 97, stored.ThumbSize, "the extent the vector was sampled at must be recorded")
730+
assert.Equal(t, 88, stored.Score, "the score must come from the detector that produced the vector")
731+
assert.Equal(t, 97, (*file.Markers())[0].ThumbSize, "and the in-memory marker must match the row")
732+
}
733+
692734
func TestFile_ValidFaceCount(t *testing.T) {
693735
t.Run("FileFixturesExampleBridge", func(t *testing.T) {
694736
file := FileFixturesExampleBridge

internal/entity/photo.go

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
)
2929

3030
const (
31+
// PhotoUID is the prefix that identifies a generated photo UID.
3132
PhotoUID = byte('p')
3233

3334
// UUIDBytes is the byte budget for the photos.uuid column (VARBINARY(255)). A
@@ -36,9 +37,13 @@ const (
3637
UUIDBytes = 255
3738
)
3839

39-
var IndexUpdateInterval = 3 * time.Hour // 3 Hours
40-
var MetadataUpdateInterval = 24 * 3 * time.Hour // 3 Days
41-
var MetadataEstimateInterval = 24 * 7 * time.Hour // 7 Days
40+
// How long a photo may go without being re-indexed, having its metadata refreshed, or having an
41+
// estimate recomputed.
42+
var (
43+
IndexUpdateInterval = 3 * time.Hour
44+
MetadataUpdateInterval = 24 * 3 * time.Hour
45+
MetadataEstimateInterval = 24 * 7 * time.Hour
46+
)
4247

4348
var photoMutex = sync.Mutex{}
4449
var labelKeywordsSkipSrc = []string{SrcTitle, SrcCaption, SrcSubject, SrcKeyword}
@@ -316,11 +321,7 @@ func (m *Photo) Create() error {
316321
return err
317322
}
318323

319-
if err := m.SaveDetails(); err != nil {
320-
return err
321-
}
322-
323-
return nil
324+
return m.SaveDetails()
324325
}
325326

326327
// Save writes Photo changes, creates missing rows, and re-resolves the primary file relationship.
@@ -628,11 +629,7 @@ func (m *Photo) UpdateLabels() error {
628629
return err
629630
}
630631

631-
if err := m.UpdateKeywordLabels(); err != nil {
632-
return err
633-
}
634-
635-
return nil
632+
return m.UpdateKeywordLabels()
636633
}
637634

638635
// SubjectNames returns all known subject names.

internal/entity/photo_quality.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/photoprism/photoprism/pkg/txt"
88
)
99

10+
// NonPhotographicKeywords names the keywords that mark a picture as not a photograph.
1011
var NonPhotographicKeywords = map[string]bool{
1112
"screenshot": true,
1213
"screenshots": true,

internal/entity/query/covers.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,9 @@ func UpdateSubjectCovers(public bool) (err error) {
498498
// One statement for both dialects, so a library cannot get a different cover per backend.
499499
// The uid guard keeps an empty subject uid from correlating against every unassigned marker,
500500
// and a person left without an eligible marker gets no cover rather than a null one.
501+
//
502+
// Ranked on size rather than thumb_size: a cover wants the face that fills most of the frame,
503+
// which a fixed-size detection thumbnail measures, not the one with the most available detail.
501504
res := Db().Exec(`UPDATE subjects SET thumb = COALESCE((
502505
SELECT m.thumb FROM markers m
503506
JOIN files f ON f.file_uid = m.file_uid AND f.deleted_at IS NULL
@@ -526,13 +529,11 @@ func UpdateSubjectCovers(public bool) (err error) {
526529
return err
527530
}
528531

529-
// UpdateCoversAsync runs UpdateCovers in a goroutine and logs the
530-
// returned error, if any, as a warning. The launched goroutine is
531-
// registered with the shared entity package WaitGroup so config.CloseDb
532-
// can drain in-flight work via entity.WaitForAsyncJobs before tearing
533-
// down the database connection. A deferred recover guards against any
534-
// future shutdown race producing a process-killing panic instead of a
535-
// clean log line.
532+
// UpdateCoversAsync runs UpdateCovers in a goroutine and logs a returned error as a warning.
533+
//
534+
// Registered with the shared WaitGroup so config.CloseDb can drain in-flight work through
535+
// entity.WaitForAsyncJobs before closing the connection, and a deferred recover keeps a shutdown
536+
// race from killing the process.
536537
func UpdateCoversAsync() {
537538
entity.AsyncJobAdd()
538539
go func() {

internal/entity/search/faces.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ func representativeMarkerJoin(facesTable, unknown string) (string, []any) {
4141
conds = append(conds, "m2.subj_uid <> ''")
4242
}
4343

44+
// Filtered on the sampled extent but ranked on the detection size, which is deliberate: the two
45+
// answer different questions. thumb_size is available detail, which decides whether a vector can
46+
// be trusted; size is the face's extent in a fixed-size thumbnail, so it tracks how much of the
47+
// frame the face fills, which is what picks a picture to represent a person.
4448
return fmt.Sprintf(`JOIN markers m ON m.marker_uid = (
4549
SELECT m2.marker_uid FROM markers m2
4650
WHERE %s

internal/photoprism/index_faces.go

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,19 @@ func DetectFaces(jpeg *MediaFile, expected int) (face.Faces, error) {
2222

2323
start := time.Now()
2424

25-
engineName := face.ActiveEngineName()
26-
27-
var thumbSize thumb.Name
28-
29-
if engineName == face.EngineONNX || Config().ThumbSizePrecached() < 1280 {
30-
thumbSize = thumb.Fit720
31-
} else {
32-
thumbSize = thumb.Fit1280
33-
}
34-
35-
thumbName, err := jpeg.Thumbnail(Config().ThumbCachePath(), thumbSize)
25+
// Always Fit720, which entity.ClusterSizeCond depends on: markers.size is recorded in the
26+
// pixels of whatever image detection ran on, and it is a lower bound on the extent an
27+
// embedding was sampled from only while that image is the narrowest rendition.
28+
thumbName, err := jpeg.Thumbnail(Config().ThumbCachePath(), thumb.Fit720)
3629

3730
if err != nil {
3831
log.Debugf("%s (detect faces)", err)
3932
return face.Faces{}, err
4033
}
4134

4235
if thumbName == "" {
43-
log.Debugf("vision: thumb %s not found in %s (detect faces)", thumbSize, clean.Log(jpeg.BaseName()))
44-
return face.Faces{}, fmt.Errorf("thumbnail %s not found", thumbSize)
36+
log.Debugf("vision: thumb %s not found in %s (detect faces)", thumb.Fit720, clean.Log(jpeg.BaseName()))
37+
return face.Faces{}, fmt.Errorf("thumbnail %s not found", thumb.Fit720)
4538
}
4639

4740
faces, err := vision.DetectFaces(thumbName, Config().FaceSize(), Config().FaceSizeRetry(), true, expected)

0 commit comments

Comments
 (0)