mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
People: Add autocomplete for selecting a person #22
This commit is contained in:
980
frontend/package-lock.json
generated
980
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,7 @@ export default class Config {
|
||||
|
||||
Event.subscribe("config.updated", (ev, data) => this.setValues(data.config));
|
||||
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
|
||||
Event.subscribe("people", (ev, data) => this.onPeople(ev, data));
|
||||
|
||||
if (this.has("settings")) {
|
||||
this.setTheme(this.get("settings").ui.theme);
|
||||
@@ -134,6 +135,53 @@ export default class Config {
|
||||
return this;
|
||||
}
|
||||
|
||||
onPeople(ev, data) {
|
||||
const type = ev.split(".")[1];
|
||||
|
||||
if (this.debug) {
|
||||
console.log(ev, data);
|
||||
}
|
||||
|
||||
if (!this.values.people) {
|
||||
this.values.people = [];
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "created":
|
||||
this.values.people.push(...data.entities);
|
||||
break;
|
||||
case "updated":
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const values = data.entities[i];
|
||||
|
||||
this.values.people
|
||||
.filter((m) => m.UID === values.UID)
|
||||
.forEach((m) => {
|
||||
for (let key in values) {
|
||||
if (
|
||||
key !== "UID" &&
|
||||
values.hasOwnProperty(key) &&
|
||||
values[key] != null &&
|
||||
typeof values[key] !== "object"
|
||||
) {
|
||||
m[key] = values[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "deleted":
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const index = this.values.people.findIndex((m) => m.UID === data.entities[i]);
|
||||
|
||||
if (index >= 0) {
|
||||
this.values.people.splice(index, 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onCount(ev, data) {
|
||||
const type = ev.split(".")[1];
|
||||
|
||||
|
||||
@@ -360,6 +360,6 @@ body.chrome #photoprism .search-results .result {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#photoprism .face-results .invalid {
|
||||
#photoprism .face-results .is-marker.is-invalid {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -17,12 +17,12 @@
|
||||
<v-flex
|
||||
v-for="(marker, index) in markers"
|
||||
:key="index"
|
||||
xs6 sm4 md3 lg2 xl1 d-flex
|
||||
xs6 md3 xl2 d-flex
|
||||
>
|
||||
<v-card tile
|
||||
:data-id="marker.UID"
|
||||
style="user-select: none"
|
||||
:class="{invalid: marker.Invalid}"
|
||||
style="user-select: none;"
|
||||
:class="marker.classes()"
|
||||
class="result accent lighten-3">
|
||||
<div class="card-background accent lighten-3"></div>
|
||||
<canvas :id="'face-' + marker.UID" :key="marker.UID" width="300" height="300" style="width: 100%"
|
||||
@@ -47,7 +47,7 @@
|
||||
</v-btn>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-layout v-else row wrap align-center>
|
||||
<v-layout v-else-if="marker.SubjectUID" row wrap align-center>
|
||||
<v-flex xs12 class="text-xs-left pa-0">
|
||||
<v-text-field
|
||||
v-model="marker.Name"
|
||||
@@ -59,12 +59,41 @@
|
||||
single-line
|
||||
solo-inverted
|
||||
clearable
|
||||
clear-icon="eject"
|
||||
@click:clear="clearSubject(marker)"
|
||||
@change="rename(marker)"
|
||||
@keyup.enter.native="rename(marker)"
|
||||
></v-text-field>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-layout v-else row wrap align-center>
|
||||
<v-flex xs12 class="text-xs-left pa-0">
|
||||
<v-combobox
|
||||
v-model="marker.Name"
|
||||
style="z-index: 250"
|
||||
:items="$config.values.people"
|
||||
item-value="Name"
|
||||
item-text="Name"
|
||||
:disabled="busy"
|
||||
:return-object="false"
|
||||
:menu-props="menuProps"
|
||||
:allow-overflow="false"
|
||||
:filter="filterSubjects"
|
||||
:hint="$gettext('Name')"
|
||||
hide-details
|
||||
single-line
|
||||
solo-inverted
|
||||
open-on-clear
|
||||
append-icon=""
|
||||
prepend-inner-icon="person_add"
|
||||
browser-autocomplete="off"
|
||||
class="input-name pa-0 ma-0"
|
||||
@change="rename(marker)"
|
||||
@keyup.enter.native="rename(marker)"
|
||||
>
|
||||
</v-combobox>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
@@ -89,6 +118,7 @@ export default {
|
||||
disabled: !this.$config.feature("edit"),
|
||||
config: this.$config.values,
|
||||
readonly: this.$config.get("readonly"),
|
||||
menuProps:{"closeOnClick":false, "closeOnContentClick":true, "openOnClick":false, "maxHeight":300},
|
||||
textRule: (v) => {
|
||||
if (!v || !v.length) {
|
||||
return this.$gettext("Name");
|
||||
@@ -148,6 +178,15 @@ export default {
|
||||
this.busy = true;
|
||||
marker.approve().finally(() => this.busy = false);
|
||||
},
|
||||
filterSubjects(item, queryText) {
|
||||
if (!item || !item.Keywords) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const q = queryText.toLocaleLowerCase();
|
||||
|
||||
return item.Keywords.findIndex((w) => w.indexOf(q) > -1) > -1;
|
||||
},
|
||||
clearSubject(marker) {
|
||||
this.busy = true;
|
||||
marker.clearSubject(marker).finally(() => this.busy = false);
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
deletable-chips multiple color="secondary-dark"
|
||||
class="my-0 input-albums"
|
||||
:items="albums"
|
||||
item-text="Title"
|
||||
item-value="UID"
|
||||
item-text="Title"
|
||||
:allow-overflow="false"
|
||||
:label="$gettext('Select albums or create a new one')"
|
||||
return-object
|
||||
|
||||
@@ -40,10 +40,13 @@ export class Marker extends RestModel {
|
||||
return {
|
||||
UID: "",
|
||||
FileUID: "",
|
||||
FileHash: "",
|
||||
FileArea: "",
|
||||
Type: "",
|
||||
Src: "",
|
||||
Name: "",
|
||||
Invalid: false,
|
||||
Review: false,
|
||||
X: 0.0,
|
||||
Y: 0.0,
|
||||
W: 0.0,
|
||||
@@ -54,7 +57,6 @@ export class Marker extends RestModel {
|
||||
SubjectUID: "",
|
||||
Score: 0,
|
||||
Size: 0,
|
||||
Review: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,9 +82,15 @@ export class Marker extends RestModel {
|
||||
return this.Name;
|
||||
}
|
||||
|
||||
thumbnailUrl(fileHash, size) {
|
||||
if (fileHash && this.CropID) {
|
||||
return `${config.contentUri}/t/${fileHash}/${config.previewToken()}/${size}_${this.CropID}`;
|
||||
thumbnailUrl(size) {
|
||||
if (!size) {
|
||||
size = "crop_160";
|
||||
}
|
||||
|
||||
if (this.FileHash && this.FileArea) {
|
||||
return `${config.contentUri}/t/${this.FileHash}/${config.previewToken()}/${size}/${
|
||||
this.FileArea
|
||||
}`;
|
||||
} else {
|
||||
return `${config.contentUri}/svg/portrait`;
|
||||
}
|
||||
@@ -106,8 +114,8 @@ export class Marker extends RestModel {
|
||||
|
||||
rename() {
|
||||
if (!this.Name || this.Name.trim() === "") {
|
||||
// Don't update empty name.
|
||||
return Promise.reject();
|
||||
// Can't save an empty name.
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
|
||||
this.SubjectSrc = src.Manual;
|
||||
|
||||
4
go.mod
4
go.mod
@@ -55,7 +55,7 @@ require (
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8
|
||||
github.com/tensorflow/tensorflow v1.15.2
|
||||
github.com/tidwall/gjson v1.8.1
|
||||
github.com/tidwall/gjson v1.9.0
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/ugorji/go v1.2.6 // indirect
|
||||
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
|
||||
@@ -63,7 +63,7 @@ require (
|
||||
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a
|
||||
golang.org/x/net v0.0.0-20210902165921-8d991716f632
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gonum.org/v1/gonum v0.9.3
|
||||
|
||||
8
go.sum
8
go.sum
@@ -288,8 +288,8 @@ github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8 h1:ipNUBPHSUmH
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/tensorflow/tensorflow v1.15.2 h1:7/f/A664Tml/nRJg04+p3StcrsT53mkcvmxYHXI21Qo=
|
||||
github.com/tensorflow/tensorflow v1.15.2/go.mod h1:itOSERT4trABok4UOoG+X4BoKds9F3rIsySdn+Lvu90=
|
||||
github.com/tidwall/gjson v1.8.1 h1:8j5EE9Hrh3l9Od1OIEDAb7IpezNA20UdRngNAj5N0WU=
|
||||
github.com/tidwall/gjson v1.8.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
|
||||
github.com/tidwall/gjson v1.9.0 h1:+Od7AE26jAaMgVC31cQV/Ope5iKXulNMflrlB7k+F9E=
|
||||
github.com/tidwall/gjson v1.9.0/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
|
||||
github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE=
|
||||
github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
@@ -385,8 +385,8 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a h1:bRuuGXV8wwSdGTB+CtJf+FjgO1APK1CoO39T4BN/XBw=
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210902165921-8d991716f632 h1:900XJE4Rn/iPU+xD5ZznOe4GKKc4AdFK0IO1P6Z3/lQ=
|
||||
golang.org/x/net v0.0.0-20210902165921-8d991716f632/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
||||
@@ -108,6 +108,7 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
||||
"albums.*",
|
||||
"labels.*",
|
||||
"subjects.*",
|
||||
"people.*",
|
||||
"sync.*",
|
||||
)
|
||||
|
||||
|
||||
@@ -18,20 +18,20 @@ import (
|
||||
// IndexCommand registers the index cli command.
|
||||
var IndexCommand = cli.Command{
|
||||
Name: "index",
|
||||
Usage: "Indexes media files in originals folder",
|
||||
UsageText: `To limit scope, a sub folder may be passed as first argument.`,
|
||||
Usage: "indexes media files in the originals folder",
|
||||
ArgsUsage: "[path]",
|
||||
Flags: indexFlags,
|
||||
Action: indexAction,
|
||||
}
|
||||
|
||||
var indexFlags = []cli.Flag{
|
||||
cli.BoolFlag{
|
||||
Name: "all, a",
|
||||
Name: "force, f",
|
||||
Usage: "re-index all originals, including unchanged files",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "cleanup",
|
||||
Usage: "removes orphan index entries and thumbnails",
|
||||
Name: "cleanup, c",
|
||||
Usage: "remove orphan index entries and thumbnails",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
@@ -43,7 +42,7 @@ type ClientConfig struct {
|
||||
Cameras entity.Cameras `json:"cameras"`
|
||||
Lenses entity.Lenses `json:"lenses"`
|
||||
Countries entity.Countries `json:"countries"`
|
||||
Subjects query.SubjectResults `json:"subjects"`
|
||||
People entity.People `json:"people"`
|
||||
Thumbs ThumbTypes `json:"thumbs"`
|
||||
Status string `json:"status"`
|
||||
MapKey string `json:"mapKey"`
|
||||
@@ -386,12 +385,6 @@ func (c *Config) UserConfig() ClientConfig {
|
||||
Select("(COUNT(*) - 1) AS countries").
|
||||
Take(&result.Count)
|
||||
|
||||
c.Db().
|
||||
Table(entity.Subject{}.TableName()).
|
||||
Select("SUM(deleted_at IS NULL) AS people").
|
||||
Where("subject_src <> ? AND subject_type = ?", entity.SrcDefault, entity.SubjectPerson).
|
||||
Take(&result.Count)
|
||||
|
||||
c.Db().
|
||||
Table("places").
|
||||
Select("SUM(photo_count > 0) AS places").
|
||||
@@ -402,7 +395,9 @@ func (c *Config) UserConfig() ClientConfig {
|
||||
Order("country_slug").
|
||||
Find(&result.Countries)
|
||||
|
||||
result.Subjects, _ = query.SubjectSearch(form.SubjectSearch{Type: entity.SubjectPerson})
|
||||
// People are subjects with type person.
|
||||
result.Count.People, _ = query.PeopleCount()
|
||||
result.People, _ = query.People()
|
||||
|
||||
c.Db().
|
||||
Where("id IN (SELECT photos.camera_id FROM photos WHERE photos.photo_quality >= 0 OR photos.deleted_at IS NULL)").
|
||||
|
||||
@@ -131,7 +131,6 @@ func CreateDefaultFixtures() {
|
||||
CreateUnknownCountry()
|
||||
CreateUnknownCamera()
|
||||
CreateUnknownLens()
|
||||
CreateUnknownFace()
|
||||
}
|
||||
|
||||
// MigrateDb creates all tables and inserts default entities as needed.
|
||||
|
||||
@@ -31,23 +31,9 @@ type Face struct {
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// UnknownFace can be used as a placeholder for unknown faces.
|
||||
var UnknownFace = Face{
|
||||
ID: UnknownID,
|
||||
FaceSrc: SrcDefault,
|
||||
MatchedAt: TimePointer(),
|
||||
SubjectUID: "",
|
||||
EmbeddingJSON: []byte{},
|
||||
}
|
||||
|
||||
// Faceless can be used as argument to match unmatched face markers.
|
||||
var Faceless = []string{""}
|
||||
|
||||
// CreateUnknownFace initializes the database with a placeholder for unknown faces.
|
||||
func CreateUnknownFace() {
|
||||
_ = UnknownFace.Create()
|
||||
}
|
||||
|
||||
// TableName returns the entity database table name.
|
||||
func (Face) TableName() string {
|
||||
return "faces_dev6"
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package entity
|
||||
|
||||
// UnknownFace can be used as a placeholder for unknown faces.
|
||||
var UnknownFace = Face{
|
||||
ID: UnknownID,
|
||||
FaceSrc: SrcDefault,
|
||||
MatchedAt: TimePointer(),
|
||||
SubjectUID: "",
|
||||
EmbeddingJSON: []byte{},
|
||||
}
|
||||
|
||||
type FaceMap map[string]Face
|
||||
|
||||
func (m FaceMap) Get(name string) Face {
|
||||
|
||||
@@ -406,7 +406,7 @@ func (m *File) AddFaces(faces face.Faces) {
|
||||
|
||||
// AddFace adds a face marker to the file.
|
||||
func (m *File) AddFace(f face.Face, subjectUID string) {
|
||||
marker := *NewFaceMarker(f, m.FileUID, subjectUID)
|
||||
marker := *NewFaceMarker(f, *m, subjectUID)
|
||||
|
||||
if markers := m.Markers(); !markers.Contains(marker) {
|
||||
markers.Append(marker)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/crop"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
|
||||
@@ -26,14 +28,15 @@ const (
|
||||
type Marker struct {
|
||||
MarkerUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
|
||||
FileUID string `gorm:"type:VARBINARY(42);index;" json:"FileUID" yaml:"FileUID"`
|
||||
FileHash string `gorm:"type:VARBINARY(128);index" json:"FileHash" yaml:"FileHash,omitempty"`
|
||||
FileArea string `gorm:"type:VARBINARY(16);default:''" json:"FileArea" yaml:"FileArea,omitempty"`
|
||||
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
|
||||
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
|
||||
MarkerName string `gorm:"type:VARCHAR(255);" json:"Name" yaml:"Name,omitempty"`
|
||||
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
|
||||
SubjectUID string `gorm:"type:VARBINARY(42);index;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
|
||||
SubjectSrc string `gorm:"type:VARBINARY(8);default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
|
||||
SubjectUID string `gorm:"type:VARBINARY(42);index:idx_markers_subject;" json:"SubjectUID" yaml:"SubjectUID,omitempty"`
|
||||
SubjectSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subject;default:'';" json:"SubjectSrc" yaml:"SubjectSrc,omitempty"`
|
||||
subject *Subject `gorm:"foreignkey:SubjectUID;association_foreignkey:SubjectUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
|
||||
CropID string `gorm:"type:VARBINARY(16);default:''" json:"CropID,omitempty" yaml:"CropID,omitempty"`
|
||||
FaceID string `gorm:"type:VARBINARY(42);index;" json:"FaceID" yaml:"FaceID,omitempty"`
|
||||
FaceDist float64 `gorm:"default:-1" json:"FaceDist" yaml:"FaceDist,omitempty"`
|
||||
face *Face `gorm:"foreignkey:FaceID;association_foreignkey:ID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
|
||||
@@ -52,9 +55,6 @@ type Marker struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UnknownMarker can be used as a default for unknown markers.
|
||||
var UnknownMarker = NewMarker("", "", SrcDefault, MarkerUnknown, 0, 0, 0, 0)
|
||||
|
||||
// TableName returns the entity database table name.
|
||||
func (Marker) TableName() string {
|
||||
return "markers_dev6"
|
||||
@@ -70,35 +70,34 @@ func (m *Marker) BeforeCreate(scope *gorm.Scope) error {
|
||||
}
|
||||
|
||||
// NewMarker creates a new entity.
|
||||
func NewMarker(fileUID, subjectUID, markerSrc, markerType string, x, y, w, h float32) *Marker {
|
||||
func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType string) *Marker {
|
||||
m := &Marker{
|
||||
FileUID: fileUID,
|
||||
SubjectUID: subjectUID,
|
||||
FileUID: file.FileUID,
|
||||
FileHash: file.FileHash,
|
||||
FileArea: area.String(),
|
||||
MarkerSrc: markerSrc,
|
||||
MarkerType: markerType,
|
||||
X: x,
|
||||
Y: y,
|
||||
W: w,
|
||||
H: h,
|
||||
SubjectUID: subjectUID,
|
||||
X: area.X,
|
||||
Y: area.Y,
|
||||
W: area.W,
|
||||
H: area.H,
|
||||
MatchedAt: nil,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewFaceMarker creates a new entity.
|
||||
func NewFaceMarker(f face.Face, fileUID, subjectUID string) *Marker {
|
||||
pos := f.Crop()
|
||||
func NewFaceMarker(f face.Face, file File, subjectUID string) *Marker {
|
||||
m := NewMarker(file, f.CropArea(), subjectUID, SrcImage, MarkerFace)
|
||||
|
||||
m := NewMarker(fileUID, subjectUID, SrcImage, MarkerFace, pos.X, pos.Y, pos.W, pos.H)
|
||||
|
||||
m.MatchedAt = nil
|
||||
m.FaceDist = -1
|
||||
m.CropID = f.Crop().ID()
|
||||
m.EmbeddingsJSON = f.EmbeddingsJSON()
|
||||
m.LandmarksJSON = f.RelativeLandmarksJSON()
|
||||
m.Size = f.Size()
|
||||
m.Score = f.Score
|
||||
m.Review = f.Score < 30
|
||||
m.FaceDist = -1
|
||||
m.EmbeddingsJSON = f.EmbeddingsJSON()
|
||||
m.LandmarksJSON = f.RelativeLandmarksJSON()
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -372,27 +371,26 @@ func (m *Marker) Subject() (subj *Subject) {
|
||||
|
||||
// ClearSubject removes an existing subject association, and reports a collision.
|
||||
func (m *Marker) ClearSubject(src string) error {
|
||||
// Find the matching face.
|
||||
if m.face == nil {
|
||||
m.face = FindFace(m.FaceID)
|
||||
}
|
||||
|
||||
if m.face == nil {
|
||||
// Do nothing
|
||||
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
|
||||
// Update index & resolve collisions.
|
||||
if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "SubjectSrc": src}); err != nil {
|
||||
return err
|
||||
} else if err := m.Updates(Values{"MarkerName": "", "FaceID": "", "FaceDist": -1.0, "SubjectUID": "", "SubjectSrc": src}); err != nil {
|
||||
} else if m.face == nil {
|
||||
m.subject = nil
|
||||
return nil
|
||||
} else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil {
|
||||
return err
|
||||
} else if resolved {
|
||||
log.Debugf("faces: resolved collision with %s", m.face.ID)
|
||||
}
|
||||
|
||||
// Clear references.
|
||||
m.face = nil
|
||||
|
||||
m.MarkerName = ""
|
||||
m.FaceID = ""
|
||||
m.FaceDist = -1.0
|
||||
m.SubjectUID = ""
|
||||
m.SubjectSrc = src
|
||||
m.subject = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -495,7 +493,7 @@ func UpdateOrCreateMarker(m *Marker) (*Marker, error) {
|
||||
err := result.Updates(map[string]interface{}{
|
||||
"MarkerType": m.MarkerType,
|
||||
"MarkerSrc": m.MarkerSrc,
|
||||
"CropID": m.CropID,
|
||||
"FileArea": m.FileArea,
|
||||
"X": m.X,
|
||||
"Y": m.Y,
|
||||
"W": m.W,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package entity
|
||||
|
||||
import "github.com/photoprism/photoprism/pkg/crop"
|
||||
|
||||
// UnknownMarker can be used as a default for unknown markers.
|
||||
var UnknownMarker = NewMarker(File{}, crop.Area{}, "", SrcDefault, MarkerUnknown)
|
||||
|
||||
type MarkerMap map[string]Marker
|
||||
|
||||
func (m MarkerMap) Get(name string) Marker {
|
||||
@@ -194,7 +199,7 @@ var MarkerFixtures = MarkerMap{
|
||||
MarkerUID: "mt9k3pw1wowuy999",
|
||||
FileUID: "ft2es49qhhinlple",
|
||||
FaceID: FaceFixtures.Get("actress-1").ID,
|
||||
CropID: "045038063041",
|
||||
FileArea: "045038063041",
|
||||
FaceDist: 0.26852392873736236,
|
||||
SubjectSrc: SrcManual,
|
||||
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
|
||||
@@ -214,7 +219,7 @@ var MarkerFixtures = MarkerMap{
|
||||
MarkerUID: "mt9k3pw1wowu1000",
|
||||
FileUID: "ft2es49whhbnlqdn",
|
||||
FaceID: FaceFixtures.Get("actress-1").ID,
|
||||
CropID: "046045043065",
|
||||
FileArea: "046045043065",
|
||||
FaceDist: 0.4507357278575355,
|
||||
SubjectSrc: "",
|
||||
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
|
||||
@@ -234,7 +239,7 @@ var MarkerFixtures = MarkerMap{
|
||||
MarkerUID: "mt9k3pw1wowu1001",
|
||||
FileUID: "ft8es39w45bnlqdw",
|
||||
FaceID: FaceFixtures.Get("actress-1").ID,
|
||||
CropID: "05403304060446",
|
||||
FileArea: "05403304060446",
|
||||
FaceDist: 0.5099754448545762,
|
||||
SubjectSrc: "",
|
||||
SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID,
|
||||
|
||||
@@ -19,40 +19,42 @@ func (m *Marker) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
UID string
|
||||
FileUID string
|
||||
FileHash string
|
||||
FileArea string
|
||||
Type string
|
||||
Src string
|
||||
Name string
|
||||
Invalid bool `json:",omitempty"`
|
||||
X float32 `json:",omitempty"`
|
||||
Y float32 `json:",omitempty"`
|
||||
Invalid bool
|
||||
Review bool
|
||||
FaceID string
|
||||
SubjectUID string
|
||||
SubjectSrc string
|
||||
X float32
|
||||
Y float32
|
||||
W float32 `json:",omitempty"`
|
||||
H float32 `json:",omitempty"`
|
||||
Size int `json:",omitempty"`
|
||||
Score int `json:",omitempty"`
|
||||
Review bool `json:",omitempty"`
|
||||
CropID string `json:",omitempty"`
|
||||
FaceID string `json:",omitempty"`
|
||||
SubjectUID string `json:",omitempty"`
|
||||
SubjectSrc string `json:",omitempty"`
|
||||
CreatedAt time.Time
|
||||
}{
|
||||
UID: m.MarkerUID,
|
||||
FileUID: m.FileUID,
|
||||
FileHash: m.FileHash,
|
||||
FileArea: m.FileArea,
|
||||
Type: m.MarkerType,
|
||||
Src: m.MarkerSrc,
|
||||
Name: name,
|
||||
Invalid: m.MarkerInvalid,
|
||||
Review: m.Review,
|
||||
FaceID: m.FaceID,
|
||||
SubjectUID: m.SubjectUID,
|
||||
SubjectSrc: m.SubjectSrc,
|
||||
X: m.X,
|
||||
Y: m.Y,
|
||||
W: m.W,
|
||||
H: m.H,
|
||||
Size: m.Size,
|
||||
Score: m.Score,
|
||||
Review: m.Review,
|
||||
CropID: m.CropID,
|
||||
FaceID: m.FaceID,
|
||||
SubjectUID: m.SubjectUID,
|
||||
SubjectSrc: m.SubjectSrc,
|
||||
CreatedAt: m.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,17 +3,27 @@ package entity
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/crop"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var testArea = crop.Area{
|
||||
Name: "face",
|
||||
X: 0.308333,
|
||||
Y: 0.206944,
|
||||
W: 0.355556,
|
||||
H: 0.355556,
|
||||
}
|
||||
|
||||
func TestMarker_TableName(t *testing.T) {
|
||||
m := &Marker{}
|
||||
assert.Contains(t, m.TableName(), "markers")
|
||||
}
|
||||
|
||||
func TestNewMarker(t *testing.T) {
|
||||
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel)
|
||||
assert.IsType(t, &Marker{}, m)
|
||||
assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID)
|
||||
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID)
|
||||
@@ -79,7 +89,7 @@ func TestMarker_SaveForm(t *testing.T) {
|
||||
|
||||
func TestUpdateOrCreateMarker(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel)
|
||||
assert.IsType(t, &Marker{}, m)
|
||||
assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID)
|
||||
assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID)
|
||||
@@ -104,7 +114,7 @@ func TestUpdateOrCreateMarker(t *testing.T) {
|
||||
|
||||
func TestMarker_Updates(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
|
||||
m, err := UpdateOrCreateMarker(m)
|
||||
|
||||
if err != nil {
|
||||
@@ -129,7 +139,7 @@ func TestMarker_Updates(t *testing.T) {
|
||||
|
||||
func TestMarker_Update(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
|
||||
m, err := UpdateOrCreateMarker(m)
|
||||
|
||||
if err != nil {
|
||||
@@ -153,7 +163,8 @@ func TestMarker_Update(t *testing.T) {
|
||||
|
||||
func TestMarker_Save(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
m := NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c4", SrcImage, MarkerLabel)
|
||||
|
||||
m, err := UpdateOrCreateMarker(m)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,13 +3,20 @@ package entity
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/crop"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var cropArea1 = crop.Area{Name: "face", X: 0.308333, Y: 0.206944, W: 0.355556, H: 0.355556}
|
||||
var cropArea2 = crop.Area{Name: "face", X: 0.308313, Y: 0.206914, W: 0.655556, H: 0.655556}
|
||||
var cropArea3 = crop.Area{Name: "face", X: 0.998133, Y: 0.816944, W: 0.0001, H: 0.0001}
|
||||
var cropArea4 = crop.Area{Name: "face", X: 0.298133, Y: 0.216944, W: 0.255556, H: 0.155556}
|
||||
|
||||
func TestMarkers_Contains(t *testing.T) {
|
||||
m1 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m2 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 0.308313, 0.206914, 0.655556, 0.655556)
|
||||
m3 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 0.998133, 0.816944, 0.0001, 0.0001)
|
||||
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace)
|
||||
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea2, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace)
|
||||
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace)
|
||||
|
||||
m := Markers{m1}
|
||||
|
||||
@@ -18,9 +25,9 @@ func TestMarkers_Contains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkers_FaceCount(t *testing.T) {
|
||||
m1 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 0.308333, 0.206944, 0.355556, 0.355556)
|
||||
m2 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c2", SrcImage, MarkerFace, 0.298133, 0.216944, 0.255556, 0.155556)
|
||||
m3 := *NewMarker("ft8es39w45bnlqdw", "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 0.998133, 0.816944, 0.0001, 0.0001)
|
||||
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace)
|
||||
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea4, "lt9k3pw1wowuy1c2", SrcImage, MarkerFace)
|
||||
m3 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace)
|
||||
m3.MarkerInvalid = true
|
||||
|
||||
m := Markers{m1, m2, m3}
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
const (
|
||||
SubjectPerson = "person"
|
||||
)
|
||||
|
||||
var subjectMutex = sync.Mutex{}
|
||||
|
||||
// Subjects represents a list of subjects.
|
||||
@@ -99,8 +95,21 @@ func (m *Subject) Create() error {
|
||||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Delete removes the entity from the database.
|
||||
// Delete marks the entity as deleted in the database.
|
||||
func (m *Subject) Delete() error {
|
||||
if m.Deleted() {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("subject: deleting %s %s", m.SubjectType, txt.Quote(m.SubjectName))
|
||||
|
||||
if m.IsPerson() {
|
||||
event.EntitiesDeleted("people", []string{m.SubjectUID})
|
||||
event.Publish("count.people", event.Data{
|
||||
"count": -1,
|
||||
})
|
||||
}
|
||||
|
||||
return Db().Delete(m).Error
|
||||
}
|
||||
|
||||
@@ -113,6 +122,17 @@ func (m *Subject) Deleted() bool {
|
||||
func (m *Subject) Restore() error {
|
||||
if m.Deleted() {
|
||||
m.DeletedAt = nil
|
||||
|
||||
log.Infof("subject: restoring %s %s", m.SubjectType, txt.Quote(m.SubjectName))
|
||||
|
||||
if m.IsPerson() {
|
||||
event.EntitiesCreated("people", []*Person{m.Person()})
|
||||
|
||||
event.Publish("count.people", event.Data{
|
||||
"count": 1,
|
||||
})
|
||||
}
|
||||
|
||||
return UnscopedDb().Model(m).Update("DeletedAt", nil).Error
|
||||
}
|
||||
|
||||
@@ -140,9 +160,10 @@ func FirstOrCreateSubject(m *Subject) *Subject {
|
||||
if found := FindSubjectByName(m.SubjectName); found != nil {
|
||||
return found
|
||||
} else if createErr := m.Create(); createErr == nil {
|
||||
if !m.Excluded && m.SubjectType == SubjectPerson {
|
||||
event.EntitiesCreated("people", []*Subject{m})
|
||||
log.Infof("subject: added %s %s", m.SubjectType, txt.Quote(m.SubjectName))
|
||||
|
||||
if m.IsPerson() {
|
||||
event.EntitiesCreated("people", []*Person{m.Person()})
|
||||
event.Publish("count.people", event.Data{
|
||||
"count": 1,
|
||||
})
|
||||
@@ -200,6 +221,16 @@ func FindSubjectByName(s string) *Subject {
|
||||
return &result
|
||||
}
|
||||
|
||||
// IsPerson tests if the subject is a person.
|
||||
func (m *Subject) IsPerson() bool {
|
||||
return m.SubjectType == SubjectPerson
|
||||
}
|
||||
|
||||
// Person creates and returns a Person based on this subject.
|
||||
func (m *Subject) Person() *Person {
|
||||
return NewPerson(*m)
|
||||
}
|
||||
|
||||
// SetName changes the subject's name.
|
||||
func (m *Subject) SetName(name string) error {
|
||||
newName := txt.Clip(name, txt.ClipDefault)
|
||||
@@ -219,6 +250,12 @@ func (m *Subject) UpdateName(name string) (*Subject, error) {
|
||||
if err := m.SetName(name); err != nil {
|
||||
return m, err
|
||||
} else if err := m.Updates(Values{"SubjectName": m.SubjectName, "SubjectSlug": m.SubjectSlug}); err == nil {
|
||||
log.Infof("subject: renamed %s %s", m.SubjectType, txt.Quote(m.SubjectName))
|
||||
|
||||
if m.IsPerson() {
|
||||
event.EntitiesUpdated("people", []*Person{m.Person()})
|
||||
}
|
||||
|
||||
return m, m.UpdateMarkerNames()
|
||||
} else if existing := FindSubjectByName(m.SubjectName); existing == nil {
|
||||
return m, err
|
||||
|
||||
53
internal/entity/subject_person.go
Normal file
53
internal/entity/subject_person.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
const (
|
||||
SubjectPerson = "person"
|
||||
)
|
||||
|
||||
// People represents a list of people.
|
||||
type People []Person
|
||||
|
||||
// Person represents a subject with type person.
|
||||
type Person struct {
|
||||
SubjectUID string `json:"UID"`
|
||||
SubjectName string `json:"Name"`
|
||||
SubjectAlias string `json:"Alias,omitempty"`
|
||||
Favorite bool `json:"Favorite,omitempty"`
|
||||
Thumb string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// NewPerson returns a new entity.
|
||||
func NewPerson(subj Subject) *Person {
|
||||
result := &Person{
|
||||
SubjectUID: subj.SubjectUID,
|
||||
SubjectName: subj.SubjectName,
|
||||
SubjectAlias: subj.SubjectAlias,
|
||||
Favorite: subj.Favorite,
|
||||
Thumb: subj.Thumb,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MarshalJSON returns the JSON encoding.
|
||||
func (m *Person) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
UID string
|
||||
Name string
|
||||
Keywords []string `json:",omitempty"`
|
||||
Favorite bool `json:",omitempty"`
|
||||
Thumb string `json:",omitempty"`
|
||||
}{
|
||||
UID: m.SubjectUID,
|
||||
Name: m.SubjectName,
|
||||
Keywords: txt.NameKeywords(m.SubjectName, m.SubjectAlias),
|
||||
Favorite: m.Favorite,
|
||||
Thumb: m.Thumb,
|
||||
})
|
||||
}
|
||||
40
internal/entity/subject_person_test.go
Normal file
40
internal/entity/subject_person_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewPerson(t *testing.T) {
|
||||
t.Run("BillGates", func(t *testing.T) {
|
||||
subj := Subject{
|
||||
SubjectUID: "jqytw12v8jjeu3e6",
|
||||
SubjectName: "William Henry Gates III",
|
||||
SubjectAlias: "Windows Guru",
|
||||
Favorite: true,
|
||||
Thumb: "622c7287967f2800e873fbc55f0328973056ce1d",
|
||||
}
|
||||
|
||||
m := NewPerson(subj)
|
||||
|
||||
assert.Equal(t, "jqytw12v8jjeu3e6", m.SubjectUID)
|
||||
assert.Equal(t, "William Henry Gates III", m.SubjectName)
|
||||
assert.Equal(t, "Windows Guru", m.SubjectAlias)
|
||||
assert.Equal(t, true, m.Favorite)
|
||||
assert.Equal(t, "622c7287967f2800e873fbc55f0328973056ce1d", m.Thumb)
|
||||
|
||||
if j, err := m.MarshalJSON(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
s := string(j)
|
||||
|
||||
expected := "{\"UID\":\"jqytw12v8jjeu3e6\",\"Name\":\"William Henry Gates III\"," +
|
||||
"\"Keywords\":[\"william\",\"henry\",\"gates\",\"iii\",\"windows\",\"guru\"]," +
|
||||
"\"Favorite\":true,\"Thumb\":\"622c7287967f2800e873fbc55f0328973056ce1d\"}"
|
||||
|
||||
assert.Equal(t, expected, s)
|
||||
t.Logf("person json: %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ func TestSubject_TableName(t *testing.T) {
|
||||
assert.Contains(t, m.TableName(), "subjects")
|
||||
}
|
||||
|
||||
func TestNewPerson(t *testing.T) {
|
||||
func TestNewSubject(t *testing.T) {
|
||||
t.Run("Jens_Mander", func(t *testing.T) {
|
||||
m := NewSubject("Jens Mander", SubjectPerson, SrcAuto)
|
||||
assert.Equal(t, "Jens Mander", m.SubjectName)
|
||||
|
||||
@@ -123,8 +123,8 @@ func (f *Face) Dim() float32 {
|
||||
return float32(1)
|
||||
}
|
||||
|
||||
// Crop returns the relative image area for cropping.
|
||||
func (f *Face) Crop() crop.Area {
|
||||
// CropArea returns the relative image area for cropping.
|
||||
func (f *Face) CropArea() crop.Area {
|
||||
if f.Rows < 1 {
|
||||
f.Cols = 1
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestDetect(t *testing.T) {
|
||||
t.Logf("results: %#v", faces)
|
||||
|
||||
for i, f := range faces {
|
||||
t.Logf("marker[%d]: %#v %#v", i, f.Crop(), f.Area)
|
||||
t.Logf("marker[%d]: %#v %#v", i, f.CropArea(), f.Area)
|
||||
t.Logf("landmarks[%d]: %s", i, f.RelativeLandmarksJSON())
|
||||
|
||||
img, err := tfInstance.getFaceCrop(fileName, fileHash, &faces[i])
|
||||
@@ -249,7 +249,7 @@ func TestFace_EmbeddingsJSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFace_Crop(t *testing.T) {
|
||||
func TestFace_CropArea(t *testing.T) {
|
||||
t.Run("Position", func(t *testing.T) {
|
||||
f := Face{
|
||||
Cols: 1000,
|
||||
@@ -265,6 +265,6 @@ func TestFace_Crop(t *testing.T) {
|
||||
Landmarks: nil,
|
||||
Embeddings: nil,
|
||||
}
|
||||
t.Logf("marker: %#v", f.Crop())
|
||||
t.Logf("marker: %#v", f.CropArea())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (t *Net) getFaceCrop(fileName, cacheHash string, f *Face) (img image.Image,
|
||||
cacheFolder := t.getCacheFolder(fileName, cacheHash)
|
||||
|
||||
if cacheHash != "" {
|
||||
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", cacheHash, CropSize, CropSize, f.Crop().ID())
|
||||
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", cacheHash, CropSize, CropSize, f.CropArea().String())
|
||||
} else {
|
||||
base := filepath.Base(fileName)
|
||||
i := strings.Index(base, "_")
|
||||
@@ -140,7 +140,7 @@ func (t *Net) getFaceCrop(fileName, cacheHash string, f *Face) (img image.Image,
|
||||
base = base[:i]
|
||||
}
|
||||
|
||||
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", base, CropSize, CropSize, f.Crop().ID())
|
||||
f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", base, CropSize, CropSize, f.CropArea().String())
|
||||
}
|
||||
|
||||
cacheFile := filepath.Join(cacheFolder, f.Thumb+fs.JpegExt)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// Faces returns all (known / unmatched) faces from the index.
|
||||
func Faces(knownOnly, unmatched bool) (result entity.Faces, err error) {
|
||||
stmt := Db().Where("face_src <> ?", entity.SrcDefault)
|
||||
stmt := Db()
|
||||
|
||||
if unmatched {
|
||||
stmt = stmt.Where("matched_at IS NULL")
|
||||
@@ -72,7 +72,7 @@ func RemoveAnonymousFaceClusters() (removed int64, err error) {
|
||||
// RemoveAutoFaceClusters removes automatically added face clusters from the index.
|
||||
func RemoveAutoFaceClusters() (removed int64, err error) {
|
||||
res := UnscopedDb().
|
||||
Delete(entity.Face{}, "id <> ? AND face_src = ?", entity.UnknownFace.ID, entity.SrcAuto)
|
||||
Delete(entity.Face{}, "face_src = ?", entity.SrcAuto)
|
||||
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func UpdateAlbumDefaultPreviews() error {
|
||||
return Db().Table(entity.Album{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr(`(
|
||||
SELECT file_hash FROM files f
|
||||
SELECT f.file_hash FROM files f
|
||||
JOIN photos_albums pa ON pa.album_uid = albums.album_uid AND pa.photo_uid = f.photo_uid AND pa.hidden = 0
|
||||
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
|
||||
WHERE f.deleted_at IS NULL AND f.file_missing = 0 AND f.file_hash <> '' AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||
@@ -23,7 +23,7 @@ func UpdateAlbumDefaultPreviews() error {
|
||||
func UpdateAlbumFolderPreviews() error {
|
||||
return Db().Table(entity.Album{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr(`(
|
||||
SELECT file_hash FROM files f
|
||||
SELECT f.file_hash FROM files f
|
||||
JOIN photos p ON p.id = f.photo_id AND p.photo_path = albums.album_path AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
|
||||
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||
ORDER BY p.taken_at DESC LIMIT 1
|
||||
@@ -35,7 +35,7 @@ func UpdateAlbumFolderPreviews() error {
|
||||
func UpdateAlbumMonthPreviews() error {
|
||||
return Db().Table(entity.Album{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr(`(
|
||||
SELECT file_hash FROM files f
|
||||
SELECT f.file_hash FROM files f
|
||||
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
|
||||
AND p.photo_year = albums.album_year AND p.photo_month = albums.album_month AND p.photo_month = albums.album_month
|
||||
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||
@@ -69,7 +69,7 @@ func UpdateLabelPreviews() (err error) {
|
||||
// Labels.
|
||||
if err = Db().Table(entity.Label{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr(`(
|
||||
SELECT file_hash FROM files f
|
||||
SELECT f.file_hash FROM files f
|
||||
JOIN photos_labels pl ON pl.label_id = labels.id AND pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
||||
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
|
||||
WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_type = 'jpg'
|
||||
@@ -82,7 +82,7 @@ func UpdateLabelPreviews() (err error) {
|
||||
// Categories.
|
||||
if err = Db().Table(entity.Label{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr(`(
|
||||
SELECT file_hash FROM files f
|
||||
SELECT f.file_hash FROM files f
|
||||
JOIN photos_labels pl ON pl.photo_id = f.photo_id AND pl.uncertainty < 100
|
||||
JOIN categories c ON c.label_id = pl.label_id AND c.category_id = labels.id
|
||||
JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > -1
|
||||
@@ -98,8 +98,10 @@ func UpdateLabelPreviews() (err error) {
|
||||
|
||||
// UpdateSubjectPreviews updates subject preview images.
|
||||
func UpdateSubjectPreviews() error {
|
||||
/* Previous implementation for reference:
|
||||
|
||||
return Db().Table(entity.Subject{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr("(SELECT file_hash FROM files f "+
|
||||
UpdateColumn("thumb", gorm.Expr("(SELECT f.file_hash FROM files f "+
|
||||
fmt.Sprintf(
|
||||
"JOIN %s m ON f.file_uid = m.file_uid AND m.subject_uid = %s.subject_uid",
|
||||
entity.Marker{}.TableName(),
|
||||
@@ -108,7 +110,17 @@ func UpdateSubjectPreviews() error {
|
||||
WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL AND f.file_hash <> '' AND p.deleted_at IS NULL
|
||||
AND f.file_primary = 1 AND f.file_missing = 0 AND p.photo_private = 0 AND p.photo_quality > -1
|
||||
ORDER BY p.taken_at DESC LIMIT 1)
|
||||
WHERE thumb_src='' AND deleted_at IS NULL AND subject_src <> 'default'`)).
|
||||
WHERE thumb_src='' AND deleted_at IS NULL`)).
|
||||
Error */
|
||||
|
||||
return Db().Table(entity.Subject{}.TableName()).
|
||||
UpdateColumn("thumb", gorm.Expr("(SELECT m.file_hash FROM "+
|
||||
fmt.Sprintf(
|
||||
"%s m WHERE m.subject_uid = %s.subject_uid AND m.subject_src = 'manual' ",
|
||||
entity.Marker{}.TableName(),
|
||||
entity.Subject{}.TableName())+
|
||||
` AND m.file_hash <> '' AND ORDER BY m.size DESC LIMIT 1)
|
||||
WHERE thumb_src='' AND deleted_at IS NULL`)).
|
||||
Error
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,30 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
)
|
||||
|
||||
// People returns the sorted names of the first 2000 people.
|
||||
func People() (people entity.People, err error) {
|
||||
err = UnscopedDb().
|
||||
Table(entity.Subject{}.TableName()).
|
||||
Select("subject_uid, subject_name, subject_alias, favorite").
|
||||
Where("deleted_at IS NULL AND subject_type = ?", entity.SubjectPerson).
|
||||
Order("subject_name").
|
||||
Limit(2000).Offset(0).
|
||||
Scan(&people).Error
|
||||
|
||||
return people, err
|
||||
}
|
||||
|
||||
// PeopleCount returns the total number of people in the index.
|
||||
func PeopleCount() (count int, err error) {
|
||||
err = Db().
|
||||
Table(entity.Subject{}.TableName()).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("subject_type = ?", entity.SubjectPerson).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Subjects returns subjects from the index.
|
||||
func Subjects(limit, offset int) (result entity.Subjects, err error) {
|
||||
stmt := Db()
|
||||
@@ -26,7 +50,7 @@ func SubjectMap() (result map[string]entity.Subject, err error) {
|
||||
|
||||
var subj entity.Subjects
|
||||
|
||||
stmt := Db().Where("subject_src <> ?", entity.SrcDefault)
|
||||
stmt := Db()
|
||||
|
||||
if err = stmt.Find(&subj).Error; err != nil {
|
||||
return result, err
|
||||
@@ -111,7 +135,6 @@ func SearchSubjectUIDs(s string) (result []string, names []string, remaining str
|
||||
var matches []Matches
|
||||
|
||||
stmt := Db().Model(entity.Subject{})
|
||||
stmt = stmt.Where("subject_src <> ?", entity.SrcDefault)
|
||||
|
||||
if where := LikeAllNames(Cols{"subject_name", "subject_alias"}, s); len(where) == 0 {
|
||||
return result, names, s
|
||||
|
||||
@@ -9,6 +9,24 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPeople(t *testing.T) {
|
||||
if results, err := People(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.LessOrEqual(t, 3, len(results))
|
||||
t.Logf("people: %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeopleCount(t *testing.T) {
|
||||
if result, err := PeopleCount(); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.LessOrEqual(t, 3, result)
|
||||
t.Logf("there are %d people", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjects(t *testing.T) {
|
||||
results, err := Subjects(3, 0)
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ type Area struct {
|
||||
H float32 `json:"h,omitempty"`
|
||||
}
|
||||
|
||||
// ID returns a string identifying the approximate marker area.
|
||||
func (m Area) ID() string {
|
||||
// String returns a string identifying the approximate marker area.
|
||||
func (m Area) String() string {
|
||||
return fmt.Sprintf("%03d%03d%03d%03d", int(m.X*100), int(m.Y*100), int(m.W*100), int(m.H*100))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,25 +6,25 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestArea_ID(t *testing.T) {
|
||||
func TestArea_String(t *testing.T) {
|
||||
t.Run("082016010006_face", func(t *testing.T) {
|
||||
m := NewArea("face", 0.822059, 0.167969, 0.1, 0.0664062)
|
||||
assert.Equal(t, "082016010006", m.ID())
|
||||
assert.Equal(t, "082016010006", m.String())
|
||||
})
|
||||
t.Run("082016010006_back", func(t *testing.T) {
|
||||
m := NewArea("back", 0.822059, 0.167969, 0.1, 0.0664062)
|
||||
assert.Equal(t, "082016010006", m.ID())
|
||||
assert.Equal(t, "082016010006", m.String())
|
||||
})
|
||||
t.Run("020100003000", func(t *testing.T) {
|
||||
m := NewArea("face", 0.201, 1.000, 0.03, 0.00000001)
|
||||
assert.Equal(t, "020100003000", m.ID())
|
||||
assert.Equal(t, "020100003000", m.String())
|
||||
})
|
||||
t.Run("000100000000", func(t *testing.T) {
|
||||
m := NewArea("face", 0.0001, 1.000, 0, 0.00000001)
|
||||
assert.Equal(t, "000100000000", m.ID())
|
||||
assert.Equal(t, "000100000000", m.String())
|
||||
})
|
||||
t.Run("000012000100", func(t *testing.T) {
|
||||
m := NewArea("", -2.0001, 0.123, -0.1, 4.00000001)
|
||||
assert.Equal(t, "000012000100", m.ID())
|
||||
assert.Equal(t, "000012000100", m.String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,3 +35,15 @@ func JoinNames(names []string) string {
|
||||
return fmt.Sprintf("%s & %s", strings.Join(names[:l-1], ", "), names[l-1])
|
||||
}
|
||||
}
|
||||
|
||||
// NameKeywords returns a list of unique, lowercase keywords based on a person's names and aliases.
|
||||
func NameKeywords(names, aliases string) (results []string) {
|
||||
if names == "" && aliases == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
names = strings.ToLower(names)
|
||||
aliases = strings.ToLower(aliases)
|
||||
|
||||
return UniqueNames(append(Words(names), Words(aliases)...))
|
||||
}
|
||||
|
||||
@@ -43,3 +43,10 @@ func TestJoinNames(t *testing.T) {
|
||||
assert.Equal(t, "Jens Mander, Name 2, Name 3 & Name 4", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNameKeywords(t *testing.T) {
|
||||
t.Run("BillGates", func(t *testing.T) {
|
||||
result := NameKeywords("William Henry Gates III", "Windows Guru")
|
||||
assert.Equal(t, []string{"william", "henry", "gates", "iii", "windows", "guru"}, result)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user