diff --git a/README.md b/README.md index dbf22b0..fc4c836 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ image][material-animation] resized to 200px square and rotated 270 degrees: 200,r270 +The smart crop feature can best be seen by comparing the following images, with and without smart crop. + +200x400 +200x400,sc ## Getting Started ## diff --git a/data.go b/data.go index f1f9299..aaeeefb 100644 --- a/data.go +++ b/data.go @@ -39,6 +39,7 @@ const ( optCropY = "cy" optCropWidth = "cw" optCropHeight = "ch" + optSmartCrop = "sc" ) // URLError reports a malformed URL error. @@ -86,6 +87,9 @@ type Options struct { CropY float64 CropWidth float64 CropHeight float64 + + // Automatically find good crop points based on image content. + SmartCrop bool } func (o Options) String() string { @@ -126,6 +130,9 @@ func (o Options) String() string { if o.CropHeight != 0 { opts = append(opts, fmt.Sprintf("%s%v", string(optCropHeight), o.CropHeight)) } + if o.SmartCrop { + opts = append(opts, optSmartCrop) + } return strings.Join(opts, ",") } @@ -159,6 +166,12 @@ func (o Options) transform() bool { // crop width or height will be adjusted, preserving the specified cx and cy // values. Rectangular crop is applied before any other transformations. // +// Smart Crop +// +// The "sc" option will perform a content-aware smart crop to fit the +// requested image width and height dimensions (see Size and Cropping below). +// The smart crop option will override any requested rectangular crop. +// // Size and Cropping // // The size option takes the general form "{width}x{height}", where width and @@ -246,6 +259,8 @@ func ParseOptions(str string) Options { options.ScaleUp = true case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF: options.Format = opt + case opt == optSmartCrop: + options.SmartCrop = true case strings.HasPrefix(opt, optRotatePrefix): value := strings.TrimPrefix(opt, optRotatePrefix) options.Rotate, _ = strconv.Atoi(value) diff --git a/data_test.go b/data_test.go index ed5026d..2062492 100644 --- a/data_test.go +++ b/data_test.go @@ -31,19 +31,19 @@ func TestOptions_String(t *testing.T) { "0x0", }, { - Options{1, 2, true, 90, true, true, 80, "", false, "", 0, 0, 0, 0}, + Options{1, 2, true, 90, true, true, 80, "", false, "", 0, 0, 0, 0, false}, "1x2,fit,r90,fv,fh,q80", }, { - Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 0, 0, 0, 0}, + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 0, 0, 0, 0, false}, "0.15x1.3,r45,q95,sc0ffee,png", }, { - Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "", 100, 200, 0, 0}, + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "", 100, 200, 0, 0, false}, "0.15x1.3,r45,q95,sc0ffee,cx100,cy200", }, { - Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 100, 200, 300, 400}, + Options{0.15, 1.3, false, 45, false, false, 95, "c0ffee", false, "png", 100, 200, 300, 400, false}, "0.15x1.3,r45,q95,sc0ffee,png,cx100,cy200,cw300,ch400", }, } @@ -94,19 +94,19 @@ func TestParseOptions(t *testing.T) { {"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}}, // flags, in different orders - {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 0, 0, 0, 0}}, - {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 0, 0, 0, 0}}, + {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 0, 0, 0, 0, false}}, + {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 0, 0, 0, 0, false}}, // all flags, in different orders with crop - {"q70,cx100,cw300,1x2,fit,cy200,r90,fv,ch400,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400}}, - {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400}}, + {"q70,cx100,cw300,1x2,fit,cy200,r90,fv,ch400,fh,sc0ffee,png", Options{1, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400, false}}, + {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,1x2,fv,fit", Options{1, 2, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400, false}}, // all flags, in different orders with crop & different resizes - {"q70,cx100,cw300,x2,fit,cy200,r90,fv,ch400,fh,sc0ffee,png", Options{0, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400}}, - {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,1x,fv,fit", Options{1, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400}}, - {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit", Options{0, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, - {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit,123x321", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, - {"123x321,ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400}}, + {"q70,cx100,cw300,x2,fit,cy200,r90,fv,ch400,fh,sc0ffee,png", Options{0, 2, true, 90, true, true, 70, "c0ffee", false, "png", 100, 200, 300, 400, false}}, + {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,1x,fv,fit", Options{1, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 300, 400, false}}, + {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit", Options{0, 0, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400, false}}, + {"ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit,123x321", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400, false}}, + {"123x321,ch400,r90,cw300,fh,sc0ffee,png,cx100,q90,cy200,cw,fv,fit", Options{123, 321, true, 90, true, true, 90, "c0ffee", false, "png", 100, 200, 0, 400, false}}, } for _, tt := range tests { diff --git a/transform.go b/transform.go index 4d4c5e6..3428eba 100644 --- a/transform.go +++ b/transform.go @@ -22,9 +22,11 @@ import ( "image/jpeg" "image/png" "io" + "log" "math" "github.com/disintegration/imaging" + "github.com/muesli/smartcrop" "github.com/rwcarlsen/goexif/exif" "golang.org/x/image/tiff" // register tiff format _ "golang.org/x/image/webp" // register webp format @@ -156,7 +158,7 @@ func resizeParams(m image.Image, opt Options) (w, h int, resize bool) { // cropParams calculates crop rectangle parameters to keep it in image bounds func cropParams(m image.Image, opt Options) image.Rectangle { - if opt.CropX == 0 && opt.CropY == 0 && opt.CropWidth == 0 && opt.CropHeight == 0 { + if !opt.SmartCrop && opt.CropX == 0 && opt.CropY == 0 && opt.CropWidth == 0 && opt.CropHeight == 0 { return m.Bounds() } @@ -164,6 +166,19 @@ func cropParams(m image.Image, opt Options) image.Rectangle { imgW := m.Bounds().Dx() imgH := m.Bounds().Dy() + if opt.SmartCrop { + w := evaluateFloat(opt.Width, imgW) + h := evaluateFloat(opt.Height, imgH) + log.Printf("smartcrop input: %dx%d", w, h) + r, err := smartcrop.SmartCrop(m, w, h) + if err != nil { + log.Printf("error with smartcrop: %v", err) + } else { + log.Printf("smartcrop rectangle: %v", r) + return r + } + } + // top left coordinate of crop x0 := evaluateFloat(math.Abs(opt.CropX), imgW) if opt.CropX < 0 {