diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddb6eb07..94830175 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,11 +74,10 @@ npm test ### Memory leak tests -Requires _valgrind_. +Requires [Valgrind](http://valgrind.org/). ```sh -cd sharp/test/leak -./leak.sh +npm run test-leak ``` ## Finally diff --git a/README.md b/README.md index 1b667619..4a126cbc 100755 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ sharp('input.png') .resize(300) .flatten() .background('#ff6600') + .overlayWith('overlay.png') .sharpen() .withMetadata() .quality(90) @@ -176,8 +177,8 @@ sharp('input.png') .toBuffer() .then(function(outputBuffer) { // outputBuffer contains upside down, 300px wide, alpha channel flattened - // onto orange background, sharpened, with metadata, 90% quality WebP image - // data + // onto orange background, composited with `overlay.png`, sharpened, + // with metadata, 90% quality WebP image data }); ``` @@ -416,6 +417,10 @@ If the background contains an alpha value then WebP and PNG format output images Merge alpha transparency channel, if any, with `background`. +#### Experimental: overlayWith(filename) + +**Experimental:** Composite image with transparent overlay. Both input and overlay image must be RGBA and their dimensions must match. + #### rotate([angle]) Rotate the output image by either an explicit angle or auto-orient based on the EXIF `Orientation` tag. @@ -661,6 +666,17 @@ Provides access to internal task counters. var counters = sharp.counters(); // { queue: 2, process: 4 } ``` +#### Experimental: sharp.compare(filename1, filename2, callback) + +`filename1` and `filename2` are strings containing the filenames of the images to compare. + +`callback` is called with two arguments `(err, info)` where: + +* `err` contains an error message, if any. +* `info` contains the info about the difference between the two images such as `isEqual` (Boolean), `meanSquaredError` (Number; present iff `status='success'`, otherwise `undefined`), and `status` (String; one of `success`, `mismatchedDimensions`, `mismatchedBands`, `mismatchedType`). + +This function can be useful for comparing test output images against reference images. + ## Contributing A [guide for contributors](https://github.com/lovell/sharp/blob/master/CONTRIBUTING.md) covers reporting bugs, requesting features and submitting code changes. @@ -771,7 +787,7 @@ This module would never have been possible without the help and code contributio * [Brandon Aaron](https://github.com/brandonaaron) * [Andreas Lind](https://github.com/papandreou) * [Maurus Cuelenaere](https://github.com/mcuelenaere) -* [Linus Unnebäck](https://github.com/LinusU) +* [Linus Unnebäck](https://github.com/LinusU) * [Victor Mateevitsi](https://github.com/mvictoras) * [Alaric Holloway](https://github.com/skedastik) * [Bernhard K. Weisshuhn](https://github.com/bkw) diff --git a/binding.gyp b/binding.gyp index bdb47907..73c37756 100755 --- a/binding.gyp +++ b/binding.gyp @@ -3,11 +3,13 @@ 'target_name': 'sharp', 'sources': [ 'src/common.cc', + 'src/compare-internal.c', + 'src/compare.cc', 'src/composite.c', - 'src/utilities.cc', 'src/metadata.cc', 'src/resize.cc', - 'src/sharp.cc' + 'src/sharp.cc', + 'src/utilities.cc' ], 'conditions': [ ['OS=="win"', { diff --git a/index.js b/index.js index dad2db7d..1d0a15b0 100755 --- a/index.js +++ b/index.js @@ -758,6 +758,30 @@ Sharp.prototype.metadata = function(callback) { } }; +/* + Reads two input images and returns stats about their differences. + Supports only callbacks. +*/ +Sharp.compare = function(input1, input2, callback) { + if (typeof input1 !== 'string' || input1 === '') { + throw new TypeError('`input1` must be a non-empty string'); + } + + if (typeof input2 !== 'string' || input2 === '') { + throw new TypeError('`input2` must be a non-empty string'); + } + + if (typeof callback !== 'function') { + throw new TypeError('`callback` must be a function'); + } + + var options = { + fileIn1: input1, + fileIn2: input2 + }; + sharp.compare(options, callback); +}; + /* Get and set cache memory and item limits */ diff --git a/package.json b/package.json index ec624453..8b549524 100755 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ ], "description": "High performance Node.js module to resize JPEG, PNG, WebP and TIFF images using the libvips library", "scripts": { + "clean": "rm -rf test/fixtures/output.*", "test": "VIPS_WARNING=0 node ./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha -- --slow=5000 --timeout=20000 ./test/unit/*.js", + "test-clean": "npm run clean && npm install && npm test", + "test-leak": "cd test/leak; ./leak.sh; cd - > /dev/null", "test-win32-node": "node ./node_modules/mocha/bin/mocha --slow=5000 --timeout=20000 ./test/unit/*.js", "test-win32-iojs": "iojs ./node_modules/mocha/bin/mocha --slow=5000 --timeout=20000 ./test/unit/*.js" }, diff --git a/src/compare-internal.c b/src/compare-internal.c new file mode 100644 index 00000000..7753f638 --- /dev/null +++ b/src/compare-internal.c @@ -0,0 +1,47 @@ +#include +#include + +#include "composite.h" + +const int STATS_SUM2_COLUMN = 3; + +/* + Compare images `actual` and `expected` and return mean squared error (MSE). + */ +int Compare(VipsObject *context, VipsImage *actual, VipsImage *expected, double *out) { + if (actual->Bands != expected->Bands) + return -1; + + if (actual->Type != expected->Type) + return -1; + + VipsImage *actualPremultiplied; + if (Premultiply(context, actual, &actualPremultiplied)) + return -1; + vips_object_local(context, actualPremultiplied); + + VipsImage *expectedPremultiplied; + if (Premultiply(context, expected, &expectedPremultiplied)) + return -1; + vips_object_local(context, expectedPremultiplied); + + VipsImage *difference; + if (vips_subtract(expectedPremultiplied, actualPremultiplied, &difference, NULL)) + return -1; + vips_object_local(context, difference); + + VipsImage *stats; + if (vips_stats(difference, &stats, NULL)) + return -1; + vips_object_local(context, stats); + + double *statsData = (double*) stats->data; + int numValues = actual->Xsize * actual->Ysize * actual->Bands; + double sumOfSquares = statsData[STATS_SUM2_COLUMN]; + double meanSquaredError = sumOfSquares / numValues; + + // Return a reference to the mean squared error: + *out = meanSquaredError; + + return 0; +} diff --git a/src/compare-internal.h b/src/compare-internal.h new file mode 100644 index 00000000..bfe6e4a4 --- /dev/null +++ b/src/compare-internal.h @@ -0,0 +1,15 @@ +#ifndef SRC_COMPARE_INTERNAL_H_ +#define SRC_COMPARE_INTERNAL_H_ + + +#ifdef __cplusplus +extern "C" { +#endif + +int Compare(VipsObject *context, VipsImage *actual, VipsImage *expected, double *out);; + +#ifdef __cplusplus +} +#endif + +#endif // SRC_COMPARE_INTERNAL_H_ diff --git a/src/compare.cc b/src/compare.cc new file mode 100644 index 00000000..c76cc574 --- /dev/null +++ b/src/compare.cc @@ -0,0 +1,186 @@ +#include +#include + +#include "nan.h" + +#include "common.h" +#include "compare-internal.h" + +using v8::Boolean; +using v8::Exception; +using v8::Function; +using v8::Handle; +using v8::Local; +using v8::Number; +using v8::Object; +using v8::String; +using v8::Value; + +using sharp::ImageType; +using sharp::DetermineImageType; +using sharp::InitImage; +using sharp::counterQueue; + +struct CompareBaton { + // Input + std::string fileIn1; + std::string fileIn2; + + // Output + bool isEqual; + float meanSquaredError; + std::string err; + std::string status; + + CompareBaton(): + isEqual(false), + meanSquaredError(0.0) {} +}; + +class CompareWorker : public NanAsyncWorker { + + public: + CompareWorker(NanCallback *callback, CompareBaton *baton) : NanAsyncWorker(callback), baton(baton) {} + ~CompareWorker() {} + + void Execute() { + // Decrement queued task counter + g_atomic_int_dec_and_test(&counterQueue); + + ImageType imageType1 = ImageType::UNKNOWN; + ImageType imageType2 = ImageType::UNKNOWN; + VipsImage *image1 = NULL; + VipsImage *image2 = NULL; + + // Create "hook" VipsObject to hang image references from + hook = reinterpret_cast(vips_image_new()); + + // From files + imageType1 = DetermineImageType(baton->fileIn1.c_str()); + imageType2 = DetermineImageType(baton->fileIn2.c_str()); + + if (imageType1 != ImageType::UNKNOWN) { + image1 = InitImage(baton->fileIn1.c_str(), VIPS_ACCESS_RANDOM); + if (image1 == NULL) { + (baton->err).append("Input file 1 has corrupt header"); + imageType1 = ImageType::UNKNOWN; + } else { + vips_object_local(hook, image1); + } + } else { + (baton->err).append("Input file 1 is of an unsupported image format"); + } + + if (imageType2 != ImageType::UNKNOWN) { + image2 = InitImage(baton->fileIn2.c_str(), VIPS_ACCESS_RANDOM); + if (image2 == NULL) { + (baton->err).append("Input file 2 has corrupt header"); + imageType2 = ImageType::UNKNOWN; + } else { + vips_object_local(hook, image2); + } + } else { + (baton->err).append("Input file 2 is of an unsupported image format"); + } + + if (image1 != NULL && imageType1 != ImageType::UNKNOWN && image2 != NULL && imageType2 != ImageType::UNKNOWN) { + double meanSquaredError; + + baton->meanSquaredError = -1.0; + baton->isEqual = FALSE; + + if (image1->Type != image2->Type) { + baton->status = "mismatchedTypes"; + } else if (image1->Bands != image2->Bands) { + baton->status = "mismatchedBands"; + } else if (image1->Xsize != image2->Xsize || image1->Ysize != image2->Ysize) { + baton->status = "mismatchedDimensions"; + } else { + if (Compare(hook, image1, image2, &meanSquaredError)) { + (baton->err).append("Failed to compare two images"); + return Error(); + } else { + baton->status = "success"; + baton->meanSquaredError = meanSquaredError; + baton->isEqual = meanSquaredError == 0.0; + } + } + } else { + return Error(); + } + + CleanUp(); + } + + void CleanUp() { + // Clean up any dangling image references + g_object_unref(hook); + + // Clean up + vips_error_clear(); + vips_thread_shutdown(); + } + + void HandleOKCallback () { + NanScope(); + + Handle argv[2] = { NanNull(), NanNull() }; + if (!baton->err.empty()) { + // Error + argv[0] = Exception::Error(NanNew(baton->err.data(), baton->err.size())); + } else { + // Compare Object + Local info = NanNew(); + info->Set(NanNew("isEqual"), NanNew(baton->isEqual)); + info->Set(NanNew("status"), NanNew(baton->status)); + if (baton->meanSquaredError >= 0.0) { + info->Set(NanNew("meanSquaredError"), NanNew(baton->meanSquaredError)); + } + argv[1] = info; + } + delete baton; + + // Return to JavaScript + callback->Call(2, argv); + } + + /* + Copy then clear the error message. + Unref all transitional images on the hook. + Clear all thread-local data. + */ + void Error() { + // Get libvips' error message + (baton->err).append(vips_error_buffer()); + + CleanUp(); + } + + private: + CompareBaton* baton; + VipsObject *hook; +}; + +/* + compare(options, callback) +*/ +NAN_METHOD(compare) { + NanScope(); + + // V8 objects are converted to non-V8 types held in the baton struct + CompareBaton *baton = new CompareBaton; + Local options = args[0]->ToObject(); + + // Input filename + baton->fileIn1 = *String::Utf8Value(options->Get(NanNew("fileIn1"))->ToString()); + baton->fileIn2 = *String::Utf8Value(options->Get(NanNew("fileIn2"))->ToString()); + + // Join queue for worker thread + NanCallback *callback = new NanCallback(args[1].As()); + NanAsyncQueueWorker(new CompareWorker(callback, baton)); + + // Increment queued task counter + g_atomic_int_inc(&counterQueue); + + NanReturnUndefined(); +} diff --git a/src/compare.h b/src/compare.h new file mode 100644 index 00000000..1dbf50a5 --- /dev/null +++ b/src/compare.h @@ -0,0 +1,8 @@ +#ifndef SRC_COMPARE_H_ +#define SRC_COMPARE_H_ + +#include "nan.h" + +NAN_METHOD(compare); + +#endif // SRC_COMPARE_H_ diff --git a/src/composite.c b/src/composite.c index d8c922c2..f35c97ba 100644 --- a/src/composite.c +++ b/src/composite.c @@ -2,45 +2,58 @@ #include +// Constants const int ALPHA_BAND_INDEX = 3; const int NUM_COLOR_BANDS = 3; +// TODO: Copied from `common.cc`. Deduplicate once this becomes a C++ module. +int HasAlpha(VipsImage *image) { + return ( + (image->Bands == 2 && image->Type == VIPS_INTERPRETATION_B_W) || + (image->Bands == 4 && image->Type != VIPS_INTERPRETATION_CMYK) || + (image->Bands == 5 && image->Type == VIPS_INTERPRETATION_CMYK) + ); +} /* - Composite images `src` and `dst` + Composite images `src` and `dst` with premultiplied alpha channel and output + image with premultiplied alpha. */ -int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out) { - if (src->Bands != 4 || dst->Bands != 4) +int Composite(VipsObject *context, VipsImage *srcPremultiplied, VipsImage *dstPremultiplied, VipsImage **outPremultiplied) { + if (srcPremultiplied->Bands != 4 || dstPremultiplied->Bands != 4) return -1; // Extract RGB bands: - VipsImage *srcRGB; - VipsImage *dstRGB; - if (vips_extract_band(src, &srcRGB, 0, "n", NUM_COLOR_BANDS, NULL) || - vips_extract_band(dst, &dstRGB, 0, "n", NUM_COLOR_BANDS, NULL)) + VipsImage *srcRGBPremultiplied; + if (vips_extract_band(srcPremultiplied, &srcRGBPremultiplied, 0, "n", NUM_COLOR_BANDS, NULL)) return -1; + vips_object_local(context, srcRGBPremultiplied); - vips_object_local(context, srcRGB); - vips_object_local(context, dstRGB); + VipsImage *dstRGBPremultiplied; + if (vips_extract_band(dstPremultiplied, &dstRGBPremultiplied, 0, "n", NUM_COLOR_BANDS, NULL)) + return -1; + vips_object_local(context, dstRGBPremultiplied); // Extract alpha bands: VipsImage *srcAlpha; - VipsImage *dstAlpha; - if (vips_extract_band(src, &srcAlpha, ALPHA_BAND_INDEX, NULL) || - vips_extract_band(dst, &dstAlpha, ALPHA_BAND_INDEX, NULL)) + if (vips_extract_band(srcPremultiplied, &srcAlpha, ALPHA_BAND_INDEX, "n", 1, NULL)) return -1; - vips_object_local(context, srcAlpha); + + VipsImage *dstAlpha; + if (vips_extract_band(dstPremultiplied, &dstAlpha, ALPHA_BAND_INDEX, "n", 1, NULL)) + return -1; vips_object_local(context, dstAlpha); // Compute normalized input alpha channels: VipsImage *srcAlphaNormalized; - VipsImage *dstAlphaNormalized; - if (vips_linear1(srcAlpha, &srcAlphaNormalized, 1.0 / 255.0, 0.0, NULL) || - vips_linear1(dstAlpha, &dstAlphaNormalized, 1.0 / 255.0, 0.0, NULL)) + if (vips_linear1(srcAlpha, &srcAlphaNormalized, 1.0 / 255.0, 0.0, NULL)) return -1; - vips_object_local(context, srcAlphaNormalized); + + VipsImage *dstAlphaNormalized; + if (vips_linear1(dstAlpha, &dstAlphaNormalized, 1.0 / 255.0, 0.0, NULL)) + return -1; vips_object_local(context, dstAlphaNormalized); // @@ -56,15 +69,18 @@ int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **o // ^^^^^^^^^^^^^^^^^^^ // t1 VipsImage *t0; - VipsImage *t1; - VipsImage *outAlphaNormalized; - if (vips_linear1(srcAlphaNormalized, &t0, -1.0, 1.0, NULL) || - vips_multiply(dstAlphaNormalized, t0, &t1, NULL) || - vips_add(srcAlphaNormalized, t1, &outAlphaNormalized, NULL)) + if (vips_linear1(srcAlphaNormalized, &t0, -1.0, 1.0, NULL)) return -1; - vips_object_local(context, t0); + + VipsImage *t1; + if (vips_multiply(dstAlphaNormalized, t0, &t1, NULL)) + return -1; vips_object_local(context, t1); + + VipsImage *outAlphaNormalized; + if (vips_add(srcAlphaNormalized, t1, &outAlphaNormalized, NULL)) + return -1; vips_object_local(context, outAlphaNormalized); // @@ -72,54 +88,142 @@ int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **o // // Wikipedia: // out_rgb = (src_rgb * src_a + dst_rgb * dst_a * (1 - src_a)) / out_a + // ^^^^^^^^^^^ + // t0 // - // `vips_ifthenelse` with `blend=TRUE`: http://bit.ly/1KoSsga - // out = (cond / 255) * in1 + (1 - cond / 255) * in2 + // Omit division by `out_a` since `Compose` is supposed to output a + // premultiplied RGBA image as reversal of premultiplication is handled + // externally. // - // Substitutions: - // - // cond --> src_a - // in1 --> src_rgb - // in2 --> dst_rgb * dst_a (premultiplied destination RGB) - // - // Finally, manually divide by `out_a` to unpremultiply the RGB channels. - // Failing to do so results in darker than expected output with low - // opacity images. - // - VipsImage *dstRGBPremultiplied; - if (vips_multiply(dstRGB, dstAlphaNormalized, &dstRGBPremultiplied, NULL)) + VipsImage *t2; + if (vips_multiply(dstRGBPremultiplied, t0, &t2, NULL)) return -1; - - vips_object_local(context, dstRGBPremultiplied); + vips_object_local(context, t2); VipsImage *outRGBPremultiplied; - if (vips_ifthenelse(srcAlpha, srcRGB, dstRGBPremultiplied, - &outRGBPremultiplied, "blend", TRUE, NULL)) + if (vips_add(srcRGBPremultiplied, t2, &outRGBPremultiplied, NULL)) return -1; - vips_object_local(context, outRGBPremultiplied); - // Unpremultiply RGB channels: - VipsImage *outRGB; - if (vips_divide(outRGBPremultiplied, outAlphaNormalized, &outRGB, NULL)) - return -1; - - vips_object_local(context, outRGB); - // Denormalize output alpha channel: VipsImage *outAlpha; if (vips_linear1(outAlphaNormalized, &outAlpha, 255.0, 0.0, NULL)) return -1; - vips_object_local(context, outAlpha); // Combine RGB and alpha channel into output image: VipsImage *joined; - if (vips_bandjoin2(outRGB, outAlpha, &joined, NULL)) + if (vips_bandjoin2(outRGBPremultiplied, outAlpha, &joined, NULL)) return -1; - // Return a reference to the output image: - *out = joined; + // Return a reference to the composited output image: + *outPremultiplied = joined; + + return 0; +} + +/* + * Premultiply alpha channel of `image`. + */ +int Premultiply(VipsObject *context, VipsImage *image, VipsImage **out) { + VipsImage *imagePremultiplied; + + // Trivial case: Copy images without alpha channel: + if (!HasAlpha(image)) { + return vips_image_write(image, *out); + } + +#if (VIPS_MAJOR_VERSION >= 9 || (VIPS_MAJOR_VERSION >= 8 && VIPS_MINOR_VERSION >= 1)) + + if (vips_premultiply(image, &imagePremultiplied, NULL)) + return -1; + +#else + + if (image->Bands != 4) + return -1; + + VipsImage *imageRGB; + if (vips_extract_band(image, &imageRGB, 0, "n", NUM_COLOR_BANDS, NULL)) + return -1; + vips_object_local(context, imageRGB); + + VipsImage *imageAlpha; + if (vips_extract_band(image, &imageAlpha, ALPHA_BAND_INDEX, "n", 1, NULL)) + return -1; + vips_object_local(context, imageAlpha); + + VipsImage *imageAlphaNormalized; + if (vips_linear1(imageAlpha, &imageAlphaNormalized, 1.0 / 255.0, 0.0, NULL)) + return -1; + vips_object_local(context, imageAlphaNormalized); + + VipsImage *imageRGBPremultiplied; + if (vips_multiply(imageRGB, imageAlphaNormalized, &imageRGBPremultiplied, NULL)) + return -1; + vips_object_local(context, imageRGBPremultiplied); + + if (vips_bandjoin2(imageRGBPremultiplied, imageAlpha, &imagePremultiplied, NULL)) + return -1; + +#endif + + // Return a reference to the premultiplied output image: + *out = imagePremultiplied; + + return 0; + +} + +/* + * Unpremultiply alpha channel of `image`. + */ +int Unpremultiply(VipsObject *context, VipsImage *image, VipsImage **out) { + VipsImage *imageUnpremultiplied; + + // Trivial case: Copy images without alpha channel: + if (!HasAlpha(image)) { + return vips_image_write(image, *out); + } + +#if (VIPS_MAJOR_VERSION >= 9 || (VIPS_MAJOR_VERSION >= 8 && VIPS_MINOR_VERSION >= 1)) + + if (vips_unpremultiply(image, &imageUnpremultiplied, NULL)) + return -1; + +#else + + if (image->Bands != 4) + return -1; + + VipsImage *imageRGBPremultipliedTransformed; + if (vips_extract_band(image, &imageRGBPremultipliedTransformed, 0, "n", NUM_COLOR_BANDS, NULL)) + return -1; + vips_object_local(context, imageRGBPremultipliedTransformed); + + VipsImage *imageAlphaTransformed; + if (vips_extract_band(image, &imageAlphaTransformed, ALPHA_BAND_INDEX, "n", 1, NULL)) + return -1; + vips_object_local(context, imageAlphaTransformed); + + VipsImage *imageAlphaNormalizedTransformed; + if (vips_linear1(imageAlphaTransformed, &imageAlphaNormalizedTransformed, 1.0 / 255.0, 0.0, NULL)) + return -1; + vips_object_local(context, imageAlphaNormalizedTransformed); + + VipsImage *imageRGBUnpremultipliedTransformed; + if (vips_divide(imageRGBPremultipliedTransformed, imageAlphaNormalizedTransformed, &imageRGBUnpremultipliedTransformed, NULL)) + return -1; + vips_object_local(context, imageRGBUnpremultipliedTransformed); + + if (vips_bandjoin2(imageRGBUnpremultipliedTransformed, imageAlphaTransformed, &imageUnpremultiplied, NULL)) + return -1; + + +#endif + + // Return a reference to the unpremultiplied output image: + *out = imageUnpremultiplied; return 0; } diff --git a/src/composite.h b/src/composite.h index 8df4acb6..b9880284 100644 --- a/src/composite.h +++ b/src/composite.h @@ -5,10 +5,10 @@ #ifdef __cplusplus extern "C" { #endif -/* - Composite images `src` and `dst`. -*/ + int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out); +int Premultiply(VipsObject *context, VipsImage *image, VipsImage **out); +int Unpremultiply(VipsObject *context, VipsImage *image, VipsImage **out); #ifdef __cplusplus } diff --git a/src/resize.cc b/src/resize.cc index 9cc2b4ad..3f329729 100755 --- a/src/resize.cc +++ b/src/resize.cc @@ -480,8 +480,29 @@ class ResizeWorker : public NanAsyncWorker { } } + // Premultiply image alpha channel before all transformations to avoid + // dark fringing around bright pixels + // See: http://entropymine.com/imageworsener/resizealpha/ + bool shouldAffineTransform = xresidual != 0.0 || yresidual != 0.0; + bool shouldBlur = baton->blurSigma != 0.0; + bool shouldSharpen = baton->sharpenRadius != 0; + bool shouldTransform = shouldAffineTransform || shouldBlur || shouldSharpen; + bool hasOverlay = !baton->overlayPath.empty(); + bool shouldPremultiplyAlpha = HasAlpha(image) && image->Bands == 4 && (shouldTransform || hasOverlay); + + if (shouldPremultiplyAlpha) { + VipsImage *imagePremultiplied; + if (Premultiply(hook, image, &imagePremultiplied)) { + (baton->err).append("Failed to premultiply alpha channel."); + return Error(); + } + + vips_object_local(hook, imagePremultiplied); + image = imagePremultiplied; + } + // Use vips_affine with the remaining float part - if (xresidual != 0.0 || yresidual != 0.0) { + if (shouldAffineTransform) { // Use average of x and y residuals to compute sigma for Gaussian blur double residual = (xresidual + yresidual) / 2.0; // Apply Gaussian blur before large affine reductions @@ -646,7 +667,7 @@ class ResizeWorker : public NanAsyncWorker { } // Blur - if (baton->blurSigma != 0.0) { + if (shouldBlur) { VipsImage *blurred; if (baton->blurSigma < 0.0) { // Fast, mild blur - averages neighbouring pixels @@ -677,7 +698,7 @@ class ResizeWorker : public NanAsyncWorker { } // Sharpen - if (baton->sharpenRadius != 0) { + if (shouldSharpen) { VipsImage *sharpened; if (baton->sharpenRadius == -1) { // Fast, mild sharpen @@ -793,7 +814,7 @@ class ResizeWorker : public NanAsyncWorker { #endif // Composite with overlay, if present - if (!baton->overlayPath.empty()) { + if (hasOverlay) { VipsImage *overlayImage = NULL; ImageType overlayImageType = ImageType::UNKNOWN; overlayImageType = DetermineImageType(baton->overlayPath.c_str()); @@ -802,6 +823,8 @@ class ResizeWorker : public NanAsyncWorker { if (overlayImage == NULL) { (baton->err).append("Overlay input file has corrupt header"); overlayImageType = ImageType::UNKNOWN; + } else { + vips_object_local(hook, overlayImage); } } else { (baton->err).append("Overlay input file is of an unsupported image format"); @@ -831,8 +854,15 @@ class ResizeWorker : public NanAsyncWorker { return Error(); } + VipsImage *overlayImagePremultiplied; + if (Premultiply(hook, overlayImage, &overlayImagePremultiplied)) { + (baton->err).append("Failed to premultiply alpha channel of overlay image."); + return Error(); + } + vips_object_local(hook, overlayImagePremultiplied); + VipsImage *composited; - if (Composite(hook, overlayImage, image, &composited)) { + if (Composite(hook, overlayImagePremultiplied, image, &composited)) { (baton->err).append("Failed to composite images"); return Error(); } @@ -840,6 +870,18 @@ class ResizeWorker : public NanAsyncWorker { image = composited; } + // Reverse premultiplication after all transformations: + if (shouldPremultiplyAlpha) { + VipsImage *imageUnpremultiplied; + if (Unpremultiply(hook, image, &imageUnpremultiplied)) { + (baton->err).append("Failed to unpremultiply alpha channel."); + return Error(); + } + + vips_object_local(hook, imageUnpremultiplied); + image = imageUnpremultiplied; + } + // Convert image to sRGB, if not already if (image->Type != VIPS_INTERPRETATION_sRGB) { // Switch intrepretation to sRGB diff --git a/src/sharp.cc b/src/sharp.cc index bab73c9f..2072fa4f 100755 --- a/src/sharp.cc +++ b/src/sharp.cc @@ -4,6 +4,7 @@ #include "nan.h" #include "common.h" +#include "compare.h" #include "metadata.h" #include "resize.h" #include "utilities.h" @@ -19,6 +20,7 @@ extern "C" void init(v8::Handle target) { // Methods available to JavaScript NODE_SET_METHOD(target, "metadata", metadata); NODE_SET_METHOD(target, "resize", resize); + NODE_SET_METHOD(target, "compare", compare); NODE_SET_METHOD(target, "cache", cache); NODE_SET_METHOD(target, "concurrency", concurrency); NODE_SET_METHOD(target, "counters", counters); diff --git a/test/fixtures/alpha-premultiply-1024x768-paper.png b/test/fixtures/alpha-premultiply-1024x768-paper.png new file mode 100644 index 00000000..b50c42e5 Binary files /dev/null and b/test/fixtures/alpha-premultiply-1024x768-paper.png differ diff --git a/test/fixtures/alpha-premultiply-2048x1536-paper.png b/test/fixtures/alpha-premultiply-2048x1536-paper.png new file mode 100644 index 00000000..c778008d Binary files /dev/null and b/test/fixtures/alpha-premultiply-2048x1536-paper.png differ diff --git a/test/fixtures/expected/alpha-layer-01-imagemagick.png b/test/fixtures/expected/alpha-layer-01-imagemagick.png new file mode 100644 index 00000000..45cdfc56 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-01-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-01-low-alpha-imagemagick.png b/test/fixtures/expected/alpha-layer-01-low-alpha-imagemagick.png new file mode 100644 index 00000000..e8ff9059 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-01-low-alpha-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-01-low-alpha.png b/test/fixtures/expected/alpha-layer-01-low-alpha.png new file mode 100644 index 00000000..c5cc9416 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-01-low-alpha.png differ diff --git a/test/fixtures/expected/alpha-layer-01.png b/test/fixtures/expected/alpha-layer-01.png index 8ad37887..a57349d5 100644 Binary files a/test/fixtures/expected/alpha-layer-01.png and b/test/fixtures/expected/alpha-layer-01.png differ diff --git a/test/fixtures/expected/alpha-layer-012-imagemagick.png b/test/fixtures/expected/alpha-layer-012-imagemagick.png new file mode 100644 index 00000000..dcf1390c Binary files /dev/null and b/test/fixtures/expected/alpha-layer-012-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-012-low-alpha-imagemagick.png b/test/fixtures/expected/alpha-layer-012-low-alpha-imagemagick.png new file mode 100644 index 00000000..a276ae9d Binary files /dev/null and b/test/fixtures/expected/alpha-layer-012-low-alpha-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-012-low-alpha.png b/test/fixtures/expected/alpha-layer-012-low-alpha.png index 697c2a00..1eb13cf9 100644 Binary files a/test/fixtures/expected/alpha-layer-012-low-alpha.png and b/test/fixtures/expected/alpha-layer-012-low-alpha.png differ diff --git a/test/fixtures/expected/alpha-layer-012.png b/test/fixtures/expected/alpha-layer-012.png index 2732965f..a88ad6c6 100644 Binary files a/test/fixtures/expected/alpha-layer-012.png and b/test/fixtures/expected/alpha-layer-012.png differ diff --git a/test/fixtures/expected/alpha-layer-12-imagemagick.png b/test/fixtures/expected/alpha-layer-12-imagemagick.png new file mode 100644 index 00000000..739e1b7b Binary files /dev/null and b/test/fixtures/expected/alpha-layer-12-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-12-low-alpha-imagemagick.png b/test/fixtures/expected/alpha-layer-12-low-alpha-imagemagick.png new file mode 100644 index 00000000..9135af46 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-12-low-alpha-imagemagick.png differ diff --git a/test/fixtures/expected/alpha-layer-12-low-alpha.png b/test/fixtures/expected/alpha-layer-12-low-alpha.png new file mode 100644 index 00000000..94001e61 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-12-low-alpha.png differ diff --git a/test/fixtures/expected/alpha-layer-12.png b/test/fixtures/expected/alpha-layer-12.png new file mode 100644 index 00000000..7573953a Binary files /dev/null and b/test/fixtures/expected/alpha-layer-12.png differ diff --git a/test/fixtures/expected/alpha-premultiply-enlargement-2048x1536-paper.png b/test/fixtures/expected/alpha-premultiply-enlargement-2048x1536-paper.png new file mode 100644 index 00000000..7a91c1fe Binary files /dev/null and b/test/fixtures/expected/alpha-premultiply-enlargement-2048x1536-paper.png differ diff --git a/test/fixtures/expected/alpha-premultiply-reduction-1024x768-paper.png b/test/fixtures/expected/alpha-premultiply-reduction-1024x768-paper.png new file mode 100644 index 00000000..6e8b2fb3 Binary files /dev/null and b/test/fixtures/expected/alpha-premultiply-reduction-1024x768-paper.png differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index e04941dd..27b9c1a6 100755 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -2,9 +2,13 @@ var path = require('path'); var assert = require('assert'); - var sharp = require('../../index'); + +// Constants +var MAX_ALLOWED_MEAN_SQUARED_ERROR = 0.0005; + +// Helpers var getPath = function(filename) { return path.join(__dirname, filename); }; @@ -57,6 +61,8 @@ module.exports = { inputPngOverlayLayer2: getPath('alpha-layer-2-ink.png'), inputPngOverlayLayer1LowAlpha: getPath('alpha-layer-1-fill-low-alpha.png'), inputPngOverlayLayer2LowAlpha: getPath('alpha-layer-2-ink-low-alpha.png'), + inputPngAlphaPremultiplicationSmall: getPath('alpha-premultiply-1024x768-paper.png'), + inputPngAlphaPremultiplicationLarge: getPath('alpha-premultiply-2048x1536-paper.png'), inputWebP: getPath('4.webp'), // http://www.gstatic.com/webp/gallery/4.webp inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm @@ -116,12 +122,37 @@ module.exports = { } if (distance > options.threshold) { - return callback(new Error('Maximum similarity distance: ' + options.threshold + '. Actual: ' + distance)); + return callback(new Error('Expected maximum similarity distance: ' + options.threshold + '. Actual: ' + distance + '.')); } callback(); }); }); + }, + + assertEqual: function(actualImagePath, expectedImagePath, callback) { + if (typeof actualImagePath !== 'string') { + throw new TypeError('`actualImagePath` must be a string; got ' + actualImagePath); + } + + if (typeof expectedImagePath !== 'string') { + throw new TypeError('`expectedImagePath` must be a string; got ' + expectedImagePath); + } + + if (typeof callback !== 'function') { + throw new TypeError('`callback` must be a function'); + } + + sharp.compare(actualImagePath, expectedImagePath, function (error, info) { + if (error) return callback(error); + + var meanSquaredError = info.meanSquaredError; + if (typeof meanSquaredError !== 'undefined' && meanSquaredError > MAX_ALLOWED_MEAN_SQUARED_ERROR) { + return callback(new Error('Expected images be equal. Mean squared error: ' + meanSquaredError + '.')); + } + + return callback(null, info); + }); } }; diff --git a/test/unit/alpha.js b/test/unit/alpha.js index 59169a8d..d0c94e68 100755 --- a/test/unit/alpha.js +++ b/test/unit/alpha.js @@ -1,9 +1,8 @@ 'use strict'; var assert = require('assert'); - -var sharp = require('../../index'); var fixtures = require('../fixtures'); +var sharp = require('../../index'); sharp.cache(0); @@ -76,4 +75,30 @@ describe('Alpha transparency', function() { }); }); + it('Enlargement with non-nearest neighbor interpolation shouldn’t cause dark edges', function(done) { + var BASE_NAME = 'alpha-premultiply-enlargement-2048x1536-paper.png'; + var actual = fixtures.path('output.' + BASE_NAME); + var expected = fixtures.expected(BASE_NAME); + sharp(fixtures.inputPngAlphaPremultiplicationSmall) + .resize(2048, 1536) + .interpolateWith('bicubic') + .toFile(actual, function(err) { + if (err) throw err; + fixtures.assertEqual(actual, expected, done); + }); + }); + + it('Reduction with non-nearest neighbor interpolation shouldn’t cause dark edges', function(done) { + var BASE_NAME = 'alpha-premultiply-reduction-1024x768-paper.png'; + var actual = fixtures.path('output.' + BASE_NAME); + var expected = fixtures.expected(BASE_NAME); + sharp(fixtures.inputPngAlphaPremultiplicationLarge) + .resize(1024, 768) + .interpolateWith('bicubic') + .toFile(actual, function(err) { + if (err) throw err; + fixtures.assertEqual(actual, expected, done); + }); + }); + }); diff --git a/test/unit/compare.js b/test/unit/compare.js new file mode 100644 index 00000000..fdb0d0a0 --- /dev/null +++ b/test/unit/compare.js @@ -0,0 +1,77 @@ +'use strict'; + +var assert = require('assert'); +var fixtures = require('../fixtures'); +var fs = require('fs'); +var sharp = require('../../index'); + +sharp.cache(0); + + +// Constants +var MAX_ALLOWED_MEAN_SQUARED_ERROR = 0.0005; + +// Tests +describe('sharp.compare', function() { + it('should report equality when comparing an image to itself', function(done) { + var image = fixtures.inputPngOverlayLayer0; + + sharp.compare(image, image, function (error, info) { + if (error) return done(error); + + assert.strictEqual(info.isEqual, true, 'image is equal to itself'); + assert.strictEqual(info.status, 'success', 'status is correct'); + assert(0 <= info.meanSquaredError && + info.meanSquaredError <= MAX_ALLOWED_MEAN_SQUARED_ERROR, + 'MSE is within tolerance'); + done(); + }); + }); + + it('should report that two images have a mismatched number of bands (channels)', function(done) { + var actual = fixtures.inputPngOverlayLayer1; + var expected = fixtures.inputJpg; + + sharp.compare(actual, expected, function (error, info) { + if (error) return done(error); + + assert.strictEqual(info.isEqual, false); + assert.strictEqual(info.status, 'mismatchedBands'); + assert(typeof info.meanSquaredError === 'undefined', 'MSE is undefined'); + done(); + }); + }); + + it('should report that two images have a mismatched dimensions', function(done) { + var actual = fixtures.inputJpg; + var expected = fixtures.inputJpgWithExif; + + sharp.compare(actual, expected, function (error, info) { + if (error) return done(error); + + assert.strictEqual(info.isEqual, false); + assert.strictEqual(info.status, 'mismatchedDimensions'); + assert(typeof info.meanSquaredError === 'undefined', 'MSE is undefined'); + done(); + }); + }); + + it('should report the correct mean squared error for two different images', function(done) { + var actual = fixtures.inputPngOverlayLayer0; + var expected = fixtures.inputPngOverlayLayer1; + + sharp.compare(actual, expected, function (error, info) { + if (error) return done(error); + + var meanSquaredError = info.meanSquaredError; + assert.strictEqual(info.isEqual, false); + assert.strictEqual(info.status, 'success'); + // ImageMagick reports: 42242.5 + // `compare -metric mse 'actual' 'expected' comparison.png` + assert(41900 <= meanSquaredError && meanSquaredError <= 41950, + 'Expected: 41900 <= meanSquaredError <= 41950. Actual: ' + meanSquaredError); + done(); + }); + }); + +}); diff --git a/test/unit/overlay.js b/test/unit/overlay.js index 3b716549..f794c227 100644 --- a/test/unit/overlay.js +++ b/test/unit/overlay.js @@ -6,19 +6,78 @@ var sharp = require('../../index'); sharp.cache(0); -// Main + +// Constants +var MAX_ALLOWED_IMAGE_MAGICK_MEAN_SQUARED_ERROR = 0.3; + +// Helpers +var getPaths = function(baseName, extension) { + if (typeof extension === 'undefined') { + extension = 'png'; + } + + var actual = fixtures.path('output.' + baseName + '.' + extension); + var expected = fixtures.expected(baseName + '.' + extension); + var expectedMagick = fixtures.expected(baseName + '-imagemagick.' + extension); + + return { + actual: actual, + expected: expected, + expectedMagick: expectedMagick + }; +}; + +var assertEqual = function (paths, callback) { + if (typeof callback !== 'function') { + throw new TypeError('`callback` must be a function'); + } + + fixtures.assertEqual(paths.actual, paths.expected, function (error) { + if (error) return callback(error); + + sharp.compare(paths.actual, paths.expectedMagick, function (error, info) { + if (error) return callback(error); + + if (info.meanSquaredError > MAX_ALLOWED_IMAGE_MAGICK_MEAN_SQUARED_ERROR) { + return callback(new Error('Expected MSE against ImageMagick to be <= ' + + MAX_ALLOWED_IMAGE_MAGICK_MEAN_SQUARED_ERROR + '. Actual: ' + + info.meanSquaredError)); + } + + callback(); + }); + }); +}; + +// Test describe('Overlays', function() { it('Overlay transparent PNG on solid background', function(done) { + var paths = getPaths('alpha-layer-01'); + sharp(fixtures.inputPngOverlayLayer0) .overlayWith(fixtures.inputPngOverlayLayer1) - .toBuffer(function (error, data, info) { + .toFile(paths.actual, function (error) { if (error) return done(error); - fixtures.assertSimilar(fixtures.expected('alpha-layer-01.png'), data, {threshold: 0}, done); + assertEqual(paths, done); + }); + }); + + it('Overlay low-alpha transparent PNG on solid background', function(done) { + var paths = getPaths('alpha-layer-01-low-alpha'); + + sharp(fixtures.inputPngOverlayLayer0) + .overlayWith(fixtures.inputPngOverlayLayer1LowAlpha) + .toFile(paths.actual, function (error) { + if (error) return done(error); + + assertEqual(paths, done); }); }); it('Composite three transparent PNGs into one', function(done) { + var paths = getPaths('alpha-layer-012'); + sharp(fixtures.inputPngOverlayLayer0) .overlayWith(fixtures.inputPngOverlayLayer1) .toBuffer(function (error, data, info) { @@ -26,26 +85,56 @@ describe('Overlays', function() { sharp(data) .overlayWith(fixtures.inputPngOverlayLayer2) - .toBuffer(function (error, data, info) { + .toFile(paths.actual, function (error) { if (error) return done(error); - fixtures.assertSimilar(fixtures.expected('alpha-layer-012.png'), data, {threshold: 0}, done); + assertEqual(paths, done); }); }); }); - // This tests that alpha channel unpremultiplication is correct: - it('Composite three low-alpha transparent PNGs into one', function(done) { - sharp(fixtures.inputPngOverlayLayer1LowAlpha) - .overlayWith(fixtures.inputPngOverlayLayer2LowAlpha) - .toBuffer(function (error, data, info) { + it('Composite two transparent PNGs into one', function(done) { + var paths = getPaths('alpha-layer-12'); + + sharp(fixtures.inputPngOverlayLayer1) + .overlayWith(fixtures.inputPngOverlayLayer2) + .toFile(paths.actual, function (error, data, info) { if (error) return done(error); - fixtures.assertSimilar(fixtures.expected('alpha-layer-012-low-alpha.png'), data, {threshold: 0}, done); + assertEqual(paths, done); + }); + }); + + it('Composite two low-alpha transparent PNGs into one', function(done) { + var paths = getPaths('alpha-layer-12-low-alpha'); + + sharp(fixtures.inputPngOverlayLayer1LowAlpha) + .overlayWith(fixtures.inputPngOverlayLayer2LowAlpha) + .toFile(paths.actual, function (error, data, info) { + if (error) return done(error); + + assertEqual(paths, done); + }); + }); + + it('Composite three low-alpha transparent PNGs into one', function(done) { + var paths = getPaths('alpha-layer-012-low-alpha'); + + sharp(fixtures.inputPngOverlayLayer0) + .overlayWith(fixtures.inputPngOverlayLayer1LowAlpha) + .toBuffer(function (error, data, info) { + if (error) return done(error); + + sharp(data) + .overlayWith(fixtures.inputPngOverlayLayer2LowAlpha) + .toFile(paths.actual, function (error, data, info) { + if (error) return done(error); + + assertEqual(paths, done); + }); }); }); - // This tests that alpha channel unpremultiplication is correct: it('Composite transparent PNG onto JPEG', function(done) { sharp(fixtures.inputJpg) .overlayWith(fixtures.inputPngOverlayLayer1)