Skip to content

Commit

Permalink
Add experimental overlayWith API
Browse files Browse the repository at this point in the history
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
- libvips/ruby-vips#28 (comment)

See #97.
  • Loading branch information
Daniel Gasienica authored and lovell committed May 31, 2015
1 parent 79b5421 commit aa16c42
Show file tree
Hide file tree
Showing 17 changed files with 312 additions and 8 deletions.
1 change: 1 addition & 0 deletions .jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"maxparams": 4,
"maxcomplexity": 13,
"globals": {
"before": true,
"describe": true,
"it": true
}
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

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

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.
Expand Down
1 change: 1 addition & 0 deletions binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
'target_name': 'sharp',
'sources': [
'src/common.cc',
'src/composite.c',
'src/utilities.cc',
'src/metadata.cc',
'src/resize.cc',
Expand Down
13 changes: 13 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ var Sharp = function(input) {
gamma: 0,
greyscale: false,
normalize: 0,
// overlay
overlayPath: '',
// output options
output: '__input',
progressive: false,
Expand Down Expand Up @@ -203,6 +205,17 @@ Sharp.prototype.flatten = function(flatten) {
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
Auto-rotation based on the EXIF Orientation tag is represented by an angle of -1
Expand Down
125 changes: 125 additions & 0 deletions src/composite.c
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions src/composite.h
Original file line number Diff line number Diff line change
@@ -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_
52 changes: 52 additions & 0 deletions src/resize.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "nan.h"

#include "common.h"
#include "composite.h"
#include "resize.h"

using v8::Handle;
Expand Down Expand Up @@ -81,6 +82,7 @@ struct ResizeBaton {
int sharpenRadius;
double sharpenFlat;
double sharpenJagged;
std::string overlayPath;
double gamma;
bool greyscale;
bool normalize;
Expand Down Expand Up @@ -790,6 +792,54 @@ class ResizeWorker : public NanAsyncWorker {
}
#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
if (image->Type != VIPS_INTERPRETATION_sRGB) {
// Switch intrepretation to sRGB
Expand Down Expand Up @@ -1175,6 +1225,8 @@ NAN_METHOD(resize) {
for (int i = 0; i < 4; i++) {
baton->background[i] = background->Get(i)->NumberValue();
}
// Overlay options
baton->overlayPath = *String::Utf8Value(options->Get(NanNew<String>("overlayPath"))->ToString());
// Resize options
baton->withoutEnlargement = options->Get(NanNew<String>("withoutEnlargement"))->BooleanValue();
baton->gravity = options->Get(NanNew<String>("gravity"))->Int32Value();
Expand Down
Binary file added test/fixtures/alpha-layer-0-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/alpha-layer-1-fill-low-alpha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/alpha-layer-1-fill.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/alpha-layer-2-ink-low-alpha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/alpha-layer-2-ink.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/alpha-layer-01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/fixtures/expected/alpha-layer-012.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 40 additions & 8 deletions test/fixtures/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var getPath = function(filename) {

// 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
var fingerprint = function(image, done) {
var fingerprint = function(image, callback) {
sharp(image)
.greyscale()
.normalise()
Expand All @@ -21,7 +21,7 @@ var fingerprint = function(image, done) {
.raw()
.toBuffer(function(err, data) {
if (err) {
done(err);
callback(err);
} else {
var fingerprint = '';
for (var col = 0; col < 8; col++) {
Expand All @@ -32,7 +32,7 @@ var fingerprint = function(image, done) {
fingerprint = fingerprint + (left < right ? '1' : '0');
}
}
done(null, fingerprint);
callback(null, fingerprint);
}
});
};
Expand All @@ -52,6 +52,11 @@ module.exports = {
inputPngWithTransparency: getPath('blackbug.png'), // public domain
inputPngWithGreyAlpha: getPath('grey-8bit-alpha.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
inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm
Expand All @@ -75,19 +80,46 @@ module.exports = {
},

// 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) {
if (err) throw err;
if (err) return callback(err);
fingerprint(actualImage, function(err, actualFingerprint) {
if (err) throw err;
if (err) return callback(err);
var distance = 0;
for (var i = 0; i < 64; i++) {
if (expectedFingerprint[i] !== actualFingerprint[i]) {
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();
});
});
}
Expand Down
Loading

0 comments on commit aa16c42

Please sign in to comment.