diff --git a/frontend/src/model/marker.js b/frontend/src/model/marker.js index 8463898a8..72308ade8 100644 --- a/frontend/src/model/marker.js +++ b/frontend/src/model/marker.js @@ -41,7 +41,7 @@ export class Marker extends RestModel { UID: "", FileUID: "", FileHash: "", - FileArea: "", + CropArea: "", Type: "", Src: "", Name: "", @@ -84,12 +84,12 @@ export class Marker extends RestModel { thumbnailUrl(size) { if (!size) { - size = "crop_160"; + size = "tile_160"; } - if (this.FileHash && this.FileArea) { + if (this.FileHash && this.CropArea) { return `${config.contentUri}/t/${this.FileHash}/${config.previewToken()}/${size}/${ - this.FileArea + this.CropArea }`; } else { return `${config.contentUri}/svg/portrait`; diff --git a/internal/api/photo_thumb.go b/internal/api/photo_thumb.go index 638bb9fc1..6bd10ccc3 100644 --- a/internal/api/photo_thumb.go +++ b/internal/api/photo_thumb.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/internal/crop" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" @@ -31,6 +30,8 @@ func GetThumb(router *gin.RouterGroup) { return } + logPrefix := "thumb" + start := time.Now() conf := service.Config() fileHash := c.Param("hash") @@ -40,7 +41,7 @@ func GetThumb(router *gin.RouterGroup) { size, ok := thumb.Sizes[thumbName] if !ok { - log.Errorf("thumbs: invalid size %s", thumbName) + log.Errorf("%s: invalid size %s", logPrefix, thumbName) c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) return } @@ -49,7 +50,7 @@ func GetThumb(router *gin.RouterGroup) { thumbName, size = thumb.Find(conf.ThumbSizePrecached()) if thumbName == "" { - log.Errorf("thumbs: invalid size %d", conf.ThumbSizePrecached()) + log.Errorf("%s: invalid size %d", logPrefix, conf.ThumbSizePrecached()) c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) return } @@ -64,7 +65,7 @@ func GetThumb(router *gin.RouterGroup) { cached := cacheData.(ThumbCache) if !fs.FileExists(cached.FileName) { - log.Errorf("thumbs: %s not found", fileHash) + log.Errorf("%s: %s not found", logPrefix, fileHash) c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) return } @@ -115,16 +116,16 @@ func GetThumb(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("thumbs: file %s is missing", txt.Quote(f.FileName)) + log.Errorf("%s: file %s is missing", logPrefix, txt.Quote(f.FileName)) c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. - logError("thumbs", f.Update("FileMissing", true)) + logError(logPrefix, f.Update("FileMissing", true)) if f.AllFilesMissing() { - log.Infof("thumbs: deleting photo, all files missing for %s", txt.Quote(f.FileName)) + log.Infof("%s: deleting photo, all files missing for %s", logPrefix, txt.Quote(f.FileName)) - logError("thumbs", f.RelatedPhoto().Delete(false)) + logError(logPrefix, f.RelatedPhoto().Delete(false)) } return @@ -132,7 +133,7 @@ func GetThumb(router *gin.RouterGroup) { // Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157 if size.ExceedsLimit() && c.Query("download") == "" { - log.Debugf("thumbs: using original, size exceeds limit (width %d, height %d)", size.Width, size.Height) + log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", logPrefix, size.Width, size.Height) AddThumbCacheHeader(c) c.File(fileName) @@ -149,11 +150,11 @@ func GetThumb(router *gin.RouterGroup) { } if err != nil { - log.Errorf("thumbs: %s", err) + log.Errorf("%s: %s", logPrefix, err) c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) return } else if thumbnail == "" { - log.Errorf("thumbs: %s has empty thumb name - bug?", filepath.Base(fileName)) + log.Errorf("%s: %s has empty thumb name - bug?", logPrefix, filepath.Base(fileName)) c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) return } @@ -170,59 +171,3 @@ func GetThumb(router *gin.RouterGroup) { } }) } - -// GetThumbCrop returns a cropped thumbnail image matching the hash and type. -// -// GET /api/v1/t/:hash/:token/:size/:area -// -// Parameters: -// hash: string sha1 file hash -// token: string url security token, see config -// size: string thumb type, see thumb.Sizes -// area: string image area identifier, e.g. 022004010015 -func GetThumbCrop(router *gin.RouterGroup) { - router.GET("/t/:hash/:token/:size/:area", func(c *gin.Context) { - if InvalidPreviewToken(c) { - c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg) - return - } - - conf := service.Config() - fileHash := c.Param("hash") - thumbName := thumb.Name(c.Param("size")) - cropArea := c.Param("area") - download := c.Query("download") != "" - - size, ok := thumb.Sizes[thumbName] - - if !ok || len(size.Options) < 1 { - log.Errorf("thumbs: invalid size %s", thumbName) - c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) - return - } else if size.Options[0] != thumb.ResampleCrop { - log.Errorf("thumbs: invalid size %s", thumbName) - c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) - return - } - - fileName, err := crop.FromCache(fileHash, conf.ThumbPath(), size.Width, size.Height, cropArea) - - if err != nil { - log.Errorf("thumbs: %s", err) - c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) - return - } else if fileName == "" { - log.Errorf("thumbs: empty file name, potential bug") - c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) - return - } - - AddThumbCacheHeader(c) - - if download { - c.FileAttachment(fileName, thumbName.Jpeg()) - } else { - c.File(fileName) - } - }) -} diff --git a/internal/api/photo_thumb_crop.go b/internal/api/photo_thumb_crop.go new file mode 100644 index 000000000..b0705e34c --- /dev/null +++ b/internal/api/photo_thumb_crop.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/crop" + "github.com/photoprism/photoprism/internal/service" +) + +// GetThumbCrop returns a cropped thumbnail image matching the hash and type. +// +// GET /api/v1/t/:hash/:token/:size/:crop +// +// Parameters: +// hash: string sha1 file hash +// token: string url security token, see config +// size: string crop size, see crop.Sizes +// area: string image area identifier, e.g. 1690960ff17f +func GetThumbCrop(router *gin.RouterGroup) { + router.GET("/t/:hash/:token/:size/:area", func(c *gin.Context) { + if InvalidPreviewToken(c) { + c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg) + return + } + + logPrefix := "thumb-crop" + + conf := service.Config() + fileHash := c.Param("hash") + cropName := crop.Name(c.Param("size")) + cropArea := c.Param("area") + download := c.Query("download") != "" + + cropSize, ok := crop.Sizes[cropName] + + if !ok { + log.Errorf("%s: invalid size %s", logPrefix, cropName) + c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) + return + } + + fileName, err := crop.FromCache(fileHash, cropArea, cropSize, conf.ThumbPath()) + + if err != nil { + log.Warnf("%s: %s", logPrefix, err) + c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) + return + } else if fileName == "" { + log.Errorf("%s: empty file name, potential bug", logPrefix) + c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) + return + } + + AddThumbCacheHeader(c) + + if download { + c.FileAttachment(fileName, cropName.Jpeg()) + } else { + c.File(fileName) + } + }) +} diff --git a/internal/api/photo_thumb_test.go b/internal/api/photo_thumb_test.go index f50b4151b..f45d988e6 100644 --- a/internal/api/photo_thumb_test.go +++ b/internal/api/photo_thumb_test.go @@ -52,7 +52,7 @@ func TestGetThumbCrop(t *testing.T) { t.Run("NotFound", func(t *testing.T) { app, router, conf := NewApiTest() GetThumbCrop(router) - r := PerformRequest(app, "GET", "/api/v1/t/46f5b5c0c027f0c1b15136644f404c57210bf20c/"+conf.PreviewToken()+"/crop_160/016014058037") + r := PerformRequest(app, "GET", "/api/v1/t/46f5b5c0c027f0c1b15136644f404c57210bf20c/"+conf.PreviewToken()+"/tile_160/016014058037") assert.Equal(t, http.StatusOK, r.Code) }) diff --git a/internal/crop/area.go b/internal/crop/area.go index dcad88b1d..d600c4a40 100644 --- a/internal/crop/area.go +++ b/internal/crop/area.go @@ -2,6 +2,7 @@ package crop import ( "fmt" + "image" ) // Areas represents a list of relative crop areas. @@ -16,9 +17,20 @@ type Area struct { H float32 `json:"h,omitempty"` } -// 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)) +// String returns a string identifying the crop area. +func (a Area) String() string { + return fmt.Sprintf("%03x%03x%03x%03x", int(a.X*1000), int(a.Y*1000), int(a.W*1000), int(a.H*1000)) +} + +// Bounds returns absolute coordinates and dimension. +func (a Area) Bounds(img image.Image) (min, max image.Point, dim int) { + size := img.Bounds().Max + + min = image.Point{X: int(float32(size.X) * a.X), Y: int(float32(size.Y) * a.Y)} + max = image.Point{X: int(float32(size.X) * (a.X + a.W)), Y: int(float32(size.Y) * (a.Y + a.H))} + dim = int(float32(size.X) * a.W) + + return min, max, dim } // clipVal ensures the relative size is within a valid range. diff --git a/internal/crop/area_test.go b/internal/crop/area_test.go index e2f1a3846..aee6bd778 100644 --- a/internal/crop/area_test.go +++ b/internal/crop/area_test.go @@ -1,30 +1,36 @@ package crop import ( + "fmt" "testing" "github.com/stretchr/testify/assert" ) func TestArea_String(t *testing.T) { - t.Run("082016010006_face", func(t *testing.T) { + t.Run("3e814d3e81f4", func(t *testing.T) { + expected := fmt.Sprintf("%x%x00%x%x", 1000, 333, 1, 500) + m := NewArea("face", 1.000, 0.33333, 0.001, 0.5) + assert.Equal(t, expected, m.String()) + }) + t.Run("3360a7064042_face", func(t *testing.T) { m := NewArea("face", 0.822059, 0.167969, 0.1, 0.0664062) - assert.Equal(t, "082016010006", m.String()) + assert.Equal(t, "3360a7064042", m.String()) }) - t.Run("082016010006_back", func(t *testing.T) { + t.Run("3360a7064042_back", func(t *testing.T) { m := NewArea("back", 0.822059, 0.167969, 0.1, 0.0664062) - assert.Equal(t, "082016010006", m.String()) + assert.Equal(t, "3360a7064042", m.String()) }) - t.Run("020100003000", func(t *testing.T) { + t.Run("0c93e801e000", func(t *testing.T) { m := NewArea("face", 0.201, 1.000, 0.03, 0.00000001) - assert.Equal(t, "020100003000", m.String()) + assert.Equal(t, "0c93e801e000", m.String()) }) - t.Run("000100000000", func(t *testing.T) { + t.Run("0003e8000000", func(t *testing.T) { m := NewArea("face", 0.0001, 1.000, 0, 0.00000001) - assert.Equal(t, "000100000000", m.String()) + assert.Equal(t, "0003e8000000", m.String()) }) - t.Run("000012000100", func(t *testing.T) { + t.Run("00007b0003e8", func(t *testing.T) { m := NewArea("", -2.0001, 0.123, -0.1, 4.00000001) - assert.Equal(t, "000012000100", m.String()) + assert.Equal(t, "00007b0003e8", m.String()) }) } diff --git a/internal/crop/cache.go b/internal/crop/cache.go index eadadee34..e8b449cad 100644 --- a/internal/crop/cache.go +++ b/internal/crop/cache.go @@ -2,6 +2,7 @@ package crop import ( "fmt" + "path/filepath" "path" @@ -10,8 +11,8 @@ import ( ) // FromCache returns the crop file name if cached. -func FromCache(hash, thumbPath string, width, height int, area string) (fileName string, err error) { - fileName, err = FileName(hash, thumbPath, width, height, area) +func FromCache(hash, area string, size Size, thumbPath string) (fileName string, err error) { + fileName, err = FileName(hash, area, size.Width, size.Height, thumbPath) if err != nil { return "", err @@ -21,11 +22,11 @@ func FromCache(hash, thumbPath string, width, height int, area string) (fileName return fileName, nil } - return "", ErrNotFound + return "", fmt.Errorf("%s not found", filepath.Base(fileName)) } // FileName returns the crop file name based on cache path, size, and area. -func FileName(hash string, thumbPath string, width, height int, area string) (fileName string, err error) { +func FileName(hash, area string, width, height int, thumbPath string) (fileName string, err error) { if len(hash) < 4 { return "", fmt.Errorf("crop: invalid file hash %s", txt.Quote(hash)) } diff --git a/internal/crop/cache_test.go b/internal/crop/cache_test.go index aac419022..68b89b65a 100644 --- a/internal/crop/cache_test.go +++ b/internal/crop/cache_test.go @@ -8,7 +8,7 @@ import ( func TestFileName(t *testing.T) { t.Run("Crop160", func(t *testing.T) { - result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "/example", 160, 160, "042008007010") + result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "042008007010", 160, 160, "/example") if err != nil { t.Fatal(err) @@ -17,7 +17,7 @@ func TestFileName(t *testing.T) { assert.Equal(t, "/example/1/4/7/147da9f0261e2d81e9a52b266f1945556588bb78_160x160_crop_042008007010.jpg", result) }) t.Run("InvalidSize", func(t *testing.T) { - result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "/example", 15000, 160, "042008007010") + result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "042008007010", 15000, 160, "/example") if err == nil { t.Fatal("error expected") @@ -27,7 +27,7 @@ func TestFileName(t *testing.T) { assert.Empty(t, result) }) t.Run("InvalidHash", func(t *testing.T) { - result, err := FileName("147", "/example", 160, 160, "042008007010") + result, err := FileName("147", "042008007010", 160, 160, "/example") if err == nil { t.Fatal("error expected") @@ -37,7 +37,7 @@ func TestFileName(t *testing.T) { assert.Empty(t, result) }) t.Run("InvalidPath", func(t *testing.T) { - result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "", 160, 160, "042008007010") + result, err := FileName("147da9f0261e2d81e9a52b266f1945556588bb78", "042008007010", 160, 160, "") if err == nil { t.Fatal("error expected") diff --git a/internal/crop/crop.go b/internal/crop/crop.go index 15915473a..c42a9afd2 100644 --- a/internal/crop/crop.go +++ b/internal/crop/crop.go @@ -30,3 +30,7 @@ https://docs.photoprism.org/developer-guide/ */ package crop + +import "github.com/photoprism/photoprism/internal/event" + +var log = event.Log diff --git a/internal/crop/errors.go b/internal/crop/errors.go index 7bfd9fa64..66d9c9f88 100644 --- a/internal/crop/errors.go +++ b/internal/crop/errors.go @@ -5,5 +5,5 @@ import ( ) var ( - ErrNotFound = errors.New("crop not found") + ErrNotFound = errors.New("not found") ) diff --git a/internal/crop/names.go b/internal/crop/names.go new file mode 100644 index 000000000..92b5b2988 --- /dev/null +++ b/internal/crop/names.go @@ -0,0 +1,17 @@ +package crop + +import "github.com/photoprism/photoprism/pkg/fs" + +// Name represents a crop size name. +type Name string + +// Jpeg returns the crop name with a jpeg file extension suffix as string. +func (n Name) Jpeg() string { + return string(n) + fs.JpegExt +} + +// Names of standard crop sizes. +const ( + Tile160 Name = "tile_160" + Tile320 Name = "tile_320" +) diff --git a/internal/crop/sizes.go b/internal/crop/sizes.go new file mode 100644 index 000000000..8e7168d67 --- /dev/null +++ b/internal/crop/sizes.go @@ -0,0 +1,24 @@ +package crop + +import "github.com/photoprism/photoprism/internal/thumb" + +var ( + DefaultOptions = []thumb.ResampleOption{thumb.ResampleFillCenter, thumb.ResampleDefault} +) + +type Size struct { + Name Name `json:"name"` + Source Name `json:"-"` + Use string `json:"use"` + Width int `json:"w"` + Height int `json:"h"` + Options []thumb.ResampleOption `json:"-"` +} + +type SizeMap map[Name]Size + +// Sizes contains the properties of all thumbnail sizes. +var Sizes = SizeMap{ + Tile160: {Tile160, Tile320, "FaceNet", 160, 160, DefaultOptions}, + Tile320: {Tile320, "", "UI", 320, 320, DefaultOptions}, +} diff --git a/internal/crop/thumb.go b/internal/crop/thumb.go new file mode 100644 index 000000000..ef2954816 --- /dev/null +++ b/internal/crop/thumb.go @@ -0,0 +1,71 @@ +package crop + +import ( + "bytes" + "fmt" + "image" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/fs" +) + +// FromThumb returns a cropped area from an existing thumbnail image. +func FromThumb(thumbName string, area Area, size Size, cache bool) (img image.Image, err error) { + // Use same folder for caching if "cache" is true. + cacheFolder := filepath.Dir(thumbName) + + // Use existing thumbnail name as cached crop filename prefix. + thumbBase := filepath.Base(thumbName) + if i := strings.Index(thumbBase, "_"); i > 0 { + thumbBase = thumbBase[:i] + } + + // Compose cached crop image file name. + cacheBase := fmt.Sprintf("%s_%dx%d_crop_%s", thumbBase, size.Width, size.Height, area.String()) + cropFile := filepath.Join(cacheFolder, cacheBase+fs.JpegExt) + + // Cached? + if !fs.FileExists(cropFile) { + // Do nothing. + } else if img, err := imaging.Open(cropFile); err != nil { + log.Errorf("crop: failed loading %s", filepath.Base(cropFile)) + } else { + return img, nil + } + + // Open image. + imageBuffer, err := ioutil.ReadFile(thumbName) + img, err = imaging.Decode(bytes.NewReader(imageBuffer), imaging.AutoOrientation(true)) + + if err != nil { + return img, err + } + + // Get absolute crop coordinates and dimension. + min, max, dim := area.Bounds(img) + + if dim < size.Width { + log.Debugf("crop: %s too small, crop size %dpx, actual size %dpx", filepath.Base(thumbName), size.Width, dim) + } + + // Crop area from image. + img = imaging.Crop(img, image.Rect(min.X, min.Y, max.X, max.Y)) + + // Resample crop area. + img = thumb.Resample(img, size.Width, size.Height, size.Options...) + + // Cache crop image? + if cache { + if err := imaging.Save(img, cropFile); err != nil { + log.Errorf("crop: failed caching %s", filepath.Base(cropFile)) + } else { + log.Debugf("crop: saved %s", filepath.Base(cropFile)) + } + } + + return img, nil +} diff --git a/internal/entity/marker.go b/internal/entity/marker.go index 884bdda11..a3e9af05f 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -28,7 +28,7 @@ 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"` + CropArea string `gorm:"type:VARBINARY(16);default:''" json:"CropArea" yaml:"CropArea,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"` @@ -73,7 +73,7 @@ func NewMarker(file File, area crop.Area, subjectUID, markerSrc, markerType stri m := &Marker{ FileUID: file.FileUID, FileHash: file.FileHash, - FileArea: area.String(), + CropArea: area.String(), MarkerSrc: markerSrc, MarkerType: markerType, SubjectUID: subjectUID, @@ -492,7 +492,7 @@ func UpdateOrCreateMarker(m *Marker) (*Marker, error) { err := result.Updates(map[string]interface{}{ "MarkerType": m.MarkerType, "MarkerSrc": m.MarkerSrc, - "FileArea": m.FileArea, + "CropArea": m.CropArea, "X": m.X, "Y": m.Y, "W": m.W, diff --git a/internal/entity/marker_fixtures.go b/internal/entity/marker_fixtures.go index 9c64e0bf3..c4f3e89e3 100644 --- a/internal/entity/marker_fixtures.go +++ b/internal/entity/marker_fixtures.go @@ -199,7 +199,7 @@ var MarkerFixtures = MarkerMap{ MarkerUID: "mt9k3pw1wowuy999", FileUID: "ft2es49qhhinlple", FaceID: FaceFixtures.Get("actress-1").ID, - FileArea: "045038063041", + CropArea: "045038063041", FaceDist: 0.26852392873736236, SubjectSrc: SrcManual, SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID, @@ -219,7 +219,7 @@ var MarkerFixtures = MarkerMap{ MarkerUID: "mt9k3pw1wowu1000", FileUID: "ft2es49whhbnlqdn", FaceID: FaceFixtures.Get("actress-1").ID, - FileArea: "046045043065", + CropArea: "046045043065", FaceDist: 0.4507357278575355, SubjectSrc: "", SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID, @@ -239,7 +239,7 @@ var MarkerFixtures = MarkerMap{ MarkerUID: "mt9k3pw1wowu1001", FileUID: "ft8es39w45bnlqdw", FaceID: FaceFixtures.Get("actress-1").ID, - FileArea: "05403304060446", + CropArea: "05403304060446", FaceDist: 0.5099754448545762, SubjectSrc: "", SubjectUID: SubjectFixtures.Get("actress-1").SubjectUID, diff --git a/internal/entity/marker_json.go b/internal/entity/marker_json.go index 6d81d6eb0..4eaec96bc 100644 --- a/internal/entity/marker_json.go +++ b/internal/entity/marker_json.go @@ -20,7 +20,7 @@ func (m *Marker) MarshalJSON() ([]byte, error) { UID string FileUID string FileHash string - FileArea string + CropArea string Type string Src string Name string @@ -40,7 +40,7 @@ func (m *Marker) MarshalJSON() ([]byte, error) { UID: m.MarkerUID, FileUID: m.FileUID, FileHash: m.FileHash, - FileArea: m.FileArea, + CropArea: m.CropArea, Type: m.MarkerType, Src: m.MarkerSrc, Name: name, diff --git a/internal/entity/marker_test.go b/internal/entity/marker_test.go index 9ea930690..5cb7634b7 100644 --- a/internal/entity/marker_test.go +++ b/internal/entity/marker_test.go @@ -25,6 +25,8 @@ func TestNewMarker(t *testing.T) { m := NewMarker(FileFixtures.Get("exampleFileName.jpg"), testArea, "lt9k3pw1wowuy3c3", SrcImage, MarkerLabel) assert.IsType(t, &Marker{}, m) assert.Equal(t, "ft8es39w45bnlqdw", m.FileUID) + assert.Equal(t, "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818", m.FileHash) + assert.Equal(t, "1340ce163163", m.CropArea) assert.Equal(t, "lt9k3pw1wowuy3c3", m.SubjectUID) assert.Equal(t, SrcImage, m.MarkerSrc) assert.Equal(t, MarkerLabel, m.MarkerType) diff --git a/internal/face/face.go b/internal/face/face.go index 81d698697..9026b7afc 100644 --- a/internal/face/face.go +++ b/internal/face/face.go @@ -39,11 +39,11 @@ import ( "github.com/photoprism/photoprism/internal/event" ) -var CropSize = 160 +var CropSize = crop.Sizes[crop.Tile160] var ClusterCore = 4 var ClusterRadius = 0.6 var ClusterMinScore = 30 -var ClusterMinSize = CropSize +var ClusterMinSize = CropSize.Width var SampleThreshold = 2 * ClusterCore var log = event.Log @@ -105,7 +105,6 @@ type Face struct { Eyes Areas `json:"eyes,omitempty"` Landmarks Areas `json:"landmarks,omitempty"` Embeddings [][]float32 `json:"embeddings,omitempty"` - Thumb string `json:"-"` } // Size returns the absolute face size in pixels. diff --git a/internal/face/face_test.go b/internal/face/face_test.go index 41e9bfc56..16dc7a55f 100644 --- a/internal/face/face_test.go +++ b/internal/face/face_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/internal/crop" "github.com/photoprism/photoprism/pkg/fastwalk" "github.com/stretchr/testify/assert" @@ -57,9 +57,9 @@ func TestDetect(t *testing.T) { var embeddings [11][]float32 - tfInstance := NewNet(modelPath, "testdata/cache", false) + faceNet := NewNet(modelPath, "testdata/cache", false) - if err := tfInstance.loadModel(); err != nil { + if err := faceNet.loadModel(); err != nil { t.Fatal(err) } @@ -69,10 +69,10 @@ func TestDetect(t *testing.T) { } t.Run(fileName, func(t *testing.T) { - fileHash := fs.Hash(fileName) baseName := filepath.Base(fileName) faces, err := Detect(fileName, true, 20) + if err != nil { t.Fatal(err) } @@ -80,24 +80,25 @@ func TestDetect(t *testing.T) { t.Logf("found %d faces in '%s'", len(faces), baseName) if len(faces) > 0 { - t.Logf("results: %#v", faces) + // t.Logf("results: %#v", faces) for i, f := range faces { 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]) + img, err := crop.FromThumb(fileName, f.CropArea(), CropSize, false) if err != nil { t.Fatal(err) } - embedding := tfInstance.getEmbeddings(img) + embedding := faceNet.getEmbeddings(img) if b, err := json.Marshal(embedding[0]); err != nil { t.Fatal(err) } else { - t.Logf("embedding: %#v", string(b)) + assert.NotEmpty(t, b) + // t.Logf("embedding: %#v", string(b)) } t.Logf("faces: %d %v", i, faceindices[baseName]) diff --git a/internal/face/net.go b/internal/face/net.go index 1892d1190..963d2878c 100644 --- a/internal/face/net.go +++ b/internal/face/net.go @@ -1,21 +1,17 @@ package face import ( - "bytes" "fmt" "image" - "io/ioutil" - "os" "path" "path/filepath" "runtime/debug" - "strings" "sync" - "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/internal/crop" - "github.com/disintegration/imaging" "github.com/photoprism/photoprism/pkg/txt" + tf "github.com/tensorflow/tensorflow/tensorflow/go" ) @@ -36,7 +32,7 @@ func NewNet(modelPath, cachePath string, disabled bool) *Net { } // Detect runs the detection and facenet algorithms over the provided source image. -func (t *Net) Detect(fileName string, minSize int) (faces Faces, err error) { +func (t *Net) Detect(fileName string, minSize int, cacheCrop bool) (faces Faces, err error) { faces, err = Detect(fileName, false, minSize) if err != nil { @@ -53,18 +49,12 @@ func (t *Net) Detect(fileName string, minSize int) (faces Faces, err error) { return faces, err } - var cacheHash string - - if t.cachePath != "" { - cacheHash = fs.Hash(fileName) - } - for i, f := range faces { if f.Area.Col == 0 && f.Area.Row == 0 { continue } - if img, err := t.getFaceCrop(fileName, cacheHash, &faces[i]); err != nil { + if img, err := crop.FromThumb(fileName, f.CropArea(), CropSize, cacheCrop); err != nil { log.Errorf("faces: failed to decode image: %v", err) } else if embeddings := t.getEmbeddings(img); len(embeddings) > 0 { faces[i].Embeddings = embeddings @@ -103,80 +93,8 @@ func (t *Net) loadModel() error { return nil } -func (t *Net) getCacheFolder(fileName, cacheHash string) string { - if t.cachePath == "" || cacheHash == "" { - return filepath.Dir(fileName) - } - - if cacheHash == "" { - log.Debugf("faces: no hash provided for caching %s crops", filepath.Base(fileName)) - cacheHash = fs.Hash(fileName) - } - - result := filepath.Join(t.cachePath, "faces", string(cacheHash[0]), string(cacheHash[1]), string(cacheHash[2])) - - if err := os.MkdirAll(result, os.ModePerm); err != nil { - log.Errorf("faces: failed creating cache folder") - } - - return result -} - -func (t *Net) getFaceCrop(fileName, cacheHash string, f *Face) (img image.Image, err error) { - if f == nil { - return img, fmt.Errorf("face is nil") - } - - area := f.Area - cacheFolder := t.getCacheFolder(fileName, cacheHash) - - if cacheHash != "" { - f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", cacheHash, CropSize, CropSize, f.CropArea().String()) - } else { - base := filepath.Base(fileName) - i := strings.Index(base, "_") - - if i > 32 { - base = base[:i] - } - - f.Thumb = fmt.Sprintf("%s_%dx%d_crop_%s", base, CropSize, CropSize, f.CropArea().String()) - } - - cacheFile := filepath.Join(cacheFolder, f.Thumb+fs.JpegExt) - - if !fs.FileExists(cacheFile) { - // Do nothing. - } else if img, err := imaging.Open(cacheFile); err != nil { - log.Errorf("faces: failed loading %s", filepath.Base(cacheFile)) - } else { - log.Debugf("faces: extracting from %s", filepath.Base(cacheFile)) - return img, nil - } - - x, y := area.TopLeft() - - imageBuffer, err := ioutil.ReadFile(fileName) - img, err = imaging.Decode(bytes.NewReader(imageBuffer), imaging.AutoOrientation(true)) - - if err != nil { - return img, err - } - - img = imaging.Crop(img, image.Rect(y, x, y+area.Scale, x+area.Scale)) - img = imaging.Fill(img, CropSize, CropSize, imaging.Center, imaging.Lanczos) - - if err := imaging.Save(img, cacheFile); err != nil { - log.Errorf("faces: failed caching %s", filepath.Base(cacheFile)) - } else { - log.Debugf("faces: saved %s", filepath.Base(cacheFile)) - } - - return img, nil -} - func (t *Net) getEmbeddings(img image.Image) [][]float32 { - tensor, err := imageToTensor(img, CropSize, CropSize) + tensor, err := imageToTensor(img, CropSize.Width, CropSize.Height) if err != nil { log.Errorf("faces: failed to convert image to tensor: %v", err) diff --git a/internal/face/net_test.go b/internal/face/net_test.go index e2b79ab5c..fd73d1883 100644 --- a/internal/face/net_test.go +++ b/internal/face/net_test.go @@ -62,7 +62,7 @@ func TestNet(t *testing.T) { t.Run(fileName, func(t *testing.T) { baseName := filepath.Base(fileName) - faces, err := faceNet.Detect(fileName, 20) + faces, err := faceNet.Detect(fileName, 20, false) if err != nil { t.Fatal(err) @@ -71,7 +71,7 @@ func TestNet(t *testing.T) { t.Logf("found %d faces in '%s'", len(faces), baseName) if len(faces) > 0 { - t.Logf("results: %#v", faces) + // t.Logf("results: %#v", faces) for i, f := range faces { if len(f.Embeddings) > 0 { diff --git a/internal/photoprism/index_faces.go b/internal/photoprism/index_faces.go index 896498cd9..cf6a99a10 100644 --- a/internal/photoprism/index_faces.go +++ b/internal/photoprism/index_faces.go @@ -20,10 +20,10 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces { // Select best thumbnail depending on configured size. if Config().ThumbSizePrecached() < 1280 { - minSize = 30 + minSize = 20 thumbSize = thumb.Fit720 } else { - minSize = 40 + minSize = 30 thumbSize = thumb.Fit1280 } @@ -41,7 +41,7 @@ func (ind *Index) detectFaces(jpeg *MediaFile) face.Faces { start := time.Now() - faces, err := ind.faceNet.Detect(thumbName, minSize) + faces, err := ind.faceNet.Detect(thumbName, minSize, true) if err != nil { log.Debugf("%s in %s", err, txt.Quote(jpeg.BaseName())) diff --git a/internal/thumb/names.go b/internal/thumb/names.go index c70a6735b..473bf7d72 100644 --- a/internal/thumb/names.go +++ b/internal/thumb/names.go @@ -14,7 +14,6 @@ func (n Name) Jpeg() string { const ( Tile50 Name = "tile_50" Tile100 Name = "tile_100" - Crop160 Name = "crop_160" Tile224 Name = "tile_224" Tile500 Name = "tile_500" Colors Name = "colors" diff --git a/internal/thumb/resample_options.go b/internal/thumb/resample_options.go index eaecb48a7..417aba7d0 100644 --- a/internal/thumb/resample_options.go +++ b/internal/thumb/resample_options.go @@ -12,7 +12,6 @@ const ( ResampleFillTopLeft ResampleFillBottomRight ResampleFit - ResampleCrop ResampleResize ResampleNearestNeighbor ResampleDefault @@ -24,7 +23,6 @@ var ResampleMethods = map[ResampleOption]string{ ResampleFillTopLeft: "left", ResampleFillBottomRight: "right", ResampleFit: "fit", - ResampleCrop: "crop", ResampleResize: "resize", } diff --git a/internal/thumb/sizes.go b/internal/thumb/sizes.go index 2593abe87..efa57f380 100644 --- a/internal/thumb/sizes.go +++ b/internal/thumb/sizes.go @@ -21,8 +21,9 @@ func InvalidSize(size int) bool { } type Size struct { - Use string `json:"use"` + Name Name `json:"name"` Source Name `json:"-"` + Use string `json:"use"` Width int `json:"w"` Height int `json:"h"` Public bool `json:"-"` @@ -33,22 +34,21 @@ type SizeMap map[Name]Size // Sizes contains the properties of all thumbnail sizes. var Sizes = SizeMap{ - Tile50: {"Lists", Tile500, 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Tile100: {"Maps", Tile500, 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Crop160: {"FaceNet", "", 160, 160, false, []ResampleOption{ResampleCrop, ResampleDefault}}, - Tile224: {"TensorFlow, Mosaic", Tile500, 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Tile500: {"Tiles", "", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, - Colors: {"Color Detection", Fit720, 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}}, - Left224: {"TensorFlow", Fit720, 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}}, - Right224: {"TensorFlow", Fit720, 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}}, - Fit720: {"Mobile, TV", "", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit1280: {"Mobile, HD Ready TV", Fit2048, 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit1920: {"Mobile, Full HD TV", Fit2048, 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit2048: {"Tablets, Cinema 2K", "", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit2560: {"Quad HD, Retina Display", "", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit3840: {"Ultra HD", "", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096 - Fit4096: {"Ultra HD, Retina 4K", "", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}}, - Fit7680: {"8K Ultra HD 2, Retina 6K", "", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Tile50: {Tile50, Tile500, "Lists", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile100: {Tile100, Tile500, "Maps", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Tile500: {Tile500, "", "Tiles", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}}, + Colors: {Colors, Fit720, "Color Detection", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}}, + Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}}, + Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}}, + Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096 + Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}}, + Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}}, } // DefaultSizes contains all default size names.