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:
parent
83473f6f93
commit
36bac7ab48
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue