From 36bac7ab481d47a0d10a922596534ddbe1b742c0 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 27 Jul 2023 19:13:00 +0200 Subject: [PATCH] Faces: Add POST REST endpoint to manually create new file markers #1548 Signed-off-by: Michael Mayer --- internal/api/markers.go | 139 +++++++++++++++++++++++++++++++---- internal/api/markers_test.go | 133 ++++++++++++++++++++++++++++++++- internal/form/marker.go | 50 +++++++++++-- internal/form/marker_test.go | 67 +++++++++++++++++ internal/server/routes.go | 1 + 5 files changed, 369 insertions(+), 21 deletions(-) diff --git a/internal/api/markers.go b/internal/api/markers.go index 670210eaa..5a6d6aa47 100644 --- a/internal/api/markers.go +++ b/internal/api/markers.go @@ -3,12 +3,16 @@ package api import ( "fmt" "net/http" + "strings" "time" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/dustin/go-humanize/english" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/crop" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" @@ -68,15 +72,113 @@ func findFileMarker(c *gin.Context) (file *entity.File, marker *entity.Marker, e return file, marker, nil } -// UpdateMarker updates an existing file marker e.g. representing a face. +// CreateMarker adds a new file area marker to assign faces or other subjects. +// +// POST /api/v1/markers +// +// See internal/form/marker.go for the values required to create a new marker. +func CreateMarker(router *gin.RouterGroup) { + router.POST("/markers", func(c *gin.Context) { + s := Auth(c, acl.ResourceFiles, acl.ActionUpdate) + + // Abort if permission was not granted. + if s.Abort(c) { + return + } + + // Initialize form. + frm := form.Marker{ + FileUID: "", + MarkerType: entity.MarkerFace, + MarkerSrc: entity.SrcManual, + MarkerReview: false, + MarkerInvalid: false, + } + + // Initialize form. + if err := c.BindJSON(&frm); err != nil { + log.Errorf("faces: %s (bind marker form)", err) + AbortBadRequest(c) + return + } + + // Find related file. + file, err := query.FileByUID(frm.FileUID) + + // Abort if not found. + if err != nil { + AbortEntityNotFound(c) + return + } + + // Validate form values. + if err = frm.Validate(); err != nil { + log.Errorf("faces: %s (validate new marker)", err) + AbortBadRequest(c) + return + } else if frm.W <= 0 || frm.H <= 0 { + log.Errorf("faces: width and height must be greater than zero") + AbortBadRequest(c) + return + } + + // Create new face marker area. + area := crop.NewArea("face", frm.X, frm.Y, frm.W, frm.H) + + // Create new marker entity. + marker := entity.NewMarker(*file, area, "", frm.MarkerSrc, frm.MarkerType, int(frm.W*float32(file.FileWidth)), 100) + + // Update marker from form values. + if err = marker.Create(); err != nil { + log.Errorf("faces: %s (create marker)", err) + AbortBadRequest(c) + return + } + + // Update marker subject if a name was provided. + if strings.TrimSpace(frm.MarkerName) == "" { + log.Infof("faces: added new %s marker", clean.Log(marker.MarkerType)) + } else if changed, saveErr := marker.SaveForm(frm); err != nil { + log.Errorf("faces: %s (update marker)", saveErr) + AbortSaveFailed(c) + return + } else if changed { + if updateErr := query.UpdateSubjectCovers(); updateErr != nil { + log.Errorf("faces: %s (update covers)", updateErr) + } + + if updateErr := entity.UpdateSubjectCounts(); updateErr != nil { + log.Errorf("faces: %s (update counts)", updateErr) + } + } + + // Update photo metadata. + if !file.FilePrimary { + log.Infof("faces: skipped updating photo for non-primary file") + } else if p, err := query.PhotoByUID(file.PhotoUID); err != nil { + log.Errorf("faces: %s (find photo))", err) + } else if err := p.UpdateAndSaveTitle(); err != nil { + log.Errorf("faces: %s (update photo title)", err) + } else { + // Publish updated photo entity. + PublishPhotoEvent(EntityUpdated, file.PhotoUID, c) + } + + // Display success message. + event.SuccessMsg(i18n.MsgChangesSaved) + + // Return new marker. + c.JSON(http.StatusOK, marker) + }) +} + +// UpdateMarker updates an existing file area marker to assign faces or other subjects. // // PUT /api/v1/markers/:marker_uid // // 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 +// marker_uid: string Marker UID as returned by the API func UpdateMarker(router *gin.RouterGroup) { router.PUT("/markers/:marker_uid", func(c *gin.Context) { // Abort if workers runs less than once per hour. @@ -100,21 +202,28 @@ func UpdateMarker(router *gin.RouterGroup) { } // Initialize form. - f, err := form.NewMarker(*marker) + frm, err := form.NewMarker(*marker) if err != nil { - log.Errorf("faces: %s (create marker update form)", err) + log.Errorf("faces: %s (create marker form)", err) AbortSaveFailed(c) return - } else if err := c.BindJSON(&f); err != nil { - log.Errorf("faces: %s (set updated marker values)", err) + } else if err = c.BindJSON(&frm); err != nil { + log.Errorf("faces: %s (bind marker form)", err) + AbortBadRequest(c) + return + } + + // Validate form values. + if err = frm.Validate(); err != nil { + log.Errorf("faces: %s (validate updated marker)", err) AbortBadRequest(c) return } // Update marker from form values. - if changed, err := marker.SaveForm(f); err != nil { - log.Errorf("faces: %s (update marker)", err) + if changed, saveErr := marker.SaveForm(frm); saveErr != nil { + log.Errorf("faces: %s (update marker)", saveErr) AbortSaveFailed(c) return } else if changed { @@ -126,12 +235,12 @@ func UpdateMarker(router *gin.RouterGroup) { } } - if err := query.UpdateSubjectCovers(); err != nil { - log.Errorf("faces: %s (update covers)", err) + if updateErr := query.UpdateSubjectCovers(); updateErr != nil { + log.Errorf("faces: %s (update covers)", updateErr) } - if err := entity.UpdateSubjectCounts(); err != nil { - log.Errorf("faces: %s (update counts)", err) + if updateErr := entity.UpdateSubjectCounts(); updateErr != nil { + log.Errorf("faces: %s (update counts)", updateErr) } } @@ -147,8 +256,10 @@ func UpdateMarker(router *gin.RouterGroup) { PublishPhotoEvent(EntityUpdated, file.PhotoUID, c) } + // Display success message. event.SuccessMsg(i18n.MsgChangesSaved) + // Return updated marker. c.JSON(http.StatusOK, marker) }) } diff --git a/internal/api/markers_test.go b/internal/api/markers_test.go index 5dae5d3cd..5441d85d5 100644 --- a/internal/api/markers_test.go +++ b/internal/api/markers_test.go @@ -12,8 +12,137 @@ import ( "github.com/photoprism/photoprism/internal/form" ) +func TestCreateMarker(t *testing.T) { + t.Run("Success", func(t *testing.T) { + app, router, _ := NewApiTest() + + GetPhoto(router) + CreateMarker(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() + markerUid := gjson.Get(r.Body.String(), "Files.0.Markers.0.UID").String() + + assert.NotEmpty(t, photoUid) + assert.NotEmpty(t, fileUid) + assert.NotEmpty(t, markerUid) + + u := "/api/v1/markers" + + frm := form.Marker{ + FileUID: fileUid, + MarkerType: "face", + X: 0.303519, + Y: 0.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "", + MarkerName: "", + MarkerReview: false, + MarkerInvalid: false, + } + + if b, err := json.Marshal(frm); err != nil { + t.Fatal(err) + } else { + t.Logf("POST %s", u) + r = PerformRequestWithBody(app, "POST", u, string(b)) + } + + assert.Equal(t, http.StatusOK, r.Code) + }) + t.Run("SuccessWithName", func(t *testing.T) { + app, router, _ := NewApiTest() + + GetPhoto(router) + CreateMarker(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() + markerUid := gjson.Get(r.Body.String(), "Files.0.Markers.0.UID").String() + + assert.NotEmpty(t, photoUid) + assert.NotEmpty(t, fileUid) + assert.NotEmpty(t, markerUid) + + u := "/api/v1/markers" + + frm := form.Marker{ + FileUID: fileUid, + MarkerType: "face", + X: 0.303519, + Y: 0.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "manual", + MarkerName: "Jens Mander", + MarkerReview: false, + MarkerInvalid: false, + } + + if b, err := json.Marshal(frm); err != nil { + t.Fatal(err) + } else { + t.Logf("POST %s", u) + r = PerformRequestWithBody(app, "POST", u, string(b)) + } + + assert.Equal(t, http.StatusOK, r.Code) + }) + t.Run("InvalidArea", func(t *testing.T) { + app, router, _ := NewApiTest() + + GetPhoto(router) + CreateMarker(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() + markerUid := gjson.Get(r.Body.String(), "Files.0.Markers.0.UID").String() + + assert.NotEmpty(t, photoUid) + assert.NotEmpty(t, fileUid) + assert.NotEmpty(t, markerUid) + + u := "/api/v1/markers" + + frm := form.Marker{ + FileUID: fileUid, + MarkerType: "face", + X: 0.5, + Y: 0.5, + W: 0, + H: 0, + SubjSrc: "", + MarkerName: "", + MarkerReview: false, + MarkerInvalid: false, + } + + if b, err := json.Marshal(frm); err != nil { + t.Fatal(err) + } else { + t.Logf("POST %s", u) + r = PerformRequestWithBody(app, "POST", u, string(b)) + } + + assert.Equal(t, http.StatusBadRequest, r.Code) + }) +} + func TestUpdateMarker(t *testing.T) { - t.Run("success", func(t *testing.T) { + t.Run("Success", func(t *testing.T) { app, router, _ := NewApiTest() GetPhoto(router) @@ -48,7 +177,7 @@ func TestUpdateMarker(t *testing.T) { assert.Equal(t, http.StatusOK, r.Code) }) - t.Run("bad request non- primary file", func(t *testing.T) { + t.Run("NonPrimaryFile", func(t *testing.T) { app, router, _ := NewApiTest() UpdateMarker(router) diff --git a/internal/form/marker.go b/internal/form/marker.go index 98fd3b283..f1530c1d2 100644 --- a/internal/form/marker.go +++ b/internal/form/marker.go @@ -1,17 +1,57 @@ package form -import "github.com/ulule/deepcopier" +import ( + "fmt" + + "github.com/ulule/deepcopier" + + "github.com/photoprism/photoprism/pkg/rnd" +) // Marker represents an image marker edit form. type Marker struct { - SubjSrc string `json:"SubjSrc"` - MarkerName string `json:"Name"` - MarkerReview bool `json:"MarkerReview"` - MarkerInvalid bool `json:"Invalid"` + FileUID string `json:"FileUID,omitempty"` + MarkerType string `json:"Type,omitempty"` + MarkerSrc string `json:"Src,omitempty"` + X float32 `json:"X"` + Y float32 `json:"Y"` + W float32 `json:"W,omitempty"` + H float32 `json:"H,omitempty"` + SubjSrc string `json:"SubjSrc"` + MarkerName string `json:"Name"` + MarkerReview bool `json:"MarkerReview"` + MarkerInvalid bool `json:"Invalid"` } +// NewMarker creates a new form initialized with model values. func NewMarker(m interface{}) (f Marker, err error) { err = deepcopier.Copy(m).To(&f) return f, err } + +// Validate returns an error if any form values are invalid. +func (frm *Marker) Validate() error { + // Check type and src length. + if len(frm.MarkerType) > 8 || len(frm.MarkerSrc) > 8 || len(frm.SubjSrc) > 8 { + return fmt.Errorf("invalid type or src") + } + + if len([]rune(frm.MarkerName)) > 160 { + return fmt.Errorf("name is too long") + } + + // Validate file UID. + if frm.FileUID == "" { + return fmt.Errorf("missing file uid") + } else if rnd.InvalidUID(frm.FileUID, 'f') { + return fmt.Errorf("invalid file uid") + } + + // Check if the coordinates are within a valid range. + if frm.X > 1 || frm.Y > 1 || frm.X < 0 || frm.Y < 0 || frm.W < 0 || frm.H < 0 || frm.W > 1 || frm.H > 1 { + return fmt.Errorf("invalid area") + } + + return nil +} diff --git a/internal/form/marker_test.go b/internal/form/marker_test.go index 3f5d17d5e..b66745f73 100644 --- a/internal/form/marker_test.go +++ b/internal/form/marker_test.go @@ -32,3 +32,70 @@ func TestNewMarker(t *testing.T) { assert.Equal(t, true, f.MarkerInvalid) }) } + +func TestMarker_Validate(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + frm := Marker{} + assert.Error(t, frm.Validate()) + }) + t.Run("False", func(t *testing.T) { + frm := Marker{ + FileUID: "frygcme3hc9re8nc", + MarkerType: "face", + X: 0.303519, + Y: 0.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "manual", + MarkerName: "Jens Mander", + MarkerReview: false, + MarkerInvalid: false, + } + assert.Nil(t, frm.Validate()) + }) + t.Run("FileUID", func(t *testing.T) { + frm := Marker{ + FileUID: "rygcme3hc9re8nc", + MarkerType: "face", + X: 0.303519, + Y: 0.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "manual", + MarkerName: "Jens Mander", + MarkerReview: false, + MarkerInvalid: false, + } + assert.Error(t, frm.Validate()) + }) + t.Run("Area", func(t *testing.T) { + frm := Marker{ + FileUID: "frygcme3hc9re8nc", + MarkerType: "face", + X: 0.303519, + Y: 1.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "manual", + MarkerName: "Jens Mander", + MarkerReview: false, + MarkerInvalid: false, + } + assert.Error(t, frm.Validate()) + }) + t.Run("Name", func(t *testing.T) { + frm := Marker{ + FileUID: "frygcme3hc9re8nc", + MarkerType: "face", + X: 0.303519, + Y: 0.260742, + W: 0.548387, + H: 0.365234, + SubjSrc: "manual", + MarkerName: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer...", + MarkerReview: false, + MarkerInvalid: false, + } + assert.Error(t, frm.Validate()) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 961ef81fe..4f8a0132c 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -97,6 +97,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.GetFile(APIv1) api.DeleteFile(APIv1) api.ChangeFileOrientation(APIv1) + api.CreateMarker(APIv1) api.UpdateMarker(APIv1) api.ClearMarkerSubject(APIv1) api.PhotoPrimary(APIv1)