Faces: Add POST REST endpoint to manually create new file markers #1548

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-07-27 19:13:00 +02:00
parent 83473f6f93
commit 36bac7ab48
5 changed files with 369 additions and 21 deletions

View File

@ -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)
})
}

View File

@ -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)

View File

@ -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
}

View File

@ -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())
})
}

View File

@ -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)