Skip to content

Commit b51b45e

Browse files
duergnerdanpersa
authored andcommitted
#95 First shot for implementing Focal Point Cropping (#96)
Closes #95 Implementing Focal Point Cropping * #95 Wrap the filterContext into the ImageFilterContext * Adding test for FocalPoint cropping * Address review comments
1 parent e38864e commit b51b45e

7 files changed

+288
-5
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Skrop provides a set of filters, which you can use within the routes:
109109
* **blur(sigma, min_ampl)** — blurs the image (for info see [here](http://www.vips.ecs.soton.ac.uk/supported/current/doc/html/libvips/libvips-convolution.html#vips-gaussblur))
110110
* **imageOverlay(filename, opacity, gravity, opt-top-margin, opt-right-margin, opt-bottom-margin, opt-left-margin)** — puts an image onverlay over the required image
111111
* **transformFromQueryParams()** - transforms the image based on the request query parameters (supports only crop for now) e.g: localhost:9090/images/S/big-ben.jpg?crop=120,300,500,300.
112+
* **cropByFocalPoint(targetX, targetY, aspectRatio)** — crops the image based on a focal point on both the source as well as on the target and desired aspect ratio of the target. TargetX and TargetY are the definition of the target image focal point defined as relative values for both width and height, i.e. if the focal point of the target image should be right in the center it would be 0.5 and 0.5. This filter expects two PathParams named **focalPointX** and **focalPointY** which are absolute X and Y coordinates of the focal point in the source image.
112113

113114
### About filters
114115
The eskip file defines a list of configuration. Every configuration is composed by a route and a list of filters to

cmd/skrop/main.go

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ func main() {
110110
skropFilters.NewCrop(),
111111
skropFilters.NewCropByWidth(),
112112
skropFilters.NewCropByHeight(),
113+
skropFilters.NewCropByFocalPoint(),
113114
skropFilters.NewResizeByWidth(),
114115
skropFilters.NewResizeByHeight(),
115116
skropFilters.NewQuality(),

eskip/sample.eskip

+5
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ cropByHeight: Path("/images/cropbyheight/:image")
4949
-> cropByHeight(1000, "south")
5050
-> "http://localhost:9090";
5151

52+
cropByFocalPoint: Path("/images/cropbyfocalpoint/:focalPointX/:focalPointY/:image")
53+
-> modPath("^/images/cropbyfocalpoint/\\d+/\\d+", "/images")
54+
-> cropByFocalPoint(0.25,0.25,0.5)
55+
-> "http://localhost:9090";
56+
5257
widthAndQuality: Path("/images/waq/:image")
5358
-> modPath("^/images/waq", "/images")
5459
-> width(1000)

filters/cropbyfocalpoint.go

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package filters
2+
3+
import (
4+
"github.com/zalando-stups/skrop/parse"
5+
"github.com/zalando/skipper/filters"
6+
"gopkg.in/h2non/bimg.v1"
7+
"strconv"
8+
)
9+
10+
// CropByFocalPointName is the name of the filter
11+
const CropByFocalPointName = "cropByFocalPoint"
12+
13+
type cropByFocalPoint struct {
14+
targetX float64
15+
targetY float64
16+
aspectRatio float64
17+
}
18+
19+
// NewCropByFocalPoint creates a new filter of this type
20+
func NewCropByFocalPoint() filters.Spec {
21+
return &cropByFocalPoint{}
22+
}
23+
24+
func (f *cropByFocalPoint) Name() string {
25+
return CropByFocalPointName
26+
}
27+
28+
func (f *cropByFocalPoint) CreateOptions(imageContext *ImageFilterContext) (*bimg.Options, error) {
29+
imageSize, err := imageContext.Image.Size()
30+
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
focalPointX := imageContext.PathParam("focalPointX")
36+
focalPointY := imageContext.PathParam("focalPointY")
37+
38+
if focalPointX == "" || focalPointY == "" {
39+
return nil, filters.ErrInvalidFilterParameters
40+
}
41+
42+
sourceX, err := strconv.Atoi(focalPointX)
43+
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
sourceY, err := strconv.Atoi(focalPointY)
49+
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
right := imageSize.Width - sourceX
55+
bottom := imageSize.Height - sourceY
56+
57+
cropLeftWidth := int(float64(sourceX) / f.targetX)
58+
cropRightWidth := int(float64(right) / (float64(1) - f.targetX))
59+
60+
width := cropRightWidth
61+
62+
if cropLeftWidth < cropRightWidth {
63+
width = cropLeftWidth
64+
}
65+
66+
cropTopHeight := int(float64(sourceY) / f.targetY)
67+
cropBottomHeight := int(float64(bottom) / (float64(1) - f.targetY))
68+
69+
height := cropBottomHeight
70+
71+
if cropTopHeight < cropBottomHeight {
72+
height = int(float64(sourceY) / f.targetY)
73+
}
74+
75+
ratio := float64(height) / float64(width)
76+
77+
if ratio > f.aspectRatio {
78+
height = int(float64(width) * f.aspectRatio)
79+
} else {
80+
width = int(float64(height) / f.aspectRatio)
81+
}
82+
83+
return &bimg.Options{
84+
AreaWidth: width,
85+
AreaHeight: height,
86+
Top: sourceY - int(float64(height) * f.targetY),
87+
Left: sourceX - int(float64(width) * f.targetX)}, nil
88+
}
89+
90+
func (f *cropByFocalPoint) CanBeMerged(other *bimg.Options, self *bimg.Options) bool {
91+
return false
92+
}
93+
94+
func (f *cropByFocalPoint) Merge(other *bimg.Options, self *bimg.Options) *bimg.Options {
95+
return self
96+
}
97+
98+
func (f *cropByFocalPoint) CreateFilter(args []interface{}) (filters.Filter, error) {
99+
var err error
100+
101+
if len(args) < 3 || len(args) > 3 {
102+
return nil, filters.ErrInvalidFilterParameters
103+
}
104+
105+
c := &cropByFocalPoint{}
106+
107+
c.targetX, err = parse.EskipFloatArg(args[0])
108+
109+
if err != nil {
110+
return nil, err
111+
}
112+
113+
c.targetY, err = parse.EskipFloatArg(args[1])
114+
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
c.aspectRatio, err = parse.EskipFloatArg(args[2])
120+
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
return c, nil
126+
}
127+
128+
func (f *cropByFocalPoint) Request(ctx filters.FilterContext) {}
129+
130+
func (f *cropByFocalPoint) Response(ctx filters.FilterContext) {
131+
HandleImageResponse(ctx, f)
132+
}

filters/cropbyfocalpoint_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package filters
2+
3+
import (
4+
"testing"
5+
"github.com/stretchr/testify/assert"
6+
"github.com/zalando-stups/skrop/filters/imagefiltertest"
7+
"github.com/zalando/skipper/filters"
8+
)
9+
10+
func TestNewCropByFocalPoint(t *testing.T) {
11+
name := NewCropByFocalPoint().Name()
12+
assert.Equal(t, "cropByFocalPoint", name)
13+
}
14+
15+
func TestCropByFocalPoint_Name(t *testing.T) {
16+
c := cropByFocalPoint{}
17+
assert.Equal(t, "cropByFocalPoint", c.Name())
18+
}
19+
20+
func TestCropByFocalPoint_CreateOptions(t *testing.T) {
21+
c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5}
22+
image := imagefiltertest.LandscapeImage()
23+
fc := createDefaultContext(t, "doesnotmatter.com")
24+
fc.FParams = make(map[string]string)
25+
fc.FParams["focalPointX"] = "500";
26+
fc.FParams["focalPointY"] = "334";
27+
28+
options, _ := c.CreateOptions(buildParameters(fc, image))
29+
30+
assert.Equal(t, 445, options.AreaWidth)
31+
assert.Equal(t, 668, options.AreaHeight)
32+
assert.Equal(t, 0, options.Top)
33+
assert.Equal(t, 278, options.Left)
34+
35+
c = cropByFocalPoint{targetX: 0.5, targetY: 0.25, aspectRatio: 1.5}
36+
image = imagefiltertest.LandscapeImage()
37+
fc = createDefaultContext(t, "doesnotmatter.com")
38+
fc.FParams = make(map[string]string)
39+
fc.FParams["focalPointX"] = "500";
40+
fc.FParams["focalPointY"] = "334";
41+
42+
options, _ = c.CreateOptions(buildParameters(fc, image))
43+
44+
assert.Equal(t, 296, options.AreaWidth)
45+
assert.Equal(t, 445, options.AreaHeight)
46+
assert.Equal(t, 223, options.Top)
47+
assert.Equal(t, 352, options.Left)
48+
}
49+
50+
func TestCropByFocalPoint_CreateOptions_MissingPathParam(t *testing.T) {
51+
c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5}
52+
image := imagefiltertest.LandscapeImage()
53+
fc := createDefaultContext(t, "doesnotmatter.com")
54+
fc.FParams = make(map[string]string)
55+
fc.FParams["focalPointY"] = "334";
56+
57+
options, err := c.CreateOptions(buildParameters(fc, image))
58+
59+
assert.Nil(t, options)
60+
assert.Equal(t, filters.ErrInvalidFilterParameters, err)
61+
62+
fc = createDefaultContext(t, "doesnotmatter.com")
63+
fc.FParams = make(map[string]string)
64+
fc.FParams["focalPointX"] = "334";
65+
66+
options, err = c.CreateOptions(buildParameters(fc, image))
67+
68+
assert.Nil(t, options)
69+
assert.Equal(t, filters.ErrInvalidFilterParameters, err)
70+
}
71+
72+
func TestCropByFocalPoint_CreateOptions_InvalidPathParam(t *testing.T) {
73+
c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5}
74+
image := imagefiltertest.LandscapeImage()
75+
fc := createDefaultContext(t, "doesnotmatter.com")
76+
fc.FParams = make(map[string]string)
77+
fc.FParams["focalPointX"] = "xyz";
78+
fc.FParams["focalPointY"] = "abc";
79+
80+
options, err := c.CreateOptions(buildParameters(fc, image))
81+
82+
assert.Nil(t, options)
83+
assert.NotNil(t, err)
84+
85+
fc.FParams["focalPointX"] = "100";
86+
fc.FParams["focalPointY"] = "abc";
87+
88+
options, err = c.CreateOptions(buildParameters(fc, image))
89+
90+
assert.Nil(t, options)
91+
assert.NotNil(t, err)
92+
}
93+
94+
func TestCropByFocalPoint_CanBeMerged(t *testing.T) {
95+
ea := transformFromQueryParams{}
96+
assert.Equal(t, false, ea.CanBeMerged(nil, nil))
97+
}
98+
99+
func TestCropByFocalPoint_CreateFilter(t *testing.T) {
100+
imagefiltertest.TestCreate(t, NewCropByFocalPoint, []imagefiltertest.CreateTestItem{{
101+
Msg: "less than 3 args",
102+
Args: nil,
103+
Err: true,
104+
}, {
105+
Msg: "invalid targetX",
106+
Args: []interface{}{"xyz", 0.5, 1.5},
107+
Err: true,
108+
}, {
109+
Msg: "invalid targetY",
110+
Args: []interface{}{0.5, "abc", 1.5},
111+
Err: true,
112+
}, {
113+
Msg: "invalid aspectRatio",
114+
Args: []interface{}{0.5, 0.5, "qwerty"},
115+
Err: true,
116+
}, {
117+
Msg: "3 args",
118+
Args: []interface{}{0.5, 0.5, 1.5},
119+
Err: false,
120+
}, {
121+
Msg: "more than 3 args",
122+
Args: []interface{}{0.5, 0.5, 1.5, 1.0},
123+
Err: true,
124+
}})
125+
}

filters/imagefilter.go

+10-5
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const (
3636
var (
3737
cropTypeToGravity map[string]bimg.Gravity
3838
cropTypes map[string]bool
39-
stripMetadata bool
39+
stripMetadata bool
4040
)
4141

4242
func init() {
@@ -67,10 +67,13 @@ type ImageFilter interface {
6767
}
6868

6969
type ImageFilterContext struct {
70-
Image *bimg.Image
71-
Parameters map[string][]string
70+
Image *bimg.Image
71+
Parameters map[string][]string
72+
filterContext *filters.FilterContext
7273
}
7374

75+
func (c *ImageFilterContext) PathParam(key string) string { return (*c.filterContext).PathParam(key) }
76+
7477
func errorResponse() *http.Response {
7578
return &http.Response{
7679
StatusCode: http.StatusInternalServerError,
@@ -83,9 +86,11 @@ func buildParameters(ctx filters.FilterContext, image *bimg.Image) *ImageFilterC
8386
if ctx != nil {
8487
parameters = ctx.Request().URL.Query()
8588
}
89+
8690
return &ImageFilterContext{
87-
Image: image,
88-
Parameters: parameters,
91+
Image: image,
92+
Parameters: parameters,
93+
filterContext: &ctx,
8994
}
9095
}
9196

filters/transformFromQueryParams.go

+14
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
const (
1111
ExtractArea = "transformFromQueryParams"
1212
cropParameters = "crop"
13+
focalPointCropParameters = "focal_point_crop"
1314
)
1415

1516
type transformFromQueryParams struct{}
@@ -30,6 +31,19 @@ func (t *transformFromQueryParams) CreateOptions(ctx *ImageFilterContext) (*bimg
3031
// Get crop prams from the request
3132
params, ok := ctx.Parameters[cropParameters]
3233
if !ok {
34+
35+
params, ok = ctx.Parameters[focalPointCropParameters]
36+
37+
if !ok {
38+
return &bimg.Options{}, nil
39+
}
40+
41+
params = strings.Split(params[0], ",")
42+
if len(params) != 5 {
43+
return &bimg.Options{}, nil
44+
}
45+
46+
// TODO do focal point crop
3347
return &bimg.Options{}, nil
3448
}
3549

0 commit comments

Comments
 (0)