People: Add unofficial env variables to tweak face matching

Adds two unofficial env variables so advanced users can experiment:

1. PHOTOPRISM_FACE_KIDS_DIST=0.6950 (range: 0.1-1.5, -1 to disable)
2. PHOTOPRISM_FACE_IGNORE_DIST=0.86 (range: 0.1-1.5, -1 to disable)
This commit is contained in:
Michael Mayer 2022-04-03 17:25:37 +02:00
parent bb09c43c49
commit 41b252d820
26 changed files with 395 additions and 310 deletions

View file

@ -54,9 +54,9 @@ func NewFace(subjUID, faceSrc string, embeddings face.Embeddings) *Face {
return result
}
// Unsuitable tests if the face is unsuitable for clustering and matching.
func (m *Face) Unsuitable() bool {
return m.Embedding().Unsuitable()
// OmitMatch checks whether the face should be skipped when matching.
func (m *Face) OmitMatch() bool {
return m.Embedding().OmitMatch()
}
// SetEmbeddings assigns face embeddings.
@ -125,7 +125,7 @@ func (m *Face) Match(embeddings face.Embeddings) (match bool, dist float64) {
// Calculate the smallest distance to embeddings.
for _, e := range embeddings {
if d := e.Distance(faceEmbedding); d < dist || dist < 0 {
if d := e.Dist(faceEmbedding); d < dist || dist < 0 {
dist = d
}
}

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/dustin/go-humanize/english"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/crop"
@ -273,7 +272,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) {
continue
}
if d := e.Distance(faceEmbedding); d < m.FaceDist || m.FaceDist < 0 {
if d := e.Dist(faceEmbedding); d < m.FaceDist || m.FaceDist < 0 {
m.FaceDist = d
}
}
@ -507,7 +506,7 @@ func (m *Marker) Face() (f *Face) {
} else if f = NewFace(m.SubjUID, m.SubjSrc, emb); f == nil {
log.Warnf("marker %s: failed assigning face", sanitize.Log(m.MarkerUID))
return nil
} else if f.Unsuitable() {
} 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 = FirstOrCreateFace(f); f == nil {
log.Warnf("marker %s: failed assigning face", sanitize.Log(m.MarkerUID))

View file

@ -2,6 +2,7 @@ package face
import (
"encoding/json"
"fmt"
"strings"
"github.com/photoprism/photoprism/pkg/clusters"
@ -26,34 +27,42 @@ func NewEmbedding(inference []float32) Embedding {
return result
}
// Blacklisted tests if the face embedding is blacklisted.
func (m Embedding) Blacklisted() bool {
return Blacklist.Contains(m, BlacklistRadius)
// IgnoreFace tests whether the embedding is generally unsuitable for matching.
func (m Embedding) IgnoreFace() bool {
if IgnoreDist <= 0 {
return false
}
return IgnoreEmbeddings.Contains(m, IgnoreDist)
}
// Child tests if the face embedding belongs to a child.
func (m Embedding) Child() bool {
return Children.Contains(m, ChildrenRadius)
// 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)
}
// Unsuitable tests if the face embedding is unsuitable for clustering and matching.
func (m Embedding) Unsuitable() bool {
return m.Child() || m.Blacklisted()
// OmitMatch tests if the face embedding is unsuitable for matching.
func (m Embedding) OmitMatch() bool {
return m.KidsFace() || m.IgnoreFace()
}
// Distance calculates the distance to another face embedding.
func (m Embedding) Distance(other Embedding) float64 {
return clusters.EuclideanDistance(m, other)
// CanMatch tests if the face embedding is not blacklisted.
func (m Embedding) CanMatch() bool {
return !m.IgnoreFace()
}
// Dist calculates the distance to another face embedding.
func (m Embedding) Dist(other Embedding) float64 {
return clusters.EuclideanDist(m, other)
}
// Magnitude returns the face embedding vector length (magnitude).
func (m Embedding) Magnitude() float64 {
return m.Distance(NullEmbedding)
}
// NotBlacklisted tests if the face embedding is not blacklisted.
func (m Embedding) NotBlacklisted() bool {
return !m.Blacklisted()
return m.Dist(NullEmbedding)
}
// JSON returns the face embedding as JSON bytes.
@ -72,14 +81,14 @@ func (m Embedding) JSON() []byte {
}
// UnmarshalEmbedding parses a single face embedding JSON.
func UnmarshalEmbedding(s string) (result Embedding) {
if !strings.HasPrefix(s, "[") {
return nil
func UnmarshalEmbedding(s string) (result Embedding, err error) {
if s == "" {
return result, fmt.Errorf("cannot unmarshal embedding, empty string provided")
} else if !strings.HasPrefix(s, "[") {
return result, fmt.Errorf("cannot unmarshal embedding, invalid json provided")
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
log.Errorf("faces: %s", err)
}
err = json.Unmarshal([]byte(s), &result)
return result
return result, err
}

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@ package face
import (
"encoding/json"
"fmt"
"strings"
"github.com/montanaflynn/stats"
@ -21,7 +22,7 @@ func NewEmbeddings(inference [][]float32) Embeddings {
for i, v = range inference {
e := NewEmbedding(v)
if e.NotBlacklisted() {
if e.CanMatch() {
result[i] = e
}
}
@ -75,7 +76,7 @@ func (embeddings Embeddings) Float64() [][]float64 {
// Contains tests if another embeddings is contained within a radius.
func (embeddings Embeddings) Contains(other Embedding, radius float64) bool {
for _, e := range embeddings {
if d := e.Distance(other); d < radius {
if d := e.Dist(other); d < radius {
return true
}
}
@ -83,12 +84,12 @@ func (embeddings Embeddings) Contains(other Embedding, radius float64) bool {
return false
}
// Distance returns the minimum distance to an embedding.
func (embeddings Embeddings) Distance(other Embedding) (dist float64) {
// Dist returns the minimum distance to an embedding.
func (embeddings Embeddings) Dist(other Embedding) (dist float64) {
dist = -1
for _, e := range embeddings {
if d := e.Distance(other); d < dist || dist < 0 {
if d := e.Dist(other); d < dist || dist < 0 {
dist = d
}
}
@ -153,7 +154,7 @@ func EmbeddingsMidpoint(embeddings Embeddings) (result Embedding, radius float64
// Radius is the max embedding distance + 0.01 from result.
for _, emb := range embeddings {
if d := clusters.EuclideanDistance(result, emb); d > radius {
if d := clusters.EuclideanDist(result, emb); d > radius {
radius = d + 0.01
}
}
@ -162,14 +163,14 @@ func EmbeddingsMidpoint(embeddings Embeddings) (result Embedding, radius float64
}
// UnmarshalEmbeddings parses face embedding JSON.
func UnmarshalEmbeddings(s string) (result Embeddings) {
if !strings.HasPrefix(s, "[[") {
return nil
func UnmarshalEmbeddings(s string) (result Embeddings, err error) {
if s == "" {
return result, fmt.Errorf("cannot unmarshal empeddings, empty string provided")
} else if !strings.HasPrefix(s, "[[") {
return result, fmt.Errorf("cannot unmarshal empeddings, invalid json provided")
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
log.Errorf("faces: %s", err)
}
err = json.Unmarshal([]byte(s), &result)
return result
return result, err
}

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

File diff suppressed because one or more lines are too long

View file

@ -103,7 +103,7 @@ func TestNet(t *testing.T) {
t.Fatal(err)
}
// Distance Matrix
// Dist Matrix
correct := 0
for i := 0; i < len(embeddings); i++ {
@ -112,7 +112,7 @@ func TestNet(t *testing.T) {
continue
}
dist := embeddings[i].Distance(embeddings[j])
dist := embeddings[i].Dist(embeddings[j])
t.Logf("Dist for %d %d (faces are %d %d) is %f", i, j, faceIndexToPersonID[i], faceIndexToPersonID[j], dist)
if faceIndexToPersonID[i] == faceIndexToPersonID[j] {

View file

@ -12,7 +12,7 @@ var ClusterScoreThreshold = 15 // Min score for faces forming
var SizeThreshold = 50 // Min face size in pixels.
var ClusterSizeThreshold = 80 // Min size for faces forming a cluster in pixels.
var ClusterDist = 0.64 // Similarity distance threshold of faces forming a cluster core.
var MatchDist = 0.46 // Distance offset threshold for matching new faces with clusters.
var MatchDist = 0.46 // Dist offset threshold for matching new faces with clusters.
var ClusterCore = 4 // Min number of faces forming a cluster core.
var SampleThreshold = 2 * ClusterCore // Threshold for automatic clustering to start.

View file

@ -40,7 +40,7 @@ func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
var c clusters.HardClusterer
// See https://dl.photoprism.app/research/ for research on face clustering algorithms.
if c, err = clusters.DBSCAN(face.ClusterCore, face.ClusterDist, w.conf.Workers(), clusters.EuclideanDistance); err != nil {
if c, err = clusters.DBSCAN(face.ClusterCore, face.ClusterDist, w.conf.Workers(), clusters.EuclideanDist); err != nil {
return added, err
} else if err = c.Learn(embeddings.Float64()); err != nil {
return added, err
@ -73,7 +73,7 @@ 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.Unsuitable() {
} else if f.OmitMatch() {
log.Infof("faces: ignoring %s, cluster unsuitable for matching", f.ID)
} else if err := f.Create(); err == nil {
added = append(added, *f)

View file

@ -122,7 +122,7 @@ func (w *Faces) MatchFaces(faces entity.Faces, force bool, matchedBefore *time.T
// Pointer to the matching face.
var f *entity.Face
// Distance to the matching face.
// Dist to the matching face.
var d float64
// Find the closest face match for marker.

View file

@ -26,7 +26,7 @@ func (w *Faces) Stats() (err error) {
continue
}
d := embeddings[i].Distance(embeddings[j])
d := embeddings[i].Dist(embeddings[j])
if min < 0 || d < min {
min = d
@ -84,7 +84,7 @@ func (w *Faces) Stats() (err error) {
continue
}
d := e1.Distance(f2.Embedding())
d := e1.Dist(f2.Embedding())
if min < 0 || d < min {
min = d

View file

@ -102,7 +102,11 @@ func Embeddings(single, unclustered bool, size, score int) (result face.Embeddin
}
for _, embeddingsJson := range col {
if embeddings := face.UnmarshalEmbeddings(embeddingsJson); !embeddings.Empty() {
if embeddingsJson == "" {
continue
} else if embeddings, err := face.UnmarshalEmbeddings(embeddingsJson); err != nil {
log.Warnf("faces: %s", err)
} else if !embeddings.Empty() {
if single {
// Single embedding per face detected.
result = append(result, embeddings[0])

View file

@ -34,8 +34,8 @@ var observation []float64
// Create a new KMeans++ clusterer with 1000 iterations,
// 8 clusters and a distance measurement function of type func([]float64, []float64) float64).
// Pass nil to use clusters.EuclideanDistance
c, e := clusters.KMeans(1000, 8, clusters.EuclideanDistance)
// Pass nil to use clusters.EuclideanDist
c, e := clusters.KMeans(1000, 8, clusters.EuclideanDist)
if e != nil {
panic(e)
}
@ -59,7 +59,7 @@ Algorithms currenly supported are KMeans++, DBSCAN and OPTICS.
Algorithms which support online learning can be trained this way using Online() function, which relies on channel communication to coordinate the process:
```go
c, e := clusters.KmeansClusterer(1000, 8, clusters.EuclideanDistance)
c, e := clusters.KmeansClusterer(1000, 8, clusters.EuclideanDist)
if e != nil {
panic(e)
}
@ -104,8 +104,8 @@ The Estimator interface defines an operation of guessing an optimal number of cl
var data [][]float64
// Create a new KMeans++ estimator with 1000 iterations,
// a maximum of 8 clusters and default (EuclideanDistance) distance measurement
c, e := clusters.KMeansEstimator(1000, 8, clusters.EuclideanDistance)
// a maximum of 8 clusters and default (EuclideanDist) distance measurement
c, e := clusters.KMeansEstimator(1000, 8, clusters.EuclideanDist)
if e != nil {
panic(e)
}

View file

@ -6,9 +6,9 @@ import (
"math"
)
// DistanceFunc represents a function for measuring distance
// DistFunc represents a function for measuring distance
// between n-dimensional vectors.
type DistanceFunc func([]float64, []float64) float64
type DistFunc func([]float64, []float64) float64
// Online represents parameters important for online learning in
// clustering algorithms.
@ -73,8 +73,8 @@ type Importer interface {
}
var (
// EuclideanDistance is one of the common distance measurement
EuclideanDistance = func(a, b []float64) float64 {
// EuclideanDist is one of the common distance measurement
EuclideanDist = func(a, b []float64) float64 {
var (
s, t float64
)
@ -87,8 +87,8 @@ var (
return math.Sqrt(s)
}
// EuclideanDistanceSquared is one of the common distance measurement
EuclideanDistanceSquared = func(a, b []float64) float64 {
// EuclideanDistSquared is one of the common distance measurement
EuclideanDistSquared = func(a, b []float64) float64 {
var (
s, t float64
)

View file

@ -8,7 +8,7 @@ type dbscanClusterer struct {
minpts, workers int
eps float64
distance DistanceFunc
distance DistFunc
// slices holding the cluster mapping and sizes. Access is synchronized to avoid read during computation.
mu sync.RWMutex
@ -40,7 +40,7 @@ type dbscanClusterer struct {
// Implementation of DBSCAN algorithm with concurrent nearest neighbour computation. The number of goroutines acting concurrently
// is controlled via workers argument. Passing 0 will result in this number being chosen arbitrarily.
func DBSCAN(minpts int, eps float64, workers int, distance DistanceFunc) (HardClusterer, error) {
func DBSCAN(minpts int, eps float64, workers int, distance DistFunc) (HardClusterer, error) {
if minpts < 1 {
return nil, errZeroMinpts
}
@ -53,12 +53,12 @@ func DBSCAN(minpts int, eps float64, workers int, distance DistanceFunc) (HardCl
return nil, errZeroEpsilon
}
var d DistanceFunc
var d DistFunc
{
if distance != nil {
d = distance
} else {
d = EuclideanDistance
d = EuclideanDist
}
}

View file

@ -56,7 +56,7 @@ func TestDBSCANCluster(t *testing.T) {
},
}
for _, test := range tests {
c, e := DBSCAN(test.MinPts, test.Eps, 0, EuclideanDistance)
c, e := DBSCAN(test.MinPts, test.Eps, 0, EuclideanDist)
if e != nil {
t.Errorf("Error initializing kmeans clusterer: %s\n", e.Error())
}

View file

@ -23,7 +23,7 @@ type kmeansClusterer struct {
alpha float64
dimension int
distance DistanceFunc
distance DistFunc
// slices holding the cluster mapping and sizes. Access is synchronized to avoid read during computation.
mu sync.RWMutex
@ -37,7 +37,7 @@ type kmeansClusterer struct {
}
// Implementation of k-means++ algorithm with online learning
func KMeans(iterations, clusters int, distance DistanceFunc) (HardClusterer, error) {
func KMeans(iterations, clusters int, distance DistFunc) (HardClusterer, error) {
if iterations < 1 {
return nil, errZeroIterations
}
@ -46,12 +46,12 @@ func KMeans(iterations, clusters int, distance DistanceFunc) (HardClusterer, err
return nil, errOneCluster
}
var d DistanceFunc
var d DistFunc
{
if distance != nil {
d = distance
} else {
d = EuclideanDistance
d = EuclideanDist
}
}

View file

@ -14,7 +14,7 @@ type kmeansEstimator struct {
// variables keeping count of changes of points' membership every iteration. User as a stopping condition.
changes, oldchanges, counter, threshold int
distance DistanceFunc
distance DistFunc
a, b []int
@ -28,7 +28,7 @@ type kmeansEstimator struct {
// Implementation of cluster number estimator using gap statistic
// ("Estimating the number of clusters in a data set via the gap statistic", Tibshirani et al.) with k-means++ as
// clustering algorithm
func KMeansEstimator(iterations, clusters int, distance DistanceFunc) (Estimator, error) {
func KMeansEstimator(iterations, clusters int, distance DistFunc) (Estimator, error) {
if iterations < 1 {
return nil, errZeroIterations
}
@ -37,12 +37,12 @@ func KMeansEstimator(iterations, clusters int, distance DistanceFunc) (Estimator
return nil, errOneCluster
}
var d DistanceFunc
var d DistFunc
{
if distance != nil {
d = distance
} else {
d = EuclideanDistance
d = EuclideanDist
}
}
@ -226,7 +226,7 @@ func (c *kmeansEstimator) wk(data [][]float64, centroids [][]float64, mapping []
)
for i := 0; i < len(mapping); i++ {
wk[mapping[i]-1] += EuclideanDistanceSquared(centroids[mapping[i]-1], data[i]) / l
wk[mapping[i]-1] += EuclideanDistSquared(centroids[mapping[i]-1], data[i]) / l
}
return floats.Sum(wk)

View file

@ -20,7 +20,7 @@ func TestKmeansEstimator(t *testing.T) {
t.Errorf("Error importing data: %s\n", e.Error())
}
c, e := KMeansEstimator(1000, C, EuclideanDistance)
c, e := KMeansEstimator(1000, C, EuclideanDist)
if e != nil {
t.Errorf("Error initializing kmeans clusterer: %s\n", e.Error())
}

View file

@ -19,7 +19,7 @@ func TestKmeansClusterNumberMatches(t *testing.T) {
t.Errorf("Error importing data: %s\n", e.Error())
}
c, e := KMeans(1000, C, EuclideanDistance)
c, e := KMeans(1000, C, EuclideanDist)
if e != nil {
t.Errorf("Error initializing kmeans clusterer: %s\n", e.Error())
}

View file

@ -18,7 +18,7 @@ type opticsClusterer struct {
minpts, workers int
eps, xi, x float64
distance DistanceFunc
distance DistFunc
// slices holding the cluster mapping and sizes. Access is synchronized to avoid read during computation.
mu sync.RWMutex
@ -48,7 +48,7 @@ type opticsClusterer struct {
// Implementation of OPTICS algorithm with concurrent nearest neighbour computation. The number of goroutines acting concurrently
// is controlled via workers argument. Passing 0 will result in this number being chosen arbitrarily.
func OPTICS(minpts int, eps, xi float64, workers int, distance DistanceFunc) (HardClusterer, error) {
func OPTICS(minpts int, eps, xi float64, workers int, distance DistFunc) (HardClusterer, error) {
if minpts < 1 {
return nil, errZeroMinpts
}
@ -65,12 +65,12 @@ func OPTICS(minpts int, eps, xi float64, workers int, distance DistanceFunc) (Ha
return nil, errZeroXi
}
var d DistanceFunc
var d DistFunc
{
if distance != nil {
d = distance
} else {
d = EuclideanDistance
d = EuclideanDist
}
}
@ -191,7 +191,7 @@ func (c *opticsClusterer) run() {
c.so = append(c.so, i)
if d = c.coreDistance(i, l, ns); d != 0 {
if d = c.coreDist(i, l, ns); d != 0 {
q = newPriorityQueue(l)
c.update(i, d, l, ns, &q)
@ -205,7 +205,7 @@ func (c *opticsClusterer) run() {
c.so = append(c.so, p.v)
if d = c.coreDistance(p.v, l, nss); d != 0 {
if d = c.coreDist(p.v, l, nss); d != 0 {
c.update(p.v, d, l, nss, &q)
}
}
@ -213,7 +213,7 @@ func (c *opticsClusterer) run() {
}
}
func (c *opticsClusterer) coreDistance(p int, l int, r []int) float64 {
func (c *opticsClusterer) coreDist(p int, l int, r []int) float64 {
if l < c.minpts {
return 0
}