diff --git a/README.md b/README.md index 472e634..026538a 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Skrop provides a set of filters, which you can use within the routes: * **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)) * **imageOverlay(filename, opacity, gravity, opt-top-margin, opt-right-margin, opt-bottom-margin, opt-left-margin)** — puts an image onverlay over the required image * **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. +* **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. ### About filters The eskip file defines a list of configuration. Every configuration is composed by a route and a list of filters to diff --git a/cmd/skrop/main.go b/cmd/skrop/main.go index 323fb63..ed9c89d 100644 --- a/cmd/skrop/main.go +++ b/cmd/skrop/main.go @@ -110,6 +110,7 @@ func main() { skropFilters.NewCrop(), skropFilters.NewCropByWidth(), skropFilters.NewCropByHeight(), + skropFilters.NewCropByFocalPoint(), skropFilters.NewResizeByWidth(), skropFilters.NewResizeByHeight(), skropFilters.NewQuality(), diff --git a/eskip/sample.eskip b/eskip/sample.eskip index 3e62414..dce4ede 100644 --- a/eskip/sample.eskip +++ b/eskip/sample.eskip @@ -49,6 +49,11 @@ cropByHeight: Path("/images/cropbyheight/:image") -> cropByHeight(1000, "south") -> "http://localhost:9090"; +cropByFocalPoint: Path("/images/cropbyfocalpoint/:focalPointX/:focalPointY/:image") + -> modPath("^/images/cropbyfocalpoint/\\d+/\\d+", "/images") + -> cropByFocalPoint(0.25,0.25,0.5) + -> "http://localhost:9090"; + widthAndQuality: Path("/images/waq/:image") -> modPath("^/images/waq", "/images") -> width(1000) diff --git a/filters/cropbyfocalpoint.go b/filters/cropbyfocalpoint.go new file mode 100644 index 0000000..eff0221 --- /dev/null +++ b/filters/cropbyfocalpoint.go @@ -0,0 +1,132 @@ +package filters + +import ( + "github.com/zalando-stups/skrop/parse" + "github.com/zalando/skipper/filters" + "gopkg.in/h2non/bimg.v1" + "strconv" +) + +// CropByFocalPointName is the name of the filter +const CropByFocalPointName = "cropByFocalPoint" + +type cropByFocalPoint struct { + targetX float64 + targetY float64 + aspectRatio float64 +} + +// NewCropByFocalPoint creates a new filter of this type +func NewCropByFocalPoint() filters.Spec { + return &cropByFocalPoint{} +} + +func (f *cropByFocalPoint) Name() string { + return CropByFocalPointName +} + +func (f *cropByFocalPoint) CreateOptions(imageContext *ImageFilterContext) (*bimg.Options, error) { + imageSize, err := imageContext.Image.Size() + + if err != nil { + return nil, err + } + + focalPointX := imageContext.PathParam("focalPointX") + focalPointY := imageContext.PathParam("focalPointY") + + if focalPointX == "" || focalPointY == "" { + return nil, filters.ErrInvalidFilterParameters + } + + sourceX, err := strconv.Atoi(focalPointX) + + if err != nil { + return nil, err + } + + sourceY, err := strconv.Atoi(focalPointY) + + if err != nil { + return nil, err + } + + right := imageSize.Width - sourceX + bottom := imageSize.Height - sourceY + + cropLeftWidth := int(float64(sourceX) / f.targetX) + cropRightWidth := int(float64(right) / (float64(1) - f.targetX)) + + width := cropRightWidth + + if cropLeftWidth < cropRightWidth { + width = cropLeftWidth + } + + cropTopHeight := int(float64(sourceY) / f.targetY) + cropBottomHeight := int(float64(bottom) / (float64(1) - f.targetY)) + + height := cropBottomHeight + + if cropTopHeight < cropBottomHeight { + height = int(float64(sourceY) / f.targetY) + } + + ratio := float64(height) / float64(width) + + if ratio > f.aspectRatio { + height = int(float64(width) * f.aspectRatio) + } else { + width = int(float64(height) / f.aspectRatio) + } + + return &bimg.Options{ + AreaWidth: width, + AreaHeight: height, + Top: sourceY - int(float64(height) * f.targetY), + Left: sourceX - int(float64(width) * f.targetX)}, nil +} + +func (f *cropByFocalPoint) CanBeMerged(other *bimg.Options, self *bimg.Options) bool { + return false +} + +func (f *cropByFocalPoint) Merge(other *bimg.Options, self *bimg.Options) *bimg.Options { + return self +} + +func (f *cropByFocalPoint) CreateFilter(args []interface{}) (filters.Filter, error) { + var err error + + if len(args) < 3 || len(args) > 3 { + return nil, filters.ErrInvalidFilterParameters + } + + c := &cropByFocalPoint{} + + c.targetX, err = parse.EskipFloatArg(args[0]) + + if err != nil { + return nil, err + } + + c.targetY, err = parse.EskipFloatArg(args[1]) + + if err != nil { + return nil, err + } + + c.aspectRatio, err = parse.EskipFloatArg(args[2]) + + if err != nil { + return nil, err + } + + return c, nil +} + +func (f *cropByFocalPoint) Request(ctx filters.FilterContext) {} + +func (f *cropByFocalPoint) Response(ctx filters.FilterContext) { + HandleImageResponse(ctx, f) +} diff --git a/filters/cropbyfocalpoint_test.go b/filters/cropbyfocalpoint_test.go new file mode 100644 index 0000000..2715ac1 --- /dev/null +++ b/filters/cropbyfocalpoint_test.go @@ -0,0 +1,125 @@ +package filters + +import ( + "testing" + "github.com/stretchr/testify/assert" + "github.com/zalando-stups/skrop/filters/imagefiltertest" + "github.com/zalando/skipper/filters" +) + +func TestNewCropByFocalPoint(t *testing.T) { + name := NewCropByFocalPoint().Name() + assert.Equal(t, "cropByFocalPoint", name) +} + +func TestCropByFocalPoint_Name(t *testing.T) { + c := cropByFocalPoint{} + assert.Equal(t, "cropByFocalPoint", c.Name()) +} + +func TestCropByFocalPoint_CreateOptions(t *testing.T) { + c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5} + image := imagefiltertest.LandscapeImage() + fc := createDefaultContext(t, "doesnotmatter.com") + fc.FParams = make(map[string]string) + fc.FParams["focalPointX"] = "500"; + fc.FParams["focalPointY"] = "334"; + + options, _ := c.CreateOptions(buildParameters(fc, image)) + + assert.Equal(t, 445, options.AreaWidth) + assert.Equal(t, 668, options.AreaHeight) + assert.Equal(t, 0, options.Top) + assert.Equal(t, 278, options.Left) + + c = cropByFocalPoint{targetX: 0.5, targetY: 0.25, aspectRatio: 1.5} + image = imagefiltertest.LandscapeImage() + fc = createDefaultContext(t, "doesnotmatter.com") + fc.FParams = make(map[string]string) + fc.FParams["focalPointX"] = "500"; + fc.FParams["focalPointY"] = "334"; + + options, _ = c.CreateOptions(buildParameters(fc, image)) + + assert.Equal(t, 296, options.AreaWidth) + assert.Equal(t, 445, options.AreaHeight) + assert.Equal(t, 223, options.Top) + assert.Equal(t, 352, options.Left) +} + +func TestCropByFocalPoint_CreateOptions_MissingPathParam(t *testing.T) { + c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5} + image := imagefiltertest.LandscapeImage() + fc := createDefaultContext(t, "doesnotmatter.com") + fc.FParams = make(map[string]string) + fc.FParams["focalPointY"] = "334"; + + options, err := c.CreateOptions(buildParameters(fc, image)) + + assert.Nil(t, options) + assert.Equal(t, filters.ErrInvalidFilterParameters, err) + + fc = createDefaultContext(t, "doesnotmatter.com") + fc.FParams = make(map[string]string) + fc.FParams["focalPointX"] = "334"; + + options, err = c.CreateOptions(buildParameters(fc, image)) + + assert.Nil(t, options) + assert.Equal(t, filters.ErrInvalidFilterParameters, err) +} + +func TestCropByFocalPoint_CreateOptions_InvalidPathParam(t *testing.T) { + c := cropByFocalPoint{targetX: 0.5, targetY: 0.5, aspectRatio: 1.5} + image := imagefiltertest.LandscapeImage() + fc := createDefaultContext(t, "doesnotmatter.com") + fc.FParams = make(map[string]string) + fc.FParams["focalPointX"] = "xyz"; + fc.FParams["focalPointY"] = "abc"; + + options, err := c.CreateOptions(buildParameters(fc, image)) + + assert.Nil(t, options) + assert.NotNil(t, err) + + fc.FParams["focalPointX"] = "100"; + fc.FParams["focalPointY"] = "abc"; + + options, err = c.CreateOptions(buildParameters(fc, image)) + + assert.Nil(t, options) + assert.NotNil(t, err) +} + +func TestCropByFocalPoint_CanBeMerged(t *testing.T) { + ea := transformFromQueryParams{} + assert.Equal(t, false, ea.CanBeMerged(nil, nil)) +} + +func TestCropByFocalPoint_CreateFilter(t *testing.T) { + imagefiltertest.TestCreate(t, NewCropByFocalPoint, []imagefiltertest.CreateTestItem{{ + Msg: "less than 3 args", + Args: nil, + Err: true, + }, { + Msg: "invalid targetX", + Args: []interface{}{"xyz", 0.5, 1.5}, + Err: true, + }, { + Msg: "invalid targetY", + Args: []interface{}{0.5, "abc", 1.5}, + Err: true, + }, { + Msg: "invalid aspectRatio", + Args: []interface{}{0.5, 0.5, "qwerty"}, + Err: true, + }, { + Msg: "3 args", + Args: []interface{}{0.5, 0.5, 1.5}, + Err: false, + }, { + Msg: "more than 3 args", + Args: []interface{}{0.5, 0.5, 1.5, 1.0}, + Err: true, + }}) +} diff --git a/filters/imagefilter.go b/filters/imagefilter.go index e56e22d..2adeb85 100644 --- a/filters/imagefilter.go +++ b/filters/imagefilter.go @@ -36,7 +36,7 @@ const ( var ( cropTypeToGravity map[string]bimg.Gravity cropTypes map[string]bool - stripMetadata bool + stripMetadata bool ) func init() { @@ -67,10 +67,13 @@ type ImageFilter interface { } type ImageFilterContext struct { - Image *bimg.Image - Parameters map[string][]string + Image *bimg.Image + Parameters map[string][]string + filterContext *filters.FilterContext } +func (c *ImageFilterContext) PathParam(key string) string { return (*c.filterContext).PathParam(key) } + func errorResponse() *http.Response { return &http.Response{ StatusCode: http.StatusInternalServerError, @@ -83,9 +86,11 @@ func buildParameters(ctx filters.FilterContext, image *bimg.Image) *ImageFilterC if ctx != nil { parameters = ctx.Request().URL.Query() } + return &ImageFilterContext{ - Image: image, - Parameters: parameters, + Image: image, + Parameters: parameters, + filterContext: &ctx, } } diff --git a/filters/transformFromQueryParams.go b/filters/transformFromQueryParams.go index 35e69e0..30c2bfc 100644 --- a/filters/transformFromQueryParams.go +++ b/filters/transformFromQueryParams.go @@ -10,6 +10,7 @@ import ( const ( ExtractArea = "transformFromQueryParams" cropParameters = "crop" + focalPointCropParameters = "focal_point_crop" ) type transformFromQueryParams struct{} @@ -30,6 +31,19 @@ func (t *transformFromQueryParams) CreateOptions(ctx *ImageFilterContext) (*bimg // Get crop prams from the request params, ok := ctx.Parameters[cropParameters] if !ok { + + params, ok = ctx.Parameters[focalPointCropParameters] + + if !ok { + return &bimg.Options{}, nil + } + + params = strings.Split(params[0], ",") + if len(params) != 5 { + return &bimg.Options{}, nil + } + + // TODO do focal point crop return &bimg.Options{}, nil }