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:
parent
335bf81491
commit
2e85b3cccd
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
118
internal/photoprism/faces_analyze.go
Normal file
118
internal/photoprism/faces_analyze.go
Normal 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
|
||||
}
|
70
internal/photoprism/faces_cluster.go
Normal file
70
internal/photoprism/faces_cluster.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
33
internal/photoprism/faces_reset.go
Normal file
33
internal/photoprism/faces_reset.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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...)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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(""))
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue