resource: Add smart cropping
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sat, 3 Feb 2018 15:47:35 +0000 (16:47 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 5 Feb 2018 12:59:15 +0000 (13:59 +0100)
This commit `smart` as a new and default anchor in `Fill`.

So:

```html
{{ $image.Fill "200x200" }}
```

Is, with default configuration, the same as:

```html
{{ $image.Fill "200x200" "smart" }}
```

You can change this default in your `config.toml`:

```toml
[imaging]
[imaging]
resampleFilter = "box"

quality = 68

anchor = "Smart"
```

Fixes #4375

Gopkg.lock
Gopkg.toml
resource/image.go
resource/image_test.go
resource/smartcrop.go [new file with mode: 0644]
resource/testhelpers_test.go

index 182d8e55546daf9195a37755c23653d8a97d1aa3..763afa5704e09ad0b00e7df70d63a8446712766c 100644 (file)
   packages = ["."]
   revision = "b4575eea38cca1123ec2dc90c26529b5c5acfcff"
 
+[[projects]]
+  branch = "master"
+  name = "github.com/muesli/smartcrop"
+  packages = [
+    ".",
+    "options"
+  ]
+  revision = "1db484956b9ef929344e51701299a017beefdaaa"
+
 [[projects]]
   name = "github.com/nicksnyder/go-i18n"
   packages = [
   name = "golang.org/x/image"
   packages = [
     "bmp",
+    "draw",
+    "math/f64",
     "riff",
     "tiff",
     "tiff/lzw",
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "c80ffe69d34005d8d72a87cc491ce1d9c91272e4b7f8fbd22d4fda8973fa8556"
+  inputs-digest = "ce63da7f660e0ba60a8ae81f5808f8e685b2055169838fbc3c4d5c418e58b3d1"
   solver-name = "gps-cdcl"
   solver-version = 1
index b07a41f7c8b83d9d5b2ed9abab510495204a6119..9c872585a21a2323b779d481c5aab8cf04d0c7f2 100644 (file)
@@ -15,7 +15,7 @@
 [[constraint]]
   branch = "master"
   name = "github.com/bep/gitmap"
-
+  
 [[constraint]]
  name = "github.com/chaseadamsio/goorgeous"
  version = "^1.1.0"
 [[constraint]]
   name = "github.com/gobwas/glob"
   version = "0.2.2"
+
+
+[[constraint]]
+  name = "github.com/muesli/smartcrop"
+  branch = "master"
+
index 7ec65f3bcc89a75d7f60765eb63f1c79924929b3..c9ee90bf117ed93bbe6d270fbd9aff14c719895c 100644 (file)
@@ -35,7 +35,6 @@ import (
        _ "image/png"
 
        "github.com/disintegration/imaging"
-
        // Import webp codec
        "sync"
 
@@ -56,6 +55,9 @@ type Imaging struct {
 
        // Resample filter used. See https://github.com/disintegration/imaging
        ResampleFilter string
+
+       // The anchor used in Fill. Default is "smart", i.e. Smart Crop.
+       Anchor string
 }
 
 const (
@@ -157,6 +159,9 @@ func (i *Image) Fit(spec string) (*Image, error) {
 // Space delimited config: 200x300 TopLeft
 func (i *Image) Fill(spec string) (*Image, error) {
        return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
+               if conf.AnchorStr == smartCropIdentifier {
+                       return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter)
+               }
                return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil
        })
 }
@@ -206,6 +211,13 @@ func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, c
                conf.Filter = imageFilters[conf.FilterStr]
        }
 
+       if conf.AnchorStr == "" {
+               conf.AnchorStr = i.imaging.Anchor
+               if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) {
+                       conf.Anchor = anchorPositions[conf.AnchorStr]
+               }
+       }
+
        key := i.relTargetPathForRel(i.filenameFromConfig(conf), false)
 
        return i.spec.imageCache.getOrCreate(i, key, func(resourceCacheFilename string) (*Image, error) {
@@ -248,18 +260,22 @@ func (i imageConfig) key() string {
        if i.Rotate != 0 {
                k += "_r" + strconv.Itoa(i.Rotate)
        }
-       k += "_" + i.FilterStr + "_" + i.AnchorStr
-       return k
-}
+       anchor := i.AnchorStr
+       if anchor == smartCropIdentifier {
+               anchor = anchor + strconv.Itoa(smartCropVersionNumber)
+       }
 
-var defaultImageConfig = imageConfig{
-       Action:    "",
-       Anchor:    imaging.Center,
-       AnchorStr: strings.ToLower("Center"),
+       k += "_" + i.FilterStr
+
+       if strings.EqualFold(i.Action, "fill") {
+               k += "_" + anchor
+       }
+
+       return k
 }
 
 func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig {
-       c := defaultImageConfig
+       var c imageConfig
 
        c.Width = width
        c.Height = height
@@ -287,7 +303,7 @@ func newImageConfig(width, height, quality, rotate int, filter, anchor string) i
 
 func parseImageConfig(config string) (imageConfig, error) {
        var (
-               c   = defaultImageConfig
+               c   imageConfig
                err error
        )
 
@@ -299,7 +315,9 @@ func parseImageConfig(config string) (imageConfig, error) {
        for _, part := range parts {
                part = strings.ToLower(part)
 
-               if pos, ok := anchorPositions[part]; ok {
+               if part == smartCropIdentifier {
+                       c.AnchorStr = smartCropIdentifier
+               } else if pos, ok := anchorPositions[part]; ok {
                        c.Anchor = pos
                        c.AnchorStr = part
                } else if filter, ok := imageFilters[part]; ok {
@@ -561,8 +579,19 @@ func decodeImaging(m map[string]interface{}) (Imaging, error) {
                return i, err
        }
 
-       if i.Quality <= 0 || i.Quality > 100 {
+       if i.Quality == 0 {
                i.Quality = defaultJPEGQuality
+       } else if i.Quality < 0 || i.Quality > 100 {
+               return i, errors.New("JPEG quality must be a number between 1 and 100")
+       }
+
+       if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) {
+               i.Anchor = smartCropIdentifier
+       } else {
+               i.Anchor = strings.ToLower(i.Anchor)
+               if _, found := anchorPositions[i.Anchor]; !found {
+                       return i, errors.New("invalid anchor value in imaging config")
+               }
        }
 
        if i.ResampleFilter == "" {
index bf097b31982a7668c409f9747d792b15326f75d0..de706b0ac1d86af007ec8b1745ca65c940db8d34 100644 (file)
@@ -82,13 +82,13 @@ func TestImageTransform(t *testing.T) {
        assert.Equal(200, resizedAndRotated.Height())
        assertFileCache(assert, image.spec.Fs, resizedAndRotated.RelPermalink(), 125, 200)
 
-       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q75_box_center.jpg", resized.RelPermalink())
+       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_resize_q68_linear.jpg", resized.RelPermalink())
        assert.Equal(300, resized.Width())
        assert.Equal(200, resized.Height())
 
        fitted, err := resized.Fit("50x50")
        assert.NoError(err)
-       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0bda5208a94b50a6e643ad139e0dfa2f.jpg", fitted.RelPermalink())
+       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_625708021e2bb281c9f1002f88e4753f.jpg", fitted.RelPermalink())
        assert.Equal(50, fitted.Width())
        assert.Equal(31, fitted.Height())
 
@@ -96,17 +96,24 @@ func TestImageTransform(t *testing.T) {
        fittedAgain, _ := fitted.Fit("10x20")
        fittedAgain, err = fittedAgain.Fit("10x20")
        assert.NoError(err)
-       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6b3034f4ca91823700bd9ff7a12acf2e.jpg", fittedAgain.RelPermalink())
+       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg", fittedAgain.RelPermalink())
        assert.Equal(10, fittedAgain.Width())
        assert.Equal(6, fittedAgain.Height())
 
        filled, err := image.Fill("200x100 bottomLeft")
        assert.NoError(err)
-       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q75_box_bottomleft.jpg", filled.RelPermalink())
+       assert.Equal("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_bottomleft.jpg", filled.RelPermalink())
        assert.Equal(200, filled.Width())
        assert.Equal(100, filled.Height())
        assertFileCache(assert, image.spec.Fs, filled.RelPermalink(), 200, 100)
 
+       smart, err := image.Fill("200x100 smart")
+       assert.NoError(err)
+       assert.Equal(fmt.Sprintf("/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_fill_q68_linear_smart%d.jpg", smartCropVersionNumber), smart.RelPermalink())
+       assert.Equal(200, smart.Width())
+       assert.Equal(100, smart.Height())
+       assertFileCache(assert, image.spec.Fs, smart.RelPermalink(), 200, 100)
+
        // Check cache
        filledAgain, err := image.Fill("200x100 bottomLeft")
        assert.NoError(err)
@@ -126,12 +133,12 @@ func TestImageTransformLongFilename(t *testing.T) {
        assert.NoError(err)
        assert.NotNil(resized)
        assert.Equal(200, resized.Width())
-       assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_fd0f8b23902abcf4092b68783834f7fe.jpg", resized.RelPermalink())
+       assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_65b757a6e14debeae720fe8831f0a9bc.jpg", resized.RelPermalink())
        resized, err = resized.Resize("100x")
        assert.NoError(err)
        assert.NotNil(resized)
        assert.Equal(100, resized.Width())
-       assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_5f399e62910070692b3034a925f1b2d7.jpg", resized.RelPermalink())
+       assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c876768085288f41211f768147ba2647.jpg", resized.RelPermalink())
 }
 
 func TestDecodeImaging(t *testing.T) {
@@ -139,6 +146,7 @@ func TestDecodeImaging(t *testing.T) {
        m := map[string]interface{}{
                "quality":        42,
                "resampleFilter": "NearestNeighbor",
+               "anchor":         "topLeft",
        }
 
        imaging, err := decodeImaging(m)
@@ -146,6 +154,37 @@ func TestDecodeImaging(t *testing.T) {
        assert.NoError(err)
        assert.Equal(42, imaging.Quality)
        assert.Equal("nearestneighbor", imaging.ResampleFilter)
+       assert.Equal("topleft", imaging.Anchor)
+
+       m = map[string]interface{}{}
+
+       imaging, err = decodeImaging(m)
+       assert.NoError(err)
+       assert.Equal(defaultJPEGQuality, imaging.Quality)
+       assert.Equal("box", imaging.ResampleFilter)
+       assert.Equal("smart", imaging.Anchor)
+
+       _, err = decodeImaging(map[string]interface{}{
+               "quality": 123,
+       })
+       assert.Error(err)
+
+       _, err = decodeImaging(map[string]interface{}{
+               "resampleFilter": "asdf",
+       })
+       assert.Error(err)
+
+       _, err = decodeImaging(map[string]interface{}{
+               "anchor": "asdf",
+       })
+       assert.Error(err)
+
+       imaging, err = decodeImaging(map[string]interface{}{
+               "anchor": "Smart",
+       })
+       assert.NoError(err)
+       assert.Equal("smart", imaging.Anchor)
+
 }
 
 func TestImageWithMetadata(t *testing.T) {
diff --git a/resource/smartcrop.go b/resource/smartcrop.go
new file mode 100644 (file)
index 0000000..2012624
--- /dev/null
@@ -0,0 +1,80 @@
+// Copyright 2017-present The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resource
+
+import (
+       "image"
+
+       "github.com/disintegration/imaging"
+       "github.com/muesli/smartcrop"
+)
+
+const (
+       // Do not change.
+       smartCropIdentifier = "smart"
+
+       // This is just a increment, starting on 1. If Smart Crop improves its cropping, we
+       // need a way to trigger a re-generation of the crops in the wild, so increment this.
+       smartCropVersionNumber = 1
+)
+
+// Needed by smartcrop
+type imagingResizer struct {
+       filter imaging.ResampleFilter
+}
+
+func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image {
+       return imaging.Resize(img, int(width), int(height), r.filter)
+}
+
+func newSmartCropAnalyzer(filter imaging.ResampleFilter) smartcrop.Analyzer {
+       return smartcrop.NewAnalyzer(imagingResizer{filter: filter})
+}
+
+func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter imaging.ResampleFilter) (*image.NRGBA, error) {
+
+       if width <= 0 || height <= 0 {
+               return &image.NRGBA{}, nil
+       }
+
+       srcBounds := img.Bounds()
+       srcW := srcBounds.Dx()
+       srcH := srcBounds.Dy()
+
+       if srcW <= 0 || srcH <= 0 {
+               return &image.NRGBA{}, nil
+       }
+
+       if srcW == width && srcH == height {
+               return imaging.Clone(img), nil
+       }
+
+       smart := newSmartCropAnalyzer(filter)
+
+       rect, err := smart.FindBestCrop(img, width, height)
+
+       if err != nil {
+               return nil, err
+       }
+
+       b := img.Bounds().Intersect(rect)
+
+       cropped, err := imaging.Crop(img, b), nil
+       if err != nil {
+               return nil, err
+       }
+
+       return imaging.Resize(cropped, width, height, filter), nil
+
+}
index 03a6d613448741ff6b0217272a0436036e875804..2b543ab641f28d00eb20fa25fc41503f5aa6512f 100644 (file)
@@ -25,6 +25,15 @@ func newTestResourceSpecForBaseURL(assert *require.Assertions, baseURL string) *
        cfg := viper.New()
        cfg.Set("baseURL", baseURL)
        cfg.Set("resourceDir", "/res")
+
+       imagingCfg := map[string]interface{}{
+               "resampleFilter": "linear",
+               "quality":        68,
+               "anchor":         "left",
+       }
+
+       cfg.Set("imaging", imagingCfg)
+
        fs := hugofs.NewMem(cfg)
 
        s, err := helpers.NewPathSpec(fs, cfg)