People: Add info on the kind of face to improve matching #2182

This commit also fixes of other potential issues and improves logging.
This commit is contained in:
Michael Mayer 2022-04-04 21:22:31 +02:00
parent cfb448e97d
commit 9986986f8f
22 changed files with 490 additions and 175 deletions

View file

@ -23,7 +23,7 @@
</v-tab>
<v-tabs-items touchless>
<v-tab-item v-for="(item, index) in tabs" :key="index" :transition="false" class="no-transition" lazy>
<v-tab-item v-for="(item, index) in tabs" :key="index" lazy>
<component :is="item.component" :static-filter="item.filter" :active="active === index"
@updateFaceCount="onUpdateFaceCount"></component>
</v-tab-item>

View file

@ -3,6 +3,7 @@ package entity
import (
"fmt"
"reflect"
"runtime/debug"
"strings"
)
@ -10,7 +11,7 @@ import (
func Save(m interface{}, primaryKeys ...string) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("save: %s (panic)", r)
err = fmt.Errorf("index: save failed (%s)\nstack: %s", r, debug.Stack())
log.Error(err)
}
}()
@ -32,29 +33,20 @@ func Save(m interface{}, primaryKeys ...string) (err error) {
func Update(m interface{}, primaryKeys ...string) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("update: %s (panic)", r)
err = fmt.Errorf("index: update failed (%s)\nstack: %s", r, debug.Stack())
log.Error(err)
}
}()
// Return with error if a primary key is empty.
v := reflect.ValueOf(m).Elem()
// Abort if a primary key is zero.
for _, k := range primaryKeys {
if field := v.FieldByName(k); field.IsZero() {
return fmt.Errorf("key '%s' not found", k)
if field := v.FieldByName(k); !field.CanSet() || field.IsZero() {
return fmt.Errorf("empty primary key '%s'", k)
}
}
// Update all values except primary keys.
if res := UnscopedDb().Model(m).Updates(GetValues(m, primaryKeys...)); res.Error != nil {
return res.Error
} else if res.RowsAffected > 1 {
log.Warnf("update: more than one row affected")
} else if res.RowsAffected == 0 {
// MariaDB may report zero rows in case no data was actually changed, even though the row exists.
log.Tracef("update: no rows affected")
}
err = UnscopedDb().FirstOrCreate(m, GetValues(m)).Error
return nil
return err
}

View file

@ -0,0 +1,64 @@
package entity
import (
"testing"
"time"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/stretchr/testify/assert"
)
func TestUpdate(t *testing.T) {
t.Run("HasCreatedUpdatedAt", func(t *testing.T) {
m := NewFace(rnd.PPID('j'), SrcAuto, face.RandomEmbeddings(1, face.RegularFace))
id := m.ID
m.CreatedAt = time.Now()
m.UpdatedAt = time.Now()
if err := m.Save(); err != nil {
t.Fatal(err)
return
}
found := FindFace(id)
assert.NotNil(t, found)
assert.Equal(t, id, found.ID)
assert.Greater(t, time.Now(), m.UpdatedAt)
assert.Equal(t, found.CreatedAt.UTC(), m.CreatedAt.UTC())
})
t.Run("HasCreatedAt", func(t *testing.T) {
m := NewFace(rnd.PPID('j'), SrcAuto, face.RandomEmbeddings(1, face.RegularFace))
id := m.ID
m.CreatedAt = time.Now()
if err := m.Save(); err != nil {
t.Fatal(err)
return
}
found := FindFace(id)
assert.NotNil(t, found)
assert.Equal(t, id, found.ID)
assert.Greater(t, time.Now().UTC(), m.UpdatedAt.UTC())
assert.Equal(t, found.CreatedAt.UTC(), m.CreatedAt.UTC())
})
t.Run("NoCreatedAt", func(t *testing.T) {
m := NewFace(rnd.PPID('j'), SrcAuto, face.RandomEmbeddings(1, face.RegularFace))
id := m.ID
if err := m.Save(); err != nil {
t.Fatal(err)
return
}
found := FindFace(id)
assert.NotNil(t, found)
assert.Equal(t, id, found.ID)
assert.Greater(t, time.Now(), m.UpdatedAt.UTC())
assert.Equal(t, found.CreatedAt.UTC(), m.CreatedAt.UTC())
})
}

View file

@ -19,6 +19,7 @@ var faceMutex = sync.Mutex{}
type Face struct {
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
FaceSrc string `gorm:"type:VARBINARY(8);" json:"Src" yaml:"Src,omitempty"`
FaceKind int `json:"Kind" yaml:"Kind,omitempty"`
FaceHidden bool `json:"Hidden" yaml:"Hidden,omitempty"`
SubjUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"SubjUID" yaml:"SubjUID,omitempty"`
Samples int `json:"Samples" yaml:"Samples,omitempty"`
@ -54,15 +55,23 @@ func NewFace(subjUID, faceSrc string, embeddings face.Embeddings) *Face {
return result
}
// OmitMatch checks whether the face should be skipped when matching.
func (m *Face) OmitMatch() bool {
return m.Embedding().OmitMatch()
// SkipMatching checks whether the face should be skipped when matching.
func (m *Face) SkipMatching() bool {
return m.Embedding().SkipMatching()
}
// SetEmbeddings assigns face embeddings.
func (m *Face) SetEmbeddings(embeddings face.Embeddings) (err error) {
if len(embeddings) == 0 {
return fmt.Errorf("empty")
}
m.embedding, m.SampleRadius, m.Samples = face.EmbeddingsMidpoint(embeddings)
if len(m.embedding) != len(face.NullEmbedding) {
return fmt.Errorf("invalid number of values")
}
// Limit sample radius to reduce false positives.
if m.SampleRadius > 0.35 {
m.SampleRadius = 0.35
@ -75,16 +84,12 @@ func (m *Face) SetEmbeddings(embeddings face.Embeddings) (err error) {
}
s := sha1.Sum(m.EmbeddingJSON)
// Update Face ID, Kind, and reset match timestamp,
m.ID = base32.StdEncoding.EncodeToString(s[:])
m.UpdatedAt = TimeStamp()
// Reset match timestamp.
m.FaceKind = int(m.embedding.Kind())
m.MatchedAt = nil
if m.CreatedAt.IsZero() {
m.CreatedAt = m.UpdatedAt
}
return nil
}
@ -187,7 +192,7 @@ func (m *Face) ResolveCollision(embeddings face.Embeddings) (resolved bool, err
if revised, err := m.ReviseMatches(); err != nil {
return true, err
} else if r := len(revised); r > 0 {
log.Infof("faces: revised %d matches after conflict", r)
log.Infof("faces: resolved %d conflicts", r)
}
return true, nil
@ -203,13 +208,13 @@ func (m *Face) ReviseMatches() (revised Markers, err error) {
if err := Db().Where("face_id = ?", m.ID).Where("marker_type = ?", MarkerFace).
Find(&matches).Error; err != nil {
log.Debugf("faces: %s (revise matches)", err)
log.Debugf("faces: found no matching markers for conflict resolution (%s)", err)
return revised, err
} else {
for _, marker := range matches {
if ok, _ := m.Match(marker.Embeddings()); !ok {
if updated, err := marker.ClearFace(); err != nil {
log.Debugf("faces: %s (revise matches)", err)
log.Debugf("faces: failed to remove match with marker (%s)", err) // Conflict resolution
return revised, err
} else if updated {
revised = append(revised, marker)
@ -230,7 +235,7 @@ func (m *Face) MatchMarkers(faceIds []string) error {
Find(&markers).Error
if err != nil {
log.Debugf("faces: %s (match markers)", err)
log.Debugf("faces: failed fetching markers matching face id %s (%s)", strings.Join(faceIds, ", "), err)
return err
}
@ -300,6 +305,10 @@ func (m *Face) Show() (err error) {
// Save updates the existing or inserts a new face.
func (m *Face) Save() error {
if m.ID == "" {
return fmt.Errorf("empty id")
}
faceMutex.Lock()
defer faceMutex.Unlock()
@ -308,6 +317,10 @@ func (m *Face) Save() error {
// Create inserts the face to the database.
func (m *Face) Create() error {
if m.ID == "" {
return fmt.Errorf("empty id")
}
faceMutex.Lock()
defer faceMutex.Unlock()
@ -316,6 +329,10 @@ func (m *Face) Create() error {
// Delete removes the face from the database.
func (m *Face) Delete() error {
if m.ID == "" {
return fmt.Errorf("empty id")
}
// Remove face id from markers before deleting.
if err := Db().Model(&Marker{}).
Where("face_id = ?", m.ID).
@ -328,28 +345,49 @@ func (m *Face) Delete() error {
// Update a face property in the database.
func (m *Face) Update(attr string, value interface{}) error {
if m.ID == "" {
return fmt.Errorf("empty id")
}
return UnscopedDb().Model(m).Update(attr, value).Error
}
// Updates face properties in the database.
func (m *Face) Updates(values interface{}) error {
if m.ID == "" {
return fmt.Errorf("empty id")
}
return UnscopedDb().Model(m).Updates(values).Error
}
// FirstOrCreateFace returns the existing entity, inserts a new entity or nil in case of errors.
func FirstOrCreateFace(m *Face) *Face {
if m == nil {
return nil
}
if m.ID == "" {
return nil
}
result := Face{}
if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
log.Warnf("faces: %s has ambiguous subject %s", m.ID, SubjNames.Log(m.SubjUID))
// Search existing face with the same ID. Report if found and it belongs to another person.
if findErr := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; findErr == nil && result.ID != "" {
if m.SubjUID != result.SubjUID {
log.Warnf("faces: %s has ambiguous subjects %s and %s", m.ID, SubjNames.Log(m.SubjUID), SubjNames.Log(result.SubjUID))
}
return &result
} else if createErr := m.Create(); createErr == nil {
} else if err := m.Create(); err == nil {
return m
} else if err := UnscopedDb().Where("id = ?", m.ID).First(&result).Error; err == nil {
log.Warnf("faces: %s has ambiguous subject %s", m.ID, SubjNames.Log(m.SubjUID))
} else if findErr = UnscopedDb().Where("id = ?", m.ID).First(&result).Error; findErr == nil && result.ID != "" {
if m.SubjUID != result.SubjUID {
log.Warnf("faces: %s has ambiguous subjects %s and %s", m.ID, SubjNames.Log(m.SubjUID), SubjNames.Log(result.SubjUID))
}
return &result
} else {
log.Errorf("faces: %s when trying to create %s", createErr, m.ID)
log.Errorf("faces: failed adding %s (%s)", m.ID, err)
}
return nil

File diff suppressed because one or more lines are too long

View file

@ -500,19 +500,21 @@ func (m *Marker) Face() (f *Face) {
if m.Size < face.ClusterSizeThreshold || m.Score < face.ClusterScoreThreshold {
log.Debugf("marker %s: skipped adding face due to low-quality (size %d, score %d)", sanitize.Log(m.MarkerUID), m.Size, m.Score)
return nil
} else if emb := m.Embeddings(); emb.Empty() {
log.Warnf("marker %s: found no face embeddings", sanitize.Log(m.MarkerUID))
}
if emb := m.Embeddings(); emb.Empty() {
log.Warnf("faces: marker %s has no face embeddings", sanitize.Log(m.MarkerUID))
return nil
} else if f = NewFace(m.SubjUID, m.SubjSrc, emb); f == nil {
log.Warnf("marker %s: failed assigning face", sanitize.Log(m.MarkerUID))
log.Warnf("faces: failed assigning face to marker %s", sanitize.Log(m.MarkerUID))
return nil
} else if f.OmitMatch() {
log.Infof("marker %s: face %s is unsuitable for clustering and matching", sanitize.Log(m.MarkerUID), f.ID)
} else if f.SkipMatching() {
log.Infof("faces: skipped matching marker %s, embedding %s not distinct enough", sanitize.Log(m.MarkerUID), f.ID)
} else if f = FirstOrCreateFace(f); f == nil {
log.Warnf("marker %s: failed assigning face", sanitize.Log(m.MarkerUID))
log.Warnf("faces: failed matching marker %s with subject %s", sanitize.Log(m.MarkerUID), SubjNames.Log(m.SubjUID))
return nil
} else if err := f.MatchMarkers(Faceless); err != nil {
log.Errorf("marker %s: %s while matching with faces", sanitize.Log(m.MarkerUID), err)
log.Errorf("faces: failed matching marker %s with subject %s (%s)", sanitize.Log(m.MarkerUID), SubjNames.Log(m.SubjUID), err)
}
m.face = f

View file

@ -10,6 +10,10 @@ type Values map[string]interface{}
// GetValues extracts entity Values.
func GetValues(m interface{}, omit ...string) (result Values) {
skip := func(name string) bool {
if name == "" || name == "UpdatedAt" || name == "CreatedAt" {
return true
}
for _, s := range omit {
if name == s {
return true
@ -23,15 +27,22 @@ func GetValues(m interface{}, omit ...string) (result Values) {
elem := reflect.ValueOf(m).Elem()
relType := elem.Type()
num := relType.NumField()
for i := 0; i < relType.NumField(); i++ {
name := relType.Field(i).Name
result = make(map[string]interface{}, num)
if skip(name) {
// Add exported fields to result.
for i := 0; i < num; i++ {
n := relType.Field(i).Name
v := elem.Field(i)
if !v.CanSet() {
continue
} else if skip(n) {
continue
}
result[name] = elem.Field(i).Interface()
result[n] = elem.Field(i).Interface()
}
return result

View file

@ -208,7 +208,7 @@ func FirstOrCreateSubject(m *Subject) *Subject {
} else if found = FindSubjectByName(m.SubjName); found != nil {
return found
} else {
log.Errorf("subject: %s while creating %s", err, sanitize.Log(m.SubjName))
log.Errorf("subject: failed adding %s (%s)", sanitize.Log(m.SubjName), err)
}
return nil
@ -245,14 +245,18 @@ func FindSubjectByName(name string) (result *Subject) {
// Restore if currently deleted.
if result = FindSubject(uid); result == nil {
log.Debugf("subject: could not find %s", sanitize.Log(result.SubjName))
return nil
} else if err := result.Restore(); err != nil {
log.Errorf("subject: %s could not be restored", sanitize.Log(result.SubjName))
} else if !result.Deleted() {
return result
} else if err := result.Restore(); err == nil {
log.Debugf("subject: restored %s", sanitize.Log(result.SubjName))
return result
} else {
log.Debugf("subject: %s restored", sanitize.Log(result.SubjName))
log.Errorf("subject: failed restoring %s (%s)", sanitize.Log(result.SubjName), err)
}
return result
return nil
}
// IsPerson tests if the subject is a person.

View file

@ -27,36 +27,33 @@ func NewEmbedding(inference []float32) Embedding {
return result
}
// IgnoreFace tests whether the embedding is generally unsuitable for matching.
func (m Embedding) IgnoreFace() bool {
if IgnoreDist <= 0 {
return false
// Kind returns the type of face e.g. regular, kids, or ignored.
func (m Embedding) Kind() Kind {
if m.KidsFace() {
return KidsFace
} else if m.Ignored() {
return IgnoredFace
}
return IgnoreEmbeddings.Contains(m, IgnoreDist)
return RegularFace
}
// KidsFace tests if the embedded face belongs to a baby or young child.
func (m Embedding) KidsFace() bool {
if KidsDist <= 0 {
return false
}
return KidsEmbeddings.Contains(m, KidsDist)
}
// OmitMatch tests if the face embedding is unsuitable for matching.
func (m Embedding) OmitMatch() bool {
return m.KidsFace() || m.IgnoreFace()
// SkipMatching checks if the face embedding seems unsuitable for matching.
func (m Embedding) SkipMatching() bool {
return m.KidsFace() || m.Ignored()
}
// CanMatch tests if the face embedding is not blacklisted.
func (m Embedding) CanMatch() bool {
return !m.IgnoreFace()
return !m.Ignored()
}
// Dist calculates the distance to another face embedding.
func (m Embedding) Dist(other Embedding) float64 {
if len(other) == 0 || len(m) != len(other) {
return -1
}
return clusters.EuclideanDist(m, other)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,98 @@
package face
import (
"math/rand"
"time"
)
type Kind int
const (
RegularFace Kind = iota + 1
KidsFace
IgnoredFace
)
var r = rand.New(rand.NewSource(time.Now().UnixNano()))
// RandomDist returns a distance threshold for matching RandomDEmbeddings.
func RandomDist() float64 {
return RandomFloat64(0.75, 0.15)
}
// RandomFloat64 adds a random distance offset to a float64.
func RandomFloat64(f, d float64) float64 {
return f + (r.Float64()-0.5)*d
}
// RandomEmbeddings returns random embeddings for testing.
func RandomEmbeddings(n int, k Kind) (result Embeddings) {
if n <= 0 {
return Embeddings{}
}
result = make(Embeddings, n)
for i := range result {
switch k {
case RegularFace:
result[i] = RandomEmbedding()
case KidsFace:
result[i] = RandomKidsEmbedding()
case IgnoredFace:
result[i] = RandomIgnoredEmbedding()
}
}
return result
}
// RandomEmbedding returns a random embedding for testing.
func RandomEmbedding() (result Embedding) {
result = make(Embedding, 512)
d := 64 / 512.0
for {
i := 0
for i = range result {
result[i] = RandomFloat64(0, d)
}
if !result.SkipMatching() {
break
}
}
return result
}
// RandomKidsEmbedding returns a random kids embedding for testing.
func RandomKidsEmbedding() (result Embedding) {
result = make(Embedding, 512)
d := 0.1 / 512.0
n := 1 + r.Intn(len(KidsEmbeddings)-1)
e := KidsEmbeddings[n]
for i := range result {
result[i] = RandomFloat64(e[i], d)
}
return result
}
// RandomIgnoredEmbedding returns a random ignored embedding for testing.
func RandomIgnoredEmbedding() (result Embedding) {
result = make(Embedding, 512)
d := 0.1 / 512.0
n := 1 + r.Intn(len(IgnoredEmbeddings)-1)
e := IgnoredEmbeddings[n]
for i := range result {
result[i] = RandomFloat64(e[i], d)
}
return result
}

View file

@ -0,0 +1,40 @@
package face
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRandomDist(t *testing.T) {
t.Run("Range", func(t *testing.T) {
d := RandomDist()
assert.GreaterOrEqual(t, d, 0.1)
assert.LessOrEqual(t, d, 1.5)
})
}
func TestRandomEmbeddings(t *testing.T) {
t.Run("Regular", func(t *testing.T) {
e := RandomEmbeddings(2, RegularFace)
for i := range e {
// t.Logf("embedding: %#v", e[i])
assert.False(t, e[i].KidsFace())
assert.False(t, e[i].Ignored())
}
})
t.Run("Kids", func(t *testing.T) {
e := RandomEmbeddings(2, KidsFace)
for i := range e {
assert.False(t, e[i].Ignored())
assert.True(t, e[i].KidsFace())
}
})
t.Run("Ignored", func(t *testing.T) {
e := RandomEmbeddings(2, IgnoredFace)
for i := range e {
assert.True(t, e[i].Ignored())
assert.False(t, e[i].KidsFace())
}
})
}

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ func (w *Faces) Audit(fix bool) (err error) {
subj, err := query.SubjectMap()
if err != nil {
log.Error(err)
log.Errorf("faces: %s (find subjects)", err)
}
if n := len(subj); n == 0 {
@ -35,9 +35,9 @@ func (w *Faces) Audit(fix bool) (err error) {
} else if !fix {
log.Infof("%s with non-existent subjects", english.Plural(n, "marker", "markers"))
} else if removed, err := query.RemoveNonExistentMarkerSubjects(); err != nil {
log.Errorf("faces: %s (remove orphan subjects)", err)
} else if removed > 0 {
log.Infof("removed %d / %d markers with non-existent subjects", removed, n)
} else {
log.Error(err)
}
// Fix non-existent marker face references?
@ -46,9 +46,9 @@ func (w *Faces) Audit(fix bool) (err error) {
} else if !fix {
log.Infof("%s with non-existent faces", english.Plural(n, "marker", "markers"))
} else if removed, err := query.RemoveNonExistentMarkerFaces(); err != nil {
log.Errorf("faces: %s (remove orphan embeddings)", err)
} else if removed > 0 {
log.Infof("removed %d / %d markers with non-existent faces", removed, n)
} else {
log.Error(err)
}
conflicts := 0
@ -92,12 +92,12 @@ func (w *Faces) Audit(fix bool) (err error) {
if !fix {
// Do nothing.
} else if ok, err := f1.ResolveCollision(face.Embeddings{f2.Embedding()}); err != nil {
log.Errorf("face %s: %s", f1.ID, err)
log.Errorf("conflict resolution for %s failed, face id %s has collisions with other persons (%s)", entity.SubjNames.Log(f1.SubjUID), f1.ID, err)
} else if ok {
log.Infof("face %s: ambiguous subject has been resolved", f1.ID)
log.Infof("successful conflict resolution for %s, face id %s had collisions with other persons", entity.SubjNames.Log(f1.SubjUID), f1.ID)
resolved++
} else {
log.Infof("face %s: ambiguous subject could not be resolved", f1.ID)
log.Infof("conflict resolution for %s not successful, face id %s still has collisions with other persons", entity.SubjNames.Log(f1.SubjUID), f1.ID)
}
}
}
@ -112,7 +112,7 @@ func (w *Faces) Audit(fix bool) (err error) {
}
if markers, err := query.MarkersWithSubjectConflict(); err != nil {
log.Error(err)
log.Errorf("faces: %s (find marker conflicts)", err)
} else {
for _, m := range markers {
log.Infof("marker %s: %s subject %s conflicts with face %s subject %s", m.MarkerUID, entity.SrcString(m.SubjSrc), sanitize.Log(subj[m.SubjUID].SubjName), m.FaceID, sanitize.Log(subj[faceMap[m.FaceID].SubjUID].SubjName))

View file

@ -73,8 +73,8 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
for _, cluster := range results {
if f := entity.NewFace("", entity.SrcAuto, cluster); f == nil {
log.Errorf("faces: face should not be nil - bug?")
} else if f.OmitMatch() {
log.Infof("faces: ignoring %s, cluster unsuitable for matching", f.ID)
} else if f.SkipMatching() {
log.Infof("faces: skipped cluster %s, embedding not distinct enough", f.ID)
} else if err := f.Create(); err == nil {
added = append(added, *f)
log.Debugf("faces: added cluster %s based on %s, radius %f", f.ID, english.Plural(f.Samples, "sample", "samples"), f.SampleRadius)

View file

@ -27,7 +27,7 @@ func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
var faces entity.Faces
// Fetch manually added faces from the database.
if faces, err = query.ManuallyAddedFaces(false); err != nil {
if faces, err = query.ManuallyAddedFaces(false, face.RegularFace); err != nil {
return result, err
} else if n = len(faces) - 1; n < 1 {
// Need at least 2 faces to optimize.

View file

@ -29,9 +29,10 @@ func Faces(knownOnly, unmatched, hidden bool) (result entity.Faces, err error) {
}
// ManuallyAddedFaces returns all manually added face clusters.
func ManuallyAddedFaces(hidden bool) (result entity.Faces, err error) {
func ManuallyAddedFaces(hidden bool, kind face.Kind) (result entity.Faces, err error) {
err = Db().
Where("face_hidden = ?", hidden).
Where("face_kind <= ?", int(kind)).
Where("face_src = ?", entity.SrcManual).
Where("subj_uid <> ''").Order("subj_uid, samples DESC").
Find(&result).Error

View file

@ -51,7 +51,7 @@ func TestFaces(t *testing.T) {
func TestManuallyAddedFaces(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
results, err := ManuallyAddedFaces(false)
results, err := ManuallyAddedFaces(false, face.RegularFace)
if err != nil {
t.Fatal(err)
@ -64,7 +64,7 @@ func TestManuallyAddedFaces(t *testing.T) {
}
})
t.Run("Hidden", func(t *testing.T) {
results, err := ManuallyAddedFaces(true)
results, err := ManuallyAddedFaces(true, face.RegularFace)
if err != nil {
t.Fatal(err)

View file

@ -53,7 +53,7 @@ type HardClusterer interface {
// the returned channel.
Online(observations chan []float64, done chan struct{}) chan *HCEvent
// Implement common operation
// Clusterer implements common operation
Clusterer
}