Skip to content

Commit 3135088

Browse files
committed
Faces: Report the markers a migration would sample above the clustering bar
A vector drawn from too few pixels is indistinguishable from one that was not, so an operator whose thumbnails are smaller than their faces need - or whose migration could not write a rendition - has nothing to read. The audit now counts the markers whose recorded extent is below the bar, and how many of those their original could still supply, which separates what a re-run recovers from what no thumbnail size can.
1 parent 2365ca9 commit 3135088

5 files changed

Lines changed: 198 additions & 1 deletion

File tree

internal/ai/face/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ Two caveats apply to the benchmark-derived recommendations. The measured centroi
195195

196196
**Each detector also carries its own cutoff** (`Detector.MinScore`), because they do not score alike: SCRFD emits one calibrated sigmoid and sits at 50, while YuNet scores as `sqrt(cls x obj)` and sits at the 65 its own corpus measured. Both bars are registered on the **0-100 scale** markers and the `FACE_*` options use, and the engine converts to the 0-1 one its decoder reports, so the registry holds one scale rather than two. **Every one of them is an operator override away** - `FACE_SCORE` replaces `MinScore`, `FACE_CLUSTER_SCORE` replaces `ClusterScore` and `FACE_MIGRATE_SCORE` replaces `MigrateScore`, in either direction - so a threshold can be explored on a running instance rather than through a rebuild. Going below the knee was tried at 9/20, the pair the last stable release ran, and the demo library answered within a day: three "people" on a photograph of a bee, two of them blurred background. That evidence bounds the migration floor as well as the index one, which is why `MigrateScore` sits at 50 rather than at the 9 a migration would otherwise want: the measurement weighs false positives and not **re-detection**, so the floor takes the recall the evidence leaves available and no more. All three remain open until the preview returns attributable data points. `TestDetectorRecall` pins the measured recall at the calibrated cutoff explicitly, so a threshold decision cannot silently rewrite it. A cutoff copied from another detector is not calibration, which `TestDetectorMinScore` states.
197197
- `SizeThreshold` (`FACE_SIZE`, default 25 px) and `ClusterSizeThreshold` (`FACE_CLUSTER_SIZE`, default 112 px) are the size pair, and they do different jobs: the first decides whether a marker is created at all, the second whether that face may contribute to automatic clustering. **They are also in different units** - `FACE_SIZE` counts detection-thumbnail pixels, because it gates whether a box is emitted before any rendition is chosen, while `FACE_CLUSTER_SIZE` counts the pixels an embedding was sampled from. A face below the second never seeds a person even though it is detected and shown.
198-
- **The clustering default is `ArcFaceTemplateSize`, and it is a bar on the pixels an embedding was sampled from.** 112 is where embedding quality turns: distance to a per-person centroid averages 0.864 below it against 0.804 at or above, flat thereafter, which agrees with the template geometry. The quantity compared is `markers.thumb_size`, the face's extent in the rendition its embedding was drawn from, recorded where the vector is produced and never recomputed. A marker predating that column, or one placed by hand with no embedding, falls back to `markers.size` - detection-thumbnail pixels, which are a **lower bound** on the sampled extent, since `Fit720` is the narrowest rendition a crop is ever taken from. The fallback therefore rejects well-resolved faces it cannot prove are well-resolved and never admits an invented one. `photoprism faces migrate` treats a missing extent as stale, so a re-run measures it; `photoprism faces audit` reports how many are still without one and never writes the column, since it would have to *predict* it from a vector sampled earlier.
198+
- **The clustering default is `ArcFaceTemplateSize`, and it is a bar on the pixels an embedding was sampled from.** 112 is where embedding quality turns: distance to a per-person centroid averages 0.864 below it against 0.804 at or above, flat thereafter, which agrees with the template geometry. The quantity compared is `markers.thumb_size`, the face's extent in the rendition its embedding was drawn from, recorded where the vector is produced and never recomputed. A marker predating that column, or one placed by hand with no embedding, falls back to `markers.size` - detection-thumbnail pixels, which are a **lower bound** on the sampled extent, since `Fit720` is the narrowest rendition a crop is ever taken from. The fallback therefore rejects well-resolved faces it cannot prove are well-resolved and never admits an invented one. `photoprism faces migrate` treats a missing extent as stale, so a re-run measures it; `photoprism faces audit` reports how many are still without one and never writes the column, since it would have to *predict* it from a vector sampled earlier. It also counts the markers whose recorded extent is *below* the bar, and how many of those their original could still supply: a vector drawn from too few pixels is indistinguishable from one that was not, so that number exists nowhere else, and it separates what a re-run recovers from what no thumbnail size can.
199199
- **Both are measured in pixels of the detection thumbnail (`Fit720`), not of the original and not of the crop the embedder receives.** The crop comes from `crop.ImageFromIdealThumb`, which opens the smallest cached rendition wide enough to fill the 112 px template and falls back to the widest one cached, so a marker is compared against one image and embedded from another. A marker at exactly `FACE_CLUSTER_SIZE` needs a source no wider than `Fit720` itself, so that is what the crop path opens and the warp onto the template is 1:1 - on every aspect ratio, because the crop area is square and the required width therefore tracks the thumbnail's own. The bar is a guarantee of no upscaling rather than any headroom above it. A **smaller** marker needs a wider source, and there the aspect ratio decides how much it gets: at the default `THUMB_SIZE` of 1920 a rendition supplies 2.67x the detection size for a 16:9 original, 2.5x for a 3:2 one, 2.22x for a 4:3 one, and 1.67x for a square or 3:4 portrait one - the last two share a factor, because `Fit1920` bounds both by its 1200 px height.
200200
- **This is the mechanism behind the `THUMB_SIZE` warning in [Advanced Settings](https://docs.photoprism.app/user-guide/settings/advanced/#static-and-dynamic-size-limits).** Lowering the static size limit does not change any face threshold, but it lowers the rendition every crop is drawn from, so each face reaches the embedder with fewer real pixels. The size thresholds keep comparing the same numbers while the crops behind them get worse, which is why the effect is easy to miss.
201201
- Two detections count as the same face when their area overlap exceeds `OverlapThresholdFloor` (41 %), which is `OverlapThreshold` (42 %) relaxed by one point to absorb rounding. Tests rely on that value (e.g., `Markers.Contains/SameFace`).

internal/entity/query/faces_migrate.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,43 @@ func CountMarkersWithoutThumbSize() (n int, err error) {
605605
return int(count), err
606606
}
607607

608+
// FaceSampleShortfall counts the markers whose vector rests on fewer pixels than the clustering
609+
// bar requires, and how many of those the original could still supply.
610+
type FaceSampleShortfall struct {
611+
Measured int
612+
BelowBar int
613+
Recoverable int
614+
}
615+
616+
// FaceMarkerSampleShortfall reports how many markers were embedded from too few pixels to be
617+
// clustered, and how many of them a re-sampling at the resolution of their original would lift
618+
// over the bar.
619+
//
620+
// The two numbers separate the causes an operator can act on from the one nobody can: a crop taken
621+
// from a rendition narrower than the original is what a migration re-samples, while a face that is
622+
// small in the original itself stays where it is at any thumbnail size. Only markers that recorded
623+
// an extent are counted, since the ones that did not are reported on their own.
624+
func FaceMarkerSampleShortfall(clusterSize int) (result FaceSampleShortfall, err error) {
625+
if clusterSize < 1 {
626+
return result, fmt.Errorf("faces: clustering size must be positive")
627+
}
628+
629+
stmt := fmt.Sprintf(`SELECT COUNT(*) AS measured,
630+
COALESCE(SUM(CASE WHEN m.thumb_size < ? THEN 1 ELSE 0 END), 0) AS below_bar,
631+
COALESCE(SUM(CASE WHEN m.thumb_size < ? AND m.w * f.file_width >= ? THEN 1 ELSE 0 END), 0) AS recoverable
632+
FROM %s m JOIN %s f ON f.file_uid = m.file_uid
633+
WHERE m.marker_type = ? AND m.marker_invalid = 0 AND m.thumb_size >= 1 AND m.w > 0
634+
AND LENGTH(m.embeddings_json) > 0
635+
AND f.file_width > 0 AND f.file_missing = 0 AND f.deleted_at IS NULL`,
636+
entity.Marker{}.TableName(), entity.File{}.TableName())
637+
638+
if err = Db().Raw(stmt, clusterSize, clusterSize, clusterSize, entity.MarkerFace).Scan(&result).Error; err != nil {
639+
return FaceSampleShortfall{}, err
640+
}
641+
642+
return result, nil
643+
}
644+
608645
// SettleMigrationThumbSize records that a sampling reached these markers and produced no extent, so
609646
// a migration filling the column does not attempt them again on every future run.
610647
//

internal/entity/query/faces_migrate_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,3 +867,72 @@ func TestFaceMigrationSampleFiles(t *testing.T) {
867867
require.Error(t, err)
868868
})
869869
}
870+
871+
// TestFaceMarkerSampleShortfall pins the two numbers an operator acts on: how many markers hold a
872+
// vector too small to be clustered, and how many of those an original could still supply. Measured
873+
// as deltas, so the shared fixtures cannot decide the outcome.
874+
func TestFaceMarkerSampleShortfall(t *testing.T) {
875+
const clusterSize = 112
876+
877+
before, err := FaceMarkerSampleShortfall(clusterSize)
878+
require.NoError(t, err)
879+
880+
file := entity.File{
881+
FileUID: rnd.GenerateUID('f'),
882+
PhotoUID: rnd.GenerateUID('p'),
883+
FileName: "sample-shortfall/large.jpg",
884+
FileRoot: entity.RootOriginals,
885+
FileWidth: 4000,
886+
}
887+
require.NoError(t, Db().Create(&file).Error)
888+
t.Cleanup(func() { Db().Unscoped().Delete(&file) })
889+
890+
// The extents are what each marker's embedding was drawn from, and the widths what its
891+
// original could supply: 0.1 of 4000 px is 400, and 0.01 of it is 40.
892+
cases := []struct {
893+
w float32
894+
thumbSize int
895+
}{
896+
{0.1, 400}, // sampled above the bar
897+
{0.1, 60}, // below it, and its original holds 400 px
898+
{0.01, 40}, // below it, and its original holds 40
899+
}
900+
901+
for _, c := range cases {
902+
m := entity.Marker{
903+
MarkerUID: rnd.GenerateUID('m'),
904+
FileUID: file.FileUID,
905+
MarkerType: entity.MarkerFace,
906+
W: c.w,
907+
H: c.w,
908+
ThumbSize: c.thumbSize,
909+
EmbeddingsJSON: []byte("[[0.1,0.2]]"),
910+
}
911+
require.NoError(t, Db().Create(&m).Error)
912+
t.Cleanup(func() { Db().Unscoped().Delete(&m) })
913+
}
914+
915+
t.Run("Counts", func(t *testing.T) {
916+
after, err := FaceMarkerSampleShortfall(clusterSize)
917+
require.NoError(t, err)
918+
919+
assert.Equal(t, before.Measured+3, after.Measured)
920+
assert.Equal(t, before.BelowBar+2, after.BelowBar)
921+
assert.Equal(t, before.Recoverable+1, after.Recoverable, "only one of the two has an original that could supply the bar")
922+
})
923+
t.Run("AtALowerBar", func(t *testing.T) {
924+
// The bar is what decides both numbers, so a smaller one has to move them.
925+
low, err := FaceMarkerSampleShortfall(50)
926+
require.NoError(t, err)
927+
928+
after, err := FaceMarkerSampleShortfall(clusterSize)
929+
require.NoError(t, err)
930+
931+
assert.Less(t, low.BelowBar, after.BelowBar)
932+
assert.Equal(t, low.Measured, after.Measured)
933+
})
934+
t.Run("InvalidSize", func(t *testing.T) {
935+
_, err := FaceMarkerSampleShortfall(0)
936+
require.Error(t, err)
937+
})
938+
}

internal/photoprism/faces_audit.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,40 @@ func (w *Faces) auditProvenance() {
691691
w.auditMarkerEmbeddingModels(face.EmbeddingModelName())
692692
w.auditMarkerDetectModels()
693693
w.auditMarkerThumbSizes()
694+
w.auditMarkerSampleShortfall()
695+
}
696+
697+
// auditMarkerSampleShortfall reports the markers whose vector rests on too few pixels to be
698+
// clustered, and how many of them the originals could still supply.
699+
//
700+
// It is the one state that leaves no trace in the vectors themselves: a marker embedded from an
701+
// upscaled crop is indistinguishable from one that was not, so without this an operator whose
702+
// thumbnail cache is smaller than their faces need, or whose migration could not write a rendition,
703+
// has no number to read and nothing to act on.
704+
func (w *Faces) auditMarkerSampleShortfall() {
705+
shortfall, err := query.FaceMarkerSampleShortfall(face.ClusterSizeThreshold)
706+
707+
if err != nil {
708+
log.Errorf("faces: %s (audit marker sample sizes)", err)
709+
return
710+
}
711+
712+
if shortfall.BelowBar == 0 {
713+
log.Debugf("faces: every measured marker was sampled at or above the %d px clustering size",
714+
face.ClusterSizeThreshold)
715+
716+
return
717+
}
718+
719+
log.Infof("faces: %s sampled below the %d px clustering size, of %d measured",
720+
english.Plural(shortfall.BelowBar, "marker", "markers"), face.ClusterSizeThreshold, shortfall.Measured)
721+
722+
if shortfall.Recoverable > 0 {
723+
log.Infof("faces: %s of those have originals that hold enough detail, so photoprism faces migrate would sample them above it",
724+
english.Plural(shortfall.Recoverable, "marker", "markers"))
725+
} else {
726+
log.Infof("faces: none of them have an original holding enough detail, so no thumbnail size or migration changes it")
727+
}
694728
}
695729

696730
// auditMarkerThumbSizes counts embedded markers with no recorded sample extent, which the size bar

internal/photoprism/faces_audit_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,3 +555,60 @@ func TestFaces_auditMarkerThumbSizes(t *testing.T) {
555555
assert.Equal(t, 1, reported, "a marker the bar falls back on must be named")
556556
assert.NotPanics(t, func() { (*Faces)(nil).auditMarkerThumbSizes() })
557557
}
558+
559+
// TestFaces_auditMarkerSampleShortfall covers the one state that leaves no trace in the vectors: a
560+
// marker embedded from an upscaled crop is indistinguishable from one that was not, so the audit is
561+
// where an operator finds out that their thumbnails, or a migration that could not write one, cost
562+
// them recognition.
563+
func TestFaces_auditMarkerSampleShortfall(t *testing.T) {
564+
w := NewFaces(config.TestConfig())
565+
566+
restore := face.ClusterSizeThreshold
567+
t.Cleanup(func() { face.ClusterSizeThreshold = restore })
568+
569+
file := &entity.File{
570+
FileUID: rnd.GenerateUID('f'),
571+
PhotoUID: rnd.GenerateUID('p'),
572+
FileName: "audit-shortfall/large.jpg",
573+
FileRoot: entity.RootOriginals,
574+
FileWidth: 4000,
575+
}
576+
require.NoError(t, entity.Db().Create(file).Error)
577+
t.Cleanup(func() { entity.UnscopedDb().Delete(file) })
578+
579+
// Sampled at 60 px while its original holds 400, so a re-sampling clears a bar of 112.
580+
marker := &entity.Marker{
581+
MarkerUID: rnd.GenerateUID('m'),
582+
FileUID: file.FileUID,
583+
MarkerType: entity.MarkerFace,
584+
W: 0.1,
585+
H: 0.1,
586+
ThumbSize: 60,
587+
EmbeddingsJSON: []byte("[[0.1,0.2]]"),
588+
}
589+
require.NoError(t, entity.Db().Create(marker).Error)
590+
t.Cleanup(func() { entity.UnscopedDb().Delete(marker) })
591+
592+
t.Run("NamesWhatAMigrationWouldRecover", func(t *testing.T) {
593+
face.ClusterSizeThreshold = 112
594+
595+
hook := captureLog(t)
596+
w.auditMarkerSampleShortfall()
597+
598+
reported := strings.Join(loggedMessages(hook, logrus.InfoLevel), "\n")
599+
assert.Contains(t, reported, "sampled below the 112 px clustering size")
600+
assert.Contains(t, reported, "faces migrate")
601+
})
602+
t.Run("NothingToReport", func(t *testing.T) {
603+
// A bar every measured marker clears is not a finding, so it stays out of the report.
604+
face.ClusterSizeThreshold = 1
605+
606+
hook := captureLog(t)
607+
w.auditMarkerSampleShortfall()
608+
609+
assert.Empty(t, loggedMessages(hook, logrus.InfoLevel))
610+
})
611+
t.Run("NilWorker", func(t *testing.T) {
612+
assert.NotPanics(t, func() { (*Faces)(nil).auditMarkerSampleShortfall() })
613+
})
614+
}

0 commit comments

Comments
 (0)