Skip to content

Commit

Permalink
resource: Add smart cropping
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bep committed Feb 5, 2018
1 parent 084cf41 commit 722086b
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 20 deletions.
13 changes: 12 additions & 1 deletion Gopkg.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Gopkg.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
[[constraint]]
branch = "master"
name = "github.com/bep/gitmap"

[[constraint]]
name = "github.com/chaseadamsio/goorgeous"
version = "^1.1.0"
Expand Down Expand Up @@ -135,3 +135,9 @@
[[constraint]]
name = "github.com/gobwas/glob"
version = "0.2.2"


[[constraint]]
name = "github.com/muesli/smartcrop"
branch = "master"

53 changes: 41 additions & 12 deletions resource/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import (
_ "image/png"

"github.com/disintegration/imaging"

// Import webp codec
"sync"

Expand All @@ -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 (
Expand Down Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)

Expand All @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down
51 changes: 45 additions & 6 deletions resource/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,31 +82,38 @@ 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())

// Check the MD5 key threshold
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)
Expand All @@ -126,26 +133,58 @@ 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) {
assert := require.New(t)
m := map[string]interface{}{
"quality": 42,
"resampleFilter": "NearestNeighbor",
"anchor": "topLeft",
}

imaging, err := decodeImaging(m)

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) {
Expand Down
80 changes: 80 additions & 0 deletions resource/smartcrop.go
Original file line number Diff line number Diff line change
@@ -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

}
9 changes: 9 additions & 0 deletions resource/testhelpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 722086b

Please sign in to comment.