diff --git a/docs/api-operation.md b/docs/api-operation.md index 44822543..7cd0600c 100644 --- a/docs/api-operation.md +++ b/docs/api-operation.md @@ -287,6 +287,41 @@ sharp(input) Returns **Sharp** +## modulate + +Transforms the image using brightness, saturation and hue rotation. + +### Parameters + +- `options` **[Object][2]?** + - `options.brightness` **[Number][1]?** Brightness multiplier + - `options.saturation` **[Number][1]?** Saturation multiplier + - `options.hue` **[Number][1]?** Degrees for hue rotation + +### Examples + +```javascript +sharp(input) + .modulate({ + brightness: 2 // increase lightness by a factor of 2 + }); + +sharp(input) + .modulate({ + hue: 180 // hue-rotate by 180 degrees + }); + +// decreate brightness and saturation while also hue-rotating by 90 degrees +sharp(input) + .modulate({ + brightness: 0.5, + saturation: 0.5, + hue: 90 + }); +``` + +Returns **Sharp** + [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number [2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object diff --git a/lib/constructor.js b/lib/constructor.js index 5ac27481..c8ac4516 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -139,6 +139,9 @@ const Sharp = function (input, options) { gammaOut: 0, greyscale: false, normalise: 0, + brightness: 1, + saturation: 1, + hue: 0, booleanBufferIn: null, booleanFileIn: '', joinChannelIn: [], diff --git a/lib/operation.js b/lib/operation.js index 82be10e6..07d1f271 100644 --- a/lib/operation.js +++ b/lib/operation.js @@ -415,6 +415,62 @@ function recomb (inputMatrix) { return this; } +/** + * Transforms the image using brightness, saturation and hue rotation. + * + * @example + * sharp(input) + * .modulate({ + * brightness: 2 // increase lightness by a factor of 2 + * }); + * + * sharp(input) + * .modulate({ + * hue: 180 // hue-rotate by 180 degrees + * }); + * + * // decreate brightness and saturation while also hue-rotating by 90 degrees + * sharp(input) + * .modulate({ + * brightness: 0.5, + * saturation: 0.5, + * hue: 90 + * }); + * + * @param {Object} [options] + * @param {Number} [options.brightness] Brightness multiplier + * @param {Number} [options.saturation] Saturation multiplier + * @param {Number} [options.hue] Degrees for hue rotation + * @returns {Sharp} + */ +function modulate (options) { + if (!is.plainObject(options)) { + throw is.invalidParameterError('options', 'plain object', options); + } + if ('brightness' in options) { + if (is.number(options.brightness) && options.brightness >= 0) { + this.options.brightness = options.brightness; + } else { + throw is.invalidParameterError('brightness', 'number above zero', options.brightness); + } + } + if ('saturation' in options) { + if (is.number(options.saturation) && options.saturation >= 0) { + this.options.saturation = options.saturation; + } else { + throw is.invalidParameterError('saturation', 'number above zero', options.saturation); + } + } + if ('hue' in options) { + if (is.integer(options.hue)) { + this.options.hue = options.hue % 360; + } else { + throw is.invalidParameterError('hue', 'number', options.hue); + } + } + return this; +} + /** * Decorate the Sharp prototype with operation-related functions. * @private @@ -436,6 +492,7 @@ module.exports = function (Sharp) { threshold, boolean, linear, - recomb + recomb, + modulate }); }; diff --git a/src/operations.cc b/src/operations.cc index e9bcbbcb..ecb8872d 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -185,6 +185,21 @@ namespace sharp { 0.0, 0.0, 0.0, 1.0)); } + VImage Modulate(VImage image, double const brightness, double const saturation, int const hue) { + if (HasAlpha(image)) { + // Separate alpha channel + VImage alpha = image[image.bands() - 1]; + return RemoveAlpha(image) + .colourspace(VIPS_INTERPRETATION_LCH) + .linear({brightness, saturation, 1}, {0, 0, static_cast(hue)}) + .bandjoin(alpha); + } else { + return image + .colourspace(VIPS_INTERPRETATION_LCH) + .linear({brightness, saturation, 1}, {0, 0, static_cast(hue)}); + } + } + /* * Sharpen flat and jagged areas. Use sigma of -1.0 for fast sharpen. */ diff --git a/src/operations.h b/src/operations.h index a5881371..ff7f8b47 100644 --- a/src/operations.h +++ b/src/operations.h @@ -97,6 +97,11 @@ namespace sharp { */ VImage Recomb(VImage image, std::unique_ptr const &matrix); + /* + * Modulate brightness, saturation and hue + */ + VImage Modulate(VImage image, double const brightness, double const saturation, int const hue); + } // namespace sharp #endif // SRC_OPERATIONS_H_ diff --git a/src/pipeline.cc b/src/pipeline.cc index a267fd58..1d0f5afd 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -349,6 +349,7 @@ class PipelineWorker : public Nan::AsyncWorker { bool const shouldSharpen = baton->sharpenSigma != 0.0; bool const shouldApplyMedian = baton->medianSize > 0; bool const shouldComposite = !baton->composite.empty(); + bool const shouldModulate = baton->brightness != 1.0 || baton->saturation != 1.0 || baton->hue != 0.0; if (shouldComposite && !HasAlpha(image)) { image = sharp::EnsureAlpha(image); @@ -528,6 +529,10 @@ class PipelineWorker : public Nan::AsyncWorker { image = sharp::Recomb(image, baton->recombMatrix); } + if (shouldModulate) { + image = sharp::Modulate(image, baton->brightness, baton->saturation, baton->hue); + } + // Sharpen if (shouldSharpen) { image = sharp::Sharpen(image, baton->sharpenSigma, baton->sharpenFlat, baton->sharpenJagged); @@ -1210,6 +1215,9 @@ NAN_METHOD(pipeline) { baton->flattenBackground = AttrAsRgba(options, "flattenBackground"); baton->negate = AttrTo(options, "negate"); baton->blurSigma = AttrTo(options, "blurSigma"); + baton->brightness = AttrTo(options, "brightness"); + baton->saturation = AttrTo(options, "saturation"); + baton->hue = AttrTo(options, "hue"); baton->medianSize = AttrTo(options, "medianSize"); baton->sharpenSigma = AttrTo(options, "sharpenSigma"); baton->sharpenFlat = AttrTo(options, "sharpenFlat"); diff --git a/src/pipeline.h b/src/pipeline.h index a80411fa..8d95a10f 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -87,6 +87,9 @@ struct PipelineBaton { std::vector flattenBackground; bool negate; double blurSigma; + double brightness; + double saturation; + int hue; int medianSize; double sharpenSigma; double sharpenFlat; @@ -189,6 +192,9 @@ struct PipelineBaton { flattenBackground{ 0.0, 0.0, 0.0 }, negate(false), blurSigma(0.0), + brightness(1.0), + saturation(1.0), + hue(0.0), medianSize(0), sharpenSigma(0.0), sharpenFlat(1.0), diff --git a/test/fixtures/expected/modulate-all.jpg b/test/fixtures/expected/modulate-all.jpg new file mode 100644 index 00000000..bdd2332a Binary files /dev/null and b/test/fixtures/expected/modulate-all.jpg differ diff --git a/test/fixtures/expected/modulate-brightness-0-5.jpg b/test/fixtures/expected/modulate-brightness-0-5.jpg new file mode 100644 index 00000000..047d9980 Binary files /dev/null and b/test/fixtures/expected/modulate-brightness-0-5.jpg differ diff --git a/test/fixtures/expected/modulate-brightness-2.jpg b/test/fixtures/expected/modulate-brightness-2.jpg new file mode 100644 index 00000000..4ffbd5f9 Binary files /dev/null and b/test/fixtures/expected/modulate-brightness-2.jpg differ diff --git a/test/fixtures/expected/modulate-hue-120.jpg b/test/fixtures/expected/modulate-hue-120.jpg new file mode 100644 index 00000000..3d6c7868 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-120.jpg differ diff --git a/test/fixtures/expected/modulate-hue-angle-120.png b/test/fixtures/expected/modulate-hue-angle-120.png new file mode 100644 index 00000000..8fbad61d Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-120.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-150.png b/test/fixtures/expected/modulate-hue-angle-150.png new file mode 100644 index 00000000..c0863293 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-150.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-180.png b/test/fixtures/expected/modulate-hue-angle-180.png new file mode 100644 index 00000000..3e6c45de Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-180.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-210.png b/test/fixtures/expected/modulate-hue-angle-210.png new file mode 100644 index 00000000..0bdfd5e4 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-210.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-240.png b/test/fixtures/expected/modulate-hue-angle-240.png new file mode 100644 index 00000000..a0d220e3 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-240.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-270.png b/test/fixtures/expected/modulate-hue-angle-270.png new file mode 100644 index 00000000..d6673b62 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-270.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-30.png b/test/fixtures/expected/modulate-hue-angle-30.png new file mode 100644 index 00000000..bcbd36dd Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-30.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-300.png b/test/fixtures/expected/modulate-hue-angle-300.png new file mode 100644 index 00000000..db93b499 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-300.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-330.png b/test/fixtures/expected/modulate-hue-angle-330.png new file mode 100644 index 00000000..a5aadc5a Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-330.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-360.png b/test/fixtures/expected/modulate-hue-angle-360.png new file mode 100644 index 00000000..380bcbeb Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-360.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-60.png b/test/fixtures/expected/modulate-hue-angle-60.png new file mode 100644 index 00000000..ce398b1a Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-60.png differ diff --git a/test/fixtures/expected/modulate-hue-angle-90.png b/test/fixtures/expected/modulate-hue-angle-90.png new file mode 100644 index 00000000..19e35243 Binary files /dev/null and b/test/fixtures/expected/modulate-hue-angle-90.png differ diff --git a/test/fixtures/expected/modulate-saturation-0.5.jpg b/test/fixtures/expected/modulate-saturation-0.5.jpg new file mode 100644 index 00000000..f922992c Binary files /dev/null and b/test/fixtures/expected/modulate-saturation-0.5.jpg differ diff --git a/test/fixtures/expected/modulate-saturation-2.jpg b/test/fixtures/expected/modulate-saturation-2.jpg new file mode 100644 index 00000000..deaef8d1 Binary files /dev/null and b/test/fixtures/expected/modulate-saturation-2.jpg differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index c4f7c37f..afe36ea1 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -120,6 +120,8 @@ module.exports = { outputTiff: getPath('output.tiff'), outputZoinks: getPath('output.zoinks'), // an 'unknown' file extension + testPattern: getPath('test-pattern.png'), + // Path for tests requiring human inspection path: getPath, diff --git a/test/fixtures/test-pattern.png b/test/fixtures/test-pattern.png new file mode 100644 index 00000000..31564413 Binary files /dev/null and b/test/fixtures/test-pattern.png differ diff --git a/test/unit/modulate.js b/test/unit/modulate.js new file mode 100644 index 00000000..b5f2ee62 --- /dev/null +++ b/test/unit/modulate.js @@ -0,0 +1,125 @@ +'use strict'; + +const sharp = require('../../'); +const assert = require('assert'); +const fixtures = require('../fixtures'); + +describe('Modulate', function () { + describe('Invalid options', function () { + [ + null, + undefined, + 10, + { brightness: -1 }, + { brightness: '50%' }, + { brightness: null }, + { saturation: -1 }, + { saturation: '50%' }, + { saturation: null }, + { hue: '50deg' }, + { hue: 1.5 }, + { hue: null } + ].forEach(function (options) { + it('should throw', function () { + assert.throws(function () { + sharp(fixtures.inputJpg).modulate(options); + }); + }); + }); + }); + + it('should be able to hue-rotate', function () { + const base = 'modulate-hue-120.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ hue: 120 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + + it('should be able to brighten', function () { + const base = 'modulate-brightness-2.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ brightness: 2 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + + it('should be able to unbrighten', function () { + const base = 'modulate-brightness-0-5.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ brightness: 0.5 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + + it('should be able to saturate', function () { + const base = 'modulate-saturation-2.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ saturation: 2 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 30); + }); + }); + + it('should be able to desaturate', function () { + const base = 'modulate-saturation-0.5.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ saturation: 0.5 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + + it('should be able to modulate all channels', function () { + const base = 'modulate-all.jpg'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.inputJpg) + .modulate({ brightness: 2, saturation: 0.5, hue: 180 }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + + describe('hue-rotate', function (done) { + [30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360].forEach(function (angle) { + it('should properly hue rotate by ' + angle + 'deg', function () { + const base = 'modulate-hue-angle-' + angle + '.png'; + const actual = fixtures.path('output.' + base); + const expected = fixtures.expected(base); + + return sharp(fixtures.testPattern) + .modulate({ hue: angle }) + .toFile(actual) + .then(function () { + fixtures.assertMaxColourDistance(actual, expected, 25); + }); + }); + }); + }); +});