diff --git a/docs/api-operation.md b/docs/api-operation.md index cfe05a53..44822543 100644 --- a/docs/api-operation.md +++ b/docs/api-operation.md @@ -254,6 +254,35 @@ Apply the linear formula a \* input + b to the image (levels adjustment) - `b` **[Number][1]** offset (optional, default `0.0`) +- Throws **[Error][5]** Invalid parameters + +Returns **Sharp** + +## recomb + +Recomb the image with the specified matrix. + +### Parameters + +- `inputMatrix` +- `3x3` **[Array][7]<[Array][7]<[Number][1]>>** Recombination matrix + +### Examples + +```javascript +sharp(input) + .recomb([ + [0.3588, 0.7044, 0.1368], + [0.2990, 0.5870, 0.1140], + [0.2392, 0.4696, 0.0912], + ]) + .raw() + .toBuffer(function(err, data, info) { + // data contains the raw pixel data after applying the recomb + // With this example input, a sepia filter has been applied + }); +``` + - Throws **[Error][5]** Invalid parameters Returns **Sharp** diff --git a/docs/changelog.md b/docs/changelog.md index 36bb6bf3..ecbaf25a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -25,6 +25,10 @@ Requires libvips v8.7.0. [#1475](https://github.com/lovell/sharp/pull/1475) [@jaubourg](https://github.com/jaubourg) +* Expose libvips' recombination matrix operation. + [#1477](https://github.com/lovell/sharp/pull/1477) + [@fromkeith](https://github.com/fromkeith) + #### v0.21.0 - 4th October 2018 * Deprecate the following resize-related functions: diff --git a/docs/index.md b/docs/index.md index 991680fb..d35fb450 100644 --- a/docs/index.md +++ b/docs/index.md @@ -120,6 +120,7 @@ the help and code contributions of the following people: * [Axel Eirola](https://github.com/aeirola) * [Freezy](https://github.com/freezy) * [Julian Aubourg](https://github.com/jaubourg) +* [Keith Belovay](https://github.com/fromkeith) Thank you! diff --git a/lib/operation.js b/lib/operation.js index 80cd3480..82be10e6 100644 --- a/lib/operation.js +++ b/lib/operation.js @@ -378,6 +378,43 @@ function linear (a, b) { return this; } +/** + * Recomb the image with the specified matrix. + * + * @example + * sharp(input) + * .recomb([ + * [0.3588, 0.7044, 0.1368], + * [0.2990, 0.5870, 0.1140], + * [0.2392, 0.4696, 0.0912], + * ]) + * .raw() + * .toBuffer(function(err, data, info) { + * // data contains the raw pixel data after applying the recomb + * // With this example input, a sepia filter has been applied + * }); + * + * @param {Array>} 3x3 Recombination matrix + * @returns {Sharp} + * @throws {Error} Invalid parameters + */ +function recomb (inputMatrix) { + if (!Array.isArray(inputMatrix) || inputMatrix.length !== 3 || + inputMatrix[0].length !== 3 || + inputMatrix[1].length !== 3 || + inputMatrix[2].length !== 3 + ) { + // must pass in a kernel + throw new Error('Invalid Recomb Matrix'); + } + this.options.recombMatrix = [ + inputMatrix[0][0], inputMatrix[0][1], inputMatrix[0][2], + inputMatrix[1][0], inputMatrix[1][1], inputMatrix[1][2], + inputMatrix[2][0], inputMatrix[2][1], inputMatrix[2][2] + ].map(Number); + return this; +} + /** * Decorate the Sharp prototype with operation-related functions. * @private @@ -398,6 +435,7 @@ module.exports = function (Sharp) { convolve, threshold, boolean, - linear + linear, + recomb }); }; diff --git a/package.json b/package.json index 485482c5..9d81d884 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "Axel Eirola ", "Freezy ", "Daiz ", - "Julian Aubourg " + "Julian Aubourg ", + "Keith Belovay " ], "scripts": { "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node-gyp rebuild && node install/dll-copy)", diff --git a/src/operations.cc b/src/operations.cc index f389b33e..71634644 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -278,6 +278,25 @@ namespace sharp { return image.conv(kernel); } + /* + * Recomb with a Matrix of the given bands/channel size. + * Eg. RGB will be a 3x3 matrix. + */ + VImage Recomb(VImage image, std::unique_ptr const &matrix) { + double *m = matrix.get(); + return image + .colourspace(VIPS_INTERPRETATION_sRGB) + .recomb(image.bands() == 3 + ? VImage::new_from_memory( + m, 9 * sizeof(double), 3, 3, 1, VIPS_FORMAT_DOUBLE + ) + : VImage::new_matrixv(4, 4, + m[0], m[1], m[2], 0.0, + m[3], m[4], m[5], 0.0, + m[6], m[7], m[8], 0.0, + 0.0, 0.0, 0.0, 1.0)); + } + /* * Sharpen flat and jagged areas. Use sigma of -1.0 for fast sharpen. */ diff --git a/src/operations.h b/src/operations.h index ee371a3f..20cc748a 100644 --- a/src/operations.h +++ b/src/operations.h @@ -107,6 +107,12 @@ namespace sharp { */ VImage Linear(VImage image, double const a, double const b); + /* + * Recomb with a Matrix of the given bands/channel size. + * Eg. RGB will be a 3x3 matrix. + */ + VImage Recomb(VImage image, std::unique_ptr const &matrix); + } // namespace sharp #endif // SRC_OPERATIONS_H_ diff --git a/src/pipeline.cc b/src/pipeline.cc index e5fcf87b..6258b11a 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -525,6 +525,11 @@ class PipelineWorker : public Nan::AsyncWorker { baton->convKernel); } + // Recomb + if (baton->recombMatrix != NULL) { + image = sharp::Recomb(image, baton->recombMatrix); + } + // Sharpen if (shouldSharpen) { image = sharp::Sharpen(image, baton->sharpenSigma, baton->sharpenFlat, baton->sharpenJagged); @@ -1234,6 +1239,13 @@ NAN_METHOD(pipeline) { baton->convKernel[i] = AttrTo(kdata, i); } } + if (HasAttr(options, "recombMatrix")) { + baton->recombMatrix = std::unique_ptr(new double[9]); + v8::Local recombMatrix = AttrAs(options, "recombMatrix"); + for (unsigned int i = 0; i < 9; i++) { + baton->recombMatrix[i] = AttrTo(recombMatrix, i); + } + } baton->colourspace = sharp::GetInterpretation(AttrAsStr(options, "colourspace")); if (baton->colourspace == VIPS_INTERPRETATION_ERROR) { baton->colourspace = VIPS_INTERPRETATION_sRGB; diff --git a/src/pipeline.h b/src/pipeline.h index fb18ab2b..5e020a57 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -146,6 +146,7 @@ struct PipelineBaton { std::string tileFormat; int tileAngle; VipsForeignDzDepth tileDepth; + std::unique_ptr recombMatrix; PipelineBaton(): input(nullptr), diff --git a/test/fixtures/expected/Landscape_1-recomb-saturation.jpg b/test/fixtures/expected/Landscape_1-recomb-saturation.jpg new file mode 100644 index 00000000..88810283 Binary files /dev/null and b/test/fixtures/expected/Landscape_1-recomb-saturation.jpg differ diff --git a/test/fixtures/expected/Landscape_1-recomb-sepia.jpg b/test/fixtures/expected/Landscape_1-recomb-sepia.jpg new file mode 100644 index 00000000..9ca75b63 Binary files /dev/null and b/test/fixtures/expected/Landscape_1-recomb-sepia.jpg differ diff --git a/test/fixtures/expected/Landscape_1-recomb-sepia2.jpg b/test/fixtures/expected/Landscape_1-recomb-sepia2.jpg new file mode 100644 index 00000000..7d0f1996 Binary files /dev/null and b/test/fixtures/expected/Landscape_1-recomb-sepia2.jpg differ diff --git a/test/fixtures/expected/alpha-recomb-sepia.png b/test/fixtures/expected/alpha-recomb-sepia.png new file mode 100644 index 00000000..6e3daf9c Binary files /dev/null and b/test/fixtures/expected/alpha-recomb-sepia.png differ diff --git a/test/unit/recomb.js b/test/unit/recomb.js new file mode 100644 index 00000000..b9f889e9 --- /dev/null +++ b/test/unit/recomb.js @@ -0,0 +1,131 @@ +'use strict'; + +const assert = require('assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +describe('Recomb', function () { + it('applies a sepia filter using recomb', function (done) { + const output = fixtures.path('output.recomb-sepia.jpg'); + sharp(fixtures.inputJpgWithLandscapeExif1) + .recomb([ + [0.3588, 0.7044, 0.1368], + [0.299, 0.587, 0.114], + [0.2392, 0.4696, 0.0912] + ]) + .toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(600, info.width); + assert.strictEqual(450, info.height); + fixtures.assertMaxColourDistance( + output, + fixtures.expected('Landscape_1-recomb-sepia.jpg') + ); + done(); + }); + }); + + it('applies a sepia filter using recomb to an PNG with Alpha', function (done) { + const output = fixtures.path('output.recomb-sepia.png'); + sharp(fixtures.inputPngAlphaPremultiplicationSmall) + .recomb([ + [0.3588, 0.7044, 0.1368], + [0.299, 0.587, 0.114], + [0.2392, 0.4696, 0.0912] + ]) + .toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(1024, info.width); + assert.strictEqual(768, info.height); + fixtures.assertMaxColourDistance( + output, + fixtures.expected('alpha-recomb-sepia.png') + ); + done(); + }); + }); + + it('applies a different sepia filter using recomb', function (done) { + const output = fixtures.path('output.recomb-sepia2.jpg'); + sharp(fixtures.inputJpgWithLandscapeExif1) + .recomb([ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131] + ]) + .toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(600, info.width); + assert.strictEqual(450, info.height); + fixtures.assertMaxColourDistance( + output, + fixtures.expected('Landscape_1-recomb-sepia2.jpg') + ); + done(); + }); + }); + it('increases the saturation of the image', function (done) { + const saturationLevel = 1; + const output = fixtures.path('output.recomb-saturation.jpg'); + sharp(fixtures.inputJpgWithLandscapeExif1) + .recomb([ + [ + saturationLevel + 1 - 0.2989, + -0.587 * saturationLevel, + -0.114 * saturationLevel + ], + [ + -0.2989 * saturationLevel, + saturationLevel + 1 - 0.587, + -0.114 * saturationLevel + ], + [ + -0.2989 * saturationLevel, + -0.587 * saturationLevel, + saturationLevel + 1 - 0.114 + ] + ]) + .toFile(output, function (err, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(600, info.width); + assert.strictEqual(450, info.height); + fixtures.assertMaxColourDistance( + output, + fixtures.expected('Landscape_1-recomb-saturation.jpg') + ); + done(); + }); + }); + + describe('invalid matrix specification', function () { + it('missing', function () { + assert.throws(function () { + sharp(fixtures.inputJpg).recomb(); + }); + }); + it('incorrect flat data', function () { + assert.throws(function () { + sharp(fixtures.inputJpg).recomb([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + it('incorrect sub size', function () { + assert.throws(function () { + sharp(fixtures.inputJpg).recomb([ + [1, 2, 3, 4], + [5, 6, 7, 8], + [1, 2, 9, 6] + ]); + }); + }); + it('incorrect top size', function () { + assert.throws(function () { + sharp(fixtures.inputJpg).recomb([[1, 2, 3, 4], [5, 6, 7, 8]]); + }); + }); + }); +});