From a67994e68919c977924bbd5be5afde4f9d755160 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Thu, 16 Oct 2014 13:49:19 -0700 Subject: [PATCH 1/3] Replace maintain_aspect_ratio with crop_mode (fill, fit, stretch) --- README.md | 12 ++--- examples/filesystem_config.json | 2 +- examples/s3_config.json | 2 +- halfshell/config.go | 16 ++++++- halfshell/image_processor.go | 83 +++++++++++++++++++++++++-------- halfshell/route.go | 2 + halfshell/util/strings.go | 10 ++++ 7 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 halfshell/util/strings.go diff --git a/README.md b/README.md index e362039..438bb43 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Halfshell uses a JSON file for configuration. An example is shown below: "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fit", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 @@ -87,7 +87,7 @@ This will start the server on port 8080, and service requests whose path begins http://localhost:8080/users/joe/default.jpg?w=100&h=100 http://localhost:8080/blog/posts/announcement.jpg?w=600&h=200 -The image_host named group in the route pattern match (e.g., `^/users(?P/.*)$`) gets extracted as the request path for the source. In this instance, the file “joe/default.jpg” is requested from the “my-company-profile-photos” S3 bucket. The processor resizes the image to a width and height of 100. Since the maintain_aspect_ratio setting is set to true, the image will have a maximum width and height of 100, but may be smaller in one dimension in order to maintain the aspect ratio. +The image_host named group in the route pattern match (e.g., `^/users(?P/.*)$`) gets extracted as the request path for the source. In this instance, the file “joe/default.jpg” is requested from the “my-company-profile-photos” S3 bucket. The processor resizes the image to a width and height of 100. ### Server @@ -139,12 +139,6 @@ Values from a processor named `default` will be inherited by all other processor The compression quality to use for JPEG images. -##### maintain_aspect_ratio - -If this is set to true, the resized images will always maintain the original -aspect ratio. When set to false, the image will be stretched to fit the width -and height requested. - ##### default_image_width In the absence of a width parameter in the request, use this as image width. A @@ -204,4 +198,4 @@ Run `make format` before sending any pull requests. ### Questions? -File an issue or send an email to rafik@oysterbooks.com. \ No newline at end of file +File an issue or send an email to rafik@oysterbooks.com. diff --git a/examples/filesystem_config.json b/examples/filesystem_config.json index e5381d9..fbab201 100644 --- a/examples/filesystem_config.json +++ b/examples/filesystem_config.json @@ -19,7 +19,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fit", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 diff --git a/examples/s3_config.json b/examples/s3_config.json index 8313b5b..f971a94 100644 --- a/examples/s3_config.json +++ b/examples/s3_config.json @@ -20,7 +20,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "crop_mode": "fill", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 diff --git a/halfshell/config.go b/halfshell/config.go index 476f596..67c0e5d 100644 --- a/halfshell/config.go +++ b/halfshell/config.go @@ -67,12 +67,15 @@ type SourceConfig struct { type ProcessorConfig struct { Name string ImageCompressionQuality uint64 - MaintainAspectRatio bool + DefaultCropMode string DefaultImageHeight uint64 DefaultImageWidth uint64 MaxImageHeight uint64 MaxImageWidth uint64 MaxBlurRadiusPercentage float64 + + // DEPRECATED + MaintainAspectRatio bool } // Parses a JSON configuration file and returns a pointer to a new Config object. @@ -170,13 +173,22 @@ func (c *configParser) parseProcessorConfig(processorName string) *ProcessorConf return &ProcessorConfig{ Name: processorName, ImageCompressionQuality: c.uintForKeypath("processors.%s.image_compression_quality", processorName), - MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + DefaultCropMode: c.stringForKeypath("processors.%s.default_crop_mode", processorName), DefaultImageHeight: c.uintForKeypath("processors.%s.default_image_height", processorName), DefaultImageWidth: c.uintForKeypath("processors.%s.default_image_width", processorName), MaxImageHeight: c.uintForKeypath("processors.%s.max_image_height", processorName), MaxImageWidth: c.uintForKeypath("processors.%s.max_image_width", processorName), MaxBlurRadiusPercentage: c.floatForKeypath("processors.%s.max_blur_radius_percentage", processorName), + + // DEPRECATED + MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + } + + if config.MaintainAspectRatio { + config.DefaultCropMode = "fill" } + + return config } func (c *configParser) valueForKeypath(valueType reflect.Kind, keypathFormat string, v ...interface{}) interface{} { diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index fb222e9..b5732a4 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -25,6 +25,7 @@ import ( "math" "strings" + "github.com/oysterbooks/halfshell/halfshell/util" "github.com/rafikk/imagick/imagick" ) @@ -39,6 +40,7 @@ type ImageProcessor interface { type ImageProcessorOptions struct { Dimensions ImageDimensions BlurRadius float64 + CropMode string } type imageProcessor struct { @@ -89,8 +91,9 @@ func (ip *imageProcessor) ProcessImage(image *Image, request *ImageProcessorOpti func (ip *imageProcessor) scaleWand(wand *imagick.MagickWand, request *ImageProcessorOptions) (modified bool, err error) { currentDimensions := ImageDimensions{uint64(wand.GetImageWidth()), uint64(wand.GetImageHeight())} newDimensions := ip.getScaledDimensions(currentDimensions, request) + requestedDimensions := request.Dimensions - if newDimensions == currentDimensions { + if newDimensions == currentDimensions && newDimensions == requestedDimensions { return false, nil } @@ -99,6 +102,13 @@ func (ip *imageProcessor) scaleWand(wand *imagick.MagickWand, request *ImageProc return true, err } + if request.CropMode == "fill" { + if err = ip.cropImage(newDimensions, request.Dimensions, wand); err != nil { + ip.Logger.Warnf("ImageMagick error cropping image: %s", err) + return true, err + } + } + if err = wand.SetImageInterpolateMethod(imagick.INTERPOLATE_PIXEL_BICUBIC); err != nil { ip.Logger.Warnf("ImageMagick error setting interpoliation method: %s", err) return true, err @@ -151,37 +161,60 @@ func (ip *imageProcessor) getScaledDimensions(currentDimensions ImageDimensions, } func (ip *imageProcessor) scaleToRequestedDimensions(currentDimensions, requestedDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { + if requestedDimensions.Width == 0 && requestedDimensions.Height == 0 { + return currentDimensions + } + imageAspectRatio := currentDimensions.AspectRatio() - if requestedDimensions.Width > 0 && requestedDimensions.Height > 0 { - requestedAspectRatio := requestedDimensions.AspectRatio() - ip.Logger.Infof("Requested image ratio %f, image ratio %f, %v", requestedAspectRatio, imageAspectRatio, ip.Config.MaintainAspectRatio) - if !ip.Config.MaintainAspectRatio { - // If we're not asked to maintain the aspect ratio, give them what they want - return requestedDimensions - } + // No height was specified, thus image proportions should be retained. + if requestedDimensions.Width > 0 && requestedDimensions.Height == 0 { + height := ip.getAspectScaledHeight(imageAspectRatio, requestedDimensions.Width) + return ImageDimensions{requestedDimensions.Width, height} + } + + // No width was specified, thus image proportions should be retained. + if requestedDimensions.Height > 0 && requestedDimensions.Width == 0 { + width := ip.getAspectScaledWidth(imageAspectRatio, requestedDimensions.Height) + return ImageDimensions{width, requestedDimensions.Height} + } + // The "stretch" crop mode is a NOOP, hence it's the default. + cropMode := util.FirstString(request.CropMode, ip.Config.DefaultCropMode, "stretch") + if cropMode == "stretch" { + return requestedDimensions + } + + // The "fit" crop mode retains the aspect ration while at least filling the + // bounds requested. No cropping will occur. + if cropMode == "fit" { + requestedAspectRatio := requestedDimensions.AspectRatio() if requestedAspectRatio > imageAspectRatio { - // The requested aspect ratio is wider than the image's natural ratio. - // Thus means the height is the restraining dimension, so unset the - // width and let the height determine the dimensions. return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{0, requestedDimensions.Height}, request) } else if requestedAspectRatio < imageAspectRatio { return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{requestedDimensions.Width, 0}, request) - } else { - return requestedDimensions } + return requestedDimensions } - if requestedDimensions.Width > 0 { - return ImageDimensions{requestedDimensions.Width, ip.getAspectScaledHeight(imageAspectRatio, requestedDimensions.Width)} - } - - if requestedDimensions.Height > 0 { - return ImageDimensions{ip.getAspectScaledWidth(imageAspectRatio, requestedDimensions.Height), requestedDimensions.Height} + // The "fill" crop mode will use the exact width/height and crop out the parts + // that bleed out of the edges. + // + // Cropping does occur (handled elsewhere). The new dimensions defined here + // ensure that clipping occurs on smallest edges possible. This is done by + // bounding to the larger of the two axes. + if cropMode == "fill" { + requestedAspectRatio := requestedDimensions.AspectRatio() + if requestedAspectRatio < imageAspectRatio { + return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{0, requestedDimensions.Height}, request) + } else if requestedAspectRatio > imageAspectRatio { + return ip.scaleToRequestedDimensions(currentDimensions, ImageDimensions{requestedDimensions.Width, 0}, request) + } + return requestedDimensions } - return currentDimensions + // Unsupported crop modes are a NOOP. + return requestedDimensions } func (ip *imageProcessor) clampDimensionsToMaxima(dimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { @@ -198,6 +231,16 @@ func (ip *imageProcessor) clampDimensionsToMaxima(dimensions ImageDimensions, re return dimensions } +func (ip *imageProcessor) cropImage(currentDimensions ImageDimensions, requestedDimensions ImageDimensions, wand *imagick.MagickWand) (err error) { + err = wand.CropImage( + uint(requestedDimensions.Width), + uint(requestedDimensions.Height), + int((currentDimensions.Width-requestedDimensions.Width)/2), + int((currentDimensions.Height-requestedDimensions.Height)/2), + ) + return +} + func (ip *imageProcessor) getAspectScaledHeight(aspectRatio float64, width uint64) uint64 { return uint64(math.Floor(float64(width)/aspectRatio + 0.5)) } diff --git a/halfshell/route.go b/halfshell/route.go index e60c29d..4fadc0d 100644 --- a/halfshell/route.go +++ b/halfshell/route.go @@ -69,9 +69,11 @@ func (p *Route) SourceAndProcessorOptionsForRequest(r *http.Request) ( width, _ := strconv.ParseUint(r.FormValue("w"), 10, 32) height, _ := strconv.ParseUint(r.FormValue("h"), 10, 32) blurRadius, _ := strconv.ParseFloat(r.FormValue("blur"), 64) + cropMode := r.FormValue("crop_mode") return &ImageSourceOptions{Path: path}, &ImageProcessorOptions{ Dimensions: ImageDimensions{width, height}, BlurRadius: blurRadius, + CropMode: cropMode, } } diff --git a/halfshell/util/strings.go b/halfshell/util/strings.go new file mode 100644 index 0000000..265b4f0 --- /dev/null +++ b/halfshell/util/strings.go @@ -0,0 +1,10 @@ +package util + +func FirstString(str ...string) (s string) { + for _, s := range str { + if s != "" { + return s + } + } + return s +} From cc928f2beffc9a41c359963e571f1a509c8a9717 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Fri, 17 Oct 2014 09:17:14 -0700 Subject: [PATCH 2/3] Add border_radius property, contextual bg_color --- halfshell/config.go | 4 ++ halfshell/image_processor.go | 72 ++++++++++++++++++++++++++++++++++-- halfshell/route.go | 10 +++-- halfshell/util/strings.go | 9 +++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/halfshell/config.go b/halfshell/config.go index 67c0e5d..7f783f4 100644 --- a/halfshell/config.go +++ b/halfshell/config.go @@ -68,8 +68,10 @@ type ProcessorConfig struct { Name string ImageCompressionQuality uint64 DefaultCropMode string + DefaultBorderRadius uint64 DefaultImageHeight uint64 DefaultImageWidth uint64 + DefaultBGColor string MaxImageHeight uint64 MaxImageWidth uint64 MaxBlurRadiusPercentage float64 @@ -174,8 +176,10 @@ func (c *configParser) parseProcessorConfig(processorName string) *ProcessorConf Name: processorName, ImageCompressionQuality: c.uintForKeypath("processors.%s.image_compression_quality", processorName), DefaultCropMode: c.stringForKeypath("processors.%s.default_crop_mode", processorName), + DefaultBorderRadius: c.uintForKeypath("processors.%s.default_border_radius", processorName), DefaultImageHeight: c.uintForKeypath("processors.%s.default_image_height", processorName), DefaultImageWidth: c.uintForKeypath("processors.%s.default_image_width", processorName), + DefaultBGColor: c.stringForKeypath("processors.%s.default_bg_color", processorName), MaxImageHeight: c.uintForKeypath("processors.%s.max_image_height", processorName), MaxImageWidth: c.uintForKeypath("processors.%s.max_image_width", processorName), MaxBlurRadiusPercentage: c.floatForKeypath("processors.%s.max_blur_radius_percentage", processorName), diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index b5732a4..5bac4ea 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -38,9 +38,11 @@ type ImageProcessor interface { // ImageProcessorOptions specify the request parameters for the processing // operation. type ImageProcessorOptions struct { - Dimensions ImageDimensions - BlurRadius float64 - CropMode string + Dimensions ImageDimensions + BlurRadius float64 + CropMode string + BorderRadius uint64 + BGColor string } type imageProcessor struct { @@ -77,7 +79,13 @@ func (ip *imageProcessor) ProcessImage(image *Image, request *ImageProcessorOpti return nil } - if !scaleModified && !blurModified { + radiusModified, err := ip.radiusWand(wand, request) + if err != nil { + ip.Logger.Warnf("Error applying radius: %s", err) + return nil + } + + if !scaleModified && !blurModified && !radiusModified { processedImage.Bytes = image.Bytes } else { processedImage.Bytes = wand.GetImageBlob() @@ -150,6 +158,62 @@ func (ip *imageProcessor) blurWand(wand *imagick.MagickWand, request *ImageProce return false, nil } +func (ip *imageProcessor) radiusWand(wand *imagick.MagickWand, request *ImageProcessorOptions) (modified bool, err error) { + radiusInt := util.FirstUInt(request.BorderRadius, ip.Config.DefaultBorderRadius, 0) + if radiusInt == 0 { + return + } + radius := float64(radiusInt) + + bgColor := util.FirstString(request.BGColor, ip.Config.DefaultBGColor, "white") + + widthI := wand.GetImageWidth() + heightI := wand.GetImageHeight() + widthF := float64(widthI) + heightF := float64(heightI) + + canvas := imagick.NewMagickWand() + defer canvas.Destroy() + + transparent := imagick.NewPixelWand() + defer transparent.Destroy() + + bg := imagick.NewPixelWand() + defer bg.Destroy() + + mask := imagick.NewDrawingWand() + defer mask.Destroy() + + border := imagick.NewDrawingWand() + defer border.Destroy() + + transparent.SetColor("none") + if !bg.SetColor(bgColor) { + bg.SetColor("bg") + } + + canvas.NewImage(widthI, heightI, transparent) + + mask.SetFillColor(bg) + mask.RoundRectangle(0, 0, widthF, heightF, radius, radius) + canvas.DrawImage(mask) + + canvas.CompositeImage(wand, imagick.COMPOSITE_OP_SRC_IN, 0, 0) + canvas.OpaquePaintImage(transparent, bg, 0, false) + + border.SetFillColor(transparent) + border.SetStrokeColor(bg) + border.SetStrokeWidth(2) + border.RoundRectangle(0, 0, widthF, heightF, radius, radius) + canvas.DrawImage(border) + + canvas.SetImageFormat(wand.GetImageFormat()) + + err = wand.SetImage(canvas) + modified = true + return +} + func (ip *imageProcessor) getScaledDimensions(currentDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { requestDimensions := request.Dimensions if requestDimensions.Width == 0 && requestDimensions.Height == 0 { diff --git a/halfshell/route.go b/halfshell/route.go index 4fadc0d..729ee95 100644 --- a/halfshell/route.go +++ b/halfshell/route.go @@ -69,11 +69,15 @@ func (p *Route) SourceAndProcessorOptionsForRequest(r *http.Request) ( width, _ := strconv.ParseUint(r.FormValue("w"), 10, 32) height, _ := strconv.ParseUint(r.FormValue("h"), 10, 32) blurRadius, _ := strconv.ParseFloat(r.FormValue("blur"), 64) + borderRadius, _ := strconv.ParseUint(r.FormValue("border_radius"), 10, 32) cropMode := r.FormValue("crop_mode") + bgColor := r.FormValue("bg_color") return &ImageSourceOptions{Path: path}, &ImageProcessorOptions{ - Dimensions: ImageDimensions{width, height}, - BlurRadius: blurRadius, - CropMode: cropMode, + Dimensions: ImageDimensions{width, height}, + BlurRadius: blurRadius, + BorderRadius: borderRadius, + CropMode: cropMode, + BGColor: bgColor, } } diff --git a/halfshell/util/strings.go b/halfshell/util/strings.go index 265b4f0..4aa3466 100644 --- a/halfshell/util/strings.go +++ b/halfshell/util/strings.go @@ -8,3 +8,12 @@ func FirstString(str ...string) (s string) { } return s } + +func FirstUInt(ints ...uint64) (n uint64) { + for _, n := range ints { + if n > 0 { + return n + } + } + return n +} From d88810b155c75269f0731cd0c4c54744926b4ef1 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Wed, 22 Oct 2014 09:01:41 -0700 Subject: [PATCH 3/3] Set border radius stroke to 1.5 --- halfshell/image_processor.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index 5bac4ea..d0b6e51 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -203,7 +203,11 @@ func (ip *imageProcessor) radiusWand(wand *imagick.MagickWand, request *ImagePro border.SetFillColor(transparent) border.SetStrokeColor(bg) - border.SetStrokeWidth(2) + + // XXX: Implement optimal stroke width depending on the circle radius. See: + // http://www.imagemagick.org/Usage/antialiasing/ + border.SetStrokeWidth(1.5) + border.RoundRectangle(0, 0, widthF, heightF, radius, radius) canvas.DrawImage(border)