People: Add "photoprism faces index" command for indexing faces only #22

This commit is contained in:
Michael Mayer 2021-09-22 19:33:41 +02:00
parent 25f35c598d
commit 4dd09f4502
45 changed files with 617 additions and 269 deletions

View file

@ -9,7 +9,8 @@ import (
// Labels is list of MediaFile labels.
type Labels []Label
// Implements functions for the Sort Interface. Default Labels sort is by priority and uncertainty
// Labels implement the sort interface to sort by priority and uncertainty.
func (l Labels) Len() int { return len(l) }
func (l Labels) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l Labels) Less(i, j int) bool {

View file

@ -11,16 +11,13 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// BackupCommand configures the backup cli command.

View file

@ -4,10 +4,11 @@ import (
"context"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
)
// CleanUpCommand registers the cleanup command.

View file

@ -6,8 +6,9 @@ import (
"time"
"unicode/utf8"
"github.com/photoprism/photoprism/internal/config"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
)
// ConfigCommand registers the display config cli command.

View file

@ -3,9 +3,10 @@ package commands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/stretchr/testify/assert"
)
func TestConfigCommand(t *testing.T) {

View file

@ -6,11 +6,11 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/txt"
)
// ConvertCommand registers the convert cli command.

View file

@ -7,10 +7,11 @@ import (
"strings"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
)
// CopyCommand registers the copy cli command.

View file

@ -2,15 +2,19 @@ package commands
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/manifoldco/promptui"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// FacesCommand registers the faces cli command.
@ -25,7 +29,7 @@ var FacesCommand = cli.Command{
},
{
Name: "audit",
Usage: "Conducts a data integrity audit",
Usage: "Scans the index for issues",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "fix, f",
@ -35,26 +39,38 @@ var FacesCommand = cli.Command{
Action: facesAuditAction,
},
{
Name: "reset",
Usage: "Resets recognized faces",
Name: "reset",
Usage: "Removes people and faces",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "remove all people and faces",
},
},
Action: facesResetAction,
},
{
Name: "index",
Usage: "Searches originals for faces",
ArgsUsage: "[path]",
Action: facesIndexAction,
},
{
Name: "match",
Usage: "Performs face clustering and matching",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "update all faces",
},
},
Action: facesMatchAction,
},
{
Name: "optimize",
Usage: "Optimizes face clusters",
Action: facesOptimizeAction,
},
{
Name: "update",
Usage: "Performs facial recognition",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "update existing faces",
},
},
Action: facesUpdateAction,
},
},
}
@ -122,6 +138,10 @@ func facesAuditAction(ctx *cli.Context) error {
// facesResetAction resets face clusters and matches.
func facesResetAction(ctx *cli.Context) error {
if ctx.Bool("force") {
return facesResetAllAction(ctx)
}
actionPrompt := promptui.Prompt{
Label: "Remove automatically recognized faces, matches, and dangling subjects?",
IsConfirm: true,
@ -160,6 +180,142 @@ func facesResetAction(ctx *cli.Context) error {
return nil
}
// facesResetAllAction removes all people, faces, and face markers.
func facesResetAllAction(ctx *cli.Context) error {
actionPrompt := promptui.Prompt{
Label: "Permanently delete all people and faces?",
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err != nil {
return nil
}
start := time.Now()
conf := config.NewConfig(ctx)
service.SetConfig(conf)
_, cancel := context.WithCancel(context.Background())
defer cancel()
if err := conf.Init(); err != nil {
return err
}
conf.InitDb()
if err := query.RemovePeopleAndFaces(); err != nil {
return err
} else {
elapsed := time.Since(start)
log.Infof("completed in %s", elapsed)
}
conf.Shutdown()
return nil
}
// facesIndexAction searches originals for faces.
func facesIndexAction(ctx *cli.Context) error {
start := time.Now()
conf := config.NewConfig(ctx)
service.SetConfig(conf)
_, cancel := context.WithCancel(context.Background())
defer cancel()
if err := conf.Init(); err != nil {
return err
}
conf.InitDb()
// Use first argument to limit scope if set.
subPath := strings.TrimSpace(ctx.Args().First())
if subPath == "" {
log.Infof("finding faces in %s", txt.Quote(conf.OriginalsPath()))
} else {
log.Infof("finding faces in %s", txt.Quote(filepath.Join(conf.OriginalsPath(), subPath)))
}
if conf.ReadOnly() {
log.Infof("index: read-only mode enabled")
}
var indexed fs.Done
if w := service.Index(); w != nil {
opt := photoprism.IndexOptions{
Path: subPath,
Rescan: true,
Convert: conf.Settings().Index.Convert && conf.SidecarWritable(),
Stack: true,
FacesOnly: true,
}
indexed = w.Start(opt)
} else if w := service.Purge(); w != nil {
opt := photoprism.PurgeOptions{
Path: subPath,
Ignore: indexed,
}
if files, photos, err := w.Start(opt); err != nil {
log.Error(err)
} else if len(files) > 0 || len(photos) > 0 {
log.Infof("purge: removed %d files and %d photos", len(files), len(photos))
}
}
elapsed := time.Since(start)
log.Infof("indexed %d files in %s", len(indexed), elapsed)
conf.Shutdown()
return nil
}
// facesMatchAction performs face clustering and matching.
func facesMatchAction(ctx *cli.Context) error {
start := time.Now()
conf := config.NewConfig(ctx)
service.SetConfig(conf)
_, cancel := context.WithCancel(context.Background())
defer cancel()
if err := conf.Init(); err != nil {
return err
}
conf.InitDb()
opt := photoprism.FacesOptions{
Force: ctx.Bool("force"),
}
w := service.Faces()
if err := w.Start(opt); err != nil {
return err
} else {
elapsed := time.Since(start)
log.Infof("completed in %s", elapsed)
}
conf.Shutdown()
return nil
}
// facesOptimizeAction optimizes existing face clusters.
func facesOptimizeAction(ctx *cli.Context) error {
start := time.Now()
@ -190,38 +346,3 @@ func facesOptimizeAction(ctx *cli.Context) error {
return nil
}
// facesUpdateAction performs face clustering and matching.
func facesUpdateAction(ctx *cli.Context) error {
start := time.Now()
conf := config.NewConfig(ctx)
service.SetConfig(conf)
_, cancel := context.WithCancel(context.Background())
defer cancel()
if err := conf.Init(); err != nil {
return err
}
conf.InitDb()
opt := photoprism.FacesOptions{
Force: ctx.Bool("force"),
}
w := service.Faces()
if err := w.Start(opt); err != nil {
return err
} else {
elapsed := time.Since(start)
log.Infof("completed in %s", elapsed)
}
conf.Shutdown()
return nil
}

View file

@ -7,10 +7,11 @@ import (
"strings"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
)
// ImportCommand registers the import cli command.

View file

@ -6,13 +6,13 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
)
// IndexCommand registers the index cli command.
@ -75,9 +75,7 @@ func indexAction(ctx *cli.Context) error {
}
indexed = w.Start(opt)
}
if w := service.Purge(); w != nil {
} else if w := service.Purge(); w != nil {
opt := photoprism.PurgeOptions{
Path: subPath,
Ignore: indexed,
@ -88,9 +86,7 @@ func indexAction(ctx *cli.Context) error {
} else if len(files) > 0 || len(photos) > 0 {
log.Infof("purge: removed %d files and %d photos", len(files), len(photos))
}
}
if ctx.Bool("cleanup") {
} else if ctx.Bool("cleanup") {
w := service.CleanUp()
opt := photoprism.CleanUpOptions{

View file

@ -4,8 +4,9 @@ import (
"context"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
)
// MigrateCommand registers the migrate cli command.

View file

@ -4,9 +4,10 @@ import (
"context"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
)
// MomentsCommand registers the moments cli command.

View file

@ -4,11 +4,11 @@ import (
"context"
"time"
"github.com/photoprism/photoprism/internal/workers"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/workers"
)
// OptimizeCommand registers the index cli command.

View file

@ -10,10 +10,11 @@ import (
"strings"
"syscall"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
)
// PasswdCommand updates a password.

View file

@ -6,12 +6,13 @@ import (
"strings"
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
)
// PurgeCommand registers the index cli command.

View file

@ -3,10 +3,11 @@ package commands
import (
"time"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
)
// ResampleCommand registers the resample cli command.

View file

@ -9,9 +9,10 @@ import (
"time"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/urfave/cli"
)
// ResetCommand resets the index and removes sidecar files after confirmation.

View file

@ -11,16 +11,14 @@ import (
"regexp"
"time"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// RestoreCommand configures the backup cli command.

View file

@ -9,18 +9,17 @@ import (
"syscall"
"time"
"github.com/sevlyar/go-daemon"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/auto"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/server"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/workers"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/sevlyar/go-daemon"
"github.com/urfave/cli"
)
// StartCommand registers the start cli command.

View file

@ -6,9 +6,10 @@ import (
"net/http"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/tidwall/gjson"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
)
// StatusCommand performs a server health check.

View file

@ -3,10 +3,11 @@ package commands
import (
"syscall"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/sevlyar/go-daemon"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/txt"
)
// StopCommand registers the stop cli command.

View file

@ -7,12 +7,13 @@ import (
"strings"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/urfave/cli"
)
// UsersCommand registers user management commands.

View file

@ -3,8 +3,9 @@ package commands
import (
"fmt"
"github.com/photoprism/photoprism/internal/config"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
)
// VersionCommand registers the version cli command.

View file

@ -12,8 +12,8 @@ import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/pkg/clusters"
"github.com/photoprism/photoprism/pkg/rnd"
)
var faceMutex = sync.Mutex{}
@ -350,3 +350,20 @@ func FindFace(id string) *Face {
return &f
}
// FaceCount counts the number of valid face markers for a file uid.
func FaceCount(fileUID string) (c int) {
if !rnd.IsPPID(fileUID, 'f') {
return
}
if err := Db().Model(Marker{}).
Where("file_uid = ? AND marker_type = ?", fileUID, MarkerFace).
Where("marker_invalid = 0").
Count(&c).Error; err != nil {
log.Errorf("file: %s (count faces)", err)
return 0
} else {
return c
}
}

View file

@ -201,7 +201,7 @@ func (m File) Missing() bool {
return m.FileMissing || m.DeletedAt != nil
}
// Delete permanently deletes the entity from the database.
// DeletePermanently permanently deletes the entity.
func (m *File) DeletePermanently() error {
Db().Unscoped().Delete(FileShare{}, "file_id = ?", m.ID)
Db().Unscoped().Delete(FileSync{}, "file_id = ?", m.ID)
@ -260,7 +260,7 @@ func (m *File) Create() error {
return err
}
if err := m.Markers().Save(m.FileUID); err != nil {
if _, err := m.SaveMarkers(); err != nil {
log.Errorf("file: %s (create markers for %s)", err, m.FileUID)
return err
}
@ -288,7 +288,7 @@ func (m *File) Save() error {
return err
}
if err := m.Markers().Save(m.FileUID); err != nil {
if _, err := m.SaveMarkers(); err != nil {
log.Errorf("file: %s (save markers for %s)", err, m.FileUID)
return err
}
@ -426,33 +426,62 @@ func (m *File) AddFaces(faces face.Faces) {
// AddFace adds a face marker to the file.
func (m *File) AddFace(f face.Face, subjUID string) {
marker := *NewFaceMarker(f, *m, subjUID)
// Only add faces with embedding, so that they can be clustered.
if len(f.Embeddings) != 1 {
return
}
if markers := m.Markers(); !markers.Contains(marker) {
markers.Append(marker)
// Create new marker from face.
marker := NewFaceMarker(f, *m, subjUID)
// Failed creating new marker?
if marker == nil {
return
}
// Append marker if it doesn't conflict with existing marker.
if markers := m.Markers(); !markers.Contains(*marker) {
markers.Append(*marker)
}
}
// FaceCount returns the current number of valid faces detected.
func (m *File) FaceCount() (c int) {
if err := Db().Model(Marker{}).
Where("file_uid = ? AND marker_type = ?", m.FileUID, MarkerFace).
Where("marker_invalid = 0").
Count(&c).Error; err != nil {
log.Errorf("file: %s (count faces)", err)
return 0
} else {
return c
return FaceCount(m.FileUID)
}
// UpdatePhotoFaceCount updates the faces count in the index and returns it if the file is primary.
func (m *File) UpdatePhotoFaceCount() (c int, err error) {
// Primary file of an existing photo?
if !m.FilePrimary || m.PhotoID == 0 {
return 0, nil
}
c = m.FaceCount()
err = UnscopedDb().Model(Photo{}).
Where("id = ?", m.PhotoID).
UpdateColumn("photo_faces", c).Error
return c, err
}
// SaveMarkers updates markers in the index.
func (m *File) SaveMarkers() (count int, err error) {
if m.markers == nil {
return 0, nil
}
return m.markers.Save(m)
}
// Markers finds and returns existing file markers.
func (m *File) Markers() *Markers {
if m.markers != nil {
return m.markers
}
if res, err := FindMarkers(m.FileUID); err != nil {
} else if m.FileUID == "" {
m.markers = &Markers{}
} else if res, err := FindMarkers(m.FileUID); err != nil {
log.Warnf("file: %s (load markers)", err)
m.markers = &Markers{}
} else {

File diff suppressed because one or more lines are too long

View file

@ -70,6 +70,11 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error {
// NewMarker creates a new entity.
func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string, size, score int) *Marker {
if file.FileHash == "" {
log.Errorf("marker: file hash is empty - you might have found a bug")
return nil
}
m := &Marker{
FileUID: file.FileUID,
MarkerSrc: markerSrc,
@ -96,6 +101,11 @@ func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string,
func NewFaceMarker(f face.Face, file File, subjUID string) *Marker {
m := NewMarker(file, f.CropArea(), subjUID, SrcImage, MarkerFace, f.Size(), f.Score)
// Failed creating new marker?
if m == nil {
return nil
}
m.EmbeddingsJSON = f.EmbeddingsJSON()
m.LandmarksJSON = f.RelativeLandmarksJSON()

View file

@ -3,7 +3,7 @@ package entity
import "github.com/photoprism/photoprism/internal/crop"
// UnknownMarker can be used as a default for unknown markers.
var UnknownMarker = NewMarker(File{}, crop.Area{}, "", SrcDefault, MarkerUnknown, 0, 0)
var UnknownMarker = NewMarker(File{FileUID: "-", FileHash: "-"}, crop.Area{}, "", SrcDefault, MarkerUnknown, 0, 0)
type MarkerMap map[string]Marker

View file

@ -8,18 +8,22 @@ import (
type Markers []Marker
// Save stores the markers in the database.
func (m Markers) Save(fileUID string) error {
func (m Markers) Save(file *File) (count int, err error) {
for _, marker := range m {
if fileUID != "" {
marker.FileUID = fileUID
if file != nil {
marker.FileUID = file.FileUID
}
if _, err := UpdateOrCreateMarker(&marker); err != nil {
return err
log.Errorf("markers: %s (save)", err)
}
}
return nil
if file == nil {
return len(m), nil
}
return file.UpdatePhotoFaceCount()
}
// Contains returns true if a marker at the same position already exists.

View file

@ -28,7 +28,7 @@ func TestMarkers_Contains(t *testing.T) {
assert.False(t, m.Contains(m3))
})
t.Run("Conflicting", func(t *testing.T) {
file := File{FileHash: "cca7c46a4d39e933c30805e546028fe3eab361b5"}
file := File{FileUID: "", FileHash: "cca7c46a4d39e933c30805e546028fe3eab361b5"}
markers := Markers{
*NewMarker(file, crop.Area{Name: "subj-1", X: 0.549479, Y: 0.179688, W: 0.393229, H: 0.294922}, "jqyzmgbquh1msz6o", SrcImage, MarkerFace, 100, 65),
@ -51,7 +51,7 @@ func TestMarkers_Contains(t *testing.T) {
assert.True(t, markers.Contains(conflicting))
})
t.Run("NoFace", func(t *testing.T) {
file := File{FileHash: "243cdbe99b865607f98a951e748d528bc22f3143"}
file := File{FileUID: "fqzuh672i9btq6gu", FileHash: "243cdbe99b865607f98a951e748d528bc22f3143"}
markers := Markers{
*NewMarker(file, crop.Area{Name: "no-face", X: 0.322656, Y: 0.3, W: 0.180469, H: 0.240625}, "jqyzmgbquh1msz6o", SrcImage, MarkerFace, 100, 65),

View file

@ -938,7 +938,7 @@ func (m *Photo) Delete(permanently bool) error {
return m.Updates(map[string]interface{}{"DeletedAt": TimeStamp(), "PhotoQuality": -1})
}
// Delete permanently deletes the entity from the database.
// DeletePermanently permanently deletes the entity.
func (m *Photo) DeletePermanently() error {
Db().Unscoped().Delete(File{}, "photo_id = ?", m.ID)
Db().Unscoped().Delete(Details{}, "photo_id = ?", m.ID)
@ -954,7 +954,7 @@ func (m *Photo) NoDescription() bool {
return m.PhotoDescription == ""
}
// Updates a column in the database.
// Update a column in the database.
func (m *Photo) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}

View file

@ -149,7 +149,7 @@ func (m *User) BeforeCreate(tx *gorm.DB) error {
return nil
}
// FirstOrCreateUser returns an existing row, inserts a new row or nil in case of errors.
// FirstOrCreateUser returns an existing row, inserts a new row, or nil in case of errors.
func FirstOrCreateUser(m *User) *User {
result := User{}

View file

@ -144,9 +144,9 @@ func (d *Detector) Detect(fileName string) (faces []pigo.Detection, params pigo.
if cols < 20 || rows < 20 || cols < d.minSize || rows < d.minSize {
return faces, params, fmt.Errorf("image size %dx%d is too small", cols, rows)
} else if cols < rows {
maxSize = cols - 8
maxSize = cols - 4
} else {
maxSize = rows - 8
maxSize = rows - 4
}
imageParams := &pigo.ImageParams{
@ -164,7 +164,7 @@ func (d *Detector) Detect(fileName string) (faces []pigo.Detection, params pigo.
ImageParams: *imageParams,
}
log.Debugf("faces: image size %dx%d, face size min %d, max %d", cols, rows, params.MinSize, params.MaxSize)
log.Tracef("faces: image size %dx%d, face size min %d, max %d", cols, rows, params.MinSize, params.MaxSize)
// Run the classifier over the obtained leaf nodes and return the Face results.
// The result contains quadruplets representing the row, column, scale and Face score.

View file

@ -32,15 +32,18 @@ func NewNet(modelPath, cachePath string, disabled bool) *Net {
}
// Detect runs the detection and facenet algorithms over the provided source image.
func (t *Net) Detect(fileName string, minSize int, cacheCrop bool) (faces Faces, err error) {
func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) {
faces, err = Detect(fileName, false, minSize)
if err != nil {
return faces, err
}
// Skip FaceNet?
if t.disabled {
return faces, nil
} else if c := len(faces); c == 0 || expected > 0 && c == expected {
return faces, nil
}
err = t.loadModel()

View file

@ -63,7 +63,7 @@ func TestNet(t *testing.T) {
t.Run(fileName, func(t *testing.T) {
baseName := filepath.Base(fileName)
faces, err := faceNet.Detect(fileName, 20, false)
faces, err := faceNet.Detect(fileName, 20, false, -1)
if err != nil {
t.Fatal(err)

View file

@ -58,6 +58,12 @@ func (imp *Import) Start(opt ImportOptions) fs.Done {
var directories []string
done := make(fs.Done)
if imp.conf == nil {
log.Errorf("import: config is nil")
return done
}
ind := imp.index
importPath := opt.Path

View file

@ -31,6 +31,8 @@ type Index struct {
convert *Convert
files *Files
photos *Photos
findFaces bool
findLabels bool
}
// NewIndex returns a new indexer and expects its dependencies as arguments.
@ -70,6 +72,12 @@ func (ind *Index) Start(opt IndexOptions) fs.Done {
}()
done := make(fs.Done)
if ind.conf == nil {
log.Errorf("index: config is nil")
return done
}
originalsPath := ind.originalsPath()
optionsPath := filepath.Join(originalsPath, opt.Path)
@ -83,6 +91,12 @@ func (ind *Index) Start(opt IndexOptions) fs.Done {
return done
}
// Detect faces in images?
ind.findFaces = ind.conf.Settings().Features.People
// Classify images with TensorFlow?
ind.findLabels = !ind.conf.DisableTensorFlow()
defer mutex.MainWorker.Stop()
if err := ind.tensorFlow.Init(); err != nil {

View file

@ -9,8 +9,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// detectFaces extracts faces from a JPEG image and returns them.
func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces {
// Faces finds faces in JPEG media files and returns them.
func (ind *Index) Faces(jpeg *MediaFile, expected int) face.Faces {
if jpeg == nil {
return face.Faces{}
}
@ -41,14 +41,16 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces {
start := time.Now()
faces, err := ind.faceNet.Detect(thumbName, minSize, true)
faces, err := ind.faceNet.Detect(thumbName, minSize, true, expected)
if err != nil {
log.Debugf("%s in %s", err, txt.Quote(jpeg.BaseName()))
}
if len(faces) > 0 {
log.Infof("index: extracted %d faces from %s [%s]", len(faces), txt.Quote(jpeg.BaseName()), time.Since(start))
if l := len(faces); l == 1 {
log.Infof("index: found %d face in %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start))
} else if l > 1 {
log.Infof("index: found %d faces in %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start))
}
return faces

View file

@ -10,8 +10,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// classifyImage classifies a JPEG image and returns matching labels.
func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) {
// Labels classifies a JPEG image and returns matching labels.
func (ind *Index) Labels(jpeg *MediaFile) (results classify.Labels) {
start := time.Now()
var sizes []thumb.Name
@ -57,8 +57,10 @@ func (ind *Index) classifyImage(jpeg *MediaFile) (results classify.Labels) {
}
}
if len(labels) > 0 {
log.Infof("index: found %d matching labels for %s [%s]", len(labels), txt.Quote(jpeg.BaseName()), time.Since(start))
if l := len(labels); l == 1 {
log.Infof("index: found %d matching label for %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start))
} else if l > 1 {
log.Infof("index: found %d matching labels for %s [%s]", l, txt.Quote(jpeg.BaseName()), time.Since(start))
}
return results

View file

@ -13,63 +13,13 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
const (
IndexUpdated IndexStatus = "updated"
IndexAdded IndexStatus = "added"
IndexStacked IndexStatus = "stacked"
IndexSkipped IndexStatus = "skipped"
IndexDuplicate IndexStatus = "skipped duplicate"
IndexArchived IndexStatus = "skipped archived"
IndexFailed IndexStatus = "failed"
)
type IndexStatus string
type IndexResult struct {
Status IndexStatus
Err error
FileID uint
FileUID string
PhotoID uint
PhotoUID string
}
func (r IndexResult) String() string {
return string(r.Status)
}
func (r IndexResult) Failed() bool {
return r.Err != nil
}
func (r IndexResult) Success() bool {
return r.Err == nil && (r.FileID > 0 || r.Stacked() || r.Skipped() || r.Archived())
}
func (r IndexResult) Indexed() bool {
return r.Status == IndexAdded || r.Status == IndexUpdated || r.Status == IndexStacked
}
func (r IndexResult) Stacked() bool {
return r.Status == IndexStacked
}
func (r IndexResult) Skipped() bool {
return r.Status == IndexSkipped
}
func (r IndexResult) Archived() bool {
return r.Status == IndexArchived
}
// MediaFile indexes a single media file.
func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (result IndexResult) {
if m == nil {
err := errors.New("index: media file is nil - you might have found a bug")
@ -79,7 +29,13 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
return result
}
// Skip file?
if ind.files.Ignore(m.RootRelName(), m.Root(), m.ModTime(), o.Rescan) {
// Skip known file.
result.Status = IndexSkipped
return result
} else if o.FacesOnly && !m.IsJpeg() {
// Skip non-jpeg file when indexing faces only.
result.Status = IndexSkipped
return result
}
@ -274,6 +230,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
}
// Set basic file information.
file.FileRoot = fileRoot
file.FileName = fileName
file.FileHash = fileHash
file.FileSize = fileSize
// Set file original name if available.
if originalName != "" {
file.OriginalName = originalName
@ -289,6 +251,33 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
photo.DeletedAt = nil
}
// Extra labels to ba added when new files have a photo id.
extraLabels := classify.Labels{}
// Detect faces in images?
if o.FacesOnly && (!photoExists || !fileExists || !file.FilePrimary) {
// New and non-primary files can be skipped when updating faces only.
result.Status = IndexSkipped
return result
} else if ind.findFaces && file.FilePrimary {
faces := ind.Faces(m, photo.PhotoFaces)
if len(faces) > 0 {
file.AddFaces(faces)
}
if c := file.Markers().FaceCount(); photo.PhotoFaces != c {
if c > photo.PhotoFaces {
extraLabels = append(extraLabels, classify.FaceLabels(faces, entity.SrcImage)...)
}
photo.PhotoFaces = c
} else if o.FacesOnly {
result.Status = IndexSkipped
return result
}
}
// Handle file types.
switch {
case m.IsJpeg():
@ -487,9 +476,14 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if file.FilePrimary {
primaryFile = file
if !Config().DisableTensorFlow() {
// Image classification via TensorFlow.
labels = ind.classifyImage(m)
// Classify images with TensorFlow?
if ind.findLabels {
labels = ind.Labels(m)
// Append labels from other sources such as face detection.
if len(extraLabels) > 0 {
labels = append(labels, extraLabels...)
}
if !photoExists && Config().Settings().Features.Private && Config().DetectNSFW() {
photo.PhotoPrivate = ind.NSFW(m)
@ -547,10 +541,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
file.FileSidecar = m.IsSidecar()
file.FileVideo = m.IsVideo()
file.FileRoot = fileRoot
file.FileName = fileName
file.FileHash = fileHash
file.FileSize = fileSize
file.FileType = string(m.FileType())
file.FileMime = m.MimeType()
file.FileOrientation = m.Orientation()
@ -600,18 +590,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
// Main JPEG file.
if file.FilePrimary {
if Config().Settings().Features.People {
faces := ind.detectFaces(m)
photo.AddLabels(classify.FaceLabels(faces, entity.SrcImage))
if len(faces) > 0 {
file.AddFaces(faces)
}
photo.PhotoFaces = file.Markers().FaceCount()
}
labels := photo.ClassifyLabels()
if err := photo.UpdateTitle(labels); err != nil {
@ -750,25 +728,3 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
return result
}
// NSFW returns true if media file might be offensive and detection is enabled.
func (ind *Index) NSFW(jpeg *MediaFile) bool {
filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb.Fit720)
if err != nil {
log.Error(err)
return false
}
if nsfwLabels, err := ind.nsfwDetector.File(filename); err != nil {
log.Error(err)
return false
} else {
if nsfwLabels.NSFW(nsfw.ThresholdHigh) {
log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelName(Config().OriginalsPath())))
return true
}
}
return false
}

View file

@ -0,0 +1,29 @@
package photoprism
import (
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/txt"
)
// NSFW returns true if media file might be offensive and detection is enabled.
func (ind *Index) NSFW(jpeg *MediaFile) bool {
filename, err := jpeg.Thumbnail(Config().ThumbPath(), thumb.Fit720)
if err != nil {
log.Error(err)
return false
}
if nsfwLabels, err := ind.nsfwDetector.File(filename); err != nil {
log.Error(err)
return false
} else {
if nsfwLabels.NSFW(nsfw.ThresholdHigh) {
log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelName(Config().OriginalsPath())))
return true
}
}
return false
}

View file

@ -1,10 +1,11 @@
package photoprism
type IndexOptions struct {
Path string
Rescan bool
Convert bool
Stack bool
Path string
Rescan bool
Convert bool
Stack bool
FacesOnly bool
}
func (o *IndexOptions) SkipUnchanged() bool {
@ -14,10 +15,24 @@ func (o *IndexOptions) SkipUnchanged() bool {
// IndexOptionsAll returns new index options with all options set to true.
func IndexOptionsAll() IndexOptions {
result := IndexOptions{
Path: "/",
Rescan: true,
Convert: true,
Stack: true,
Path: "/",
Rescan: true,
Convert: true,
Stack: true,
FacesOnly: false,
}
return result
}
// IndexOptionsFacesOnly returns new index options for updating faces only.
func IndexOptionsFacesOnly() IndexOptions {
result := IndexOptions{
Path: "/",
Rescan: true,
Convert: true,
Stack: true,
FacesOnly: true,
}
return result
@ -26,10 +41,11 @@ func IndexOptionsAll() IndexOptions {
// IndexOptionsSingle returns new index options for unstacked, single files.
func IndexOptionsSingle() IndexOptions {
result := IndexOptions{
Path: "/",
Rescan: true,
Convert: true,
Stack: false,
Path: "/",
Rescan: true,
Convert: true,
Stack: false,
FacesOnly: false,
}
return result

View file

@ -7,21 +7,39 @@ import (
)
func TestIndexOptionsNone(t *testing.T) {
result := IndexOptionsNone()
assert.Equal(t, false, result.Rescan)
assert.Equal(t, false, result.Convert)
opt := IndexOptionsNone()
assert.Equal(t, "", opt.Path)
assert.Equal(t, false, opt.Rescan)
assert.Equal(t, false, opt.Convert)
assert.Equal(t, false, opt.Stack)
assert.Equal(t, false, opt.FacesOnly)
}
func TestIndexOptions_SkipUnchanged(t *testing.T) {
result := IndexOptionsNone()
assert.True(t, result.SkipUnchanged())
result.Rescan = true
assert.False(t, result.SkipUnchanged())
opt := IndexOptionsNone()
assert.True(t, opt.SkipUnchanged())
opt.Rescan = true
assert.False(t, opt.SkipUnchanged())
}
func TestIndexOptionsSingle(t *testing.T) {
r := IndexOptionsSingle()
assert.Equal(t, false, r.Stack)
assert.Equal(t, true, r.Convert)
assert.Equal(t, true, r.Rescan)
opt := IndexOptionsSingle()
assert.Equal(t, false, opt.Stack)
assert.Equal(t, true, opt.Convert)
assert.Equal(t, true, opt.Rescan)
}
func TestIndexOptionsFacesOnly(t *testing.T) {
opt := IndexOptionsFacesOnly()
assert.Equal(t, "/", opt.Path)
assert.Equal(t, true, opt.Rescan)
assert.Equal(t, true, opt.Convert)
assert.Equal(t, true, opt.Stack)
assert.Equal(t, true, opt.FacesOnly)
}

View file

@ -0,0 +1,50 @@
package photoprism
const (
IndexUpdated IndexStatus = "updated"
IndexAdded IndexStatus = "added"
IndexStacked IndexStatus = "stacked"
IndexSkipped IndexStatus = "skipped"
IndexDuplicate IndexStatus = "skipped duplicate"
IndexArchived IndexStatus = "skipped archived"
IndexFailed IndexStatus = "failed"
)
type IndexStatus string
type IndexResult struct {
Status IndexStatus
Err error
FileID uint
FileUID string
PhotoID uint
PhotoUID string
}
func (r IndexResult) String() string {
return string(r.Status)
}
func (r IndexResult) Failed() bool {
return r.Err != nil
}
func (r IndexResult) Success() bool {
return r.Err == nil && (r.FileID > 0 || r.Stacked() || r.Skipped() || r.Archived())
}
func (r IndexResult) Indexed() bool {
return r.Status == IndexAdded || r.Status == IndexUpdated || r.Status == IndexStacked
}
func (r IndexResult) Stacked() bool {
return r.Status == IndexStacked
}
func (r IndexResult) Skipped() bool {
return r.Status == IndexSkipped
}
func (r IndexResult) Archived() bool {
return r.Status == IndexArchived
}

View file

@ -208,3 +208,49 @@ func ResolveFaceCollisions() (conflicts, resolved int, err error) {
return conflicts, resolved, nil
}
// RemovePeopleAndFaces permanently deletes all people, faces, and face markers.
func RemovePeopleAndFaces() (err error) {
// Delete people.
if err = UnscopedDb().Delete(entity.Subject{}, "subj_type = ?", entity.SubjPerson).Error; err != nil {
return err
}
// Delete all faces.
if err = UnscopedDb().Delete(entity.Face{}).Error; err != nil {
return err
}
// Delete face markers.
if err = UnscopedDb().Delete(entity.Marker{}, "marker_type = ?", entity.MarkerFace).Error; err != nil {
return err
}
// Reset face counters.
if err = UnscopedDb().Model(entity.Photo{}).
UpdateColumn("photo_faces", 0).Error; err != nil {
return err
}
// Reset people label.
if label, err := LabelBySlug("people"); err != nil {
return err
} else if err = UnscopedDb().
Delete(entity.PhotoLabel{}, "label_id = ?", label.ID).Error; err != nil {
return err
} else if err = label.Update("PhotoCount", 0); err != nil {
return err
}
// Reset portrait label.
if label, err := LabelBySlug("portrait"); err != nil {
return err
} else if err = UnscopedDb().
Delete(entity.PhotoLabel{}, "label_id = ?", label.ID).Error; err != nil {
return err
} else if err = label.Update("PhotoCount", 0); err != nil {
return err
}
return nil
}