From a302955c02adc0077abdd310b45e365c896a07d9 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 8 Oct 2025 13:41:51 +0200 Subject: [PATCH] Faces: Remove PHOTOPRISM_FACE_ENGINE_RUN config option #5167 Signed-off-by: Michael Mayer --- internal/ai/face/README.md | 2 + internal/ai/vision/config.go | 15 +++++ internal/ai/vision/model_run.go | 12 ++++ internal/config/config_faces.go | 8 +++ internal/config/config_faces_test.go | 88 +++++++++++++++------------- internal/config/config_vision.go | 8 +++ internal/config/flags.go | 6 -- internal/config/options.go | 1 - internal/config/report.go | 4 +- internal/photoprism/index_options.go | 3 +- internal/workers/meta.go | 2 +- internal/workers/vision.go | 8 +-- 12 files changed, 102 insertions(+), 55 deletions(-) diff --git a/internal/ai/face/README.md b/internal/ai/face/README.md index 673feb1cc..1d8574355 100644 --- a/internal/ai/face/README.md +++ b/internal/ai/face/README.md @@ -116,6 +116,8 @@ Additional safeguards were introduced in October 2025 so stubborn clusters are o | `FACE_SCORE` | `9.0` (with dynamic offsets) | Base quality threshold before scale adjustments. | | `FACE_OVERLAP` | `42` | Maximum allowed IoU when deduplicating markers. | +Run scheduling is configured through the face model entry in `vision.yml`. Adjust the model’s `Run` value (for example `on-schedule`, `manual`, or `never`) to control when detection and embedding jobs execute—no separate `FACE_ENGINE_RUN` flag is required. + > Additional merge tuning: set `PHOTOPRISM_FACE_MERGE_MAX_RETRY` to control how often manual clusters are retried (default 1, `0` = unlimited). See the optimiser notes above. ### Benchmark Reference diff --git a/internal/ai/vision/config.go b/internal/ai/vision/config.go index 97792b876..6be2f8dba 100644 --- a/internal/ai/vision/config.go +++ b/internal/ai/vision/config.go @@ -164,6 +164,21 @@ func (c *ConfigValues) ShouldRun(t ModelType, when RunType) bool { return m.ShouldRun(when) } +// RunType returns the normalized run type for the first enabled model matching +// the provided type. Disabled or missing models fall back to RunNever so +// callers can treat the result as authoritative scheduling information. +func (c *ConfigValues) RunType(t ModelType) RunType { + m := c.Model(t) + + if m == nil { + return RunNever + } else if m.Disabled { + return RunNever + } + + return m.RunType() +} + // IsDefault checks whether the specified type is the built-in default model. func (c *ConfigValues) IsDefault(t ModelType) bool { m := c.Model(t) diff --git a/internal/ai/vision/model_run.go b/internal/ai/vision/model_run.go index 1fc61a0ca..9d23c3175 100644 --- a/internal/ai/vision/model_run.go +++ b/internal/ai/vision/model_run.go @@ -18,6 +18,18 @@ const ( RunOnIndex RunType = "on-index" // Run manually and on-index. ) +// ReportRunType returns a human-readable string for the run type, preserving the +// explicit value when set or "auto" when delegation is in effect. +func ReportRunType(when RunType) string { + when = ParseRunType(when) + + if when == RunAuto { + return "auto" + } + + return when +} + // RunTypes maps configuration strings to standard RunType model settings. var RunTypes = map[string]RunType{ RunAuto: RunAuto, diff --git a/internal/config/config_faces.go b/internal/config/config_faces.go index 5ee5bf2f5..93ea9225e 100644 --- a/internal/config/config_faces.go +++ b/internal/config/config_faces.go @@ -123,6 +123,10 @@ func (c *Config) FaceEngine() string { return c.options.FaceEngine } + if vision.Config == nil { + return face.EngineNone + } + desired := face.ParseEngine(c.options.FaceEngine) modelPath := c.FaceEngineModelPath() @@ -152,6 +156,10 @@ func (c *Config) FaceEngineRunType() vision.RunType { return vision.RunNever } + if vision.Config == nil { + return vision.RunNever + } + if c.DisableFaces() || c.FaceEngine() == face.EngineNone { return vision.RunNever } diff --git a/internal/config/config_faces_test.go b/internal/config/config_faces_test.go index 7a367c1a0..eae7416c9 100644 --- a/internal/config/config_faces_test.go +++ b/internal/config/config_faces_test.go @@ -145,54 +145,62 @@ func TestConfig_FaceEngine(t *testing.T) { } func TestConfig_FaceEngineRunType(t *testing.T) { - c := NewConfig(CliTestContext()) + t.Run("AutoDefaults", func(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.FaceEngineThreads = 1 + assert.Equal(t, "auto", vision.ReportRunType(c.FaceEngineRunType())) - c.options.FaceEngineThreads = 1 - assert.Equal(t, "auto", vision.ReportRunType(c.FaceEngineRunType())) + c.options.DisableFaces = true + assert.Equal(t, "never", vision.ReportRunType(c.FaceEngineRunType())) + c.options.DisableFaces = false - c.options.DisableFaces = true - assert.Equal(t, "never", vision.ReportRunType(c.FaceEngineRunType())) - c.options.DisableFaces = false - - c.options.FaceEngineThreads = 4 - assert.Equal(t, "auto", vision.ReportRunType(c.FaceEngineRunType())) -} - -func TestConfig_FaceEngineRunType_DisabledFaceModel(t *testing.T) { - origVision := vision.Config - t.Cleanup(func() { - vision.Config = origVision + c.options.FaceEngineThreads = 4 + assert.Equal(t, "auto", vision.ReportRunType(c.FaceEngineRunType())) }) + t.Run("DisabledFaceModel", func(t *testing.T) { + origVision := vision.Config + t.Cleanup(func() { vision.Config = origVision }) - c := NewConfig(CliTestContext()) - vision.Config = &vision.ConfigValues{Models: vision.Models{{Type: vision.ModelTypeFace, Disabled: true}}} - assert.Equal(t, vision.RunNever, c.FaceEngineRunType()) -} - -func TestConfig_FaceEngineRunType_NoFaceModel(t *testing.T) { - origVision := vision.Config - t.Cleanup(func() { - vision.Config = origVision + vision.Config = &vision.ConfigValues{Models: vision.Models{{Type: vision.ModelTypeFace, Disabled: true}}} + c := NewConfig(CliTestContext()) + assert.Equal(t, vision.RunNever, c.FaceEngineRunType()) }) + t.Run("NoFaceModel", func(t *testing.T) { + origVision := vision.Config + t.Cleanup(func() { vision.Config = origVision }) - c := NewConfig(CliTestContext()) - vision.Config = &vision.ConfigValues{Models: vision.Models{}} - assert.Equal(t, vision.RunNever, c.FaceEngineRunType()) -} - -func TestConfig_FaceEngineRunType_DelegatesToVisionModel(t *testing.T) { - origVision := vision.Config - t.Cleanup(func() { - vision.Config = origVision + vision.Config = &vision.ConfigValues{Models: vision.Models{}} + c := NewConfig(CliTestContext()) + assert.Equal(t, vision.RunNever, c.FaceEngineRunType()) }) + t.Run("DelegatesToVisionModel", func(t *testing.T) { + origVision := vision.Config + t.Cleanup(func() { vision.Config = origVision }) - c := NewConfig(CliTestContext()) - vision.Config = &vision.ConfigValues{Models: vision.Models{{Type: vision.ModelTypeFace}}} - m := vision.Config.Model(vision.ModelTypeFace) - require.NotNil(t, m) - m.Run = string(vision.RunOnSchedule) - require.Equal(t, vision.RunOnSchedule, vision.Config.RunType(vision.ModelTypeFace)) - assert.Equal(t, vision.RunOnSchedule, c.FaceEngineRunType()) + vision.Config = &vision.ConfigValues{Models: vision.Models{{Type: vision.ModelTypeFace}}} + c := NewConfig(CliTestContext()) + m := vision.Config.Model(vision.ModelTypeFace) + require.NotNil(t, m) + m.Run = vision.RunOnSchedule + require.Equal(t, vision.RunOnSchedule, vision.Config.RunType(vision.ModelTypeFace)) + assert.Equal(t, vision.RunOnSchedule, c.FaceEngineRunType()) + }) + t.Run("VisionModelShouldRunFace", func(t *testing.T) { + origVision := vision.Config + t.Cleanup(func() { vision.Config = origVision }) + + vision.Config = &vision.ConfigValues{Models: vision.Models{{Type: vision.ModelTypeFace}}} + c := NewConfig(CliTestContext()) + + m := vision.Config.Model(vision.ModelTypeFace) + require.NotNil(t, m) + m.Run = vision.RunOnSchedule + + assert.True(t, c.VisionModelShouldRun(vision.ModelTypeFace, vision.RunOnSchedule)) + + m.Disabled = true + assert.False(t, c.VisionModelShouldRun(vision.ModelTypeFace, vision.RunOnSchedule)) + }) } func TestConfig_FaceEngineThreads(t *testing.T) { diff --git a/internal/config/config_vision.go b/internal/config/config_vision.go index 18f3dee3e..1405c8a96 100644 --- a/internal/config/config_vision.go +++ b/internal/config/config_vision.go @@ -51,6 +51,10 @@ func (c *Config) VisionModelShouldRun(t vision.ModelType, when vision.RunType) b return false } + if t == vision.ModelTypeFace && c.DisableFaces() { + return false + } + if t == vision.ModelTypeLabels && c.DisableClassification() { return false } @@ -63,6 +67,10 @@ func (c *Config) VisionModelShouldRun(t vision.ModelType, when vision.RunType) b return false } + if t == vision.ModelTypeFace { + return c.FaceEngineShouldRun(when) + } + return vision.Config.ShouldRun(t, when) } diff --git a/internal/config/flags.go b/internal/config/flags.go index c264a354a..6e32cb4ad 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -1173,12 +1173,6 @@ var Flags = CliFlags{ Value: face.EngineAuto, EnvVars: EnvVars("FACE_ENGINE"), }}, { - Flag: &cli.StringFlag{ - Name: "face-engine-run", - Usage: "face detection run `MODE` (auto, never, manual, newly-indexed, on-demand, on-index, on-schedule, always)", - Value: "auto", - EnvVars: EnvVars("FACE_ENGINE_RUN"), - }}, { Flag: &cli.IntFlag{ Name: "face-engine-threads", Usage: "face detection thread `COUNT` (0 uses half the available CPU cores)", diff --git a/internal/config/options.go b/internal/config/options.go index 58d6b3a67..59c3d2152 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -231,7 +231,6 @@ type Options struct { VisionFilter string `yaml:"VisionFilter" json:"VisionFilter" flag:"vision-filter"` DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"` FaceEngine string `yaml:"FaceEngine" json:"-" flag:"face-engine"` - FaceEngineRun string `yaml:"FaceEngineRun" json:"-" flag:"face-engine-run"` FaceEngineRetry bool `yaml:"-" json:"-" flag:"-"` FaceEngineThreads int `yaml:"FaceEngineThreads" json:"-" flag:"face-engine-threads"` FaceSize int `yaml:"-" json:"-" flag:"face-size"` diff --git a/internal/config/report.go b/internal/config/report.go index d6ca775be..0f207d5fb 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -5,6 +5,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/photoprism/photoprism/internal/ai/vision" ) // Report returns global config values as a table for reporting. @@ -284,7 +286,7 @@ func (c *Config) Report() (rows [][]string, cols []string) { // Facial Recognition. {"face-engine", c.FaceEngine()}, - {"face-engine-run", c.FaceEngineRunType()}, + {"face-engine-run", vision.ReportRunType(c.FaceEngineRunType())}, {"face-engine-threads", fmt.Sprintf("%d", c.FaceEngineThreads())}, {"face-size", fmt.Sprintf("%d", c.FaceSize())}, {"face-score", fmt.Sprintf("%f", c.FaceScore())}, diff --git a/internal/photoprism/index_options.go b/internal/photoprism/index_options.go index 7f20ae6ba..d1a8a785e 100644 --- a/internal/photoprism/index_options.go +++ b/internal/photoprism/index_options.go @@ -48,8 +48,7 @@ func NewIndexOptions(path string, rescan, convert, stack, facesOnly, skipArchive facesRunType = vision.RunOnIndex } - result.DetectFaces = c.FaceEngineShouldRun(facesRunType) - + result.DetectFaces = c.VisionModelShouldRun(vision.ModelTypeFace, facesRunType) result.DetectNsfw = !facesOnly && c.VisionModelShouldRun(vision.ModelTypeNsfw, vision.RunOnIndex) result.GenerateLabels = !facesOnly && c.VisionModelShouldRun(vision.ModelTypeLabels, vision.RunOnIndex) diff --git a/internal/workers/meta.go b/internal/workers/meta.go index f5219cfcc..322c36d8e 100644 --- a/internal/workers/meta.go +++ b/internal/workers/meta.go @@ -65,7 +65,7 @@ func (w *Meta) Start(delay, interval time.Duration, force bool) (err error) { labelsModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeLabels, vision.RunNewlyIndexed) captionModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeCaption, vision.RunNewlyIndexed) nsfwModelShouldRun := w.conf.VisionModelShouldRun(vision.ModelTypeNsfw, vision.RunNewlyIndexed) - detectFaces := w.conf.FaceEngineShouldRun(vision.RunNewlyIndexed) + detectFaces := w.conf.VisionModelShouldRun(vision.ModelTypeFace, vision.RunNewlyIndexed) if nsfwModelShouldRun { log.Debugf("index: cannot run %s model on %s", vision.ModelTypeNsfw, vision.RunNewlyIndexed) diff --git a/internal/workers/vision.go b/internal/workers/vision.go index 9b0073db5..794455e07 100644 --- a/internal/workers/vision.go +++ b/internal/workers/vision.go @@ -57,6 +57,10 @@ func (w *Vision) StartScheduled() { // scheduledModels returns the model types that should run for scheduled jobs. func (w *Vision) scheduledModels() []string { + if w.conf == nil { + return nil + } + models := make([]string, 0, 4) if w.conf.VisionModelShouldRun(vision.ModelTypeLabels, vision.RunOnSchedule) { @@ -103,10 +107,6 @@ func (w *Vision) Start(filter string, count int, models []string, customSrc stri defer mutex.VisionWorker.Stop() models = vision.FilterModels(models, runType, func(mt vision.ModelType, when vision.RunType) bool { - if mt == vision.ModelTypeFace { - return w.conf.FaceEngineShouldRun(when) - } - return w.conf.VisionModelShouldRun(mt, when) })