People: Add "photoprism faces index" command for indexing faces only #22
This commit is contained in:
parent
25f35c598d
commit
4dd09f4502
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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{}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
29
internal/photoprism/index_nsfw.go
Normal file
29
internal/photoprism/index_nsfw.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
50
internal/photoprism/index_result.go
Normal file
50
internal/photoprism/index_result.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue