Add experimental overlayWith API

Composites an overlay image with alpha channel into the input image (which
must have alpha channel) using ‘over’ alpha compositing blend mode. This API
requires both images to have the same dimensions.

References:
- http://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
- https://github.com/jcupitt/ruby-vips/issues/28#issuecomment-9014826

See #97.
This commit is contained in:
Daniel Gasienica 2015-04-24 14:57:48 -07:00 committed by Lovell Fuller
parent c886eaa6b0
commit 64f7f1d662
17 changed files with 312 additions and 8 deletions

View File

@ -4,6 +4,7 @@
"maxparams": 4, "maxparams": 4,
"maxcomplexity": 13, "maxcomplexity": 13,
"globals": { "globals": {
"before": true,
"describe": true, "describe": true,
"it": true "it": true
} }

View File

@ -30,6 +30,8 @@ Please select the `master` branch as the destination for your Pull Request so yo
Please squash your changes into a single commit using a command like `git rebase -i upstream/master`. Please squash your changes into a single commit using a command like `git rebase -i upstream/master`.
To test C++ changes, you can compile the module using `npm install` and then run the tests using `npm test`.
## Submit a Pull Request with a new feature ## Submit a Pull Request with a new feature
Please add JavaScript [unit tests](https://github.com/lovell/sharp/tree/master/test/unit) to cover your new feature. A test coverage report for the JavaScript code is generated in the `coverage/lcov-report` directory. Please add JavaScript [unit tests](https://github.com/lovell/sharp/tree/master/test/unit) to cover your new feature. A test coverage report for the JavaScript code is generated in the `coverage/lcov-report` directory.

View File

@ -3,6 +3,7 @@
'target_name': 'sharp', 'target_name': 'sharp',
'sources': [ 'sources': [
'src/common.cc', 'src/common.cc',
'src/composite.c',
'src/utilities.cc', 'src/utilities.cc',
'src/metadata.cc', 'src/metadata.cc',
'src/resize.cc', 'src/resize.cc',

View File

@ -60,6 +60,8 @@ var Sharp = function(input) {
gamma: 0, gamma: 0,
greyscale: false, greyscale: false,
normalize: 0, normalize: 0,
// overlay
overlayPath: '',
// output options // output options
output: '__input', output: '__input',
progressive: false, progressive: false,
@ -203,6 +205,17 @@ Sharp.prototype.flatten = function(flatten) {
return this; return this;
}; };
Sharp.prototype.overlayWith = function(overlayPath) {
if (typeof overlayPath !== 'string') {
throw new Error('The overlay path must be a string');
}
if (overlayPath === '') {
throw new Error('The overlay path cannot be empty');
}
this.options.overlayPath = overlayPath;
return this;
};
/* /*
Rotate output image by 0, 90, 180 or 270 degrees Rotate output image by 0, 90, 180 or 270 degrees
Auto-rotation based on the EXIF Orientation tag is represented by an angle of -1 Auto-rotation based on the EXIF Orientation tag is represented by an angle of -1

125
src/composite.c Normal file
View File

@ -0,0 +1,125 @@
#include <stdio.h>
#include <vips/vips.h>
const int ALPHA_BAND_INDEX = 3;
const int NUM_COLOR_BANDS = 3;
/*
Composite images `src` and `dst`
*/
int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out) {
if (src->Bands != 4 || dst->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))
return -1;
vips_object_local(context, srcRGB);
vips_object_local(context, dstRGB);
// 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))
return -1;
vips_object_local(context, srcAlpha);
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))
return -1;
vips_object_local(context, srcAlphaNormalized);
vips_object_local(context, dstAlphaNormalized);
//
// Compute normalized output alpha channel:
//
// References:
// - http://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
// - https://github.com/jcupitt/ruby-vips/issues/28#issuecomment-9014826
//
// out_a = src_a + dst_a * (1 - src_a)
// ^^^^^^^^^^^
// t0
// ^^^^^^^^^^^^^^^^^^^
// 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))
return -1;
vips_object_local(context, t0);
vips_object_local(context, t1);
vips_object_local(context, outAlphaNormalized);
//
// Compute output RGB channels:
//
// Wikipedia:
// out_rgb = (src_rgb * src_a + dst_rgb * dst_a * (1 - src_a)) / out_a
//
// `vips_ifthenelse` with `blend=TRUE`: http://bit.ly/1KoSsga
// out = (cond / 255) * in1 + (1 - cond / 255) * in2
//
// 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))
return -1;
vips_object_local(context, dstRGBPremultiplied);
VipsImage *outRGBPremultiplied;
if (vips_ifthenelse(srcAlpha, srcRGB, dstRGBPremultiplied,
&outRGBPremultiplied, "blend", TRUE, 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))
return -1;
// Return a reference to the output image:
*out = joined;
return 0;
}

17
src/composite.h Normal file
View File

@ -0,0 +1,17 @@
#ifndef SRC_COMPOSITE_H_
#define SRC_COMPOSITE_H_
#ifdef __cplusplus
extern "C" {
#endif
/*
Composite images `src` and `dst`.
*/
int Composite(VipsObject *context, VipsImage *src, VipsImage *dst, VipsImage **out);
#ifdef __cplusplus
}
#endif
#endif // SRC_COMPOSITE_H_

View File

@ -8,6 +8,7 @@
#include "nan.h" #include "nan.h"
#include "common.h" #include "common.h"
#include "composite.h"
#include "resize.h" #include "resize.h"
using v8::Handle; using v8::Handle;
@ -81,6 +82,7 @@ struct ResizeBaton {
int sharpenRadius; int sharpenRadius;
double sharpenFlat; double sharpenFlat;
double sharpenJagged; double sharpenJagged;
std::string overlayPath;
double gamma; double gamma;
bool greyscale; bool greyscale;
bool normalize; bool normalize;
@ -790,6 +792,54 @@ class ResizeWorker : public NanAsyncWorker {
} }
#endif #endif
// Composite with overlay, if present
if (!baton->overlayPath.empty()) {
VipsImage *overlayImage = NULL;
ImageType overlayImageType = ImageType::UNKNOWN;
overlayImageType = DetermineImageType(baton->overlayPath.c_str());
if (overlayImageType != ImageType::UNKNOWN) {
overlayImage = InitImage(baton->overlayPath.c_str(), baton->accessMethod);
if (overlayImage == NULL) {
(baton->err).append("Overlay input file has corrupt header");
overlayImageType = ImageType::UNKNOWN;
}
} else {
(baton->err).append("Overlay input file is of an unsupported image format");
}
if (overlayImage == NULL || overlayImageType == ImageType::UNKNOWN) {
return Error();
}
if (!HasAlpha(overlayImage)) {
(baton->err).append("Overlay input must have an alpha channel");
return Error();
}
if (!HasAlpha(image)) {
(baton->err).append("Input image must have an alpha channel");
return Error();
}
if (overlayImage->Bands != 4) {
(baton->err).append("Overlay input image must have 4 channels");
return Error();
}
if (image->Bands != 4) {
(baton->err).append("Input image must have 4 channels");
return Error();
}
VipsImage *composited;
if (Composite(hook, overlayImage, image, &composited)) {
(baton->err).append("Failed to composite images");
return Error();
}
vips_object_local(hook, composited);
image = composited;
}
// Convert image to sRGB, if not already // Convert image to sRGB, if not already
if (image->Type != VIPS_INTERPRETATION_sRGB) { if (image->Type != VIPS_INTERPRETATION_sRGB) {
// Switch intrepretation to sRGB // Switch intrepretation to sRGB
@ -1175,6 +1225,8 @@ NAN_METHOD(resize) {
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
baton->background[i] = background->Get(i)->NumberValue(); baton->background[i] = background->Get(i)->NumberValue();
} }
// Overlay options
baton->overlayPath = *String::Utf8Value(options->Get(NanNew<String>("overlayPath"))->ToString());
// Resize options // Resize options
baton->withoutEnlargement = options->Get(NanNew<String>("withoutEnlargement"))->BooleanValue(); baton->withoutEnlargement = options->Get(NanNew<String>("withoutEnlargement"))->BooleanValue();
baton->gravity = options->Get(NanNew<String>("gravity"))->Int32Value(); baton->gravity = options->Get(NanNew<String>("gravity"))->Int32Value();

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

BIN
test/fixtures/alpha-layer-1-fill.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
test/fixtures/alpha-layer-2-ink.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

View File

@ -11,7 +11,7 @@ var getPath = function(filename) {
// Generates a 64-bit-as-binary-string image fingerprint // Generates a 64-bit-as-binary-string image fingerprint
// Based on the dHash gradient method - see http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html // Based on the dHash gradient method - see http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html
var fingerprint = function(image, done) { var fingerprint = function(image, callback) {
sharp(image) sharp(image)
.greyscale() .greyscale()
.normalise() .normalise()
@ -21,7 +21,7 @@ var fingerprint = function(image, done) {
.raw() .raw()
.toBuffer(function(err, data) { .toBuffer(function(err, data) {
if (err) { if (err) {
done(err); callback(err);
} else { } else {
var fingerprint = ''; var fingerprint = '';
for (var col = 0; col < 8; col++) { for (var col = 0; col < 8; col++) {
@ -32,7 +32,7 @@ var fingerprint = function(image, done) {
fingerprint = fingerprint + (left < right ? '1' : '0'); fingerprint = fingerprint + (left < right ? '1' : '0');
} }
} }
done(null, fingerprint); callback(null, fingerprint);
} }
}); });
}; };
@ -52,6 +52,11 @@ module.exports = {
inputPngWithTransparency: getPath('blackbug.png'), // public domain inputPngWithTransparency: getPath('blackbug.png'), // public domain
inputPngWithGreyAlpha: getPath('grey-8bit-alpha.png'), inputPngWithGreyAlpha: getPath('grey-8bit-alpha.png'),
inputPngWithOneColor: getPath('2x2_fdcce6.png'), inputPngWithOneColor: getPath('2x2_fdcce6.png'),
inputPngOverlayLayer0: getPath('alpha-layer-0-background.png'),
inputPngOverlayLayer1: getPath('alpha-layer-1-fill.png'),
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'),
inputWebP: getPath('4.webp'), // http://www.gstatic.com/webp/gallery/4.webp 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 inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm
@ -75,19 +80,46 @@ module.exports = {
}, },
// Verify similarity of expected vs actual images via fingerprint // Verify similarity of expected vs actual images via fingerprint
assertSimilar: function(expectedImage, actualImage, done) { // Specify distance threshold using `options={threshold: 42}`, default
// `threshold` is 5;
assertSimilar: function(expectedImage, actualImage, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
if (typeof options === 'undefined' && options === null) {
options = {};
}
if (options.threshold === null || typeof options.threshold === 'undefined') {
options.threshold = 5; // ~7% threshold
}
if (typeof options.threshold !== 'number') {
throw new TypeError('`options.threshold` must be a number');
}
if (typeof callback !== 'function') {
throw new TypeError('`callback` must be a function');
}
fingerprint(expectedImage, function(err, expectedFingerprint) { fingerprint(expectedImage, function(err, expectedFingerprint) {
if (err) throw err; if (err) return callback(err);
fingerprint(actualImage, function(err, actualFingerprint) { fingerprint(actualImage, function(err, actualFingerprint) {
if (err) throw err; if (err) return callback(err);
var distance = 0; var distance = 0;
for (var i = 0; i < 64; i++) { for (var i = 0; i < 64; i++) {
if (expectedFingerprint[i] !== actualFingerprint[i]) { if (expectedFingerprint[i] !== actualFingerprint[i]) {
distance++; distance++;
} }
} }
assert.strictEqual(true, distance <= 5); // ~7% threshold
done(); if (distance > options.threshold) {
return callback(new Error('Maximum similarity distance: ' + options.threshold + '. Actual: ' + distance));
}
callback();
}); });
}); });
} }

61
test/unit/overlay.js Normal file
View File

@ -0,0 +1,61 @@
'use strict';
var fixtures = require('../fixtures');
var fs = require('fs');
var sharp = require('../../index');
sharp.cache(0);
// Main
describe('Overlays', function() {
it('Overlay transparent PNG on solid background', function(done) {
sharp(fixtures.inputPngOverlayLayer0)
.overlayWith(fixtures.inputPngOverlayLayer1)
.toBuffer(function (error, data, info) {
if (error) return done(error);
fixtures.assertSimilar(fixtures.expected('alpha-layer-01.png'), data, {threshold: 0}, done);
});
});
it('Composite three transparent PNGs into one', function(done) {
sharp(fixtures.inputPngOverlayLayer0)
.overlayWith(fixtures.inputPngOverlayLayer1)
.toBuffer(function (error, data, info) {
if (error) return done(error);
sharp(data)
.overlayWith(fixtures.inputPngOverlayLayer2)
.toBuffer(function (error, data, info) {
if (error) return done(error);
fixtures.assertSimilar(fixtures.expected('alpha-layer-012.png'), data, {threshold: 0}, 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) {
if (error) return done(error);
fixtures.assertSimilar(fixtures.expected('alpha-layer-012-low-alpha.png'), data, {threshold: 0}, done);
});
});
// This tests that alpha channel unpremultiplication is correct:
it('Composite transparent PNG onto JPEG', function(done) {
sharp(fixtures.inputJpg)
.overlayWith(fixtures.inputPngOverlayLayer1)
.toBuffer(function (error, data, info) {
if (error.message !== 'Input image must have an alpha channel') {
return done(new Error('Unexpected error: ' + error.message));
}
done();
});
});
});