From 4dd09f45023d9ae27b340008fb4ce7576a8b6801 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 22 Sep 2021 19:33:41 +0200 Subject: [PATCH] People: Add "photoprism faces index" command for indexing faces only #22 --- internal/classify/labels.go | 3 +- internal/commands/backup.go | 13 +- internal/commands/cleanup.go | 3 +- internal/commands/config.go | 3 +- internal/commands/config_test.go | 3 +- internal/commands/convert.go | 4 +- internal/commands/copy.go | 3 +- internal/commands/faces.go | 225 ++++++++++++++---- internal/commands/import.go | 3 +- internal/commands/index.go | 12 +- internal/commands/migrate.go | 3 +- internal/commands/moments.go | 3 +- internal/commands/optimize.go | 4 +- internal/commands/passwd.go | 3 +- internal/commands/purge.go | 3 +- internal/commands/resample.go | 3 +- internal/commands/reset.go | 3 +- internal/commands/restore.go | 14 +- internal/commands/start.go | 9 +- internal/commands/status.go | 3 +- internal/commands/stop.go | 5 +- internal/commands/users.go | 3 +- internal/commands/version.go | 3 +- internal/entity/face.go | 19 +- internal/entity/file.go | 63 +++-- internal/entity/file_test.go | 37 ++- internal/entity/marker.go | 10 + internal/entity/marker_fixtures.go | 2 +- internal/entity/markers.go | 14 +- internal/entity/markers_test.go | 4 +- internal/entity/photo.go | 4 +- internal/entity/user.go | 2 +- internal/face/detector.go | 6 +- internal/face/net.go | 5 +- internal/face/net_test.go | 2 +- internal/photoprism/import.go | 6 + internal/photoprism/index.go | 14 ++ internal/photoprism/index_faces.go | 12 +- .../{index_classify.go => index_labels.go} | 10 +- internal/photoprism/index_mediafile.go | 140 ++++------- internal/photoprism/index_nsfw.go | 29 +++ internal/photoprism/index_options.go | 40 +++- internal/photoprism/index_options_test.go | 40 +++- internal/photoprism/index_result.go | 50 ++++ internal/query/faces.go | 46 ++++ 45 files changed, 617 insertions(+), 269 deletions(-) rename internal/photoprism/{index_classify.go => index_labels.go} (73%) create mode 100644 internal/photoprism/index_nsfw.go create mode 100644 internal/photoprism/index_result.go diff --git a/internal/classify/labels.go b/internal/classify/labels.go index e0c30116d..6922ad398 100644 --- a/internal/classify/labels.go +++ b/internal/classify/labels.go @@ -9,7 +9,8 @@ import ( // Labels is list of MediaFile labels. type Labels []Label -// Implements functions for the Sort Interface. Default Labels sort is by priority and uncertainty +// Labels implement the sort interface to sort by priority and uncertainty. + func (l Labels) Len() int { return len(l) } func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] } func (l Labels) Less(i, j int) bool { diff --git a/internal/commands/backup.go b/internal/commands/backup.go index 725981ff9..1c24095ea 100644 --- a/internal/commands/backup.go +++ b/internal/commands/backup.go @@ -11,16 +11,13 @@ import ( "path/filepath" "time" - "github.com/photoprism/photoprism/pkg/fs" - - "github.com/photoprism/photoprism/internal/service" - - "github.com/photoprism/photoprism/internal/photoprism" - - "github.com/photoprism/photoprism/pkg/txt" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/txt" ) // BackupCommand configures the backup cli command. diff --git a/internal/commands/cleanup.go b/internal/commands/cleanup.go index 282749bd3..07dae50c6 100644 --- a/internal/commands/cleanup.go +++ b/internal/commands/cleanup.go @@ -4,10 +4,11 @@ import ( "context" "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" ) // CleanUpCommand registers the cleanup command. diff --git a/internal/commands/config.go b/internal/commands/config.go index a968f139a..8ff15d711 100644 --- a/internal/commands/config.go +++ b/internal/commands/config.go @@ -6,8 +6,9 @@ import ( "time" "unicode/utf8" - "github.com/photoprism/photoprism/internal/config" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/config" ) // ConfigCommand registers the display config cli command. diff --git a/internal/commands/config_test.go b/internal/commands/config_test.go index 9af135f66..c43179dac 100644 --- a/internal/commands/config_test.go +++ b/internal/commands/config_test.go @@ -3,9 +3,10 @@ package commands import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/pkg/capture" - "github.com/stretchr/testify/assert" ) func TestConfigCommand(t *testing.T) { diff --git a/internal/commands/convert.go b/internal/commands/convert.go index 65c6a530d..2a9746502 100644 --- a/internal/commands/convert.go +++ b/internal/commands/convert.go @@ -6,11 +6,11 @@ import ( "strings" "time" - "github.com/photoprism/photoprism/pkg/txt" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/pkg/txt" ) // ConvertCommand registers the convert cli command. diff --git a/internal/commands/copy.go b/internal/commands/copy.go index de1675903..03ea06834 100644 --- a/internal/commands/copy.go +++ b/internal/commands/copy.go @@ -7,10 +7,11 @@ import ( "strings" "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" ) // CopyCommand registers the copy cli command. diff --git a/internal/commands/faces.go b/internal/commands/faces.go index f15d6110a..4f1416afa 100644 --- a/internal/commands/faces.go +++ b/internal/commands/faces.go @@ -2,15 +2,19 @@ package commands import ( "context" + "path/filepath" + "strings" "time" "github.com/manifoldco/promptui" - - "github.com/photoprism/photoprism/internal/photoprism" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/txt" ) // FacesCommand registers the faces cli command. @@ -25,7 +29,7 @@ var FacesCommand = cli.Command{ }, { Name: "audit", - Usage: "Conducts a data integrity audit", + Usage: "Scans the index for issues", Flags: []cli.Flag{ cli.BoolFlag{ Name: "fix, f", @@ -35,26 +39,38 @@ var FacesCommand = cli.Command{ Action: facesAuditAction, }, { - Name: "reset", - Usage: "Resets recognized faces", + Name: "reset", + Usage: "Removes people and faces", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "force, f", + Usage: "remove all people and faces", + }, + }, Action: facesResetAction, }, + { + Name: "index", + Usage: "Searches originals for faces", + ArgsUsage: "[path]", + Action: facesIndexAction, + }, + { + Name: "match", + Usage: "Performs face clustering and matching", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "force, f", + Usage: "update all faces", + }, + }, + Action: facesMatchAction, + }, { Name: "optimize", Usage: "Optimizes face clusters", Action: facesOptimizeAction, }, - { - Name: "update", - Usage: "Performs facial recognition", - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "force, f", - Usage: "update existing faces", - }, - }, - Action: facesUpdateAction, - }, }, } @@ -122,6 +138,10 @@ func facesAuditAction(ctx *cli.Context) error { // facesResetAction resets face clusters and matches. func facesResetAction(ctx *cli.Context) error { + if ctx.Bool("force") { + return facesResetAllAction(ctx) + } + actionPrompt := promptui.Prompt{ Label: "Remove automatically recognized faces, matches, and dangling subjects?", IsConfirm: true, @@ -160,6 +180,142 @@ func facesResetAction(ctx *cli.Context) error { return nil } +// facesResetAllAction removes all people, faces, and face markers. +func facesResetAllAction(ctx *cli.Context) error { + actionPrompt := promptui.Prompt{ + Label: "Permanently delete all people and faces?", + IsConfirm: true, + } + + if _, err := actionPrompt.Run(); err != nil { + return nil + } + + start := time.Now() + + conf := config.NewConfig(ctx) + service.SetConfig(conf) + + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := conf.Init(); err != nil { + return err + } + + conf.InitDb() + + if err := query.RemovePeopleAndFaces(); err != nil { + return err + } else { + elapsed := time.Since(start) + + log.Infof("completed in %s", elapsed) + } + + conf.Shutdown() + + return nil +} + +// facesIndexAction searches originals for faces. +func facesIndexAction(ctx *cli.Context) error { + start := time.Now() + + conf := config.NewConfig(ctx) + service.SetConfig(conf) + + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := conf.Init(); err != nil { + return err + } + + conf.InitDb() + + // Use first argument to limit scope if set. + subPath := strings.TrimSpace(ctx.Args().First()) + + if subPath == "" { + log.Infof("finding faces in %s", txt.Quote(conf.OriginalsPath())) + } else { + log.Infof("finding faces in %s", txt.Quote(filepath.Join(conf.OriginalsPath(), subPath))) + } + + if conf.ReadOnly() { + log.Infof("index: read-only mode enabled") + } + + var indexed fs.Done + + if w := service.Index(); w != nil { + opt := photoprism.IndexOptions{ + Path: subPath, + Rescan: true, + Convert: conf.Settings().Index.Convert && conf.SidecarWritable(), + Stack: true, + FacesOnly: true, + } + + indexed = w.Start(opt) + } else if w := service.Purge(); w != nil { + opt := photoprism.PurgeOptions{ + Path: subPath, + Ignore: indexed, + } + + if files, photos, err := w.Start(opt); err != nil { + log.Error(err) + } else if len(files) > 0 || len(photos) > 0 { + log.Infof("purge: removed %d files and %d photos", len(files), len(photos)) + } + } + + elapsed := time.Since(start) + + log.Infof("indexed %d files in %s", len(indexed), elapsed) + + conf.Shutdown() + + return nil +} + +// facesMatchAction performs face clustering and matching. +func facesMatchAction(ctx *cli.Context) error { + start := time.Now() + + conf := config.NewConfig(ctx) + service.SetConfig(conf) + + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := conf.Init(); err != nil { + return err + } + + conf.InitDb() + + opt := photoprism.FacesOptions{ + Force: ctx.Bool("force"), + } + + w := service.Faces() + + if err := w.Start(opt); err != nil { + return err + } else { + elapsed := time.Since(start) + + log.Infof("completed in %s", elapsed) + } + + conf.Shutdown() + + return nil +} + // facesOptimizeAction optimizes existing face clusters. func facesOptimizeAction(ctx *cli.Context) error { start := time.Now() @@ -190,38 +346,3 @@ func facesOptimizeAction(ctx *cli.Context) error { return nil } - -// facesUpdateAction performs face clustering and matching. -func facesUpdateAction(ctx *cli.Context) error { - start := time.Now() - - conf := config.NewConfig(ctx) - service.SetConfig(conf) - - _, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := conf.Init(); err != nil { - return err - } - - conf.InitDb() - - opt := photoprism.FacesOptions{ - Force: ctx.Bool("force"), - } - - w := service.Faces() - - if err := w.Start(opt); err != nil { - return err - } else { - elapsed := time.Since(start) - - log.Infof("completed in %s", elapsed) - } - - conf.Shutdown() - - return nil -} diff --git a/internal/commands/import.go b/internal/commands/import.go index 99ec769f7..1ec73e7a2 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -7,10 +7,11 @@ import ( "strings" "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" ) // ImportCommand registers the import cli command. diff --git a/internal/commands/index.go b/internal/commands/index.go index 0eb090101..9769cf0c1 100644 --- a/internal/commands/index.go +++ b/internal/commands/index.go @@ -6,13 +6,13 @@ import ( "strings" "time" - "github.com/photoprism/photoprism/pkg/fs" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" - "github.com/urfave/cli" ) // IndexCommand registers the index cli command. @@ -75,9 +75,7 @@ func indexAction(ctx *cli.Context) error { } indexed = w.Start(opt) - } - - if w := service.Purge(); w != nil { + } else if w := service.Purge(); w != nil { opt := photoprism.PurgeOptions{ Path: subPath, Ignore: indexed, @@ -88,9 +86,7 @@ func indexAction(ctx *cli.Context) error { } else if len(files) > 0 || len(photos) > 0 { log.Infof("purge: removed %d files and %d photos", len(files), len(photos)) } - } - - if ctx.Bool("cleanup") { + } else if ctx.Bool("cleanup") { w := service.CleanUp() opt := photoprism.CleanUpOptions{ diff --git a/internal/commands/migrate.go b/internal/commands/migrate.go index f6928ecac..0a60a74af 100644 --- a/internal/commands/migrate.go +++ b/internal/commands/migrate.go @@ -4,8 +4,9 @@ import ( "context" "time" - "github.com/photoprism/photoprism/internal/config" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/config" ) // MigrateCommand registers the migrate cli command. diff --git a/internal/commands/moments.go b/internal/commands/moments.go index e969f6960..e2c51eb99 100644 --- a/internal/commands/moments.go +++ b/internal/commands/moments.go @@ -4,9 +4,10 @@ import ( "context" "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" ) // MomentsCommand registers the moments cli command. diff --git a/internal/commands/optimize.go b/internal/commands/optimize.go index 46380702d..f9133dc06 100644 --- a/internal/commands/optimize.go +++ b/internal/commands/optimize.go @@ -4,11 +4,11 @@ import ( "context" "time" - "github.com/photoprism/photoprism/internal/workers" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/workers" ) // OptimizeCommand registers the index cli command. diff --git a/internal/commands/passwd.go b/internal/commands/passwd.go index 79a677d9b..d113a94ab 100644 --- a/internal/commands/passwd.go +++ b/internal/commands/passwd.go @@ -10,10 +10,11 @@ import ( "strings" "syscall" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/txt" - "github.com/urfave/cli" ) // PasswdCommand updates a password. diff --git a/internal/commands/purge.go b/internal/commands/purge.go index c4fcdd53f..dd4ac7a5f 100644 --- a/internal/commands/purge.go +++ b/internal/commands/purge.go @@ -6,12 +6,13 @@ import ( "strings" "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" - "github.com/urfave/cli" ) // PurgeCommand registers the index cli command. diff --git a/internal/commands/resample.go b/internal/commands/resample.go index d12887b27..db688cf84 100644 --- a/internal/commands/resample.go +++ b/internal/commands/resample.go @@ -3,10 +3,11 @@ package commands import ( "time" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/pkg/txt" - "github.com/urfave/cli" ) // ResampleCommand registers the resample cli command. diff --git a/internal/commands/reset.go b/internal/commands/reset.go index 852a67609..d66ae0aba 100644 --- a/internal/commands/reset.go +++ b/internal/commands/reset.go @@ -9,9 +9,10 @@ import ( "time" "github.com/manifoldco/promptui" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" - "github.com/urfave/cli" ) // ResetCommand resets the index and removes sidecar files after confirmation. diff --git a/internal/commands/restore.go b/internal/commands/restore.go index e4a1f22d3..1c4c3e1a3 100644 --- a/internal/commands/restore.go +++ b/internal/commands/restore.go @@ -11,16 +11,14 @@ import ( "regexp" "time" - "github.com/photoprism/photoprism/internal/service" - - "github.com/photoprism/photoprism/internal/photoprism" - - "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/txt" + "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/txt" ) // RestoreCommand configures the backup cli command. diff --git a/internal/commands/start.go b/internal/commands/start.go index c5849e015..c889e332b 100644 --- a/internal/commands/start.go +++ b/internal/commands/start.go @@ -9,18 +9,17 @@ import ( "syscall" "time" + "github.com/sevlyar/go-daemon" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/auto" - - "github.com/photoprism/photoprism/internal/photoprism" - "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/server" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/workers" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" - "github.com/sevlyar/go-daemon" - "github.com/urfave/cli" ) // StartCommand registers the start cli command. diff --git a/internal/commands/status.go b/internal/commands/status.go index e48c8d84d..8c35b7b5a 100644 --- a/internal/commands/status.go +++ b/internal/commands/status.go @@ -6,9 +6,10 @@ import ( "net/http" "time" - "github.com/photoprism/photoprism/internal/config" "github.com/tidwall/gjson" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/config" ) // StatusCommand performs a server health check. diff --git a/internal/commands/stop.go b/internal/commands/stop.go index 0730a5217..84ec0d015 100644 --- a/internal/commands/stop.go +++ b/internal/commands/stop.go @@ -3,10 +3,11 @@ package commands import ( "syscall" - "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/pkg/txt" "github.com/sevlyar/go-daemon" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/pkg/txt" ) // StopCommand registers the stop cli command. diff --git a/internal/commands/users.go b/internal/commands/users.go index eaa7f5b4f..82e36a0dc 100644 --- a/internal/commands/users.go +++ b/internal/commands/users.go @@ -7,12 +7,13 @@ import ( "strings" "github.com/manifoldco/promptui" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/pkg/txt" - "github.com/urfave/cli" ) // UsersCommand registers user management commands. diff --git a/internal/commands/version.go b/internal/commands/version.go index 650b9b10b..14791d999 100644 --- a/internal/commands/version.go +++ b/internal/commands/version.go @@ -3,8 +3,9 @@ package commands import ( "fmt" - "github.com/photoprism/photoprism/internal/config" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/internal/config" ) // VersionCommand registers the version cli command. diff --git a/internal/entity/face.go b/internal/entity/face.go index cc5dad33d..1c3d91959 100644 --- a/internal/entity/face.go +++ b/internal/entity/face.go @@ -12,8 +12,8 @@ import ( "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/face" - "github.com/photoprism/photoprism/pkg/clusters" + "github.com/photoprism/photoprism/pkg/rnd" ) var faceMutex = sync.Mutex{} @@ -350,3 +350,20 @@ func FindFace(id string) *Face { return &f } + +// FaceCount counts the number of valid face markers for a file uid. +func FaceCount(fileUID string) (c int) { + if !rnd.IsPPID(fileUID, 'f') { + return + } + + if err := Db().Model(Marker{}). + Where("file_uid = ? AND marker_type = ?", fileUID, MarkerFace). + Where("marker_invalid = 0"). + Count(&c).Error; err != nil { + log.Errorf("file: %s (count faces)", err) + return 0 + } else { + return c + } +} diff --git a/internal/entity/file.go b/internal/entity/file.go index 817c83638..9adfa948a 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -201,7 +201,7 @@ func (m File) Missing() bool { return m.FileMissing || m.DeletedAt != nil } -// Delete permanently deletes the entity from the database. +// DeletePermanently permanently deletes the entity. func (m *File) DeletePermanently() error { Db().Unscoped().Delete(FileShare{}, "file_id = ?", m.ID) Db().Unscoped().Delete(FileSync{}, "file_id = ?", m.ID) @@ -260,7 +260,7 @@ func (m *File) Create() error { return err } - if err := m.Markers().Save(m.FileUID); err != nil { + if _, err := m.SaveMarkers(); err != nil { log.Errorf("file: %s (create markers for %s)", err, m.FileUID) return err } @@ -288,7 +288,7 @@ func (m *File) Save() error { return err } - if err := m.Markers().Save(m.FileUID); err != nil { + if _, err := m.SaveMarkers(); err != nil { log.Errorf("file: %s (save markers for %s)", err, m.FileUID) return err } @@ -426,33 +426,62 @@ func (m *File) AddFaces(faces face.Faces) { // AddFace adds a face marker to the file. func (m *File) AddFace(f face.Face, subjUID string) { - marker := *NewFaceMarker(f, *m, subjUID) + // Only add faces with embedding, so that they can be clustered. + if len(f.Embeddings) != 1 { + return + } - if markers := m.Markers(); !markers.Contains(marker) { - markers.Append(marker) + // Create new marker from face. + marker := NewFaceMarker(f, *m, subjUID) + + // Failed creating new marker? + if marker == nil { + return + } + + // Append marker if it doesn't conflict with existing marker. + if markers := m.Markers(); !markers.Contains(*marker) { + markers.Append(*marker) } } // FaceCount returns the current number of valid faces detected. func (m *File) FaceCount() (c int) { - if err := Db().Model(Marker{}). - Where("file_uid = ? AND marker_type = ?", m.FileUID, MarkerFace). - Where("marker_invalid = 0"). - Count(&c).Error; err != nil { - log.Errorf("file: %s (count faces)", err) - return 0 - } else { - return c + return FaceCount(m.FileUID) +} + +// UpdatePhotoFaceCount updates the faces count in the index and returns it if the file is primary. +func (m *File) UpdatePhotoFaceCount() (c int, err error) { + // Primary file of an existing photo? + if !m.FilePrimary || m.PhotoID == 0 { + return 0, nil } + + c = m.FaceCount() + + err = UnscopedDb().Model(Photo{}). + Where("id = ?", m.PhotoID). + UpdateColumn("photo_faces", c).Error + + return c, err +} + +// SaveMarkers updates markers in the index. +func (m *File) SaveMarkers() (count int, err error) { + if m.markers == nil { + return 0, nil + } + + return m.markers.Save(m) } // Markers finds and returns existing file markers. func (m *File) Markers() *Markers { if m.markers != nil { return m.markers - } - - if res, err := FindMarkers(m.FileUID); err != nil { + } else if m.FileUID == "" { + m.markers = &Markers{} + } else if res, err := FindMarkers(m.FileUID); err != nil { log.Warnf("file: %s (load markers)", err) m.markers = &Markers{} } else { diff --git a/internal/entity/file_test.go b/internal/entity/file_test.go index 81b4460c1..88243a46f 100644 --- a/internal/entity/file_test.go +++ b/internal/entity/file_test.go @@ -463,8 +463,33 @@ func TestFile_Undelete(t *testing.T) { } func TestFile_AddFaces(t *testing.T) { - t.Run("success", func(t *testing.T) { - file := &File{FileType: "jpg", FileWidth: 720, FileName: "FacesTest", PhotoID: 1000003} + t.Run("Primary", func(t *testing.T) { + file := &File{FileUID: "fqzuh65p4sjk3kdn", FileHash: "346b3897eec9ef75e35fbf0bbc4c83c55ca41e31", FileType: "jpg", FileWidth: 720, FileName: "FacesTest", PhotoID: 1000003, FilePrimary: true} + + faces := face.Faces{face.Face{ + Rows: 480, + Cols: 720, + Score: 45, + Area: face.NewArea("face", 250, 200, 10), + Eyes: face.Areas{face.NewArea("eye_l", 240, 195, 1), face.NewArea("eye_r", 240, 205, 1)}, + Landmarks: face.Areas{face.NewArea("a", 250, 185, 2), face.NewArea("b", 250, 215, 2)}, + Embeddings: [][]float32{{0.012816238, 0.0054710666, 0.06963101, 0.037285835, 0.04412884, 0.017333193, 0.03373656, -0.033069234, 0.025952332, -0.0035901065, -0.029420156, 0.07464688, -0.043232113, 0.060328174, 0.028897963, -0.027495274, -0.02622295, 0.038605634, -0.030962847, 0.05343173, -0.05042871, 0.010407827, 0.014773584, 0.04305641, -0.045918103, 0.014705811, 0.0031296816, 0.08703609, 0.012646829, 0.040463835, 0.080548696, -0.04496776, 0.032542497, -0.046235666, 0.0018886769, -0.09422433, -0.006701393, 0.0601084, 0.05649471, -0.02277308, 0.048038833, -0.022022927, -0.024692882, -0.0067683067, 0.02597589, -0.026766079, -0.04489042, -0.060946267, 0.052194964, 0.0098239435, -0.063547, 0.008626338, -0.041202333, -0.057555206, -0.05206756, -0.007974572, -0.036597952, -0.04232167, 0.0064586936, 0.011131428, -0.076106876, -0.014716604, 0.027977718, 0.060634963, 0.0046368516, -0.035929997, -0.079733424, -0.051017676, -0.03521493, -0.0062531913, -0.030387852, 0.101194955, -0.027980363, -0.010152243, -0.005128962, -0.026926627, 0.008371125, -0.088778615, 0.022396773, -0.025815062, -0.0027552384, -0.049987435, -0.019902563, -0.024667386, 0.064883195, -0.010091326, -0.024541432, -0.03390568, -0.04975766, -0.05255319, 0.0462333, -0.062871166, 0.070803925, -0.020970127, 0.012365979, -0.048543453, 0.027297763, 0.02785581, 0.09220687, -0.021206442, 0.015040259, 0.11726589, 0.00079200073, 0.08544253, 0.08694455, -0.037786104, -0.09956117, 0.07473473, 0.086737245, 0.02916126, 0.0355523, 0.067868374, -0.056218974, 0.007066174, 0.046310645, -0.025015457, -0.019863142, -0.018884404, 0.00076502684, -0.0699868, 0.043558553, 0.11221989, -0.036503807, -0.07346668, 0.023614183, 0.008353507, 0.05629068, -0.05628395, -0.030611087, 0.013364313, -0.014508443, 0.013493559, 0.061809033, 0.06598724, -0.03538405, -0.08597677, -0.06253287, -0.032587055, 0.030790405, 0.031729434, 0.0349981, 0.09145327, 0.012044479, 0.09593962, -0.011460096, -0.014851449, -0.041916795, 0.0037967677, -0.028313408, -0.016944066, -0.023236271, 0.046519253, 0.09026307, -0.014203754, 0.0048228586, 0.012194195, -0.062746234, -0.02189861, -0.030368697, 0.004226377, -0.044146035, 0.04542304, 0.046805177, 0.03882082, -0.06006401, 0.06286592, 0.03714168, -0.011287339, -0.0129849315, 0.01757729, -0.031555075, 0.005606887, 0.0045785876, 0.031963747, 0.040269036, 0.033833507, -0.06477002, -0.0039275866, 0.079373375, 0.044617712, 0.012070597, -0.06322144, 0.011061547, -0.006825576, 0.033158936, -0.10063759, -0.016583988, 0.008227036, 0.05604638, -0.0039418507, 0.030264255, 0.006545456, -0.046788998, -0.06612186, 0.019110108, 0.010173552, -0.0015304928, -0.02745248, 0.08436771, -0.05111628, 0.03491268, -0.018905425, 0.009436589, -0.071091056, 0.06312779, 0.055885248, -0.008187491, 0.013967105, 0.049851406, -0.046775173, -0.05380721, -0.02520902, 0.048415728, -0.053037673, 0.08821214, -0.04349023, -0.002511317, -0.013129268, -0.04000359, -0.0100794975, -0.0659472, 0.044489317, -0.03651276, 0.0032823374, -0.004647774, -0.019675476, 0.11854173, 0.035627883, 0.015952459, -0.017490689, -0.009468227, -0.034936972, -0.0040077316, -0.014501512, -0.040732305, -0.004475036, 0.026295308, 0.11893579, -0.012221011, 0.01921595, 0.003704211, -0.00081420684, 0.031362444, 0.021526098, -0.03796045, -0.04051389, 0.08994492, 0.020430982, -0.13368618, 0.059530005, 0.02978135, -0.020171875, 0.07243986, 0.08047519, 0.014236827, 0.023928184, -0.056827813, 0.030533543, -0.01695773, 0.0019564428, 0.019315101, 0.048426118, 0.012069902, 0.014532966, 0.07157925, -0.00082132005, -0.03102693, 0.05207618, 0.033050887, -0.06816059, 0.037159886, 0.012156096, 0.0906456, 0.05786973, 0.021087963, -0.03615757, -0.0006905898, 0.0062891473, 0.054622658, -0.02605763, -0.050890833, -0.00017370642, -0.010385195, 0.022578984, 0.001822225, -0.045328267, 0.015035055, 0.05529688, 0.046605356, -0.0007772419, -0.09158666, -0.039371215, -0.0026332953, 0.022653094, 0.077683136, -0.027678892, -0.07956019, -0.08317627, 0.012950206, -0.04643972, 0.027308058, -0.007675166, 0.009162879, -0.0064983773, -0.0073145335, 0.041186735, -0.027793638, -0.00047516363, 0.014808601, 0.052241515, 0.07800082, 0.048793413, 0.018123679, 0.06639319, 0.0056572245, -0.0023089426, -0.0012806753, 0.07676211, -0.08715853, -0.02962473, -0.009583457, -0.028001878, -0.0037823156, 0.048585914, -0.017176645, -0.028013503, -0.04553737, -0.04014757, -0.012503475, -0.098679036, -0.031309552, -0.07011677, 0.0286711, -0.007448121, -0.03362688, 0.014612736, 0.006140878, 0.050224025, -0.03131365, 0.017277641, -0.012991993, -0.045904126, 0.006959225, 0.044762693, -0.0052471757, -0.009494742, 0.020247253, -0.025165197, -0.007513343, -0.007732138, -0.03059627, -0.027137207, 0.030832471, -0.0006397405, 0.026458293, 0.048394475, -0.014066572, -0.008397393, 0.030369336, -0.0018644024, -0.08373501, 0.02299318, 0.08410273, 0.03791566, 0.016693544, -0.022285318, -0.107647866, 0.008533737, 0.05805777, 0.063223496, 0.043848637, -0.033787355, 0.013578734, 0.020149017, 0.059982095, -0.016969858, 0.04481642, 0.027871825, 0.037242968, 0.04364479, -0.05280717, 0.008205654, -0.03536789, 0.020066299, 0.02891452, 0.029394835, 0.09834288, 0.03443311, 0.038843676, -0.023331352, -0.0022070059, 0.039741606, 0.033018216, 0.04989029, 0.035506245, 0.026467659, 0.034031004, 0.029856045, -0.06866382, -0.0496181, 0.063887335, -0.02873221, 0.024889331, 0.01833896, -0.010304041, -0.048351713, -0.083444275, -0.030584292, -0.092650875, 0.012108162, -0.022506258, 0.014489741, -0.037093587, -0.0041784635, 0.08624283, -0.012284314, -0.014817595, -0.0073567405, -0.013233772, -0.07208923, 0.049182527, -0.019994823, 0.006094942, -0.014795295, -0.017715558, -0.021894615, -0.01329216, 0.0032535691, -0.061918758, -0.0027641011, -0.04525581, 0.051380426, -0.027817326, 0.040541418, 0.020033667, 0.027792405, -0.059075374, 0.026320897, 0.012968171, -0.002865264, -0.017004456, 0.041212566, 0.0038082711, -0.08282011, -0.052709907, -0.041330304, 0.06054631, -0.08095043, 0.017253665, 0.066494696, -0.0356273, -0.059468318, 0.032792054, 0.10238864, 0.029640062, 0.06367693, -0.000065915876, -0.07408563, -0.035968937, -0.06602596, 0.024129247, 0.002624706, -0.0044429703, -0.038953166, -0.02367998, -0.009588521, 0.031618122, -0.063372254, 0.05579818, -0.00065322284, -0.012777491, -0.04045443, -0.015359356, -0.08424052, 0.016582847, 0.04319089, 0.03904139, -0.004957754, 0.03633682, 0.016728338, 0.0071737715, 0.07263827, -0.059946816, 0.020960696, 0.05819421, -0.0047716517, -0.00028777352, -0.044942997, 0.019640505, -0.0060415184, -0.009499886, 0.03395488, -0.05268878, -0.040615927, 0.05501862, 0.0143708, -0.084489234, -0.046911728, -0.04033474, 0.050277222, 0.04054947, 0.014454217, -0.023438897, -0.05800994, -0.029950928, 0.0032126154, 0.0017874262, 0.025801007, 0.08680619, 0.017868958, 0.0035924045, -0.04201902}}, + }} + + file.AddFaces(faces) + + assert.Equal(t, 1, len(*file.Markers())) + + if err := file.Save(); err != nil { + t.Fatal(err) + } + + assert.Equal(t, false, file.FileMissing) + assert.NotEmpty(t, file.FileUID) + assert.NotEmpty(t, file.Markers()) + }) + t.Run("NoEmbeddings", func(t *testing.T) { + file := &File{FileUID: "fqzuh65p4sjk3kd1", FileHash: "146b3897eec9ef75e35fbf0bbc4c83c55ca41e31", FileType: "jpg", FileWidth: 720, FileName: "FacesTest", PhotoID: 1000003, FilePrimary: false} faces := face.Faces{face.Face{ Rows: 480, @@ -477,13 +502,7 @@ func TestFile_AddFaces(t *testing.T) { file.AddFaces(faces) - if err := file.Save(); err != nil { - t.Fatal(err) - } - - assert.Equal(t, false, file.FileMissing) - assert.NotEmpty(t, file.FileUID) - assert.NotEmpty(t, file.Markers()) + assert.Equal(t, 0, len(*file.Markers())) }) } diff --git a/internal/entity/marker.go b/internal/entity/marker.go index 9d6227844..71579e386 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -70,6 +70,11 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error { // NewMarker creates a new entity. func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string, size, score int) *Marker { + if file.FileHash == "" { + log.Errorf("marker: file hash is empty - you might have found a bug") + return nil + } + m := &Marker{ FileUID: file.FileUID, MarkerSrc: markerSrc, @@ -96,6 +101,11 @@ func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string, func NewFaceMarker(f face.Face, file File, subjUID string) *Marker { m := NewMarker(file, f.CropArea(), subjUID, SrcImage, MarkerFace, f.Size(), f.Score) + // Failed creating new marker? + if m == nil { + return nil + } + m.EmbeddingsJSON = f.EmbeddingsJSON() m.LandmarksJSON = f.RelativeLandmarksJSON() diff --git a/internal/entity/marker_fixtures.go b/internal/entity/marker_fixtures.go index c2bdb5bae..23e64d710 100644 --- a/internal/entity/marker_fixtures.go +++ b/internal/entity/marker_fixtures.go @@ -3,7 +3,7 @@ package entity import "github.com/photoprism/photoprism/internal/crop" // UnknownMarker can be used as a default for unknown markers. -var UnknownMarker = NewMarker(File{}, crop.Area{}, "", SrcDefault, MarkerUnknown, 0, 0) +var UnknownMarker = NewMarker(File{FileUID: "-", FileHash: "-"}, crop.Area{}, "", SrcDefault, MarkerUnknown, 0, 0) type MarkerMap map[string]Marker diff --git a/internal/entity/markers.go b/internal/entity/markers.go index 3712d7542..b5c460915 100644 --- a/internal/entity/markers.go +++ b/internal/entity/markers.go @@ -8,18 +8,22 @@ import ( type Markers []Marker // Save stores the markers in the database. -func (m Markers) Save(fileUID string) error { +func (m Markers) Save(file *File) (count int, err error) { for _, marker := range m { - if fileUID != "" { - marker.FileUID = fileUID + if file != nil { + marker.FileUID = file.FileUID } if _, err := UpdateOrCreateMarker(&marker); err != nil { - return err + log.Errorf("markers: %s (save)", err) } } - return nil + if file == nil { + return len(m), nil + } + + return file.UpdatePhotoFaceCount() } // Contains returns true if a marker at the same position already exists. diff --git a/internal/entity/markers_test.go b/internal/entity/markers_test.go index 849e76b74..efc40108c 100644 --- a/internal/entity/markers_test.go +++ b/internal/entity/markers_test.go @@ -28,7 +28,7 @@ func TestMarkers_Contains(t *testing.T) { assert.False(t, m.Contains(m3)) }) t.Run("Conflicting", func(t *testing.T) { - file := File{FileHash: "cca7c46a4d39e933c30805e546028fe3eab361b5"} + file := File{FileUID: "", FileHash: "cca7c46a4d39e933c30805e546028fe3eab361b5"} markers := Markers{ *NewMarker(file, crop.Area{Name: "subj-1", X: 0.549479, Y: 0.179688, W: 0.393229, H: 0.294922}, "jqyzmgbquh1msz6o", SrcImage, MarkerFace, 100, 65), @@ -51,7 +51,7 @@ func TestMarkers_Contains(t *testing.T) { assert.True(t, markers.Contains(conflicting)) }) t.Run("NoFace", func(t *testing.T) { - file := File{FileHash: "243cdbe99b865607f98a951e748d528bc22f3143"} + file := File{FileUID: "fqzuh672i9btq6gu", FileHash: "243cdbe99b865607f98a951e748d528bc22f3143"} markers := Markers{ *NewMarker(file, crop.Area{Name: "no-face", X: 0.322656, Y: 0.3, W: 0.180469, H: 0.240625}, "jqyzmgbquh1msz6o", SrcImage, MarkerFace, 100, 65), diff --git a/internal/entity/photo.go b/internal/entity/photo.go index 9704734ce..436598b0f 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -938,7 +938,7 @@ func (m *Photo) Delete(permanently bool) error { return m.Updates(map[string]interface{}{"DeletedAt": TimeStamp(), "PhotoQuality": -1}) } -// Delete permanently deletes the entity from the database. +// DeletePermanently permanently deletes the entity. func (m *Photo) DeletePermanently() error { Db().Unscoped().Delete(File{}, "photo_id = ?", m.ID) Db().Unscoped().Delete(Details{}, "photo_id = ?", m.ID) @@ -954,7 +954,7 @@ func (m *Photo) NoDescription() bool { return m.PhotoDescription == "" } -// Updates a column in the database. +// Update a column in the database. func (m *Photo) Update(attr string, value interface{}) error { return UnscopedDb().Model(m).UpdateColumn(attr, value).Error } diff --git a/internal/entity/user.go b/internal/entity/user.go index f51bdab48..e7068bab6 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -149,7 +149,7 @@ func (m *User) BeforeCreate(tx *gorm.DB) error { return nil } -// FirstOrCreateUser returns an existing row, inserts a new row or nil in case of errors. +// FirstOrCreateUser returns an existing row, inserts a new row, or nil in case of errors. func FirstOrCreateUser(m *User) *User { result := User{} diff --git a/internal/face/detector.go b/internal/face/detector.go index de04c346b..6a7467aad 100644 --- a/internal/face/detector.go +++ b/internal/face/detector.go @@ -144,9 +144,9 @@ func (d *Detector) Detect(fileName string) (faces []pigo.Detection, params pigo. if cols < 20 || rows < 20 || cols < d.minSize || rows < d.minSize { return faces, params, fmt.Errorf("image size %dx%d is too small", cols, rows) } else if cols < rows { - maxSize = cols - 8 + maxSize = cols - 4 } else { - maxSize = rows - 8 + maxSize = rows - 4 } imageParams := &pigo.ImageParams{ @@ -164,7 +164,7 @@ func (d *Detector) Detect(fileName string) (faces []pigo.Detection, params pigo. ImageParams: *imageParams, } - log.Debugf("faces: image size %dx%d, face size min %d, max %d", cols, rows, params.MinSize, params.MaxSize) + log.Tracef("faces: image size %dx%d, face size min %d, max %d", cols, rows, params.MinSize, params.MaxSize) // Run the classifier over the obtained leaf nodes and return the Face results. // The result contains quadruplets representing the row, column, scale and Face score. diff --git a/internal/face/net.go b/internal/face/net.go index 619462870..af37e9e7c 100644 --- a/internal/face/net.go +++ b/internal/face/net.go @@ -32,15 +32,18 @@ func NewNet(modelPath, cachePath string, disabled bool) *Net { } // Detect runs the detection and facenet algorithms over the provided source image. -func (t *Net) Detect(fileName string, minSize int, cacheCrop bool) (faces Faces, err error) { +func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) { faces, err = Detect(fileName, false, minSize) if err != nil { return faces, err } + // Skip FaceNet? if t.disabled { return faces, nil + } else if c := len(faces); c == 0 || expected > 0 && c == expected { + return faces, nil } err = t.loadModel() diff --git a/internal/face/net_test.go b/internal/face/net_test.go index bbd581c88..2beb4abcf 100644 --- a/internal/face/net_test.go +++ b/internal/face/net_test.go @@ -63,7 +63,7 @@ func TestNet(t *testing.T) { t.Run(fileName, func(t *testing.T) { baseName := filepath.Base(fileName) - faces, err := faceNet.Detect(fileName, 20, false) + faces, err := faceNet.Detect(fileName, 20, false, -1) if err != nil { t.Fatal(err) diff --git a/internal/photoprism/import.go b/internal/photoprism/import.go index a5f62e36e..6ff851b57 100644 --- a/internal/photoprism/import.go +++ b/internal/photoprism/import.go @@ -58,6 +58,12 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { var directories []string done := make(fs.Done) + + if imp.conf == nil { + log.Errorf("import: config is nil") + return done + } + ind := imp.index importPath := opt.Path diff --git a/internal/photoprism/index.go b/internal/photoprism/index.go index deaa8d8ed..20757e200 100644 --- a/internal/photoprism/index.go +++ b/internal/photoprism/index.go @@ -31,6 +31,8 @@ type Index struct { convert *Convert files *Files photos *Photos + findFaces bool + findLabels bool } // NewIndex returns a new indexer and expects its dependencies as arguments. @@ -70,6 +72,12 @@ func (ind *Index) Start(opt IndexOptions) fs.Done { }() done := make(fs.Done) + + if ind.conf == nil { + log.Errorf("index: config is nil") + return done + } + originalsPath := ind.originalsPath() optionsPath := filepath.Join(originalsPath, opt.Path) @@ -83,6 +91,12 @@ func (ind *Index) Start(opt IndexOptions) fs.Done { return done } + // Detect faces in images? + ind.findFaces = ind.conf.Settings().Features.People + + // Classify images with TensorFlow? + ind.findLabels = !ind.conf.DisableTensorFlow() + defer mutex.MainWorker.Stop() if err := ind.tensorFlow.Init(); err != nil { diff --git a/internal/photoprism/index_faces.go b/internal/photoprism/index_faces.go index cf6a99a10..09e10e2df 100644 --- a/internal/photoprism/index_faces.go +++ b/internal/photoprism/index_faces.go @@ -9,8 +9,8 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) -// detectFaces extracts faces from a JPEG image and returns them. -func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces { +// Faces finds faces in JPEG media files and returns them. +func (ind *Index) Faces(jpeg *MediaFile, expected int) face.Faces { if jpeg == nil { return face.Faces{} } @@ -41,14 +41,16 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces { start := time.Now() - faces, err := ind.faceNet.Detect(thumbName, minSize, true) + faces, err := ind.faceNet.Detect(thumbName, minSize, true, expected) if err != nil { log.Debugf("%s in %s", err, txt.Quote(jpeg.BaseName())) } - if len(faces) > 0 { - log.Infof("index: extracted %d faces from %s [%s]", len(faces), txt.Quote(jpeg.BaseName()), time.Since(start)) + if l := len(faces); l == 1 { + log.Infof("index: found %d face in %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start)) + } else if l > 1 { + log.Infof("index: found %d faces in %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start)) } return faces diff --git a/internal/photoprism/index_classify.go b/internal/photoprism/index_labels.go similarity index 73% rename from internal/photoprism/index_classify.go rename to internal/photoprism/index_labels.go index e28bf5325..98d8ebda5 100644 --- a/internal/photoprism/index_classify.go +++ b/internal/photoprism/index_labels.go @@ -10,8 +10,8 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) -// classifyImage classifies a JPEG image and returns matching labels. -func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) { +// Labels classifies a JPEG image and returns matching labels. +func (ind *Index) Labels(jpeg *MediaFile) (results classify.Labels) { start := time.Now() var sizes []thumb.Name @@ -57,8 +57,10 @@ func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) { } } - if len(labels) > 0 { - log.Infof("index: found %d matching labels for %s [%s]", len(labels), txt.Quote(jpeg.BaseName()), time.Since(start)) + if l := len(labels); l == 1 { + log.Infof("index: found %d matching label for %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start)) + } else if l > 1 { + log.Infof("index: found %d matching labels for %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start)) } return results diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index 3dc6398ae..dcc014deb 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -13,63 +13,13 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/meta" - "github.com/photoprism/photoprism/internal/nsfw" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" ) -const ( - IndexUpdated IndexStatus = "updated" - IndexAdded IndexStatus = "added" - IndexStacked IndexStatus = "stacked" - IndexSkipped IndexStatus = "skipped" - IndexDuplicate IndexStatus = "skipped duplicate" - IndexArchived IndexStatus = "skipped archived" - IndexFailed IndexStatus = "failed" -) - -type IndexStatus string - -type IndexResult struct { - Status IndexStatus - Err error - FileID uint - FileUID string - PhotoID uint - PhotoUID string -} - -func (r IndexResult) String() string { - return string(r.Status) -} - -func (r IndexResult) Failed() bool { - return r.Err != nil -} - -func (r IndexResult) Success() bool { - return r.Err == nil && (r.FileID > 0 || r.Stacked() || r.Skipped() || r.Archived()) -} - -func (r IndexResult) Indexed() bool { - return r.Status == IndexAdded || r.Status == IndexUpdated || r.Status == IndexStacked -} - -func (r IndexResult) Stacked() bool { - return r.Status == IndexStacked -} - -func (r IndexResult) Skipped() bool { - return r.Status == IndexSkipped -} - -func (r IndexResult) Archived() bool { - return r.Status == IndexArchived -} - +// MediaFile indexes a single media file. func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (result IndexResult) { if m == nil { err := errors.New("index: media file is nil - you might have found a bug") @@ -79,7 +29,13 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( return result } + // Skip file? if ind.files.Ignore(m.RootRelName(), m.Root(), m.ModTime(), o.Rescan) { + // Skip known file. + result.Status = IndexSkipped + return result + } else if o.FacesOnly && !m.IsJpeg() { + // Skip non-jpeg file when indexing faces only. result.Status = IndexSkipped return result } @@ -274,6 +230,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( } } + // Set basic file information. + file.FileRoot = fileRoot + file.FileName = fileName + file.FileHash = fileHash + file.FileSize = fileSize + // Set file original name if available. if originalName != "" { file.OriginalName = originalName @@ -289,6 +251,33 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( photo.DeletedAt = nil } + // Extra labels to ba added when new files have a photo id. + extraLabels := classify.Labels{} + + // Detect faces in images? + if o.FacesOnly && (!photoExists || !fileExists || !file.FilePrimary) { + // New and non-primary files can be skipped when updating faces only. + result.Status = IndexSkipped + return result + } else if ind.findFaces && file.FilePrimary { + faces := ind.Faces(m, photo.PhotoFaces) + + if len(faces) > 0 { + file.AddFaces(faces) + } + + if c := file.Markers().FaceCount(); photo.PhotoFaces != c { + if c > photo.PhotoFaces { + extraLabels = append(extraLabels, classify.FaceLabels(faces, entity.SrcImage)...) + } + + photo.PhotoFaces = c + } else if o.FacesOnly { + result.Status = IndexSkipped + return result + } + } + // Handle file types. switch { case m.IsJpeg(): @@ -487,9 +476,14 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( if file.FilePrimary { primaryFile = file - if !Config().DisableTensorFlow() { - // Image classification via TensorFlow. - labels = ind.classifyImage(m) + // Classify images with TensorFlow? + if ind.findLabels { + labels = ind.Labels(m) + + // Append labels from other sources such as face detection. + if len(extraLabels) > 0 { + labels = append(labels, extraLabels...) + } if !photoExists && Config().Settings().Features.Private && Config().DetectNSFW() { photo.PhotoPrivate = ind.NSFW(m) @@ -547,10 +541,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( file.FileSidecar = m.IsSidecar() file.FileVideo = m.IsVideo() - file.FileRoot = fileRoot - file.FileName = fileName - file.FileHash = fileHash - file.FileSize = fileSize file.FileType = string(m.FileType()) file.FileMime = m.MimeType() file.FileOrientation = m.Orientation() @@ -600,18 +590,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( // Main JPEG file. if file.FilePrimary { - if Config().Settings().Features.People { - faces := ind.detectFaces(m) - - photo.AddLabels(classify.FaceLabels(faces, entity.SrcImage)) - - if len(faces) > 0 { - file.AddFaces(faces) - } - - photo.PhotoFaces = file.Markers().FaceCount() - } - labels := photo.ClassifyLabels() if err := photo.UpdateTitle(labels); err != nil { @@ -750,25 +728,3 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( return result } - -// NSFW returns true if media file might be offensive and detection is enabled. -func (ind *Index) NSFW(jpeg *MediaFile) bool { - filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb.Fit720) - - if err != nil { - log.Error(err) - return false - } - - if nsfwLabels, err := ind.nsfwDetector.File(filename); err != nil { - log.Error(err) - return false - } else { - if nsfwLabels.NSFW(nsfw.ThresholdHigh) { - log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelName(Config().OriginalsPath()))) - return true - } - } - - return false -} diff --git a/internal/photoprism/index_nsfw.go b/internal/photoprism/index_nsfw.go new file mode 100644 index 000000000..3f2b7057c --- /dev/null +++ b/internal/photoprism/index_nsfw.go @@ -0,0 +1,29 @@ +package photoprism + +import ( + "github.com/photoprism/photoprism/internal/nsfw" + "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/txt" +) + +// NSFW returns true if media file might be offensive and detection is enabled. +func (ind *Index) NSFW(jpeg *MediaFile) bool { + filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb.Fit720) + + if err != nil { + log.Error(err) + return false + } + + if nsfwLabels, err := ind.nsfwDetector.File(filename); err != nil { + log.Error(err) + return false + } else { + if nsfwLabels.NSFW(nsfw.ThresholdHigh) { + log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelName(Config().OriginalsPath()))) + return true + } + } + + return false +} diff --git a/internal/photoprism/index_options.go b/internal/photoprism/index_options.go index e6e1dad91..e12ca88fd 100644 --- a/internal/photoprism/index_options.go +++ b/internal/photoprism/index_options.go @@ -1,10 +1,11 @@ package photoprism type IndexOptions struct { - Path string - Rescan bool - Convert bool - Stack bool + Path string + Rescan bool + Convert bool + Stack bool + FacesOnly bool } func (o *IndexOptions) SkipUnchanged() bool { @@ -14,10 +15,24 @@ func (o *IndexOptions) SkipUnchanged() bool { // IndexOptionsAll returns new index options with all options set to true. func IndexOptionsAll() IndexOptions { result := IndexOptions{ - Path: "/", - Rescan: true, - Convert: true, - Stack: true, + Path: "/", + Rescan: true, + Convert: true, + Stack: true, + FacesOnly: false, + } + + return result +} + +// IndexOptionsFacesOnly returns new index options for updating faces only. +func IndexOptionsFacesOnly() IndexOptions { + result := IndexOptions{ + Path: "/", + Rescan: true, + Convert: true, + Stack: true, + FacesOnly: true, } return result @@ -26,10 +41,11 @@ func IndexOptionsAll() IndexOptions { // IndexOptionsSingle returns new index options for unstacked, single files. func IndexOptionsSingle() IndexOptions { result := IndexOptions{ - Path: "/", - Rescan: true, - Convert: true, - Stack: false, + Path: "/", + Rescan: true, + Convert: true, + Stack: false, + FacesOnly: false, } return result diff --git a/internal/photoprism/index_options_test.go b/internal/photoprism/index_options_test.go index 18b50a66b..88e110672 100644 --- a/internal/photoprism/index_options_test.go +++ b/internal/photoprism/index_options_test.go @@ -7,21 +7,39 @@ import ( ) func TestIndexOptionsNone(t *testing.T) { - result := IndexOptionsNone() - assert.Equal(t, false, result.Rescan) - assert.Equal(t, false, result.Convert) + opt := IndexOptionsNone() + + assert.Equal(t, "", opt.Path) + assert.Equal(t, false, opt.Rescan) + assert.Equal(t, false, opt.Convert) + assert.Equal(t, false, opt.Stack) + assert.Equal(t, false, opt.FacesOnly) } func TestIndexOptions_SkipUnchanged(t *testing.T) { - result := IndexOptionsNone() - assert.True(t, result.SkipUnchanged()) - result.Rescan = true - assert.False(t, result.SkipUnchanged()) + opt := IndexOptionsNone() + + assert.True(t, opt.SkipUnchanged()) + + opt.Rescan = true + + assert.False(t, opt.SkipUnchanged()) } func TestIndexOptionsSingle(t *testing.T) { - r := IndexOptionsSingle() - assert.Equal(t, false, r.Stack) - assert.Equal(t, true, r.Convert) - assert.Equal(t, true, r.Rescan) + opt := IndexOptionsSingle() + + assert.Equal(t, false, opt.Stack) + assert.Equal(t, true, opt.Convert) + assert.Equal(t, true, opt.Rescan) +} + +func TestIndexOptionsFacesOnly(t *testing.T) { + opt := IndexOptionsFacesOnly() + + assert.Equal(t, "/", opt.Path) + assert.Equal(t, true, opt.Rescan) + assert.Equal(t, true, opt.Convert) + assert.Equal(t, true, opt.Stack) + assert.Equal(t, true, opt.FacesOnly) } diff --git a/internal/photoprism/index_result.go b/internal/photoprism/index_result.go new file mode 100644 index 000000000..c40a94923 --- /dev/null +++ b/internal/photoprism/index_result.go @@ -0,0 +1,50 @@ +package photoprism + +const ( + IndexUpdated IndexStatus = "updated" + IndexAdded IndexStatus = "added" + IndexStacked IndexStatus = "stacked" + IndexSkipped IndexStatus = "skipped" + IndexDuplicate IndexStatus = "skipped duplicate" + IndexArchived IndexStatus = "skipped archived" + IndexFailed IndexStatus = "failed" +) + +type IndexStatus string + +type IndexResult struct { + Status IndexStatus + Err error + FileID uint + FileUID string + PhotoID uint + PhotoUID string +} + +func (r IndexResult) String() string { + return string(r.Status) +} + +func (r IndexResult) Failed() bool { + return r.Err != nil +} + +func (r IndexResult) Success() bool { + return r.Err == nil && (r.FileID > 0 || r.Stacked() || r.Skipped() || r.Archived()) +} + +func (r IndexResult) Indexed() bool { + return r.Status == IndexAdded || r.Status == IndexUpdated || r.Status == IndexStacked +} + +func (r IndexResult) Stacked() bool { + return r.Status == IndexStacked +} + +func (r IndexResult) Skipped() bool { + return r.Status == IndexSkipped +} + +func (r IndexResult) Archived() bool { + return r.Status == IndexArchived +} diff --git a/internal/query/faces.go b/internal/query/faces.go index 5a8075cb7..b76dc8544 100644 --- a/internal/query/faces.go +++ b/internal/query/faces.go @@ -208,3 +208,49 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) { return conflicts, resolved, nil } + +// RemovePeopleAndFaces permanently deletes all people, faces, and face markers. +func RemovePeopleAndFaces() (err error) { + // Delete people. + if err = UnscopedDb().Delete(entity.Subject{}, "subj_type = ?", entity.SubjPerson).Error; err != nil { + return err + } + + // Delete all faces. + if err = UnscopedDb().Delete(entity.Face{}).Error; err != nil { + return err + } + + // Delete face markers. + if err = UnscopedDb().Delete(entity.Marker{}, "marker_type = ?", entity.MarkerFace).Error; err != nil { + return err + } + + // Reset face counters. + if err = UnscopedDb().Model(entity.Photo{}). + UpdateColumn("photo_faces", 0).Error; err != nil { + return err + } + + // Reset people label. + if label, err := LabelBySlug("people"); err != nil { + return err + } else if err = UnscopedDb(). + Delete(entity.PhotoLabel{}, "label_id = ?", label.ID).Error; err != nil { + return err + } else if err = label.Update("PhotoCount", 0); err != nil { + return err + } + + // Reset portrait label. + if label, err := LabelBySlug("portrait"); err != nil { + return err + } else if err = UnscopedDb(). + Delete(entity.PhotoLabel{}, "label_id = ?", label.ID).Error; err != nil { + return err + } else if err = label.Update("PhotoCount", 0); err != nil { + return err + } + + return nil +}