People: Implement marker update API #22

This commit is contained in:
Michael Mayer 2021-06-02 17:25:04 +02:00
parent 525f9a1869
commit 49fd531420
19 changed files with 516 additions and 84 deletions

View file

@ -359,3 +359,7 @@ body.chrome #photoprism .search-results .result {
transform: translateY(-50%); transform: translateY(-50%);
text-align: center; text-align: center;
} }
#photoprism .face-results .invalid {
opacity: 0.5;
}

View file

@ -13,7 +13,7 @@
</div> </div>
</v-card-title> </v-card-title>
</v-card> </v-card>
<v-layout row wrap class="search-results photo-results cards-view"> <v-layout row wrap class="search-results face-results cards-view">
<v-flex <v-flex
v-for="(marker, index) in markers" v-for="(marker, index) in markers"
:key="index" :key="index"
@ -22,12 +22,13 @@
<v-card tile <v-card tile
:data-id="marker.ID" :data-id="marker.ID"
style="user-select: none" style="user-select: none"
:class="{invalid: marker.Invalid}"
class="result accent lighten-3"> class="result accent lighten-3">
<div class="card-background accent lighten-3"></div> <div class="card-background accent lighten-3"></div>
<canvas :id="'face-' + marker.ID" :key="marker.ID" width="300" height="300" style="width: 100%" class="v-responsive v-image accent lighten-2"></canvas> <canvas :id="'face-' + marker.ID" :key="marker.ID" width="300" height="300" style="width: 100%" class="v-responsive v-image accent lighten-2"></canvas>
<v-card-actions v-if="marker.Score < 30" class="card-details pa-0"> <v-card-actions class="card-details pa-0">
<v-layout row wrap align-center> <v-layout v-if="marker.Score < 30" row wrap align-center>
<v-flex xs6 class="text-xs-center pa-1"> <v-flex xs6 class="text-xs-center pa-1">
<v-btn color="accent lighten-2" <v-btn color="accent lighten-2"
small depressed dark block :round="false" small depressed dark block :round="false"
@ -45,18 +46,24 @@
</v-btn> </v-btn>
</v-flex> </v-flex>
</v-layout> </v-layout>
<v-layout v-else row wrap align-center>
<v-flex xs12 class="text-xs-left pa-1">
<v-text-field
v-model="marker.Label"
:rules="[textRule]"
color="secondary-dark"
browser-autocomplete="off"
class="input-name pa-0 ma-1"
hide-details
single-line
clearable
@click:clear="clearName(marker)"
@change="updateName(marker)"
@keyup.enter.native="updateName(marker)"
></v-text-field>
</v-flex>
</v-layout>
</v-card-actions> </v-card-actions>
<!-- v-card-title primary-title class="pa-3 card-details" style="user-select: none;">
<div>
<h3 class="body-2 mb-2">
<button class="action-title-edit" :data-uid="marker.ID"
@click.exact="alert('Name')">
Jens Mander
</button>
</h3>
</div>
</v-card-title -->
</v-card> </v-card>
</v-flex> </v-flex>
</v-layout> </v-layout>
@ -73,11 +80,12 @@ export default {
}, },
data() { data() {
return { return {
markers: this.model.getMarkers(), markers: this.model.getMarkers(true),
imageUrl: this.model.thumbnailUrl("fit_720"), imageUrl: this.model.thumbnailUrl("fit_720"),
disabled: !this.$config.feature("edit"), disabled: !this.$config.feature("edit"),
config: this.$config.values, config: this.$config.values,
readonly: this.$config.get("readonly"), readonly: this.$config.get("readonly"),
textRule: v => v.length <= this.$config.get('clip') || this.$gettext("Text too long"),
}; };
}, },
mounted () { mounted () {
@ -115,10 +123,21 @@ export default {
refresh() { refresh() {
}, },
reject(marker) { reject(marker) {
this.$notify.warn("Work in progress"); marker.Invalid = true;
this.model.updateMarker(marker);
}, },
confirm(marker) { confirm(marker) {
this.$notify.warn("Work in progress"); marker.Score = 100;
marker.Invalid = false;
this.model.updateMarker(marker);
},
clearName(marker) {
marker.Label = "";
marker.RefUID = "";
this.model.updateMarker(marker);
},
updateName(marker) {
this.model.updateMarker(marker);
}, },
}, },
}; };

View file

@ -742,7 +742,7 @@ export class Photo extends RestModel {
); );
} }
getMarkers() { getMarkers(valid) {
let result = []; let result = [];
let file = this.Files.find((f) => !!f.Primary); let file = this.Files.find((f) => !!f.Primary);
@ -752,7 +752,9 @@ export class Photo extends RestModel {
} }
file.Markers.forEach((m) => { file.Markers.forEach((m) => {
if (!valid || !m.Invalid) {
result.push(m); result.push(m);
}
}); });
return result; return result;
@ -828,6 +830,26 @@ export class Photo extends RestModel {
}); });
} }
updateMarker(marker) {
if (!marker || !marker.ID) {
return Promise.reject("invalid marker id");
}
marker.MarkerSrc = SrcManual;
const file = this.mainFile();
if (!file || !file.UID) {
return Promise.reject("invalid file uid");
}
const url = `${this.getEntityResource()}/files/${file.UID}/markers/${marker.ID}`;
return Api.put(url, marker).then((resp) => {
return Promise.resolve(this.setValues(resp.data));
});
}
static batchSize() { static batchSize() {
return 60; return 60;
} }

113
internal/api/file_marker.go Normal file
View file

@ -0,0 +1,113 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// PUT /api/v1/photos/:uid/files/:file_uid/markers/:id
//
// Parameters:
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
// id: int Marker ID as returned by the API
func UpdateFileMarker(router *gin.RouterGroup) {
router.PUT("/photos/:uid/files/:file_uid/markers/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFiles, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if !conf.Settings().Features.Edit {
AbortFeatureDisabled(c)
return
}
photoUID := c.Param("uid")
fileUID := c.Param("file_uid")
markerID := txt.UInt(c.Param("id"))
if photoUID == "" || fileUID == "" || markerID < 1 {
AbortBadRequest(c)
return
}
file, err := query.FileByUID(fileUID)
if err != nil {
log.Errorf("photo: %s (update marker)", err)
AbortEntityNotFound(c)
return
}
if !file.FilePrimary {
log.Errorf("photo: can't update markers for non-primary files")
AbortBadRequest(c)
return
} else if file.PhotoUID != photoUID {
log.Errorf("photo: file uid doesn't match")
AbortBadRequest(c)
return
}
marker, err := query.MarkerByID(markerID)
if err != nil {
log.Errorf("photo: %s (update marker)", err)
AbortEntityNotFound(c)
return
}
markerForm, err := form.NewMarker(marker)
if err != nil {
log.Errorf("photo: %s (new marker form)", err)
AbortSaveFailed(c)
return
}
if err := c.BindJSON(&markerForm); err != nil {
log.Errorf("photo: %s (update marker form)", err)
AbortBadRequest(c)
return
}
if err := marker.SaveForm(markerForm); err != nil {
log.Errorf("photo: %s (save marker form)", err)
AbortSaveFailed(c)
return
}
event.SuccessMsg(i18n.MsgChangesSaved)
if p, err := query.PhotoPreloadByUID(photoUID); err != nil {
AbortEntityNotFound(c)
return
} else {
if faceCount := file.FaceCount(); p.PhotoFaces == faceCount {
// Do nothing.
} else if err := p.Update("PhotoFaces", faceCount); err != nil {
log.Errorf("photo: %s (update face count)", err)
} else {
// Notify clients by publishing events.
PublishPhotoEvent(EntityUpdated, photoUID, c)
p.PhotoFaces = faceCount
}
c.JSON(http.StatusOK, p)
}
})
}

View file

@ -0,0 +1,62 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/tidwall/gjson"
"github.com/stretchr/testify/assert"
)
func TestUpdateFileMarker(t *testing.T) {
t.Run("success", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
UpdateFileMarker(router)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0y11")
assert.Equal(t, http.StatusOK, r.Code)
photoUID := gjson.Get(r.Body.String(), "UID").String()
fileUID := gjson.Get(r.Body.String(), "Files.0.UID").String()
markerID := gjson.Get(r.Body.String(), "Files.0.Markers.0.ID").String()
assert.NotEmpty(t, photoUID)
assert.NotEmpty(t, fileUID)
assert.NotEmpty(t, markerID)
u := fmt.Sprintf("/api/v1/photos/%s/files/%s/markers/%s", photoUID, fileUID, markerID)
var m = struct {
RefUID string
RefSrc string
MarkerSrc string
MarkerType string
MarkerScore int
MarkerInvalid bool
MarkerLabel string
}{
RefUID: "3h59wvth837b5vyiub35",
RefSrc: "meta",
MarkerSrc: "image",
MarkerType: "Face",
MarkerScore: 100,
MarkerInvalid: true,
MarkerLabel: "Foo",
}
if b, err := json.Marshal(m); err != nil {
t.Fatal(err)
} else {
t.Logf("PUT %s", u)
r = PerformRequestWithBody(app, "PUT", u, string(b))
}
assert.Equal(t, http.StatusOK, r.Code)
})
}

View file

@ -313,7 +313,7 @@ func (m *Album) SetTitle(title string) {
} }
} }
// Saves the entity using form data and stores it in the database. // SaveForm updates the entity using form data and stores it in the database.
func (m *Album) SaveForm(f form.Album) error { func (m *Album) SaveForm(f form.Album) error {
if err := deepcopier.Copy(m).From(f); err != nil { if err := deepcopier.Copy(m).From(f); err != nil {
return err return err

View file

@ -412,6 +412,17 @@ func (m *File) AddFace(f face.Face, refUID string) {
} }
} }
// FaceCount returns the current number of valid faces detected.
func (m *File) FaceCount() (c int) {
if err := Db().Model(Marker{}).Where("marker_invalid = 0 AND file_id = ?", m.ID).
Count(&c).Error; err != nil {
log.Errorf("file: %s (count faces)", err)
return 0
} else {
return c
}
}
// PreloadMarkers loads existing file markers. // PreloadMarkers loads existing file markers.
func (m *File) PreloadMarkers() { func (m *File) PreloadMarkers() {
if res, err := FindMarkers(m.ID); err != nil { if res, err := FindMarkers(m.ID); err != nil {

View file

@ -447,3 +447,13 @@ func TestFile_AddFaces(t *testing.T) {
assert.NotEmpty(t, file.Markers) assert.NotEmpty(t, file.Markers)
}) })
} }
func TestFile_FaceCount(t *testing.T) {
t.Run("FileFixturesExampleBridge", func(t *testing.T) {
file := FileFixturesExampleBridge
result := file.FaceCount()
assert.GreaterOrEqual(t, result, 3)
})
}

View file

@ -24,4 +24,5 @@ func CreateTestFixtures() {
CreateFileShareFixtures() CreateFileShareFixtures()
CreateFileSyncFixtures() CreateFileSyncFixtures()
CreateLensFixtures() CreateLensFixtures()
CreateMarkerFixtures()
} }

View file

@ -4,6 +4,10 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/face"
) )
@ -18,6 +22,7 @@ type Marker struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"-"` ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
FileID uint `gorm:"index;" json:"-" yaml:"-"` FileID uint `gorm:"index;" json:"-" yaml:"-"`
RefUID string `gorm:"type:VARBINARY(42);index;" json:"RefUID" yaml:"RefUID,omitempty"` RefUID string `gorm:"type:VARBINARY(42);index;" json:"RefUID" yaml:"RefUID,omitempty"`
RefSrc string `gorm:"type:VARBINARY(8);default:'';" json:"RefSrc" yaml:"RefSrc,omitempty"`
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"` MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"` MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
MarkerScore int `gorm:"type:SMALLINT" json:"Score" yaml:"Score"` MarkerScore int `gorm:"type:SMALLINT" json:"Score" yaml:"Score"`
@ -32,6 +37,9 @@ type Marker struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
// UnknownMarker can be used as a default for unknown markers.
var UnknownMarker = NewMarker(0, "", SrcAuto, MarkerUnknown, 0, 0, 0, 0)
// TableName returns the entity database table name. // TableName returns the entity database table name.
func (Marker) TableName() string { func (Marker) TableName() string {
return "markers_dev" return "markers_dev"
@ -75,6 +83,23 @@ func (m *Marker) Update(attr string, value interface{}) error {
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
} }
// SaveForm updates the entity using form data and stores it in the database.
func (m *Marker) SaveForm(f form.Marker) error {
if err := deepcopier.Copy(m).From(f); err != nil {
return err
}
if f.MarkerLabel != "" {
m.MarkerLabel = txt.Title(txt.Clip(f.MarkerLabel, txt.ClipKeyword))
}
if err := m.Save(); err != nil {
return err
}
return nil
}
// Save updates the existing or inserts a new row. // Save updates the existing or inserts a new row.
func (m *Marker) Save() error { func (m *Marker) Save() error {
if m.X == 0 || m.Y == 0 || m.X > 1 || m.Y > 1 || m.X < -1 || m.Y < -1 { if m.X == 0 || m.Y == 0 || m.X > 1 || m.Y > 1 || m.X < -1 || m.Y < -1 {

View file

@ -0,0 +1,61 @@
package entity
type MarkerMap map[string]Marker
func (m MarkerMap) Get(name string) Marker {
if result, ok := m[name]; ok {
return result
}
return *UnknownMarker
}
func (m MarkerMap) Pointer(name string) *Marker {
if result, ok := m[name]; ok {
return &result
}
return UnknownMarker
}
var MarkerFixtures = MarkerMap{
"1000003-1": Marker{
FileID: 1000003,
RefUID: "lt9k3pw1wowuy3c3",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
X: 0.308333,
Y: 0.206944,
W: 0.355556,
H: .355556,
},
"1000003-2": Marker{
FileID: 1000003,
RefUID: "",
MarkerLabel: "Unknown",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
X: 0.208333,
Y: 0.106944,
W: 0.05,
H: 0.05,
},
"1000003-3": Marker{
FileID: 1000003,
RefUID: "",
MarkerSrc: SrcImage,
MarkerType: MarkerLabel,
MarkerLabel: "Center",
X: 0.5,
Y: 0.5,
W: 0,
H: 0,
},
}
// CreateMarkerFixtures inserts known entities into the database for testing.
func CreateMarkerFixtures() {
for _, entity := range MarkerFixtures {
Db().Create(&entity)
}
}

20
internal/form/marker.go Normal file
View file

@ -0,0 +1,20 @@
package form
import "github.com/ulule/deepcopier"
// Marker represents an image marker edit form.
type Marker struct {
RefUID string `json:"RefUID"`
RefSrc string `json:"RefSrc"`
MarkerSrc string `json:"Src"`
MarkerType string `json:"Type"`
MarkerScore int `json:"Score"`
MarkerInvalid bool `json:"Invalid"`
MarkerLabel string `json:"Label"`
}
func NewMarker(m interface{}) (f Marker, err error) {
err = deepcopier.Copy(m).To(&f)
return f, err
}

View file

@ -0,0 +1,43 @@
package form
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewMarker(t *testing.T) {
t.Run("success", func(t *testing.T) {
var m = struct {
RefUID string
RefSrc string
MarkerSrc string
MarkerType string
MarkerScore int
MarkerInvalid bool
MarkerLabel string
}{
RefUID: "3h59wvth837b5vyiub35",
RefSrc: "meta",
MarkerSrc: "image",
MarkerType: "Face",
MarkerScore: 100,
MarkerInvalid: true,
MarkerLabel: "Foo",
}
f, err := NewMarker(m)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "3h59wvth837b5vyiub35", f.RefUID)
assert.Equal(t, "meta", f.RefSrc)
assert.Equal(t, "image", f.MarkerSrc)
assert.Equal(t, "Face", f.MarkerType)
assert.Equal(t, 100, f.MarkerScore)
assert.Equal(t, true, f.MarkerInvalid)
assert.Equal(t, "Foo", f.MarkerLabel)
})
}

15
internal/query/markers.go Normal file
View file

@ -0,0 +1,15 @@
package query
import (
"github.com/photoprism/photoprism/internal/entity"
)
// MarkerByID returns a Marker based on the ID.
func MarkerByID(id uint) (marker entity.Marker, err error) {
if err := UnscopedDb().Where("id = ?", id).
First(&marker).Error; err != nil {
return marker, err
}
return marker, nil
}

View file

@ -72,6 +72,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetMomentsTime(v1) api.GetMomentsTime(v1)
api.GetFile(v1) api.GetFile(v1)
api.DeleteFile(v1) api.DeleteFile(v1)
api.UpdateFileMarker(v1)
api.PhotoPrimary(v1) api.PhotoPrimary(v1)
api.PhotoUnstack(v1) api.PhotoUnstack(v1)

View file

@ -2,43 +2,12 @@ package txt
import ( import (
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
var UnknownCountryCode = "zz" var UnknownCountryCode = "zz"
var CountryWordsRegexp = regexp.MustCompile("[\\p{L}]{2,}") var CountryWordsRegexp = regexp.MustCompile("[\\p{L}]{2,}")
// Int returns a string as int or 0 if it can not be converted.
func Int(s string) int {
if s == "" {
return 0
}
result, err := strconv.ParseInt(s, 10, 32)
if err != nil {
return 0
}
return int(result)
}
// IsUInt returns true if a string only contains an unsigned integer.
func IsUInt(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < 48 || r > 57 {
return false
}
}
return true
}
// CountryCode tries to find a matching country code for a given string e.g. from a file oder directory name. // CountryCode tries to find a matching country code for a given string e.g. from a file oder directory name.
func CountryCode(s string) (code string) { func CountryCode(s string) (code string) {
code = UnknownCountryCode code = UnknownCountryCode

View file

@ -6,33 +6,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestInt(t *testing.T) {
t.Run("empty", func(t *testing.T) {
result := Int("")
assert.Equal(t, 0, result)
})
t.Run("non-numeric", func(t *testing.T) {
result := Int("Screenshot")
assert.Equal(t, 0, result)
})
t.Run("zero", func(t *testing.T) {
result := Int("0")
assert.Equal(t, 0, result)
})
t.Run("int", func(t *testing.T) {
result := Int("123")
assert.Equal(t, 123, result)
})
t.Run("negative int", func(t *testing.T) {
result := Int("-123")
assert.Equal(t, -123, result)
})
}
func TestCountryCode(t *testing.T) { func TestCountryCode(t *testing.T) {
t.Run("London", func(t *testing.T) { t.Run("London", func(t *testing.T) {
result := CountryCode("London") result := CountryCode("London")
@ -134,9 +107,3 @@ func TestCountryCode(t *testing.T) {
assert.Equal(t, "it", result) assert.Equal(t, "it", result)
}) })
} }
func TestIsUInt(t *testing.T) {
assert.False(t, IsUInt(""))
assert.False(t, IsUInt("12 3"))
assert.True(t, IsUInt("123"))
}

50
pkg/txt/int.go Normal file
View file

@ -0,0 +1,50 @@
package txt
import (
"strconv"
)
// Int converts a string to a signed integer or 0 if invalid.
func Int(s string) int {
if s == "" {
return 0
}
result, err := strconv.ParseInt(s, 10, 32)
if err != nil {
return 0
}
return int(result)
}
// UInt converts a string to an unsigned integer or 0 if invalid.
func UInt(s string) uint {
if s == "" {
return 0
}
result, err := strconv.ParseInt(s, 10, 32)
if err != nil || result < 0 {
return 0
}
return uint(result)
}
// IsUInt tests if a string represents an unsigned integer.
func IsUInt(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < 48 || r > 57 {
return false
}
}
return true
}

39
pkg/txt/int_test.go Normal file
View file

@ -0,0 +1,39 @@
package txt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInt(t *testing.T) {
t.Run("empty", func(t *testing.T) {
result := Int("")
assert.Equal(t, 0, result)
})
t.Run("non-numeric", func(t *testing.T) {
result := Int("Screenshot")
assert.Equal(t, 0, result)
})
t.Run("zero", func(t *testing.T) {
result := Int("0")
assert.Equal(t, 0, result)
})
t.Run("int", func(t *testing.T) {
result := Int("123")
assert.Equal(t, 123, result)
})
t.Run("negative int", func(t *testing.T) {
result := Int("-123")
assert.Equal(t, -123, result)
})
}
func TestIsUInt(t *testing.T) {
assert.False(t, IsUInt(""))
assert.False(t, IsUInt("12 3"))
assert.True(t, IsUInt("123"))
}