From 57aa8811fcf66d9501a6ee9e7edef69a729c801b Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 5 Oct 2021 10:12:48 +0200 Subject: [PATCH] People: Add additional face cluster config options #22 --- internal/commands/config.go | 2 + internal/config/config.go | 2 + internal/config/face.go | 20 ++++++- internal/config/face_test.go | 79 ++++++++++++++++++++++++++++ internal/config/flags.go | 16 +++++- internal/config/options.go | 2 + internal/entity/marker.go | 2 +- internal/face/thresholds.go | 5 +- internal/photoprism/faces_cluster.go | 4 +- 9 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 internal/config/face_test.go diff --git a/internal/commands/config.go b/internal/commands/config.go index 3f8b1b881..08299ea57 100644 --- a/internal/commands/config.go +++ b/internal/commands/config.go @@ -147,6 +147,8 @@ func configAction(ctx *cli.Context) error { fmt.Printf("%-25s %d\n", "face-size", conf.FaceSize()) fmt.Printf("%-25s %f\n", "face-score", conf.FaceScore()) fmt.Printf("%-25s %d\n", "face-overlap", conf.FaceOverlap()) + fmt.Printf("%-25s %d\n", "face-cluster-size", conf.FaceClusterSize()) + fmt.Printf("%-25s %d\n", "face-cluster-score", conf.FaceClusterScore()) fmt.Printf("%-25s %d\n", "face-cluster-core", conf.FaceClusterCore()) fmt.Printf("%-25s %f\n", "face-cluster-dist", conf.FaceClusterDist()) fmt.Printf("%-25s %f\n", "face-match-dist", conf.FaceMatchDist()) diff --git a/internal/config/config.go b/internal/config/config.go index bc7a49575..d7310d0fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -143,6 +143,8 @@ func (c *Config) Propagate() { // Set facial recognition parameters. face.ScoreThreshold = c.FaceScore() face.OverlapThreshold = c.FaceOverlap() + face.ClusterScoreThreshold = c.FaceClusterScore() + face.ClusterSizeThreshold = c.FaceClusterSize() face.ClusterCore = c.FaceClusterCore() face.ClusterDist = c.FaceClusterDist() face.MatchDist = c.FaceMatchDist() diff --git a/internal/config/face.go b/internal/config/face.go index 8cea76a56..398725d2c 100644 --- a/internal/config/face.go +++ b/internal/config/face.go @@ -5,7 +5,7 @@ import "github.com/photoprism/photoprism/internal/face" // FaceSize returns the face size threshold in pixels. func (c *Config) FaceSize() int { if c.options.FaceSize < 20 || c.options.FaceSize > 10000 { - return 50 + return face.SizeThreshold } return c.options.FaceSize @@ -29,6 +29,24 @@ func (c *Config) FaceOverlap() int { return c.options.FaceOverlap } +// FaceClusterSize returns the size threshold for faces forming a cluster in pixels. +func (c *Config) FaceClusterSize() int { + if c.options.FaceClusterSize < 20 || c.options.FaceClusterSize > 10000 { + return face.ClusterSizeThreshold + } + + return c.options.FaceClusterSize +} + +// FaceClusterScore returns the quality threshold for faces forming a cluster. +func (c *Config) FaceClusterScore() int { + if c.options.FaceClusterScore < 1 || c.options.FaceClusterScore > 100 { + return face.ClusterScoreThreshold + } + + return c.options.FaceClusterScore +} + // FaceClusterCore returns the number of faces forming a cluster core. func (c *Config) FaceClusterCore() int { if c.options.FaceClusterCore < 1 || c.options.FaceClusterCore > 100 { diff --git a/internal/config/face_test.go b/internal/config/face_test.go new file mode 100644 index 000000000..b02d3fa82 --- /dev/null +++ b/internal/config/face_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_FaceSize(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 40, c.FaceSize()) + c.options.FaceSize = 30 + assert.Equal(t, 30, c.FaceSize()) + c.options.FaceSize = 1 + assert.Equal(t, 40, c.FaceSize()) +} + +func TestConfig_FaceScore(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 9.0, c.FaceScore()) + c.options.FaceScore = 8.5 + assert.Equal(t, 8.5, c.FaceScore()) + c.options.FaceScore = 0.1 + assert.Equal(t, 9.0, c.FaceScore()) +} + +func TestConfig_FaceOverlap(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 42, c.FaceOverlap()) + c.options.FaceOverlap = 300 + assert.Equal(t, 42, c.FaceOverlap()) + c.options.FaceOverlap = 1 + assert.Equal(t, 1, c.FaceOverlap()) +} + +func TestConfig_FaceClusterSize(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 80, c.FaceClusterSize()) + c.options.FaceClusterSize = 10 + assert.Equal(t, 80, c.FaceClusterSize()) + c.options.FaceClusterSize = 66 + assert.Equal(t, 66, c.FaceClusterSize()) +} + +func TestConfig_FaceClusterScore(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 15, c.FaceClusterScore()) + c.options.FaceClusterScore = 0 + assert.Equal(t, 15, c.FaceClusterScore()) + c.options.FaceClusterScore = 55 + assert.Equal(t, 55, c.FaceClusterScore()) +} + +func TestConfig_FaceClusterCore(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 4, c.FaceClusterCore()) + c.options.FaceClusterCore = 1000 + assert.Equal(t, 4, c.FaceClusterCore()) + c.options.FaceClusterCore = 1 + assert.Equal(t, 1, c.FaceClusterCore()) +} + +func TestConfig_FaceClusterDist(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 0.64, c.FaceClusterDist()) + c.options.FaceClusterDist = 0.01 + assert.Equal(t, 0.64, c.FaceClusterDist()) + c.options.FaceClusterDist = 0.34 + assert.Equal(t, 0.34, c.FaceClusterDist()) +} + +func TestConfig_FaceMatchDist(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 0.46, c.FaceMatchDist()) + c.options.FaceMatchDist = 0.1 + assert.Equal(t, 0.1, c.FaceMatchDist()) + c.options.FaceMatchDist = 0.01 + assert.Equal(t, 0.46, c.FaceMatchDist()) +} diff --git a/internal/config/flags.go b/internal/config/flags.go index dc604a1ee..e9b2fe585 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -440,8 +440,8 @@ var GlobalFlags = []cli.Flag{ }, cli.IntFlag{ Name: "face-size", - Usage: "min face size in `PIXELS`", - Value: 50, + Usage: "face size threshold in `PIXELS`", + Value: face.SizeThreshold, EnvVar: "PHOTOPRISM_FACE_SIZE", }, cli.Float64Flag{ @@ -456,6 +456,18 @@ var GlobalFlags = []cli.Flag{ Value: face.OverlapThreshold, EnvVar: "PHOTOPRISM_FACE_OVERLAP", }, + cli.IntFlag{ + Name: "face-cluster-size", + Usage: "size threshold for faces forming a cluster in `PIXELS`", + Value: face.ClusterSizeThreshold, + EnvVar: "PHOTOPRISM_FACE_CLUSTER_SIZE", + }, + cli.IntFlag{ + Name: "face-cluster-score", + Usage: "`QUALITY` threshold for faces forming a cluster", + Value: face.ClusterScoreThreshold, + EnvVar: "PHOTOPRISM_FACE_CLUSTER_SCORE", + }, cli.IntFlag{ Name: "face-cluster-core", Usage: "`NUMBER` of faces forming a cluster core", diff --git a/internal/config/options.go b/internal/config/options.go index 695b8f77b..3add1dd2e 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -117,6 +117,8 @@ type Options struct { FaceSize int `yaml:"-" json:"-" flag:"face-size"` FaceScore float64 `yaml:"-" json:"-" flag:"face-score"` FaceOverlap int `yaml:"-" json:"-" flag:"face-overlap"` + FaceClusterSize int `yaml:"-" json:"-" flag:"face-cluster-size"` + FaceClusterScore int `yaml:"-" json:"-" flag:"face-cluster-score"` FaceClusterCore int `yaml:"-" json:"-" flag:"face-cluster-core"` FaceClusterDist float64 `yaml:"-" json:"-" flag:"face-cluster-dist"` FaceMatchDist float64 `yaml:"-" json:"-" flag:"face-match-dist"` diff --git a/internal/entity/marker.go b/internal/entity/marker.go index dd8088ca4..0dfa8720c 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -458,7 +458,7 @@ func (m *Marker) Face() (f *Face) { // Add face if size if m.SubjSrc != SrcAuto && m.FaceID == "" { - if m.Size < face.ClusterMinSize || m.Score < face.ClusterMinScore { + if m.Size < face.ClusterSizeThreshold || m.Score < face.ClusterScoreThreshold { log.Debugf("marker: skipped adding face due to low-quality (uid %s, size %d, score %d)", txt.Quote(m.MarkerUID), m.Size, m.Score) return nil } else if emb := m.Embeddings(); emb.Empty() { diff --git a/internal/face/thresholds.go b/internal/face/thresholds.go index 6e3531ac6..602e4d53e 100644 --- a/internal/face/thresholds.go +++ b/internal/face/thresholds.go @@ -8,12 +8,13 @@ var CropSize = crop.Sizes[crop.Tile160] var MatchDist = 0.46 var ClusterDist = 0.64 var ClusterCore = 4 -var ClusterMinScore = 15 -var ClusterMinSize = 80 +var ClusterScoreThreshold = 15 +var ClusterSizeThreshold = 80 var SampleThreshold = 2 * ClusterCore var OverlapThreshold = 42 var OverlapThresholdFloor = OverlapThreshold - 1 var ScoreThreshold = 9.0 +var SizeThreshold = 40 // QualityThreshold returns the scale adjusted quality score threshold. func QualityThreshold(scale int) (score float32) { diff --git a/internal/photoprism/faces_cluster.go b/internal/photoprism/faces_cluster.go index be101b210..f6dd58e78 100644 --- a/internal/photoprism/faces_cluster.go +++ b/internal/photoprism/faces_cluster.go @@ -20,13 +20,13 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) { // Skip clustering if index contains no new face markers, and force option isn't set. if opt.Force { log.Infof("faces: forced clustering") - } else if n := query.CountNewFaceMarkers(face.ClusterMinSize, face.ClusterMinScore); n < opt.SampleThreshold() { + } else if n := query.CountNewFaceMarkers(face.ClusterSizeThreshold, face.ClusterScoreThreshold); n < opt.SampleThreshold() { log.Debugf("faces: skipping clustering") return added, nil } // Fetch unclustered face embeddings. - embeddings, err := query.Embeddings(false, true, face.ClusterMinSize, face.ClusterMinScore) + embeddings, err := query.Embeddings(false, true, face.ClusterSizeThreshold, face.ClusterScoreThreshold) log.Debugf("faces: %d unclustered samples found", len(embeddings))