diff --git a/README.md b/README.md index 1baf72c..0debd8a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # bimg [![Build Status](https://travis-ci.org/h2non/bimg.png)](https://travis-ci.org/h2non/bimg) [![GitHub release](https://img.shields.io/github/tag/h2non/bimg.svg)]() [![GoDoc](https://godoc.org/github.com/h2non/bimg?status.png)](https://godoc.org/github.com/h2non/bimg) -Go library for blazing fast image processing based on [libvips](https://github.com/jcupitt/libvips) using C bindings. +Go small but featured library for blazing fast image processing based on [libvips](https://github.com/jcupitt/libvips) using C bindings. -bimg is designed to be a small and efficient library with a limited by generic specific set of features. -Thanks to libvips it's typically 4x faster than using the quickest ImageMagick and GraphicsMagick settings -or Go native image processing package, and in some cases it's even 8x faster processing JPEG images. -It supports JPEG, PNG, WEBP, TIFF and Magick image formats. +bimg is designed to be a small and efficient library with a specific but useful and generic set of features. +It uses internally libvips, which is typically 4x faster than using the quickest ImageMagick and GraphicsMagick settings or Go native `image` package, and in some cases it's even 8x faster processing JPEG images. +bimg can read JPEG, PNG, WEBP, TIFF and Magick formats and it can output to JPEG, PNG and WEBP. + +It support common [image transformation](#supported-image-operations) operations such as crop, resize, rotate... and image conversion into multiple formats. + +To getting started see the [examples](#examples) and [programmatic API](https://godoc.org/github.com/h2non/bimg) documentation. bimg was heavily inspired in [sharp](https://github.com/lovell/sharp), a great node.js package for image processing build by [Lovell Fuller](https://github.com/lovell). -`Work in progress` +**Note**: bimg is still a beta package. PR and issues are highly appreciated ## Prerequisites @@ -38,10 +41,10 @@ The [install script](https://github.com/lovell/sharp/blob/master/preinstall.sh) - Enlarge - Crop - Rotate -- Flip/Flop +- Flip - Extract area -- Extract image metadata (size, format, profile, orientation...) -- Image conversion to multiple formats +- EXIF metadata (size, alpha channel, profile, orientation...) +- Format conversion ## Performance diff --git a/file.go b/file.go index 4b2b652..b6c5b04 100644 --- a/file.go +++ b/file.go @@ -6,15 +6,20 @@ import ( ) func Read(path string) ([]byte, error) { - data, err := os.Open(path) + file, err := os.Open(path) if err != nil { return nil, err } + defer file.Close() - buf, err := ioutil.ReadAll(data) + buf, err := ioutil.ReadAll(file) if err != nil { return nil, err } return buf, nil } + +func Write(path string, buf []byte) error { + return ioutil.WriteFile(path, buf, 0644) +} diff --git a/file_test.go b/file_test.go index 8ca2a41..5805e74 100644 --- a/file_test.go +++ b/file_test.go @@ -19,3 +19,20 @@ func TestRead(t *testing.T) { t.Fatal("Image is not jpeg") } } + +func TestWrite(t *testing.T) { + buf, err := Read("fixtures/test.jpg") + + if err != nil { + t.Errorf("Cannot read the image: %#v", err) + } + + if len(buf) == 0 { + t.Fatal("Empty buffer") + } + + err = Write("fixtures/test_write_out.jpg", buf) + if err != nil { + t.Fatal("Cannot write the file: %#v", err) + } +} diff --git a/image.go b/image.go index e45c675..45c7dcf 100644 --- a/image.go +++ b/image.go @@ -4,7 +4,7 @@ type Image struct { buffer []byte } -func (i *Image) Resize(width int, height int) ([]byte, error) { +func (i *Image) Resize(width, height int) ([]byte, error) { options := Options{ Width: width, Height: height, @@ -12,17 +12,17 @@ func (i *Image) Resize(width int, height int) ([]byte, error) { return i.Process(options) } -func (i *Image) Extract(top int, left int, width int, height int) ([]byte, error) { +func (i *Image) Extract(top, left, width, height int) ([]byte, error) { options := Options{ - Width: width, - Height: height, - Top: top, - Left: left, + Top: top, + Left: left, + AreaWidth: width, + AreaHeight: height, } return i.Process(options) } -func (i *Image) Crop(width int, height int) ([]byte, error) { +func (i *Image) Crop(width, height int) ([]byte, error) { options := Options{ Width: width, Height: height, @@ -31,14 +31,6 @@ func (i *Image) Crop(width int, height int) ([]byte, error) { return i.Process(options) } -func (i *Image) Thumbnail(width int, height int) ([]byte, error) { - options := Options{ - Width: width, - Height: height, - } - return i.Process(options) -} - func (i *Image) Rotate(a Angle) ([]byte, error) { options := Options{Rotate: a} return i.Process(options) @@ -49,11 +41,6 @@ func (i *Image) Flip() ([]byte, error) { return i.Process(options) } -func (i *Image) Flop() ([]byte, error) { - options := Options{Flip: HORIZONTAL} - return i.Process(options) -} - func (i *Image) Convert(t ImageType) ([]byte, error) { options := Options{Type: t} return i.Process(options) diff --git a/image_test.go b/image_test.go index fa1fd02..17d5225 100644 --- a/image_test.go +++ b/image_test.go @@ -1,37 +1,59 @@ package bimg import ( - "io/ioutil" - "os" + "path" "testing" ) func TestImageResize(t *testing.T) { - image := readImage() - _, err := image.Resize(300, 240) + buf, err := initImage("test.jpg").Resize(300, 240) if err != nil { t.Errorf("Cannot process the image: %#v", err) } + Write("fixtures/test_resize_out.jpg", buf) +} + +func TestImageExtract(t *testing.T) { + buf, err := initImage("test.jpg").Extract(100, 100, 300, 300) + if err != nil { + t.Errorf("Cannot process the image: %#v", err) + } + Write("fixtures/test_extract_out.jpg", buf) } func TestImageCrop(t *testing.T) { - image := readImage() - _, err := image.Crop(800, 600) + buf, err := initImage("test.jpg").Crop(800, 600) if err != nil { t.Errorf("Cannot process the image: %#v", err) } + Write("fixtures/test_crop_out.jpg", buf) +} + +func TestImageFlip(t *testing.T) { + buf, err := initImage("test.jpg").Flip() + if err != nil { + t.Errorf("Cannot process the image: %#v", err) + } + Write("fixtures/test_flip_out.jpg", buf) } func TestImageRotate(t *testing.T) { - image := readImage() - _, err := image.Rotate(D90) + buf, err := initImage("test_flip_out.jpg").Rotate(90) + if err != nil { + t.Errorf("Cannot process the image: %#v", err) + } + Write("fixtures/test_image_rotate_out.jpg", buf) +} + +func TestImageConvert(t *testing.T) { + buf, err := initImage("test_rotate_out.jpg").Convert(PNG) if err != nil { t.Errorf("Cannot process the image: %#v", err) } + Write("fixtures/test_image_convert_out.png", buf) } -func readImage() *Image { - data, _ := os.Open("fixtures/test.jpg") - buf, _ := ioutil.ReadAll(data) +func initImage(file string) *Image { + buf, _ := Read(path.Join("fixtures", file)) return NewImage(buf) } diff --git a/options.go b/options.go index dd44bd4..7b55734 100644 --- a/options.go +++ b/options.go @@ -55,6 +55,8 @@ const ( type Options struct { Height int Width int + AreaHeight int + AreaWidth int Top int Left int Crop bool diff --git a/resize.go b/resize.go index 703eab0..2bd9653 100644 --- a/resize.go +++ b/resize.go @@ -8,6 +8,7 @@ import "C" import ( "errors" + "fmt" "math" ) @@ -19,7 +20,7 @@ func Resize(buf []byte, o Options) ([]byte, error) { return nil, err } - // defaults + // Defaults if o.Quality == 0 { o.Quality = QUALITY } @@ -36,7 +37,6 @@ func Resize(buf []byte, o Options) ([]byte, error) { debug("Options: %#v", o) - // get WxH inWidth := int(image.Xsize) inHeight := int(image.Ysize) @@ -58,7 +58,6 @@ func Resize(buf []byte, o Options) ([]byte, error) { // Try to use libjpeg shrink-on-load if imageType == JPEG && shrink >= 2 { - // Recalculate integral shrink and double residual tmpImage, factor, err := shrinkJpegImage(buf, factor, shrink) if err != nil { return nil, err @@ -78,122 +77,178 @@ func Resize(buf []byte, o Options) ([]byte, error) { shrink = int(math.Max(float64(math.Floor(factor*3.0/windowSize)), 1)) } - // Use vips_shrink with the integral reduction - if shrink > 1 { - image, residual, err = shrinkImage(image, o, residual, shrink) - if err != nil { - return nil, err + // Transform image if necessary + if o.Width != inWidth || o.Height != inHeight { + // Use vips_shrink with the integral reduction + if shrink > 1 { + image, residual, err = shrinkImage(image, o, residual, shrink) + if err != nil { + return nil, err + } } - } + // Use vips_affine with the remaining float part + if residual != 0 { + image, err = vipsAffine(image, residual, o.Interpolator) + if err != nil { + return nil, err + } + } + debug("factor: %v, shrink: %v, residual: %v", factor, shrink, residual) - // Use vips_affine with the remaining float part - if residual != 0 { - image, err = vipsAffine(image, residual, o.Interpolator) + // Extract area from image + image, err = extractImage(image, o) if err != nil { return nil, err } } - debug("factor: %v, shrink: %v, residual: %v", factor, shrink, residual) + // Rotate / flip image if necessary based on EXIF metadata + image, err = rotateImage(image, o) + if err != nil { + return nil, err + } + + saveOptions := vipsSaveOptions{ + Quality: o.Quality, + Type: o.Type, + Compression: o.Compression, + } - // Extract image - image, err = extractImage(image, o) + // Finally save as buffer + buf, err = vipsSave(image, saveOptions) if err != nil { return nil, err } - if o.Rotate == 0 { - rotation, flip := calculateRotationAndFlip(image, o.Rotate) - if flip { - o.Flip = HORIZONTAL - } - if rotation > D0 { - o.Rotate = rotation + return buf, nil +} + +func extractImage(image *C.struct__VipsImage, o Options) (*C.struct__VipsImage, error) { + var err error = nil + inWidth := int(image.Xsize) + inHeight := int(image.Ysize) + + switch { + case o.Crop: + width := int(math.Min(float64(inWidth), float64(o.Width))) + height := int(math.Min(float64(inHeight), float64(o.Height))) + left, top := calculateCrop(inWidth, inHeight, o.Width, o.Height, o.Gravity) + image, err = vipsExtract(image, left, top, width, height) + break + case o.Embed: + left, top := (o.Width-inWidth)/2, (o.Height-inHeight)/2 + image, err = vipsEmbed(image, left, top, o.Width, o.Height, o.Extend) + break + case o.Top > 0 && o.Left > 0: + if o.AreaWidth == 0 || o.AreaHeight == 0 { + err = errors.New(fmt.Sprintf("Invalid area to extract %dx%d", o.AreaWidth, o.AreaHeight)) + } else { + image, err = vipsExtract(image, o.Left, o.Top, o.AreaWidth, o.AreaHeight) } + break + } + + return image, err +} + +func rotateImage(image *C.struct__VipsImage, o Options) (*C.struct__VipsImage, error) { + var err error + + rotation, flip := calculateRotationAndFlip(image, o.Rotate) + if flip { + o.Flip = HORIZONTAL + } + if rotation > D0 && o.Rotate == 0 { + o.Rotate = rotation } if o.Rotate > 0 { image, err = vipsRotate(image, getAngle(o.Rotate)) - if err != nil { - return nil, err - } } if o.Flip > 0 { image, err = vipsFlip(image, o.Flip) - if err != nil { - return nil, err - } } - saveOptions := vipsSaveOptions{ - Quality: o.Quality, - Type: o.Type, - Compression: o.Compression, - } + return image, err +} - buf, err = vipsSave(image, saveOptions) +func shrinkImage(image *C.struct__VipsImage, o Options, residual float64, shrink int) (*C.struct__VipsImage, float64, error) { + // Use vips_shrink with the integral reduction + image, err := vipsShrink(image, shrink) if err != nil { - return nil, err + return nil, 0, err } - return buf, nil + // Recalculate residual float based on dimensions of required vs shrunk images + residualx := float64(o.Width) / float64(image.Xsize) + residualy := float64(o.Height) / float64(image.Ysize) + + if o.Crop { + residual = math.Max(residualx, residualy) + } else { + residual = math.Min(residualx, residualy) + } + + return image, residual, nil } -func extractImage(image *C.struct__VipsImage, o Options) (*C.struct__VipsImage, error) { - var err error = nil - affinedWidth := int(image.Xsize) - affinedHeight := int(image.Ysize) +func shrinkJpegImage(buf []byte, factor float64, shrink int) (*C.struct__VipsImage, float64, error) { + var image *C.struct__VipsImage + var err error + shrinkOnLoad := 1 - if affinedWidth != o.Width || affinedHeight != o.Height { - width := int(math.Min(float64(affinedWidth), float64(o.Width))) - height := int(math.Min(float64(affinedHeight), float64(o.Height))) + // Recalculate integral shrink and double residual + switch { + case shrink >= 8: + factor = factor / 8 + shrinkOnLoad = 8 + case shrink >= 4: + factor = factor / 4 + shrinkOnLoad = 4 + case shrink >= 2: + factor = factor / 2 + shrinkOnLoad = 2 + } - switch { - case o.Crop: - left, top := calculateCrop(affinedWidth, affinedHeight, o.Width, o.Height, o.Gravity) - image, err = vipsExtract(image, left, top, width, height) - break - case o.Embed: - left, top := (o.Width-affinedWidth)/2, (o.Height-affinedHeight)/2 - image, err = vipsEmbed(image, left, top, o.Width, o.Height, o.Extend) - break - case o.Top > 0 && o.Left > 0: - image, err = vipsExtract(image, o.Left, o.Top, width, height) - break - } + // Reload input using shrink-on-load + if shrinkOnLoad > 1 { + image, err = vipsShrinkJpeg(buf, shrinkOnLoad) } - return image, err + return image, factor, err } func imageCalculations(o Options, inWidth, inHeight int) float64 { factor := 1.0 + xfactor := float64(inWidth) / float64(o.Width) + yfactor := float64(inHeight) / float64(o.Height) switch { // Fixed width and height case o.Width > 0 && o.Height > 0: - xf := float64(inWidth) / float64(o.Width) - yf := float64(inHeight) / float64(o.Height) if o.Crop { - factor = math.Min(xf, yf) + factor = math.Min(xfactor, yfactor) } else { - factor = math.Max(xf, yf) + factor = math.Max(xfactor, yfactor) } // Fixed width, auto height case o.Width > 0: - factor = float64(inWidth) / float64(o.Width) + factor = xfactor o.Height = int(math.Floor(float64(inHeight) / factor)) // Fixed height, auto width case o.Height > 0: - factor = float64(inHeight) / float64(o.Height) + factor = yfactor o.Width = int(math.Floor(float64(inWidth) / factor)) - // Identity transform default: + // Identity transform o.Width = inWidth o.Height = inHeight + break } + debug("Resolution %dx%d", o.Width, o.Height) + return factor } @@ -263,53 +318,6 @@ func calculateRotationAndFlip(image *C.struct__VipsImage, angle Angle) (Angle, b return rotate, flip } -func shrinkImage(image *C.struct__VipsImage, o Options, residual float64, shrink int) (*C.struct__VipsImage, float64, error) { - // Use vips_shrink with the integral reduction - image, err := vipsShrink(image, shrink) - if err != nil { - return nil, 0, err - } - - // Recalculate residual float based on dimensions of required vs shrunk images - residualx := float64(o.Width) / float64(image.Xsize) - residualy := float64(o.Height) / float64(image.Ysize) - - if o.Crop { - residual = math.Max(residualx, residualy) - } else { - residual = math.Min(residualx, residualy) - } - - return image, residual, nil -} - -func shrinkJpegImage(buf []byte, factor float64, shrink int) (*C.struct__VipsImage, float64, error) { - shrinkOnLoad := 1 - - switch { - case shrink >= 8: - factor = factor / 8 - shrinkOnLoad = 8 - case shrink >= 4: - factor = factor / 4 - shrinkOnLoad = 4 - case shrink >= 2: - factor = factor / 2 - shrinkOnLoad = 2 - } - - if shrinkOnLoad > 1 { - // Reload input using shrink-on-load - image, err := vipsShrinkJpeg(buf, shrinkOnLoad) - if err != nil { - return nil, factor, err - } - return image, factor, err - } - - return nil, factor, nil -} - func getAngle(angle Angle) Angle { divisor := angle % 90 if divisor != 0 { diff --git a/resize_test.go b/resize_test.go index 0cb8389..88428e0 100644 --- a/resize_test.go +++ b/resize_test.go @@ -7,18 +7,47 @@ import ( ) func TestResize(t *testing.T) { - options := Options{Width: 800, Height: 600, Crop: false, Rotate: 270} - img, err := os.Open("fixtures/test.jpg") + options := Options{Width: 800, Height: 600} + buf, _ := Read("fixtures/test.jpg") + + newImg, err := Resize(buf, options) if err != nil { - t.Fatal(err) + t.Errorf("Resize(imgData, %#v) error: %#v", options, err) } - defer img.Close() - buf, err := ioutil.ReadAll(img) + if DetermineImageType(newImg) != JPEG { + t.Fatal("Image is not jpeg") + } + + err = Write("fixtures/test_out.jpg", newImg) if err != nil { - t.Fatal(err) + t.Fatal("Cannot save the image") + } +} + +func TestRotate(t *testing.T) { + options := Options{Width: 800, Height: 600, Rotate: 270} + buf, _ := Read("fixtures/test.jpg") + + newImg, err := Resize(buf, options) + if err != nil { + t.Errorf("Resize(imgData, %#v) error: %#v", options, err) } + if DetermineImageType(newImg) != JPEG { + t.Fatal("Image is not jpeg") + } + + err = Write("fixtures/test_rotate_out.jpg", newImg) + if err != nil { + t.Fatal("Cannot save the image") + } +} + +func TestInvalidRotate(t *testing.T) { + options := Options{Width: 800, Height: 600, Rotate: 111} + buf, _ := Read("fixtures/test.jpg") + newImg, err := Resize(buf, options) if err != nil { t.Errorf("Resize(imgData, %#v) error: %#v", options, err) @@ -28,7 +57,7 @@ func TestResize(t *testing.T) { t.Fatal("Image is not jpeg") } - err = ioutil.WriteFile("fixtures/test_out.jpg", newImg, 0644) + err = Write("fixtures/test_invalid_rotate_out.jpg", newImg) if err != nil { t.Fatal("Cannot save the image") } @@ -63,7 +92,7 @@ func TestConvert(t *testing.T) { t.Fatal("Invalid image size") } - err = ioutil.WriteFile("fixtures/test_out.png", newImg, 0644) + err = Write("fixtures/test_out.png", newImg) if err != nil { t.Fatal("Cannot save the image") } @@ -98,7 +127,7 @@ func TestResizePngWithTransparency(t *testing.T) { t.Fatal("Invalid image size") } - err = ioutil.WriteFile("fixtures/transparent_out.png", newImg, 0644) + err = Write("fixtures/transparent_out.png", newImg) if err != nil { t.Fatal("Cannot save the image") } diff --git a/vips.h b/vips.h index 0fc395e..aada7b7 100644 --- a/vips.h +++ b/vips.h @@ -157,10 +157,10 @@ vips_init_image(void *buf, size_t len, int imageType, VipsImage **out) { #endif } - if (out != NULL) { - // Listen for "postclose" signal to delete input buffer + // Listen for "postclose" signal to delete input buffer + //if (out != NULL) { //g_signal_connect(out, "postclose", G_CALLBACK(vips_malloc_cb), buf); - } + //} return code; };