package commands import ( "context" "path/filepath" "strings" "time" "github.com/dustin/go-humanize/english" "github.com/manifoldco/promptui" "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" ) // FacesCommand configures the command name, flags, and action. var FacesCommand = cli.Command{ Name: "faces", Usage: "Face recognition subcommands", Subcommands: []cli.Command{ { Name: "stats", Usage: "Shows stats on face samples", Action: facesStatsAction, }, { Name: "audit", Usage: "Scans the index for issues", Flags: []cli.Flag{ cli.BoolFlag{ Name: "fix, f", Usage: "fix discovered issues", }, }, Action: facesAuditAction, }, { Name: "reset", Usage: "Removes people and faces after confirmation", Flags: []cli.Flag{ cli.BoolFlag{ Name: "force, f", Usage: "remove all people and faces", }, }, Action: facesResetAction, }, { Name: "index", Usage: "Searches originals for faces", ArgsUsage: "[subfolder]", Action: facesIndexAction, }, { Name: "update", Usage: "Performs face clustering and matching", Flags: []cli.Flag{ cli.BoolFlag{ Name: "force, f", Usage: "update all faces", }, }, Action: facesUpdateAction, }, { Name: "optimize", Usage: "Optimizes face clusters", Action: facesOptimizeAction, }, }, } // facesStatsAction shows stats on face embeddings. func facesStatsAction(ctx *cli.Context) error { start := time.Now() conf, err := InitConfig(ctx) _, cancel := context.WithCancel(context.Background()) defer cancel() if err != nil { return err } conf.InitDb() defer conf.Shutdown() w := get.Faces() if err := w.Stats(); err != nil { return err } else { elapsed := time.Since(start) log.Infof("completed in %s", elapsed) } return nil } // facesAuditAction shows stats on face embeddings. func facesAuditAction(ctx *cli.Context) error { start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() w := get.Faces() if err := w.Audit(ctx.Bool("fix")); err != nil { return err } else { elapsed := time.Since(start) log.Infof("completed in %s", elapsed) } return nil } // 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, } if _, err := actionPrompt.Run(); err != nil { return nil } start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() w := get.Faces() if err := w.Reset(); err != nil { return err } else { elapsed := time.Since(start) log.Infof("completed in %s", elapsed) } return nil } // facesResetAllAction removes all people, faces, and face markers. func facesResetAllAction(ctx *cli.Context) error { actionPrompt := promptui.Prompt{ Label: "Permanently remove all people and faces?", IsConfirm: true, } if _, err := actionPrompt.Run(); err != nil { return nil } start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() if err := query.RemovePeopleAndFaces(); err != nil { return err } else { elapsed := time.Since(start) log.Infof("completed in %s", elapsed) } return nil } // facesIndexAction searches originals for faces. func facesIndexAction(ctx *cli.Context) error { start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() // Use first argument to limit scope if set. subPath := strings.TrimSpace(ctx.Args().First()) if subPath == "" { log.Infof("finding faces in %s", clean.Log(conf.OriginalsPath())) } else { log.Infof("finding faces in %s", clean.Log(filepath.Join(conf.OriginalsPath(), subPath))) } if conf.ReadOnly() { log.Infof("config: enabled read-only mode") } var found fs.Done var lastFound, indexed int settings := conf.Settings() if w := get.Index(); w != nil { indexStart := time.Now() _, lastFound = w.LastRun() convert := settings.Index.Convert && conf.SidecarWritable() opt := photoprism.NewIndexOptions(subPath, true, convert, true, true, true) found, indexed = w.Start(opt) log.Infof("index: updated %s [%s]", english.Plural(indexed, "file", "files"), time.Since(indexStart)) } if w := get.Purge(); w != nil { opt := photoprism.PurgeOptions{ Path: subPath, Ignore: found, Force: lastFound != len(found) || indexed > 0, } if files, photos, updated, err := w.Start(opt); err != nil { log.Error(err) } else if updated > 0 { log.Infof("purge: removed %s and %s", english.Plural(len(files), "file", "files"), english.Plural(len(photos), "photo", "photos")) } } elapsed := time.Since(start) log.Infof("indexed %s in %s", english.Plural(len(found), "file", "files"), elapsed) return nil } // facesUpdateAction performs face clustering and matching. func facesUpdateAction(ctx *cli.Context) error { start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() opt := photoprism.FacesOptions{ Force: ctx.Bool("force"), } w := get.Faces() if err := w.Start(opt); err != nil { return err } else { elapsed := time.Since(start) log.Infof("completed in %s", elapsed) } return nil } // facesOptimizeAction optimizes existing face clusters. func facesOptimizeAction(ctx *cli.Context) error { start := time.Now() conf := config.NewConfig(ctx) get.SetConfig(conf) _, cancel := context.WithCancel(context.Background()) defer cancel() if err := conf.Init(); err != nil { return err } conf.InitDb() defer conf.Shutdown() w := get.Faces() if res, err := w.Optimize(); err != nil { return err } else { elapsed := time.Since(start) log.Infof("merged %s in %s", english.Plural(res.Merged, "face cluster", "face clusters"), elapsed) } return nil }