Skip to content
This repository was archived by the owner on Oct 8, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<image_path>/.*)$`) 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<image_path>/.*)$`) 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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -204,4 +198,4 @@ Run `make format` before sending any pull requests.

### Questions?

File an issue or send an email to rafik@oysterbooks.com.
File an issue or send an email to rafik@oysterbooks.com.
2 changes: 1 addition & 1 deletion examples/filesystem_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/s3_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions halfshell/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@ type SourceConfig struct {
type ProcessorConfig struct {
Name string
ImageCompressionQuality uint64
MaintainAspectRatio bool
DefaultCropMode string
DefaultBorderRadius uint64
DefaultImageHeight uint64
DefaultImageWidth uint64
DefaultBGColor string
MaxImageHeight uint64
MaxImageWidth uint64
MaxBlurRadiusPercentage float64

// DEPRECATED
MaintainAspectRatio bool
}

// Parses a JSON configuration file and returns a pointer to a new Config object.
Expand Down Expand Up @@ -170,13 +175,24 @@ 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),
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),

// 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{} {
Expand Down
157 changes: 134 additions & 23 deletions halfshell/image_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"math"
"strings"

"github.com/oysterbooks/halfshell/halfshell/util"
"github.com/rafikk/imagick/imagick"
)

Expand All @@ -37,8 +38,11 @@ type ImageProcessor interface {
// ImageProcessorOptions specify the request parameters for the processing
// operation.
type ImageProcessorOptions struct {
Dimensions ImageDimensions
BlurRadius float64
Dimensions ImageDimensions
BlurRadius float64
CropMode string
BorderRadius uint64
BGColor string
}

type imageProcessor struct {
Expand Down Expand Up @@ -75,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()
Expand All @@ -89,8 +99,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
}

Expand All @@ -99,6 +110,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
Expand Down Expand Up @@ -140,6 +158,66 @@ 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)

// 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)

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 {
Expand All @@ -151,37 +229,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 {
Expand All @@ -198,6 +299,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))
}
Expand Down
10 changes: 8 additions & 2 deletions halfshell/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +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,
Dimensions: ImageDimensions{width, height},
BlurRadius: blurRadius,
BorderRadius: borderRadius,
CropMode: cropMode,
BGColor: bgColor,
}
}
19 changes: 19 additions & 0 deletions halfshell/util/strings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package util

func FirstString(str ...string) (s string) {
for _, s := range str {
if s != "" {
return s
}
}
return s
}

func FirstUInt(ints ...uint64) (n uint64) {
for _, n := range ints {
if n > 0 {
return n
}
}
return n
}