From d6b9f59ce8ec08b4b379960600f93e028c70c5af Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Wed, 5 Nov 2014 10:38:23 -0800 Subject: [PATCH 1/5] Refactor image processor interfaces - Replace NewImageFromHTTPResponse with NewImageFromBuffer - Check for errors instead of nil image structs - Image struct wrapper with helper methods --- halfshell/image.go | 84 ++++++++++++++++++++++------------ halfshell/image_processor.go | 6 +-- halfshell/server.go | 23 +++++----- halfshell/source.go | 2 +- halfshell/source_filesystem.go | 9 ++-- halfshell/source_s3.go | 12 +++-- 6 files changed, 80 insertions(+), 56 deletions(-) diff --git a/halfshell/image.go b/halfshell/image.go index 79b881b..0e2d207 100644 --- a/halfshell/image.go +++ b/halfshell/image.go @@ -22,57 +22,81 @@ package halfshell import ( "fmt" + "io" "io/ioutil" - "mime" - "net/http" "os" - "path/filepath" + "strings" + + "github.com/rafikk/imagick/imagick" ) -// Image contains a byte array of the image data and its MIME type. -// TODO: See if we can use the std library's Image type without incurring -// the hit of extra copying. +var EmptyImageDimensions = ImageDimensions{} +var EmptyResizeDimensions = ResizeDimensions{} + type Image struct { - Bytes []byte - MimeType string + Wand *imagick.MagickWand Signature string + destroyed bool } -// NewImageFromHTTPResponse returns a pointer to a new Image created from an -// HTTP response object. -func NewImageFromHTTPResponse(httpResponse *http.Response) (*Image, error) { - imageBytes, err := ioutil.ReadAll(httpResponse.Body) - defer httpResponse.Body.Close() +func NewImageFromBuffer(buffer io.Reader) (image *Image, err error) { + bytes, err := ioutil.ReadAll(buffer) if err != nil { return nil, err } - return &Image{ - Bytes: imageBytes, - MimeType: httpResponse.Header.Get("Content-Type"), - }, nil -} - -// NewImageFromFile returns a pointer to a new Image created from a file. -func NewImageFromFile(file *os.File) (*Image, error) { - imageBytes, err := ioutil.ReadAll(file) + image = &Image{Wand: imagick.NewMagickWand()} + err = image.Wand.ReadImageBlob(bytes) if err != nil { return nil, err } - return &Image{ - Bytes: imageBytes, - MimeType: mime.TypeByExtension(filepath.Ext(file.Name())), - }, nil + return image, nil +} + +func NewImageFromFile(file *os.File) (image *Image, err error) { + image, err = NewImageFromBuffer(file) + return image, err +} + +func (i *Image) GetMIMEType() string { + return fmt.Sprintf("image/%s", strings.ToLower(i.Wand.GetImageFormat())) +} + +func (i *Image) GetBytes() (bytes []byte, size int) { + bytes = i.Wand.GetImageBlob() + size = len(bytes) + return bytes, size +} + +func (i *Image) GetWidth() uint { + return i.Wand.GetImageWidth() +} + +func (i *Image) GetHeight() uint { + return i.Wand.GetImageHeight() +} + +func (i *Image) GetDimensions() ImageDimensions { + return ImageDimensions{i.GetWidth(), i.GetHeight()} +} + +func (i *Image) GetSignature() string { + return i.Wand.GetImageSignature() +} + +func (i *Image) Destroy() { + if !i.destroyed { + i.Wand.Destroy() + i.destroyed = true + } } -// ImageDimensions is the width and height of an image. type ImageDimensions struct { - Width uint64 - Height uint64 + Width uint + Height uint } -// AspectRatio returns the image dimension's aspect ratio. func (d ImageDimensions) AspectRatio() float64 { return float64(d.Width) / float64(d.Height) } diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index 21b39a8..10a071c 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -31,11 +31,9 @@ import ( // ImageProcessor is the public interface for the image processor. It exposes a // single method to process an image with options. type ImageProcessor interface { - ProcessImage(*Image, *ImageProcessorOptions) *Image + ProcessImage(*Image, *ImageProcessorOptions) error } -// ImageProcessorOptions specify the request parameters for the processing -// operation. type ImageProcessorOptions struct { Dimensions ImageDimensions BlurRadius float64 @@ -46,8 +44,6 @@ type imageProcessor struct { Logger *Logger } -// NewImageProcessorWithConfig creates a new ImageProcessor instance using -// configuration settings. func NewImageProcessorWithConfig(config *ProcessorConfig) ImageProcessor { return &imageProcessor{ Config: config, diff --git a/halfshell/server.go b/halfshell/server.go index abedfc2..ef29805 100644 --- a/halfshell/server.go +++ b/halfshell/server.go @@ -69,23 +69,23 @@ func (s *Server) ImageRequestHandler(w *ResponseWriter, r *Request) { s.Logger.Infof("Handling request for image %s with dimensions %v", r.SourceOptions.Path, r.ProcessorOptions.Dimensions) - image := r.Route.Source.GetImage(r.SourceOptions) - if image == nil { + image, err := r.Route.Source.GetImage(r.SourceOptions) + if err != nil { w.WriteError("Not Found", http.StatusNotFound) return } + defer image.Destroy() - processedImage := r.Route.Processor.ProcessImage(image, r.ProcessorOptions) - if processedImage == nil { - s.Logger.Warnf("Error processing image data %s to dimensions: %v", - r.ProcessorOptions.Dimensions) + err = r.Route.Processor.ProcessImage(image, r.ProcessorOptions) + if err != nil { + s.Logger.Warnf("Error processing image data %s to dimensions: %v", r.ProcessorOptions.Dimensions) w.WriteError("Internal Server Error", http.StatusNotFound) return } s.Logger.Infof("Returning resized image %s to dimensions %v", r.SourceOptions.Path, r.ProcessorOptions.Dimensions) - w.WriteImage(processedImage) + w.WriteImage(image) } func (s *Server) LogRequest(w *ResponseWriter, r *Request) { @@ -161,10 +161,11 @@ func (hw *ResponseWriter) WriteError(message string, status int) { // WriteImage writes an image to the output stream and sets the appropriate headers. func (hw *ResponseWriter) WriteImage(image *Image) { - hw.SetHeader("Content-Type", image.MimeType) - hw.SetHeader("Content-Length", fmt.Sprintf("%d", len(image.Bytes))) + bytes, size := image.GetBytes() + hw.SetHeader("Content-Type", image.GetMIMEType()) + hw.SetHeader("Content-Length", fmt.Sprintf("%d", size)) hw.SetHeader("Cache-Control", "no-transform,public,max-age=86400,s-maxage=2592000") - hw.SetHeader("ETag", image.Signature) + hw.SetHeader("ETag", image.GetSignature()) hw.WriteHeader(http.StatusOK) - hw.Write(image.Bytes) + hw.Write(bytes) } diff --git a/halfshell/source.go b/halfshell/source.go index d4e962f..fc902dc 100644 --- a/halfshell/source.go +++ b/halfshell/source.go @@ -33,7 +33,7 @@ var ( ) type ImageSource interface { - GetImage(*ImageSourceOptions) *Image + GetImage(*ImageSourceOptions) (*Image, error) } type ImageSourceOptions struct { diff --git a/halfshell/source_filesystem.go b/halfshell/source_filesystem.go index 936cf56..f0bfbbf 100644 --- a/halfshell/source_filesystem.go +++ b/halfshell/source_filesystem.go @@ -60,21 +60,22 @@ func NewFileSystemImageSourceWithConfig(config *SourceConfig) ImageSource { return source } -func (s *FileSystemImageSource) GetImage(request *ImageSourceOptions) *Image { +func (s *FileSystemImageSource) GetImage(request *ImageSourceOptions) (*Image, error) { fileName := s.fileNameForRequest(request) file, err := os.Open(fileName) if err != nil { s.Logger.Warnf("Failed to open file: %v", err) - return nil + return nil, err } image, err := NewImageFromFile(file) if err != nil { s.Logger.Warnf("Failed to read image: %v", err) - return nil + return nil, err } - return image + + return image, nil } func (s *FileSystemImageSource) fileNameForRequest(request *ImageSourceOptions) string { diff --git a/halfshell/source_s3.go b/halfshell/source_s3.go index 1ced903..d6bc91c 100644 --- a/halfshell/source_s3.go +++ b/halfshell/source_s3.go @@ -47,24 +47,26 @@ func NewS3ImageSourceWithConfig(config *SourceConfig) ImageSource { } } -func (s *S3ImageSource) GetImage(request *ImageSourceOptions) *Image { +func (s *S3ImageSource) GetImage(request *ImageSourceOptions) (*Image, error) { httpRequest := s.signedHTTPRequestForRequest(request) httpResponse, err := http.DefaultClient.Do(httpRequest) + defer httpResponse.Body.Close() if err != nil { s.Logger.Warnf("Error downlading image: %v", err) - return nil + return nil, err } if httpResponse.StatusCode != 200 { s.Logger.Warnf("Error downlading image (url=%v)", httpRequest.URL) - return nil + return nil, err } - image, err := NewImageFromHTTPResponse(httpResponse) + image, err := NewImageFromBuffer(httpResponse.Body) if err != nil { responseBody, _ := ioutil.ReadAll(httpResponse.Body) s.Logger.Warnf("Unable to create image from response body: %v (url=%v)", string(responseBody), httpRequest.URL) + return nil, err } s.Logger.Infof("Successfully retrieved image from S3: %v", httpRequest.URL) - return image + return image, nil } func (s *S3ImageSource) signedHTTPRequestForRequest(request *ImageSourceOptions) *http.Request { From 013a8d0e63c02c8cbb8cd98d91945d0e4544bbb5 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Wed, 5 Nov 2014 10:43:31 -0800 Subject: [PATCH 2/5] Add scale mode, refactor image processor --- halfshell/config.go | 35 ++++- halfshell/image.go | 5 + halfshell/image_processor.go | 271 +++++++++++++++++++++-------------- halfshell/route.go | 6 +- 4 files changed, 203 insertions(+), 114 deletions(-) diff --git a/halfshell/config.go b/halfshell/config.go index a1c1db7..c95fbc8 100644 --- a/halfshell/config.go +++ b/halfshell/config.go @@ -68,12 +68,14 @@ type SourceConfig struct { type ProcessorConfig struct { Name string ImageCompressionQuality uint64 - MaintainAspectRatio bool + DefaultScaleMode uint DefaultImageHeight uint64 DefaultImageWidth uint64 - MaxImageHeight uint64 - MaxImageWidth uint64 + MaxImageDimensions ImageDimensions MaxBlurRadiusPercentage float64 + + // DEPRECATED + MaintainAspectRatio bool } // StatterConfig holds configuration data for StatsD @@ -207,16 +209,35 @@ func (c *configParser) parseSourceConfig(sourceName string) *SourceConfig { } func (c *configParser) parseProcessorConfig(processorName string) *ProcessorConfig { - return &ProcessorConfig{ + scaleModeName := c.stringForKeypath("processors.%s.default_scale_mode", processorName) + scaleMode, _ := ScaleModes[scaleModeName] + if scaleMode == 0 { + scaleMode = ScaleFill + } + + maxDimensions := ImageDimensions{ + Width: uint(c.uintForKeypath("processors.%s.max_image_width", processorName)), + Height: uint(c.uintForKeypath("processors.%s.max_image_height", processorName)), + } + + config := &ProcessorConfig{ Name: processorName, ImageCompressionQuality: c.uintForKeypath("processors.%s.image_compression_quality", processorName), - MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + DefaultScaleMode: scaleMode, 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), + MaxImageDimensions: maxDimensions, MaxBlurRadiusPercentage: c.floatForKeypath("processors.%s.max_blur_radius_percentage", processorName), + + // DEPRECATED + MaintainAspectRatio: c.boolForKeypath("processors.%s.maintain_aspect_ratio", processorName), + } + + if config.MaintainAspectRatio { + config.DefaultScaleMode = ScaleAspectFit } + + return config } func (c *configParser) valueForKeypath(valueType reflect.Kind, keypathFormat string, v ...interface{}) interface{} { diff --git a/halfshell/image.go b/halfshell/image.go index 0e2d207..eb86c55 100644 --- a/halfshell/image.go +++ b/halfshell/image.go @@ -104,3 +104,8 @@ func (d ImageDimensions) AspectRatio() float64 { func (d ImageDimensions) String() string { return fmt.Sprintf("%dx%d", d.Width, d.Height) } + +type ResizeDimensions struct { + Scale ImageDimensions + Crop ImageDimensions +} diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index 10a071c..871d311 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -21,15 +21,23 @@ package halfshell import ( - "fmt" "math" - "strings" "github.com/rafikk/imagick/imagick" ) -// ImageProcessor is the public interface for the image processor. It exposes a -// single method to process an image with options. +const ( + ScaleFill = 10 + ScaleAspectFit = 21 + ScaleAspectFill = 22 +) + +var ScaleModes = map[string]uint{ + "fill": ScaleFill, + "aspect_fit": ScaleAspectFit, + "aspect_fill": ScaleAspectFill, +} + type ImageProcessor interface { ProcessImage(*Image, *ImageProcessorOptions) error } @@ -37,6 +45,7 @@ type ImageProcessor interface { type ImageProcessorOptions struct { Dimensions ImageDimensions BlurRadius float64 + ScaleMode uint } type imageProcessor struct { @@ -51,154 +60,204 @@ func NewImageProcessorWithConfig(config *ProcessorConfig) ImageProcessor { } } -// The public method for processing an image. The method receives an original -// image and options and returns the processed image. -func (ip *imageProcessor) ProcessImage(image *Image, request *ImageProcessorOptions) *Image { - processedImage := Image{} - wand := imagick.NewMagickWand() - defer wand.Destroy() - - wand.ReadImageBlob(image.Bytes) - scaleModified, err := ip.scaleWand(wand, request) - if err != nil { - ip.Logger.Warnf("Error scaling image: %s", err) - return nil +func (ip *imageProcessor) ProcessImage(img *Image, req *ImageProcessorOptions) error { + if req.Dimensions == EmptyImageDimensions { + req.Dimensions.Width = uint(ip.Config.DefaultImageWidth) + req.Dimensions.Height = uint(ip.Config.DefaultImageHeight) } - blurModified, err := ip.blurWand(wand, request) + err := ip.resize(img, req) if err != nil { - ip.Logger.Warnf("Error blurring image: %s", err) - return nil + ip.Logger.Errorf("Error resizing image: %s", err) + return err } - if !scaleModified && !blurModified { - processedImage.Bytes = image.Bytes - } else { - processedImage.Bytes = wand.GetImageBlob() + err = ip.blur(img, req) + if err != nil { + ip.Logger.Errorf("Error blurring image: %s", err) + return err } - processedImage.Signature = wand.GetImageSignature() - processedImage.MimeType = fmt.Sprintf("image/%s", strings.ToLower(wand.GetImageFormat())) - - return &processedImage + return nil } -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) +func (ip *imageProcessor) resize(img *Image, req *ImageProcessorOptions) error { + scaleMode := req.ScaleMode + if scaleMode == 0 { + scaleMode = ip.Config.DefaultScaleMode + } + + resize, err := ip.resizePrepare(img.GetDimensions(), req.Dimensions, scaleMode) + if err != nil { + return err + } - if newDimensions == currentDimensions { - return false, nil + err = ip.resizeApply(img, resize.Scale) + if err != nil { + return err } - if err = wand.ResizeImage(uint(newDimensions.Width), uint(newDimensions.Height), imagick.FILTER_LANCZOS, 1); err != nil { - ip.Logger.Warnf("ImageMagick error resizing image: %s", err) - return true, err + err = ip.cropApply(img, resize.Crop) + if err != nil { + return err } - if err = wand.SetImageInterpolateMethod(imagick.INTERPOLATE_PIXEL_BICUBIC); err != nil { - ip.Logger.Warnf("ImageMagick error setting interpoliation method: %s", err) - return true, err + return nil +} + +func (ip *imageProcessor) resizePrepare(oldDimensions, reqDimensions ImageDimensions, scaleMode uint) (*ResizeDimensions, error) { + resize := &ResizeDimensions{ + Scale: ImageDimensions{}, + Crop: ImageDimensions{}, } - if err = wand.StripImage(); err != nil { - ip.Logger.Warnf("ImageMagick error stripping image routes and metadata") - return true, err + if reqDimensions == EmptyImageDimensions { + return resize, nil + } + if oldDimensions == reqDimensions { + return resize, nil } - if "JPEG" == wand.GetImageFormat() { - if err = wand.SetImageInterlaceScheme(imagick.INTERLACE_PLANE); err != nil { - ip.Logger.Warnf("ImageMagick error setting the image interlace scheme") - return true, err - } + reqDimensions = clampDimensionsToMaxima(oldDimensions, reqDimensions, ip.Config.MaxImageDimensions) + oldAspectRatio := oldDimensions.AspectRatio() + + // Unspecified dimensions are automatically computed relative to the specified + // dimension using the old image's aspect ratio. + if reqDimensions.Width > 0 && reqDimensions.Height == 0 { + reqDimensions.Height = aspectHeight(oldAspectRatio, reqDimensions.Width) + } else if reqDimensions.Height > 0 && reqDimensions.Width == 0 { + reqDimensions.Width = aspectWidth(oldAspectRatio, reqDimensions.Height) + } - if err = wand.SetImageCompression(imagick.COMPRESSION_JPEG); err != nil { - ip.Logger.Warnf("ImageMagick error setting the image compression type") - return true, err + // Retain the aspect ratio while at least filling the bounds requested. No + // cropping will occur but the image will be resized. + if scaleMode == ScaleAspectFit { + newAspectRatio := reqDimensions.AspectRatio() + if newAspectRatio > oldAspectRatio { + resize.Scale.Width = aspectWidth(oldAspectRatio, reqDimensions.Height) + resize.Scale.Height = reqDimensions.Height + } else if newAspectRatio < oldAspectRatio { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = aspectHeight(oldAspectRatio, reqDimensions.Width) + } else { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = reqDimensions.Height } + return resize, nil + } - if err = wand.SetImageCompressionQuality(uint(ip.Config.ImageCompressionQuality)); err != nil { - ip.Logger.Warnf("sImageMagick error setting compression quality: %s", err) - return true, err + // Use exact width/height and clip off the parts that bleed. The image is + // first resized to ensure clipping occurs on the smallest edges possible. + if scaleMode == ScaleAspectFill { + newAspectRatio := reqDimensions.AspectRatio() + if newAspectRatio > oldAspectRatio { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = aspectHeight(oldAspectRatio, reqDimensions.Width) + } else if newAspectRatio < oldAspectRatio { + resize.Scale.Width = aspectWidth(oldAspectRatio, reqDimensions.Height) + resize.Scale.Height = reqDimensions.Height + } else { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = reqDimensions.Height } + resize.Crop.Width = reqDimensions.Width + resize.Crop.Height = reqDimensions.Height + return resize, nil } - return true, nil + // Use the new dimensions exactly as is. Don't correct for aspect ratio and + // don't do any cropping. This is equivalent to ScaleFill. + resize.Scale = reqDimensions + return resize, nil } -func (ip *imageProcessor) blurWand(wand *imagick.MagickWand, request *ImageProcessorOptions) (modified bool, err error) { - if request.BlurRadius != 0 { - blurRadius := float64(wand.GetImageWidth()) * request.BlurRadius * ip.Config.MaxBlurRadiusPercentage - if err = wand.GaussianBlurImage(blurRadius, blurRadius); err != nil { - ip.Logger.Warnf("ImageMagick error setting blur radius: %s", err) - } - return true, err +func (ip *imageProcessor) resizeApply(img *Image, dimensions ImageDimensions) error { + if dimensions == EmptyImageDimensions { + return nil } - return false, nil -} -func (ip *imageProcessor) getScaledDimensions(currentDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { - requestDimensions := request.Dimensions - if requestDimensions.Width == 0 && requestDimensions.Height == 0 { - requestDimensions = ImageDimensions{Width: ip.Config.DefaultImageWidth, Height: ip.Config.DefaultImageHeight} + err := img.Wand.ResizeImage(dimensions.Width, dimensions.Height, imagick.FILTER_LANCZOS, 1) + if err != nil { + ip.Logger.Errorf("Failed resizing image: %s", err) + return err } - dimensions := ip.scaleToRequestedDimensions(currentDimensions, requestDimensions, request) - return ip.clampDimensionsToMaxima(dimensions, request) -} + err = img.Wand.SetImageInterpolateMethod(imagick.INTERPOLATE_PIXEL_BICUBIC) + if err != nil { + ip.Logger.Errorf("Failed getting interpolation method: %s", err) + return err + } -func (ip *imageProcessor) scaleToRequestedDimensions(currentDimensions, requestedDimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { - 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) + err = img.Wand.StripImage() + if err != nil { + ip.Logger.Errorf("Failed stripping image metadata: %s", err) + return err + } - if !ip.Config.MaintainAspectRatio { - // If we're not asked to maintain the aspect ratio, give them what they want - return requestedDimensions + if img.Wand.GetImageFormat() == "JPEG" { + err = img.Wand.SetImageInterlaceScheme(imagick.INTERLACE_PLANE) + if err != nil { + ip.Logger.Errorf("Failed setting image interlace scheme: %s", err) + return err } - 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 + err = img.Wand.SetImageCompression(imagick.COMPRESSION_JPEG) + if err != nil { + ip.Logger.Errorf("Failed setting image compression type: %s", err) + return err } - } - 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} + err = img.Wand.SetImageCompressionQuality(uint(ip.Config.ImageCompressionQuality)) + if err != nil { + ip.Logger.Errorf("Failed setting compression quality: %s", err) + return err + } } - return currentDimensions + return nil } -func (ip *imageProcessor) clampDimensionsToMaxima(dimensions ImageDimensions, request *ImageProcessorOptions) ImageDimensions { - if ip.Config.MaxImageWidth > 0 && dimensions.Width > ip.Config.MaxImageWidth { - scaledHeight := ip.getAspectScaledHeight(dimensions.AspectRatio(), ip.Config.MaxImageWidth) - return ip.clampDimensionsToMaxima(ImageDimensions{ip.Config.MaxImageWidth, scaledHeight}, request) +func (ip *imageProcessor) cropApply(img *Image, reqDimensions ImageDimensions) error { + if reqDimensions == EmptyImageDimensions { + return nil } + oldDimensions := img.GetDimensions() + return img.Wand.CropImage( + reqDimensions.Width, + reqDimensions.Height, + int((oldDimensions.Width-reqDimensions.Width)/2), + int((oldDimensions.Height-reqDimensions.Height)/2), + ) +} - if ip.Config.MaxImageHeight > 0 && dimensions.Height > ip.Config.MaxImageHeight { - scaledWidth := ip.getAspectScaledWidth(dimensions.AspectRatio(), ip.Config.MaxImageHeight) - return ip.clampDimensionsToMaxima(ImageDimensions{scaledWidth, ip.Config.MaxImageHeight}, request) +func (ip *imageProcessor) blur(image *Image, request *ImageProcessorOptions) error { + if request.BlurRadius == 0 { + return nil } + blurRadius := float64(image.GetWidth()) * request.BlurRadius * ip.Config.MaxBlurRadiusPercentage + return image.Wand.GaussianBlurImage(blurRadius, blurRadius) +} - return dimensions +func aspectHeight(aspectRatio float64, width uint) uint { + return uint(math.Floor(float64(width)/aspectRatio + 0.5)) } -func (ip *imageProcessor) getAspectScaledHeight(aspectRatio float64, width uint64) uint64 { - return uint64(math.Floor(float64(width)/aspectRatio + 0.5)) +func aspectWidth(aspectRatio float64, height uint) uint { + return uint(math.Floor(float64(height)*aspectRatio + 0.5)) } -func (ip *imageProcessor) getAspectScaledWidth(aspectRatio float64, height uint64) uint64 { - return uint64(math.Floor(float64(height)*aspectRatio + 0.5)) +func clampDimensionsToMaxima(imgDimensions, reqDimensions, maxDimensions ImageDimensions) ImageDimensions { + if maxDimensions.Width > 0 && reqDimensions.Width > maxDimensions.Width { + reqDimensions.Width = maxDimensions.Width + reqDimensions.Height = aspectHeight(imgDimensions.AspectRatio(), maxDimensions.Width) + return clampDimensionsToMaxima(imgDimensions, reqDimensions, maxDimensions) + } + + if maxDimensions.Height > 0 && reqDimensions.Height > maxDimensions.Height { + reqDimensions.Width = aspectWidth(imgDimensions.AspectRatio(), maxDimensions.Height) + reqDimensions.Height = maxDimensions.Height + return clampDimensionsToMaxima(imgDimensions, reqDimensions, maxDimensions) + } + + return reqDimensions } diff --git a/halfshell/route.go b/halfshell/route.go index f8ec22e..c54ed60 100644 --- a/halfshell/route.go +++ b/halfshell/route.go @@ -70,8 +70,12 @@ func (p *Route) SourceAndProcessorOptionsForRequest(r *http.Request) ( height, _ := strconv.ParseUint(r.FormValue("h"), 10, 32) blurRadius, _ := strconv.ParseFloat(r.FormValue("blur"), 64) + scaleModeName := r.FormValue("scale_mode") + scaleMode, _ := ScaleModes[scaleModeName] + return &ImageSourceOptions{Path: path}, &ImageProcessorOptions{ - Dimensions: ImageDimensions{width, height}, + Dimensions: ImageDimensions{uint(width), uint(height)}, BlurRadius: blurRadius, + ScaleMode: uint(scaleMode), } } From e4fefe538ffb8144e4f9024c67e0e4be676a7e1d Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Wed, 5 Nov 2014 10:43:48 -0800 Subject: [PATCH 3/5] Update README and examples --- README.md | 24 +++++++++++++++++++++--- examples/filesystem_config.json | 2 +- examples/s3_config.json | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e362039..d7bc2aa 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, + "scale_mode": "aspect_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 @@ -141,10 +141,28 @@ The compression quality to use for JPEG images. ##### maintain_aspect_ratio +DEPRECATED: Use the `aspect_fit` `scale_mode` instead. + 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. +##### scale_mode + +When changing the dimensions of an image, you may want to crop edges or +constrain proportions. Use the `scale_mode` setting to define these rules. + +A value of `aspect_fit` will change the image size to fit in the given +dimensions while retaining original proportions. No part of the image will be +cut away. + +A value of `aspect_fill` will change the image size to fit in the given +dimensions while retaining original proportions. Edges that do not fit in the +given dimensions will be cut off. + +The default behavior is to `fill`, which changes the image size to fit the given +dimensions and will NOT retain the original proportions. + ##### default_image_width In the absence of a width parameter in the request, use this as image width. A @@ -204,4 +222,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 cfebe58..eac6e0f 100644 --- a/examples/filesystem_config.json +++ b/examples/filesystem_config.json @@ -23,7 +23,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "scale_mode": "aspect_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 88ad8aa..2d9b5ca 100644 --- a/examples/s3_config.json +++ b/examples/s3_config.json @@ -23,7 +23,7 @@ "processors": { "default": { "image_compression_quality": 85, - "maintain_aspect_ratio": true, + "scale_mode": "aspect_fill", "max_blur_radius_percentage": 0, "max_image_height": 0, "max_image_width": 1000 From b823668d3c568b7ad45a6af8ad352137b92524d9 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Tue, 11 Nov 2014 11:17:53 -0800 Subject: [PATCH 4/5] Add aspect_crop. Change semantics of aspect_fill. --- halfshell/image_processor.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/halfshell/image_processor.go b/halfshell/image_processor.go index 871d311..360a1d5 100644 --- a/halfshell/image_processor.go +++ b/halfshell/image_processor.go @@ -30,12 +30,14 @@ const ( ScaleFill = 10 ScaleAspectFit = 21 ScaleAspectFill = 22 + ScaleAspectCrop = 23 ) var ScaleModes = map[string]uint{ "fill": ScaleFill, "aspect_fit": ScaleAspectFit, "aspect_fill": ScaleAspectFill, + "aspect_crop": ScaleAspectCrop, } type ImageProcessor interface { @@ -146,9 +148,27 @@ func (ip *imageProcessor) resizePrepare(oldDimensions, reqDimensions ImageDimens return resize, nil } + // Retain the aspect ratio while filling the bounds requested completely. New + // dimensions are at least as large as the requested dimensions. No cropping + // will occur but the image will be resized. + if scaleMode == ScaleAspectFill { + newAspectRatio := reqDimensions.AspectRatio() + if newAspectRatio < oldAspectRatio { + resize.Scale.Width = aspectWidth(oldAspectRatio, reqDimensions.Height) + resize.Scale.Height = reqDimensions.Height + } else if newAspectRatio > oldAspectRatio { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = aspectHeight(oldAspectRatio, reqDimensions.Width) + } else { + resize.Scale.Width = reqDimensions.Width + resize.Scale.Height = reqDimensions.Height + } + return resize, nil + } + // Use exact width/height and clip off the parts that bleed. The image is // first resized to ensure clipping occurs on the smallest edges possible. - if scaleMode == ScaleAspectFill { + if scaleMode == ScaleAspectCrop { newAspectRatio := reqDimensions.AspectRatio() if newAspectRatio > oldAspectRatio { resize.Scale.Width = reqDimensions.Width From a858e162312f242a0487d5b0dd957898263468f2 Mon Sep 17 00:00:00 2001 From: Artem Nezvigin Date: Tue, 11 Nov 2014 11:27:31 -0800 Subject: [PATCH 5/5] Update scale_mode in README, add cheat sheet --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7bc2aa..9326ff1 100644 --- a/README.md +++ b/README.md @@ -156,13 +156,42 @@ A value of `aspect_fit` will change the image size to fit in the given dimensions while retaining original proportions. No part of the image will be cut away. -A value of `aspect_fill` will change the image size to fit in the given +A value of `aspect_fill` will change the image size to at least fit the given +dimensions while retaining original proportions. No part of the image will be +cut away. + +A value of `aspect_crop` will change the image size to fit in the given dimensions while retaining original proportions. Edges that do not fit in the given dimensions will be cut off. The default behavior is to `fill`, which changes the image size to fit the given dimensions and will NOT retain the original proportions. +Cheat Sheet: + + Image dimensions: 500x800 + Requested dimensions: 400x400 + + Scale mode: fill + New dimensions: 400x400 + Maintain aspect ratio: NO + Cropping: NO + + Scale mode: aspect_fit + New dimensions: 250x400 + Maintain aspect ratio: YES + Cropping: NO + + Scale mode: aspect_fill + New dimensions: 400x640 + Maintain aspect ratio: YES + Cropping: NO + + Scale mode: aspect_crop + New dimensions: 400x400 + Maintain aspect ratio: YES + Cropping: YES + ##### default_image_width In the absence of a width parameter in the request, use this as image width. A