People: Store detected face embeddings in markers table #22 #1406

This commit is contained in:
Michael Mayer 2021-07-16 14:34:05 +02:00
parent ccbf8d732e
commit 2d9918e72b
19 changed files with 474 additions and 23 deletions

View file

@ -194,6 +194,12 @@ func TestConfig_NSFWModelPath(t *testing.T) {
assert.Contains(t, c.NSFWModelPath(), "/assets/nsfw")
}
func TestConfig_FaceNetModelPath(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.FaceNetModelPath(), "/assets/facenet")
}
func TestConfig_ExamplesPath(t *testing.T) {
c := NewConfig(CliTestContext())

View file

@ -20,3 +20,8 @@ func (c *Config) TensorFlowModelPath() string {
func (c *Config) NSFWModelPath() string {
return filepath.Join(c.AssetsPath(), "nsfw")
}
// FaceNetModelPath returns the FaceNet model path.
func (c *Config) FaceNetModelPath() string {
return filepath.Join(c.AssetsPath(), "facenet")
}

View file

@ -29,6 +29,7 @@ type Marker struct {
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
MarkerLabel string `gorm:"type:VARCHAR(255);" json:"Label" yaml:"Label,omitempty"`
MarkerMeta string `gorm:"type:TEXT;" json:"Meta" yaml:"Meta,omitempty"`
Embedding string `gorm:"type:TEXT;" json:"Embedding" yaml:"Embedding,omitempty"`
X float32 `gorm:"type:FLOAT;" json:"X" yaml:"X,omitempty"`
Y float32 `gorm:"type:FLOAT;" json:"Y" yaml:"Y,omitempty"`
W float32 `gorm:"type:FLOAT;" json:"W" yaml:"W,omitempty"`
@ -69,6 +70,7 @@ func NewFaceMarker(f face.Face, fileID uint, refUID string) *Marker {
m.MarkerScore = f.Score
m.MarkerMeta = string(f.RelativeLandmarksJSON())
m.Embedding = string(f.EmbeddingJSON())
return m
}
@ -143,6 +145,7 @@ func UpdateOrCreateMarker(m *Marker) (*Marker, error) {
"H": m.H,
"MarkerScore": m.MarkerScore,
"MarkerMeta": m.MarkerMeta,
"Embedding": m.Embedding,
"RefUID": m.RefUID,
})

13
internal/face/distance.go Normal file
View file

@ -0,0 +1,13 @@
package face
import "math"
func EuclidianDistance(face1 []float32, face2 []float32) float64 {
var dist float64
// TODO use more efficient implementation
// either with TF or some go library, and batch processing
for k := 0; k < 512; k++ {
dist += math.Pow(float64(face1[k]-face2[k]), 2)
}
return math.Sqrt(dist)
}

View file

@ -96,6 +96,7 @@ type Face struct {
Face Point `json:"face,omitempty"`
Eyes Points `json:"eyes,omitempty"`
Landmarks Points `json:"landmarks,omitempty"`
Embedding []float32 `json:"embedding,omitempty"`
}
// Dim returns the max number of rows and cols as float32 to calculate relative coordinates.
@ -163,3 +164,14 @@ func (f *Face) RelativeLandmarksJSON() (b []byte) {
return result
}
}
// EmbeddingJSON returns detected face embedding as JSON.
func (f *Face) EmbeddingJSON() (b []byte) {
var noResult = []byte("")
if result, err := json.Marshal(f.Embedding); err != nil {
return noResult
} else {
return result
}
}

View file

@ -1,6 +1,7 @@
package face
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@ -10,6 +11,8 @@ import (
"github.com/stretchr/testify/assert"
)
var modelPath, _ = filepath.Abs("../../assets/facenet")
func TestDetect(t *testing.T) {
expected := map[string]int{
"1.jpg": 1,
@ -33,6 +36,31 @@ func TestDetect(t *testing.T) {
"19.jpg": 0,
}
faceindices := map[string][]int{
"18.jpg": {0, 1},
"1.jpg": {2},
"4.jpg": {3},
"5.jpg": {4},
"6.jpg": {5},
"2.jpg": {6},
"12.jpg": {7},
"16.jpg": {8},
"17.jpg": {9},
"3.jpg": {10},
}
faceindexToPersonid := [11]int{
0, 1, 1, 1, 2, 0, 1, 0, 0, 1, 0,
}
var embeddings [11][]float32
tfInstance := NewNet(modelPath, false)
if err := tfInstance.loadModel(); err != nil {
t.Fatal(err)
}
if err := fastwalk.Walk("testdata", func(fileName string, info os.FileMode) error {
if info.IsDir() || strings.HasPrefix(filepath.Base(fileName), ".") {
return nil
@ -42,19 +70,31 @@ func TestDetect(t *testing.T) {
baseName := filepath.Base(fileName)
faces, err := Detect(fileName)
if err != nil {
t.Fatal(err)
}
t.Logf("Found %d faces in '%s'", len(faces), baseName)
t.Logf("found %d faces in '%s'", len(faces), baseName)
if len(faces) > 0 {
t.Logf("results: %#v", faces)
for i, f := range faces {
t.Logf("marker[%d]: %#v", i, f.Marker())
t.Logf("marker[%d]: %#v %#v", i, f.Marker(), f.Face)
t.Logf("landmarks[%d]: %s", i, f.RelativeLandmarksJSON())
embedding := tfInstance.getFaceEmbedding(fileName, f.Face)
if b, err := json.Marshal(embedding[0]); err != nil {
t.Fatal(err)
} else {
t.Logf("embedding: %#v", string(b))
}
t.Logf("face: %d %v", i, faceindices[baseName])
embeddings[faceindices[baseName][i]] = embedding[0]
// t.Logf("face: created embedding of face %v", embeddings[len(embeddings)-1])
}
}
@ -76,4 +116,32 @@ func TestDetect(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
// Distance Matrix
correct := 0
for i := 0; i < len(embeddings); i++ {
for j := 0; j < len(embeddings); j++ {
if i >= j {
continue
}
dist := EuclidianDistance(embeddings[i], 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] {
if dist < 1.21 {
correct += 1
}
} else {
if dist >= 1.21 {
correct += 1
}
}
}
}
t.Logf("Correct for %d", correct)
// there are a few incorrect results
// 4 out of 55 with the 1.21 threshold
assert.True(t, correct == 51)
}

173
internal/face/net.go Normal file
View file

@ -0,0 +1,173 @@
package face
import (
"bytes"
"fmt"
"image"
"io/ioutil"
"path"
"path/filepath"
"runtime/debug"
"sync"
"github.com/disintegration/imaging"
"github.com/photoprism/photoprism/pkg/txt"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
)
// Net is a wrapper for the TensorFlow Facenet model.
type Net struct {
model *tf.SavedModel
modelPath string
disabled bool
modelName string
modelTags []string
mutex sync.Mutex
}
// NewNet returns new TensorFlow instance with Facenet model.
func NewNet(modelPath string, disabled bool) *Net {
return &Net{modelPath: modelPath, disabled: disabled, modelTags: []string{"serve"}}
}
// Detect runs the detection and facenet algorithms over the provided source image.
func (t *Net) Detect(fileName string) (faces Faces, err error) {
faces, err = Detect(fileName)
if err != nil {
return faces, err
}
if t.disabled {
return faces, nil
}
err = t.loadModel()
if err != nil {
return faces, err
}
for i, f := range faces {
if f.Face.Col == 0 && f.Face.Row == 0 {
continue
}
embedding := t.getFaceEmbedding(fileName, f.Face)
if len(embedding) > 0 {
faces[i].Embedding = embedding[0]
}
}
return faces, nil
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (t *Net) ModelLoaded() bool {
return t.model != nil
}
func (t *Net) loadModel() error {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.ModelLoaded() {
return nil
}
modelPath := path.Join(t.modelPath)
log.Infof("face: loading %s", txt.Quote(filepath.Base(modelPath)))
// Load model
model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil)
if err != nil {
return err
}
t.model = model
return nil
}
func (t *Net) getFaceEmbedding(fileName string, f Point) [][]float32 {
x, y := f.TopLeft()
imageBuffer, err := ioutil.ReadFile(fileName)
img, err := imaging.Decode(bytes.NewReader(imageBuffer), imaging.AutoOrientation(true))
if err != nil {
log.Errorf("face: failed to decode image: %v", err)
}
img = imaging.Crop(img, image.Rect(y, x, y+f.Scale, x+f.Scale))
img = imaging.Fill(img, 160, 160, imaging.Center, imaging.Lanczos)
// err = imaging.Save(img, "testdata_out/face" + strconv.Itoa(t.count) + ".jpg")
// if err != nil {
// log.Fatalf("failed to save image: %v", err)
// }
tensor, err := imageToTensor(img, 160, 160)
if err != nil {
log.Errorf("face: failed to convert image to tensor: %v", err)
}
// TODO: prewhiten image as in facenet
trainPhaseBoolTensor, err := tf.NewTensor(false)
output, err := t.model.Session.Run(
map[tf.Output]*tf.Tensor{
t.model.Graph.Operation("input").Output(0): tensor,
t.model.Graph.Operation("phase_train").Output(0): trainPhaseBoolTensor,
},
[]tf.Output{
t.model.Graph.Operation("embeddings").Output(0),
},
nil)
if err != nil {
log.Errorf("face: faled to infer embeddings of face: %v", err)
}
if len(output) < 1 {
log.Errorf("face: inference failed, no output")
} else {
return output[0].Value().([][]float32)
// embeddings = append(embeddings, output[0].Value().([][]float32)[0])
}
return nil
}
func imageToTensor(img image.Image, imageHeight, imageWidth int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("face: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if imageHeight <= 0 || imageWidth <= 0 {
return tfTensor, fmt.Errorf("face: image width and height must be > 0")
}
var tfImage [1][][][3]float32
for j := 0; j < imageHeight; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, imageWidth))
}
for i := 0; i < imageWidth; i++ {
for j := 0; j < imageHeight; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r)
tfImage[0][j][i][1] = convertValue(g)
tfImage[0][j][i][2] = convertValue(b)
}
}
return tf.NewTensor(tfImage)
}
func convertValue(value uint32) float32 {
return (float32(value>>8) - float32(127.5)) / float32(127.5)
}

127
internal/face/net_test.go Normal file
View file

@ -0,0 +1,127 @@
package face
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/photoprism/photoprism/pkg/fastwalk"
"github.com/stretchr/testify/assert"
)
func TestNet(t *testing.T) {
expected := map[string]int{
"1.jpg": 1,
"2.jpg": 1,
"3.jpg": 1,
"4.jpg": 1,
"5.jpg": 1,
"6.jpg": 1,
"7.jpg": 0,
"8.jpg": 0,
"9.jpg": 0,
"10.jpg": 0,
"11.jpg": 0,
"12.jpg": 1,
"13.jpg": 0,
"14.jpg": 0,
"15.jpg": 0,
"16.jpg": 1,
"17.jpg": 1,
"18.jpg": 2,
"19.jpg": 0,
}
faceindices := map[string][]int{
"18.jpg": {0, 1},
"1.jpg": {2},
"4.jpg": {3},
"5.jpg": {4},
"6.jpg": {5},
"2.jpg": {6},
"12.jpg": {7},
"16.jpg": {8},
"17.jpg": {9},
"3.jpg": {10},
}
faceindexToPersonid := [11]int{
0, 1, 1, 1, 2, 0, 1, 0, 0, 1, 0,
}
var embeddings [11][]float32
faceNet := NewNet(modelPath, false)
if err := fastwalk.Walk("testdata", func(fileName string, info os.FileMode) error {
if info.IsDir() || strings.HasPrefix(filepath.Base(fileName), ".") {
return nil
}
t.Run(fileName, func(t *testing.T) {
baseName := filepath.Base(fileName)
faces, err := faceNet.Detect(fileName)
if err != nil {
t.Fatal(err)
}
t.Logf("found %d faces in '%s'", len(faces), baseName)
if len(faces) > 0 {
t.Logf("results: %#v", faces)
for i, f := range faces {
embeddings[faceindices[baseName][i]] = f.Embedding
}
}
if i, ok := expected[baseName]; ok {
assert.Equal(t, i, len(faces))
assert.Equal(t, i, faces.Count())
if faces.Count() == 0 {
assert.Equal(t, 100, faces.Uncertainty())
} else {
assert.Truef(t, faces.Uncertainty() >= 0 && faces.Uncertainty() <= 50, "uncertainty should be between 0 and 50")
}
t.Logf("uncertainty: %d", faces.Uncertainty())
} else {
t.Logf("unknown test result for %s", baseName)
}
})
return nil
}); err != nil {
t.Fatal(err)
}
// Distance Matrix
correct := 0
for i := 0; i < len(embeddings); i++ {
for j := 0; j < len(embeddings); j++ {
if i >= j {
continue
}
dist := EuclidianDistance(embeddings[i], 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] {
if dist < 1.21 {
correct += 1
}
} else {
if dist >= 1.21 {
correct += 1
}
}
}
}
t.Logf("Correct for %d", correct)
// there are a few incorrect results
// 4 out of 55 with the 1.21 threshold
assert.True(t, correct == 51)
}

View file

@ -48,3 +48,8 @@ func (p Point) Marker(r Point, rows, cols float32) Marker {
float32(p.Scale)/cols,
)
}
// TopLeft returns the top left position of the face.
func (p Point) TopLeft() (int, int) {
return p.Row - (p.Scale / 2), p.Col - (p.Scale / 2)
}

View file

@ -5,6 +5,7 @@ import (
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/stretchr/testify/assert"
)
@ -14,9 +15,10 @@ func TestNewImport(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
assert.IsType(t, &Import{}, imp)
@ -29,9 +31,10 @@ func TestImport_DestinationFilename(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
@ -61,9 +64,10 @@ func TestImport_Start(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)

View file

@ -3,6 +3,7 @@ package photoprism
import (
"errors"
"fmt"
"github.com/photoprism/photoprism/internal/face"
"path/filepath"
"runtime"
"runtime/debug"
@ -25,17 +26,19 @@ type Index struct {
conf *config.Config
tensorFlow *classify.TensorFlow
nsfwDetector *nsfw.Detector
faceNet *face.Net
convert *Convert
files *Files
photos *Photos
}
// NewIndex returns a new indexer and expects its dependencies as arguments.
func NewIndex(conf *config.Config, tensorFlow *classify.TensorFlow, nsfwDetector *nsfw.Detector, convert *Convert, files *Files, photos *Photos) *Index {
func NewIndex(conf *config.Config, tensorFlow *classify.TensorFlow, nsfwDetector *nsfw.Detector, faceNet *face.Net, convert *Convert, files *Files, photos *Photos) *Index {
i := &Index{
conf: conf,
tensorFlow: tensorFlow,
nsfwDetector: nsfwDetector,
faceNet: faceNet,
convert: convert,
files: files,
photos: photos,

View file

@ -858,7 +858,7 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces {
start := time.Now()
faces, err := face.Detect(thumbName)
faces, err := ind.faceNet.Detect(thumbName)
if err != nil {
log.Debugf("%s in %s", err, txt.Quote(jpeg.BaseName()))

View file

@ -1,6 +1,7 @@
package photoprism
import (
"github.com/photoprism/photoprism/internal/face"
"testing"
"github.com/photoprism/photoprism/internal/classify"
@ -21,9 +22,10 @@ func TestIndex_MediaFile(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
mediaFile, err := NewMediaFile("testdata/flash.jpg")
@ -52,9 +54,10 @@ func TestIndex_MediaFile(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
@ -73,9 +76,10 @@ func TestIndex_MediaFile(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
indexOpt := IndexOptionsAll()
result := ind.MediaFile(nil, indexOpt, "blue-go-video.mp4")

View file

@ -1,6 +1,7 @@
package photoprism
import (
"github.com/photoprism/photoprism/internal/face"
"path/filepath"
"testing"
@ -53,9 +54,10 @@ func TestIndexRelated(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)
@ -113,9 +115,10 @@ func TestIndexRelated(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)

View file

@ -1,6 +1,7 @@
package photoprism
import (
"github.com/photoprism/photoprism/internal/face"
"testing"
"github.com/stretchr/testify/assert"
@ -21,9 +22,10 @@ func TestIndex_Start(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
opt := ImportOptionsMove(conf.ImportPath())
@ -46,9 +48,10 @@ func TestIndex_File(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
err := ind.FileName("xxx", IndexOptionsAll())

View file

@ -1,6 +1,7 @@
package photoprism
import (
"github.com/photoprism/photoprism/internal/face"
"os"
"strings"
"testing"
@ -30,9 +31,10 @@ func TestResample_Start(t *testing.T) {
tf := classify.New(conf.AssetsPath(), conf.DisableTensorFlow())
nd := nsfw.New(conf.NSFWModelPath())
fn := face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert, NewFiles(), NewPhotos())
ind := NewIndex(conf, tf, nd, fn, convert, NewFiles(), NewPhotos())
imp := NewImport(conf, ind, convert)
opt := ImportOptionsMove(conf.ImportPath())

18
internal/service/face.go Normal file
View file

@ -0,0 +1,18 @@
package service
import (
"github.com/photoprism/photoprism/internal/face"
"sync"
)
var onceFaceNet sync.Once
func initFaceNet() {
services.FaceNet = face.NewNet(conf.FaceNetModelPath(), conf.DisableTensorFlow())
}
func FaceNet() *face.Net {
onceFaceNet.Do(initFaceNet)
return services.FaceNet
}

View file

@ -9,7 +9,7 @@ import (
var onceIndex sync.Once
func initIndex() {
services.Index = photoprism.NewIndex(Config(), Classify(), NsfwDetector(), Convert(), Files(), Photos())
services.Index = photoprism.NewIndex(Config(), Classify(), NsfwDetector(), FaceNet(), Convert(), Files(), Photos())
}
func Index() *photoprism.Index {

View file

@ -3,6 +3,7 @@ package service
import (
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
@ -27,6 +28,7 @@ var services struct {
Purge *photoprism.Purge
CleanUp *photoprism.CleanUp
Nsfw *nsfw.Detector
FaceNet *face.Net
Query *query.Query
Resample *photoprism.Resample
Session *session.Session