People: Split facial recognition into smaller functions #22

Clustering and matching have been improved along the way. This opens
the door for further optimizations while keeping the code readable.
This commit is contained in:
Michael Mayer 2021-08-22 16:14:34 +02:00
parent 335bf81491
commit 2e85b3cccd
24 changed files with 418 additions and 304 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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")

View file

@ -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 {

View file

@ -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
}

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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{

View file

@ -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")
}

View file

@ -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.

View file

@ -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) {

View file

@ -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
}

View file

@ -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())
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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...)

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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(""))
})
}