diff --git a/internal/api/marker.go b/internal/api/marker.go index 25d75794e..bc4a320fd 100644 --- a/internal/api/marker.go +++ b/internal/api/marker.go @@ -144,7 +144,7 @@ func ClearMarkerSubject(router *gin.RouterGroup) { } if err := marker.ClearSubject(entity.SrcManual); err != nil { - log.Errorf("face: %s (clear subject)", err) + log.Errorf("faces: %s (clear subject)", err) AbortSaveFailed(c) return } diff --git a/internal/commands/faces.go b/internal/commands/faces.go index 6638491ac..417134ff0 100644 --- a/internal/commands/faces.go +++ b/internal/commands/faces.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/manifoldco/promptui" + "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/config" @@ -73,6 +75,15 @@ func facesStatsAction(ctx *cli.Context) error { // facesResetAction resets face clusters and matches. func facesResetAction(ctx *cli.Context) error { + actionPrompt := promptui.Prompt{ + Label: "Remove automatically recognized faces, matches, and dangling subjects?", + IsConfirm: true, + } + + if _, err := actionPrompt.Run(); err != nil { + return nil + } + start := time.Now() conf := config.NewConfig(ctx) diff --git a/internal/commands/reset.go b/internal/commands/reset.go index 1f03fecda..665a86624 100644 --- a/internal/commands/reset.go +++ b/internal/commands/reset.go @@ -25,7 +25,7 @@ var ResetCommand = cli.Command{ func resetAction(ctx *cli.Context) error { log.Warnf("'photoprism reset' resets the index and removes sidecar files after confirmation") - removeIndex := promptui.Prompt{ + removeIndexPrompt := promptui.Prompt{ Label: "Reset index database incl albums, labels, users and metadata?", IsConfirm: true, } @@ -40,7 +40,7 @@ func resetAction(ctx *cli.Context) error { entity.SetDbProvider(conf) - if _, err := removeIndex.Run(); err == nil { + if _, err := removeIndexPrompt.Run(); err == nil { start := time.Now() tables := entity.Entities @@ -61,12 +61,12 @@ func resetAction(ctx *cli.Context) error { log.Infof("keeping index database") } - removeSidecarJson := promptui.Prompt{ + removeSidecarJsonPrompt := promptui.Prompt{ Label: "Permanently delete all *.json photo sidecar files?", IsConfirm: true, } - if _, err := removeSidecarJson.Run(); err == nil { + if _, err := removeSidecarJsonPrompt.Run(); err == nil { start := time.Now() matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.json") @@ -96,12 +96,12 @@ func resetAction(ctx *cli.Context) error { log.Infof("keeping json sidecar files") } - removeSidecarYaml := promptui.Prompt{ + removeSidecarYamlPrompt := promptui.Prompt{ Label: "Permanently delete all *.yml photo metadata backups?", IsConfirm: true, } - if _, err := removeSidecarYaml.Run(); err == nil { + if _, err := removeSidecarYamlPrompt.Run(); err == nil { start := time.Now() matches, err := filepath.Glob(regexp.QuoteMeta(conf.SidecarPath()) + "/**/*.yml") @@ -131,12 +131,12 @@ func resetAction(ctx *cli.Context) error { log.Infof("keeping backup files") } - removeAlbumYaml := promptui.Prompt{ + removeAlbumYamlPrompt := promptui.Prompt{ Label: "Permanently delete all *.yml album backups?", IsConfirm: true, } - if _, err := removeAlbumYaml.Run(); err == nil { + if _, err := removeAlbumYamlPrompt.Run(); err == nil { start := time.Now() matches, err := filepath.Glob(regexp.QuoteMeta(conf.AlbumsPath()) + "/**/*.yml") diff --git a/internal/entity/face.go b/internal/entity/face.go index 6a1f9360d..182e41b5d 100644 --- a/internal/entity/face.go +++ b/internal/entity/face.go @@ -129,7 +129,7 @@ func (m *Face) Match(embeddings Embeddings) (match bool, dist float64) { case dist > (m.SampleRadius + face.ClusterRadius): // Too far. return false, dist - case m.CollisionRadius > 0 && dist > m.CollisionRadius: + case m.CollisionRadius > 0.1 && dist > m.CollisionRadius: // Within radius of reported collisions. return false, dist } @@ -146,7 +146,7 @@ func (m *Face) ReportCollision(embeddings Embeddings) (reported bool, err error) } else if m.ID == "" || len(m.EmbeddingJSON) == 0 { return false, fmt.Errorf("invalid face id") } else if len(m.EmbeddingJSON) == 0 { - return false, fmt.Errorf("face embedding must not be empty") + return false, fmt.Errorf("embedding must not be empty") } if match, dist := m.Match(embeddings); !match { @@ -155,7 +155,7 @@ func (m *Face) ReportCollision(embeddings Embeddings) (reported bool, err error) } else if dist < 0 { // Should never happen. return false, fmt.Errorf("collision distance must be positive") - } else if dist > 0.5 { + } else if dist > 0.2 { m.Collisions++ m.CollisionRadius = dist - 0.1 } else { diff --git a/internal/entity/marker.go b/internal/entity/marker.go index 1314c6f5e..bfc58b412 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -22,10 +22,10 @@ const ( type Marker struct { ID uint `gorm:"primary_key" json:"ID" yaml:"-"` FileID uint `gorm:"index;" json:"-" yaml:"-"` - MarkerType string `gorm:"type:VARBINARY(8);index:idx_markers_subject;default:'';" json:"Type" yaml:"Type"` + MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"` MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"` MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"` - SubjectUID string `gorm:"type:VARBINARY(42);index:idx_markers_subject;" json:"SubjectUID" yaml:"SubjectUID,omitempty"` + SubjectUID string `gorm:"type:VARBINARY(42);index;" json:"SubjectUID" yaml:"SubjectUID,omitempty"` SubjectSrc string `gorm:"type:VARBINARY(8);default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"` Subject *Subject `gorm:"foreignkey:SubjectUID;association_foreignkey:SubjectUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Subject,omitempty" yaml:"-"` FaceID string `gorm:"type:VARBINARY(42);index;" json:"FaceID" yaml:"FaceID,omitempty"` @@ -117,20 +117,27 @@ func (m *Marker) SetFace(f *Face) (updated bool, err error) { return false, fmt.Errorf("not a face marker") } + // Any reason we don't want to set a new face for this marker? + if m.SubjectSrc != SrcManual || f.SubjectUID == m.SubjectUID { + // Don't skip if subject wasn't set manually, or subjects match. + } else if f.SubjectUID != "" { + log.Debugf("faces: ambiguous subjects %s / %s for marker %d", txt.Quote(f.SubjectUID), txt.Quote(m.SubjectUID), m.ID) + return false, nil + } + + // Update face with known subject from marker? if f.SubjectUID != "" || m.SubjectUID == "" { - // Do nothing. + // Don't update if face has a known subject, or marker subject is unknown. } else if err := f.Update("SubjectUID", m.SubjectUID); err != nil { return false, err } - // Skip update? - if m.SubjectSrc == SrcManual { - return false, nil - } else if m.SubjectUID == f.SubjectUID && m.FaceID == f.ID { + // Skip update if the same face is already set. + if m.SubjectUID == f.SubjectUID && m.FaceID == f.ID { return false, nil } - // Remember current values. + // Remember current values for comparison. faceID := m.FaceID subjectUID := m.SubjectUID SubjectSrc := m.SubjectSrc @@ -266,22 +273,27 @@ func (m *Marker) GetSubject() (subj *Subject) { } // ClearSubject removes an existing subject association, and reports a collision. -func (m *Marker) ClearSubject(src string) (err error) { +func (m *Marker) ClearSubject(src string) error { if m.Face == nil { m.Face = FindFace(m.FaceID) - } else if m.Face == nil { - // Do nothing - } else if _, err = m.Face.ReportCollision(m.Embeddings()); err != nil { - return err - } else if err = m.Updates(Values{"MarkerName": "", "FaceID": "", "SubjectUID": "", "SubjectSrc": src}); err != nil { - return err } + if m.Face == nil { + // Do nothing + } else if reported, err := m.Face.ReportCollision(m.Embeddings()); err != nil { + return err + } else if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "SubjectUID": "", "SubjectSrc": src}); err != nil { + return err + } else if reported { + log.Debugf("faces: collision with %s", m.Face.ID) + } + + m.Face = nil + m.MarkerName = "" m.FaceID = "" - m.Face = nil m.SubjectUID = "" - m.SubjectSrc = "" + m.SubjectSrc = src return nil } diff --git a/internal/entity/subject_fixtures.go b/internal/entity/subject_fixtures.go index 8f593447f..eccf98f62 100644 --- a/internal/entity/subject_fixtures.go +++ b/internal/entity/subject_fixtures.go @@ -53,6 +53,23 @@ var SubjectFixtures = SubjectMap{ UpdatedAt: Timestamp(), DeletedAt: nil, }, + "dangling": Subject{ + SubjectUID: "jqy1y111h1njaaaa", + SubjectSlug: "dangling-subject", + SubjectName: "Dangling Subject", + SubjectType: SubjectPerson, + SubjectSrc: SrcMarker, + Favorite: false, + Private: false, + Hidden: false, + SubjectDescription: "", + SubjectNotes: "", + MetadataJSON: []byte(""), + PhotoCount: 0, + CreatedAt: Timestamp(), + UpdatedAt: Timestamp(), + DeletedAt: nil, + }, } // CreateSubjectFixtures inserts known entities into the database for testing. diff --git a/internal/photoprism/faces.go b/internal/photoprism/faces.go index e672d14ca..4f2ac38e4 100644 --- a/internal/photoprism/faces.go +++ b/internal/photoprism/faces.go @@ -4,14 +4,9 @@ import ( "fmt" "runtime/debug" - "github.com/photoprism/photoprism/internal/face" - - "github.com/montanaflynn/stats" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/clusters" ) // Faces represents a worker for face clustering and matching. @@ -28,145 +23,6 @@ func NewFaces(conf *config.Config) *Faces { return instance } -// Analyze face embeddings. -func (w *Faces) Analyze() (err error) { - if embeddings, err := query.Embeddings(true); err != nil { - return err - } else if samples := len(embeddings); samples == 0 { - log.Infof("faces: no samples found") - } else { - log.Infof("faces: computing distance of %d samples", samples) - - distMin := make([]float64, samples) - distMax := make([]float64, samples) - - for i := 0; i < samples; i++ { - min := -1.0 - max := -1.0 - - for j := 0; j < samples; j++ { - if i == j { - continue - } - - d := clusters.EuclideanDistance(embeddings[i], embeddings[j]) - - if min < 0 || d < min { - min = d - } - - if max < 0 || d > max { - max = d - } - } - - distMin[i] = min - distMax[i] = max - } - - minMedian, _ := stats.Median(distMin) - minMin, _ := stats.Min(distMin) - minMax, _ := stats.Max(distMin) - - log.Infof("faces: min Ø %f < median %f < %f", minMin, minMedian, minMax) - - maxMedian, _ := stats.Median(distMax) - maxMin, _ := stats.Min(distMax) - maxMax, _ := stats.Max(distMax) - - log.Infof("faces: max Ø %f < median %f < %f", maxMin, maxMedian, maxMax) - } - - if faces, err := query.Faces(true); err != nil { - log.Errorf("faces: %s", err) - } else if samples := len(faces); samples > 0 { - log.Infof("faces: computing distance of faces matching to the same person") - - dist := make(map[string][]float64) - - for i := 0; i < samples; i++ { - f1 := faces[i] - - e1 := f1.Embedding() - min := -1.0 - max := -1.0 - - if k, ok := dist[f1.SubjectUID]; ok { - min = k[0] - max = k[1] - } - - for j := 0; j < samples; j++ { - if i == j { - continue - } - - f2 := faces[j] - - if f1.SubjectUID != f2.SubjectUID { - continue - } - - e2 := f2.Embedding() - - d := clusters.EuclideanDistance(e1, e2) - - if min < 0 || d < min { - min = d - } - - if max < 0 || d > max { - max = d - } - } - - if max > 0 { - dist[f1.SubjectUID] = []float64{min, max} - } - } - - if l := len(dist); l == 0 { - log.Infof("faces: analyzed %d clusters, no matches", samples) - } else { - log.Infof("faces: %d faces match to the same person", l) - } - - for subj, d := range dist { - log.Infof("faces: %s Ø min %f, max %f", subj, d[0], d[1]) - } - } - - return nil -} - -// Reset face clusters and matches. -func (w *Faces) Reset() (err error) { - if err := query.ResetFaceMarkerMatches(); err != nil { - log.Errorf("faces: %s (reset)", err) - } else { - log.Infof("faces: reset markers") - } - - if err := query.ResetFaces(); err != nil { - log.Errorf("faces: %s (reset)", err) - } else { - log.Infof("faces: reset faces") - } - - if err := query.ResetSubjects(); err != nil { - log.Errorf("faces: %s (reset)", err) - } else { - log.Infof("faces: reset subjects") - } - - return nil -} - -// Disabled tests if facial recognition is disabled. -func (w *Faces) Disabled() bool { - return !(w.conf.Experimental() && w.conf.Settings().Features.People) -} - // Start face clustering and matching. func (w *Faces) Start(opt FacesOptions) (err error) { defer func() { @@ -213,86 +69,47 @@ func (w *Faces) Start(opt FacesOptions) (err error) { log.Debug("faces: no changes") } - // Clean-up invalid marker data. - if err := query.TidyMarkers(); err != nil { - log.Errorf("faces: %s (tidy)", err) + // Remove invalid ids from marker table. + if err := query.CleanInvalidMarkerReferences(); err != nil { + log.Errorf("faces: %s (clean)", err) } return nil } else { - log.Infof("faces: found %d new markers", n) + log.Infof("faces: %d new samples", n) } - var added, recognized, unknown, dbErrors int64 + var clustersAdded, clustersRemoved int64 - // Fetch and cluster all face embeddings. - embeddings, err := query.Embeddings(false) + // Cluster existing face embeddings. + if clustersAdded, clustersRemoved, err = w.Cluster(opt); err != nil { + log.Errorf("faces: %s (cluster)", err) + } - // Anything that keeps us from doing this? - if err != nil { - return err - } else if samples := len(embeddings); samples < opt.SampleThreshold() { - log.Warnf("faces: at least %d samples needed for matching similar faces", face.SampleThreshold) - return nil + // Log face clustering results. + if (clustersAdded - clustersRemoved) != 0 { + log.Infof("faces: %d clusters added, %d removed", clustersAdded, clustersRemoved) } else { - var c clusters.HardClusterer + log.Debugf("faces: %d clusters added, %d removed", clustersAdded, clustersRemoved) + } - // See https://dl.photoprism.org/research/ for research on face clustering algorithms. - if c, err = clusters.DBSCAN(face.ClusterCore, face.ClusterRadius, w.conf.Workers(), clusters.EuclideanDistance); err != nil { - return err - } else if err = c.Learn(embeddings); err != nil { - return err - } - - sizes := c.Sizes() - - log.Debugf("faces: indexing %d samples, %d clusters", len(embeddings), len(sizes)) - - results := make([]entity.Embeddings, len(sizes)) - - for i := range sizes { - results[i] = entity.Embeddings{} - } - - guesses := c.Guesses() - - for i, n := range guesses { - if n < 1 { - continue - } - - results[n-1] = append(results[n-1], embeddings[i]) - } - - if err := query.PurgeAnonymousFaces(); err != nil { - dbErrors++ - log.Errorf("faces: %s", err) - } - - for _, embedding := range results { - if f := entity.NewFace("", entity.SrcAuto, embedding); f == nil { - dbErrors++ - log.Errorf("faces: face should not be nil - bug?") - } else if err := f.Create(); err == nil { - added++ - log.Tracef("faces: added face %s", f.ID) - } else if err := f.Updates(entity.Values{"UpdatedAt": entity.Timestamp()}); err != nil { - dbErrors++ - log.Errorf("faces: %s", err) - } - } + // Remove invalid marker references. + if err = query.CleanInvalidMarkerReferences(); err != nil { + log.Errorf("faces: %s (clean)", err) } // Match markers with faces and subjects. - if recognized, unknown, err = w.Match(); err != nil { - return err + matches, err := w.Match() + + if err != nil { + log.Errorf("faces: %s (match)", err) } - // Log results. - if added > 0 || recognized > 0 || dbErrors > 0 { - log.Infof("faces: %d added, %d recognized, %d unknown, %d errors", added, recognized, unknown, dbErrors) + // Log face matching results. + if matches.Updated > 0 { + log.Infof("faces: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) } else { - log.Debugf("faces: %d added, %d recognized, %d unknown, %d errors", added, recognized, unknown, dbErrors) + log.Debugf("faces: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) } return nil @@ -302,3 +119,8 @@ func (w *Faces) Start(opt FacesOptions) (err error) { func (w *Faces) Cancel() { mutex.MainWorker.Cancel() } + +// Disabled tests if facial recognition is disabled. +func (w *Faces) Disabled() bool { + return !(w.conf.Experimental() && w.conf.Settings().Features.People) +} diff --git a/internal/photoprism/faces_analyze.go b/internal/photoprism/faces_analyze.go new file mode 100644 index 000000000..8514910b1 --- /dev/null +++ b/internal/photoprism/faces_analyze.go @@ -0,0 +1,118 @@ +package photoprism + +import ( + "github.com/montanaflynn/stats" + "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clusters" +) + +// Analyze face embeddings. +func (w *Faces) Analyze() (err error) { + if embeddings, err := query.Embeddings(true); err != nil { + return err + } else if samples := len(embeddings); samples == 0 { + log.Infof("faces: no samples found") + } else { + log.Infof("faces: computing distance of %d samples", samples) + + distMin := make([]float64, samples) + distMax := make([]float64, samples) + + for i := 0; i < samples; i++ { + min := -1.0 + max := -1.0 + + for j := 0; j < samples; j++ { + if i == j { + continue + } + + d := clusters.EuclideanDistance(embeddings[i], embeddings[j]) + + if min < 0 || d < min { + min = d + } + + if max < 0 || d > max { + max = d + } + } + + distMin[i] = min + distMax[i] = max + } + + minMedian, _ := stats.Median(distMin) + minMin, _ := stats.Min(distMin) + minMax, _ := stats.Max(distMin) + + log.Infof("faces: min Ø %f < median %f < %f", minMin, minMedian, minMax) + + maxMedian, _ := stats.Median(distMax) + maxMin, _ := stats.Min(distMax) + maxMax, _ := stats.Max(distMax) + + log.Infof("faces: max Ø %f < median %f < %f", maxMin, maxMedian, maxMax) + } + + if faces, err := query.Faces(true); err != nil { + log.Errorf("faces: %s", err) + } else if samples := len(faces); samples > 0 { + log.Infof("faces: computing distance of faces matching to the same person") + + dist := make(map[string][]float64) + + for i := 0; i < samples; i++ { + f1 := faces[i] + + e1 := f1.Embedding() + min := -1.0 + max := -1.0 + + if k, ok := dist[f1.SubjectUID]; ok { + min = k[0] + max = k[1] + } + + for j := 0; j < samples; j++ { + if i == j { + continue + } + + f2 := faces[j] + + if f1.SubjectUID != f2.SubjectUID { + continue + } + + e2 := f2.Embedding() + + d := clusters.EuclideanDistance(e1, e2) + + if min < 0 || d < min { + min = d + } + + if max < 0 || d > max { + max = d + } + } + + if max > 0 { + dist[f1.SubjectUID] = []float64{min, max} + } + } + + if l := len(dist); l == 0 { + log.Infof("faces: analyzed %d clusters, no matches", samples) + } else { + log.Infof("faces: %d faces match to the same person", l) + } + + for subj, d := range dist { + log.Infof("faces: %s Ø min %f, max %f", subj, d[0], d[1]) + } + } + + return nil +} diff --git a/internal/photoprism/faces_cluster.go b/internal/photoprism/faces_cluster.go new file mode 100644 index 000000000..2608a3b2c --- /dev/null +++ b/internal/photoprism/faces_cluster.go @@ -0,0 +1,70 @@ +package photoprism + +import ( + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/face" + "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clusters" +) + +// Cluster clusters indexed face embeddings. +func (w *Faces) Cluster(opt FacesOptions) (added int64, removed int64, err error) { + // Fetch and cluster all face embeddings. + embeddings, err := query.Embeddings(false) + + // Anything that keeps us from doing this? + if err != nil { + return added, removed, err + } else if samples := len(embeddings); samples < opt.SampleThreshold() { + log.Warnf("faces: at least %d samples needed for matching similar faces", face.SampleThreshold) + return added, removed, nil + } else { + var c clusters.HardClusterer + + // See https://dl.photoprism.org/research/ for research on face clustering algorithms. + if c, err = clusters.DBSCAN(face.ClusterCore, face.ClusterRadius, w.conf.Workers(), clusters.EuclideanDistance); err != nil { + return added, removed, err + } else if err = c.Learn(embeddings); err != nil { + return added, removed, err + } + + sizes := c.Sizes() + + log.Debugf("faces: %d samples in %d clusters", len(embeddings), len(sizes)) + + results := make([]entity.Embeddings, len(sizes)) + + for i := range sizes { + results[i] = entity.Embeddings{} + } + + guesses := c.Guesses() + + for i, n := range guesses { + if n < 1 { + continue + } + + results[n-1] = append(results[n-1], embeddings[i]) + } + + if removed, err = query.RemoveAnonymousFaceClusters(); err != nil { + log.Errorf("faces: %s", err) + } else if removed > 0 { + log.Debugf("faces: removed %d anonymous clusters", removed) + } + + for _, cluster := range results { + if f := entity.NewFace("", entity.SrcAuto, cluster); f == nil { + log.Errorf("faces: face should not be nil - bug?") + } else if err := f.Create(); err == nil { + added++ + log.Tracef("faces: added face %s", f.ID) + } else if err := f.Updates(entity.Values{"UpdatedAt": entity.Timestamp()}); err != nil { + log.Errorf("faces: %s", err) + } + } + } + + return added, removed, nil +} diff --git a/internal/photoprism/faces_match.go b/internal/photoprism/faces_match.go index 6055312b2..b71660509 100644 --- a/internal/photoprism/faces_match.go +++ b/internal/photoprism/faces_match.go @@ -5,20 +5,24 @@ import ( "time" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/clusters" ) +type FacesMatchResult struct { + Updated int64 + Recognized int64 + Unknown int64 +} + // Match matches markers with faces and subjects. -func (w *Faces) Match() (recognized, unknown int64, err error) { +func (w *Faces) Match() (result FacesMatchResult, err error) { if w.Disabled() { - return 0, 0, nil + return result, nil } if faces, err := query.Faces(false); err != nil { - return recognized, unknown, err + return result, err } else { limit := 500 offset := 0 @@ -27,7 +31,7 @@ func (w *Faces) Match() (recognized, unknown int64, err error) { markers, err := query.Markers(limit, offset, entity.MarkerFace, true, false) if err != nil { - return recognized, unknown, err + return result, err } if len(markers) == 0 { @@ -36,7 +40,7 @@ func (w *Faces) Match() (recognized, unknown int64, err error) { for _, marker := range markers { if mutex.MainWorker.Canceled() { - return recognized, unknown, fmt.Errorf("worker canceled") + return result, fmt.Errorf("worker canceled") } // Pointer to the matching face. @@ -46,12 +50,10 @@ func (w *Faces) Match() (recognized, unknown int64, err error) { var d float64 // Find the closest face match for marker. - for _, e := range marker.Embeddings() { - for i, match := range faces { - if dist := clusters.EuclideanDistance(e, match.Embedding()); f == nil || dist < d { - f = &faces[i] - d = dist - } + for i, m := range faces { + if ok, dist := m.Match(marker.Embeddings()); ok && (f == nil || dist < d) { + f = &faces[i] + d = dist } } @@ -60,19 +62,22 @@ func (w *Faces) Match() (recognized, unknown int64, err error) { continue } - // Too distant? - if d > (f.SampleRadius + face.ClusterRadius) { + // Assign matching face to marker. + markerUpdated, err := marker.SetFace(f) + + if err != nil { + log.Warnf("faces: %s", err) continue } - if updated, err := marker.SetFace(f); err != nil { - log.Errorf("faces: %s", err) - } else if updated { - recognized++ + if markerUpdated { + result.Updated++ } - if marker.SubjectUID == "" { - unknown++ + if marker.SubjectUID != "" { + result.Recognized++ + } else { + result.Unknown++ } } @@ -84,15 +89,15 @@ func (w *Faces) Match() (recognized, unknown int64, err error) { // Update remaining markers based on current matches. if m, err := query.MatchFaceMarkers(); err != nil { - return recognized, unknown, err + return result, err } else { - recognized += m + result.Recognized += m } // Reset invalid marker data. - if err := query.TidyMarkers(); err != nil { - return recognized, unknown, err + if err := query.CleanInvalidMarkerReferences(); err != nil { + return result, err } - return recognized, unknown, nil + return result, nil } diff --git a/internal/photoprism/faces_reset.go b/internal/photoprism/faces_reset.go new file mode 100644 index 000000000..83f640216 --- /dev/null +++ b/internal/photoprism/faces_reset.go @@ -0,0 +1,33 @@ +package photoprism + +import ( + "fmt" + + "github.com/photoprism/photoprism/internal/query" +) + +// Reset removes automatically added face clusters, marker matches, and dangling subjects. +func (w *Faces) Reset() (err error) { + // Remove automatically added subject and face references from the markers table. + if removed, err := query.ResetFaceMarkerMatches(); err != nil { + return fmt.Errorf("faces: %s (reset markers)", err) + } else { + log.Infof("faces: removed %d face matches", removed) + } + + // Remove automatically added face clusters from the index. + if removed, err := query.RemoveAutoFaceClusters(); err != nil { + return fmt.Errorf("faces: %s (reset faces)", err) + } else { + log.Infof("faces: removed %d face clusters", removed) + } + + // Remove dangling marker subjects. + if removed, err := query.RemoveDanglingMarkerSubjects(); err != nil { + return fmt.Errorf("faces: %s (reset subjects)", err) + } else { + log.Infof("faces: removed %d dangling subjects", removed) + } + + return nil +} diff --git a/internal/photoprism/import.go b/internal/photoprism/import.go index f113bc7f4..1821a8b57 100644 --- a/internal/photoprism/import.go +++ b/internal/photoprism/import.go @@ -241,10 +241,12 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { // Match existing faces if facial recognition is enabled. if w := NewFaces(imp.conf); w.Disabled() { log.Debugf("import: skipping facial recognition") - } else if recognized, unknown, err := w.Match(); err != nil { + } else if matches, err := w.Match(); err != nil { log.Errorf("import: %s", err) - } else if recognized > 0 || unknown > 0 { - log.Infof("faces: %d recognized, %d unknown", recognized, unknown) + } else if matches.Updated > 0 { + log.Infof("import: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) + } else { + log.Debugf("import: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) } if err := entity.UpdatePhotoCounts(); err != nil { diff --git a/internal/photoprism/index.go b/internal/photoprism/index.go index 528ddcc32..0ccd77f07 100644 --- a/internal/photoprism/index.go +++ b/internal/photoprism/index.go @@ -233,10 +233,12 @@ func (ind *Index) Start(opt IndexOptions) fs.Done { // Match existing faces if facial recognition is enabled. if w := NewFaces(ind.conf); w.Disabled() { log.Debugf("index: skipping facial recognition") - } else if recognized, unknown, err := w.Match(); err != nil { + } else if matches, err := w.Match(); err != nil { log.Errorf("index: %s", err) - } else if recognized > 0 || unknown > 0 { - log.Infof("faces: %d recognized, %d unknown", recognized, unknown) + } else if matches.Updated > 0 { + log.Infof("index: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) + } else { + log.Debugf("index: %d markers updated, %d faces recognized, %d unknown", matches.Updated, matches.Recognized, matches.Unknown) } event.Publish("index.updating", event.Data{ diff --git a/internal/photoprism/resample_test.go b/internal/photoprism/resample_test.go index cdab1a5b8..b974da556 100644 --- a/internal/photoprism/resample_test.go +++ b/internal/photoprism/resample_test.go @@ -138,7 +138,7 @@ func TestThumb_FromFile(t *testing.T) { t.Fatal("err should NOT be nil") } - assert.Equal(t, "resample: file hash is empty or too short (123)", err.Error()) + assert.Equal(t, "resample: invalid file hash 123", err.Error()) }) t.Run("filename too short", func(t *testing.T) { file := &entity.File{ @@ -147,7 +147,7 @@ func TestThumb_FromFile(t *testing.T) { } if _, err := thumb.FromFile(file.FileName, file.FileHash, thumbsPath, 224, 224, file.FileOrientation); err != nil { - assert.Equal(t, "resample: image filename is empty or too short (xxx)", err.Error()) + assert.Equal(t, "resample: invalid file name xxx", err.Error()) } else { t.Error("error is nil") } diff --git a/internal/query/faces.go b/internal/query/faces.go index 0f5bcee00..a9f7fcb4b 100644 --- a/internal/query/faces.go +++ b/internal/query/faces.go @@ -42,18 +42,21 @@ func MatchFaceMarkers() (affected int64, err error) { return affected, nil } -// PurgeAnonymousFaces removes anonymous faces from the index. -func PurgeAnonymousFaces() error { - return UnscopedDb().Delete( +// RemoveAnonymousFaceClusters removes anonymous faces from the index. +func RemoveAnonymousFaceClusters() (removed int64, err error) { + res := UnscopedDb().Delete( entity.Face{}, - "face_src = ? AND subject_uid = ''", entity.SrcAuto).Error + "face_src = ? AND subject_uid = ''", entity.SrcAuto) + + return res.RowsAffected, res.Error } -// ResetFaces removes all face clusters from the index. -func ResetFaces() error { - return UnscopedDb(). - Delete(entity.Face{}, "id <> ? AND face_src = ''", entity.UnknownFace.ID). - Error +// RemoveAutoFaceClusters removes automatically added face clusters from the index. +func RemoveAutoFaceClusters() (removed int64, err error) { + res := UnscopedDb(). + Delete(entity.Face{}, "id <> ? AND face_src = ?", entity.UnknownFace.ID, entity.SrcAuto) + + return res.RowsAffected, res.Error } // CountNewFaceMarkers returns the number of new face markers in the index. diff --git a/internal/query/faces_test.go b/internal/query/faces_test.go index 9f14538fd..d2bea7917 100644 --- a/internal/query/faces_test.go +++ b/internal/query/faces_test.go @@ -54,8 +54,14 @@ func TestMatchFaceMarkers(t *testing.T) { } } -func TestPurgeAnonymousFaces(t *testing.T) { - assert.NoError(t, PurgeAnonymousFaces()) +func TestRemoveAnonymousFaceClusters(t *testing.T) { + removed, err := RemoveAnonymousFaceClusters() + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, int64(1), removed) } func TestCountNewFaceMarkers(t *testing.T) { diff --git a/internal/query/markers.go b/internal/query/markers.go index 57b36fe23..e29046070 100644 --- a/internal/query/markers.go +++ b/internal/query/markers.go @@ -104,8 +104,8 @@ func AddMarkerSubjects() (affected int64, err error) { return affected, err } -// TidyMarkers resets invalid marker data as needed. -func TidyMarkers() (err error) { +// CleanInvalidMarkerReferences deletes invalid reference IDs from the markers table. +func CleanInvalidMarkerReferences() (err error) { // Reset subject and face relationships for invalid markers. err = Db(). Model(&entity.Marker{}). @@ -120,14 +120,16 @@ func TidyMarkers() (err error) { // Reset invalid face IDs. return Db(). Model(&entity.Marker{}). - Where(fmt.Sprintf("face_id NOT IN (SELECT id FROM %s)", entity.Face{}.TableName())). + Where(fmt.Sprintf("face_id <> '' AND face_id NOT IN (SELECT id FROM %s)", entity.Face{}.TableName())). UpdateColumns(entity.Values{"face_id": ""}). Error } -// ResetFaceMarkerMatches removes people and face matches from face markers. -func ResetFaceMarkerMatches() error { - v := entity.Values{"subject_uid": "", "subject_src": "", "face_id": ""} +// ResetFaceMarkerMatches removes automatically added subject and face references from the markers table. +func ResetFaceMarkerMatches() (removed int64, err error) { + res := Db().Model(&entity.Marker{}). + Where("subject_src <> ? AND marker_type = ?", entity.SrcManual, entity.MarkerFace). + UpdateColumns(entity.Values{"subject_uid": "", "subject_src": "", "face_id": ""}) - return Db().Model(&entity.Marker{}).Where("marker_type = ?", entity.MarkerFace).UpdateColumns(v).Error + return res.RowsAffected, res.Error } diff --git a/internal/query/markers_test.go b/internal/query/markers_test.go index d09bc3461..75731f396 100644 --- a/internal/query/markers_test.go +++ b/internal/query/markers_test.go @@ -66,6 +66,6 @@ func TestAddMarkerSubjects(t *testing.T) { assert.GreaterOrEqual(t, affected, int64(1)) } -func TestTidyMarkers(t *testing.T) { - assert.NoError(t, TidyMarkers()) +func TestCleanInvalidMarkerReferences(t *testing.T) { + assert.NoError(t, CleanInvalidMarkerReferences()) } diff --git a/internal/query/subjects.go b/internal/query/subjects.go index 911238712..e5ecdae8c 100644 --- a/internal/query/subjects.go +++ b/internal/query/subjects.go @@ -16,11 +16,13 @@ func Subjects(limit, offset int) (result entity.Subjects, err error) { return result, err } -// ResetSubjects removes all unused subjects from the index. -func ResetSubjects() error { - return UnscopedDb(). +// RemoveDanglingMarkerSubjects permanently deletes dangling marker subjects from the index. +func RemoveDanglingMarkerSubjects() (removed int64, err error) { + res := UnscopedDb(). Where("subject_src = ?", entity.SrcMarker). Where(fmt.Sprintf("subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Face{}.TableName())). - Delete(&entity.Subject{}). - Error + Where(fmt.Sprintf("subject_uid NOT IN (SELECT subject_uid FROM %s)", entity.Marker{}.TableName())). + Delete(&entity.Subject{}) + + return res.RowsAffected, res.Error } diff --git a/internal/query/subjects_test.go b/internal/query/subjects_test.go index 985c31b5b..d66d2c57c 100644 --- a/internal/query/subjects_test.go +++ b/internal/query/subjects_test.go @@ -22,6 +22,12 @@ func TestSubjects(t *testing.T) { } } -func TestResetSubjects(t *testing.T) { - assert.NoError(t, ResetSubjects()) +func TestRemoveDanglingMarkerSubjects(t *testing.T) { + affected, err := RemoveDanglingMarkerSubjects() + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, int64(1), affected) } diff --git a/internal/thumb/create.go b/internal/thumb/create.go index eb44667d9..a1216fc0c 100644 --- a/internal/thumb/create.go +++ b/internal/thumb/create.go @@ -103,11 +103,11 @@ func Filename(hash string, thumbPath string, width, height int, opts ...Resample func FromCache(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) { if len(hash) < 4 { - return "", fmt.Errorf("resample: file hash is empty or too short (%s)", txt.Quote(hash)) + return "", fmt.Errorf("resample: invalid file hash %s", txt.Quote(hash)) } if len(imageFilename) < 4 { - return "", fmt.Errorf("resample: image filename is empty or too short (%s)", txt.Quote(imageFilename)) + return "", fmt.Errorf("resample: invalid file name %s", txt.Quote(imageFilename)) } fileName, err = Filename(hash, thumbPath, width, height, opts...) diff --git a/internal/thumb/create_test.go b/internal/thumb/create_test.go index d6b09c8ee..1cd55833b 100644 --- a/internal/thumb/create_test.go +++ b/internal/thumb/create_test.go @@ -271,7 +271,7 @@ func TestFromFile(t *testing.T) { t.Fatal("error expected") } assert.Equal(t, "", fileName) - assert.Equal(t, "resample: image filename is empty or too short ()", err.Error()) + assert.Equal(t, "resample: invalid file name “”", err.Error()) }) } @@ -313,7 +313,7 @@ func TestFromCache(t *testing.T) { if err == nil { t.Fatal("error expected") } - assert.Equal(t, "resample: file hash is empty or too short (12)", err.Error()) + assert.Equal(t, "resample: invalid file hash 12", err.Error()) assert.Empty(t, fileName) }) t.Run("empty filename", func(t *testing.T) { @@ -324,7 +324,7 @@ func TestFromCache(t *testing.T) { if err == nil { t.Fatal("error expected") } - assert.Equal(t, "resample: image filename is empty or too short ()", err.Error()) + assert.Equal(t, "resample: invalid file name “”", err.Error()) assert.Empty(t, fileName) }) } diff --git a/pkg/txt/quote.go b/pkg/txt/quote.go index 60daa2689..dcdccfe98 100644 --- a/pkg/txt/quote.go +++ b/pkg/txt/quote.go @@ -7,7 +7,7 @@ import ( // Quote adds quotation marks to a string if needed. func Quote(text string) string { - if strings.ContainsAny(text, " \n'\"") { + if text == "" || strings.ContainsAny(text, " \n'\"") { return fmt.Sprintf("“%s”", text) } diff --git a/pkg/txt/quote_test.go b/pkg/txt/quote_test.go index db76975bd..87cd1e596 100644 --- a/pkg/txt/quote_test.go +++ b/pkg/txt/quote_test.go @@ -13,4 +13,7 @@ func TestQuote(t *testing.T) { t.Run("filename.txt", func(t *testing.T) { assert.Equal(t, "filename.txt", Quote("filename.txt")) }) + t.Run("empty string", func(t *testing.T) { + assert.Equal(t, "“”", Quote("")) + }) }