Add brightness, saturation and hue modulation #609 (#1601)

This commit is contained in:
Jakub Michálek 2019-03-25 08:44:07 +01:00 committed by Lovell Fuller
parent 18afcf5f90
commit b494b2e872
28 changed files with 257 additions and 1 deletions

View File

@ -287,6 +287,41 @@ sharp(input)
Returns **Sharp** 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 [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number
[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object

View File

@ -139,6 +139,9 @@ const Sharp = function (input, options) {
gammaOut: 0, gammaOut: 0,
greyscale: false, greyscale: false,
normalise: 0, normalise: 0,
brightness: 1,
saturation: 1,
hue: 0,
booleanBufferIn: null, booleanBufferIn: null,
booleanFileIn: '', booleanFileIn: '',
joinChannelIn: [], joinChannelIn: [],

View File

@ -415,6 +415,62 @@ function recomb (inputMatrix) {
return this; 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. * Decorate the Sharp prototype with operation-related functions.
* @private * @private
@ -436,6 +492,7 @@ module.exports = function (Sharp) {
threshold, threshold,
boolean, boolean,
linear, linear,
recomb recomb,
modulate
}); });
}; };

View File

@ -185,6 +185,21 @@ namespace sharp {
0.0, 0.0, 0.0, 1.0)); 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<double>(hue)})
.bandjoin(alpha);
} else {
return image
.colourspace(VIPS_INTERPRETATION_LCH)
.linear({brightness, saturation, 1}, {0, 0, static_cast<double>(hue)});
}
}
/* /*
* Sharpen flat and jagged areas. Use sigma of -1.0 for fast sharpen. * Sharpen flat and jagged areas. Use sigma of -1.0 for fast sharpen.
*/ */

View File

@ -97,6 +97,11 @@ namespace sharp {
*/ */
VImage Recomb(VImage image, std::unique_ptr<double[]> const &matrix); VImage Recomb(VImage image, std::unique_ptr<double[]> const &matrix);
/*
* Modulate brightness, saturation and hue
*/
VImage Modulate(VImage image, double const brightness, double const saturation, int const hue);
} // namespace sharp } // namespace sharp
#endif // SRC_OPERATIONS_H_ #endif // SRC_OPERATIONS_H_

View File

@ -349,6 +349,7 @@ class PipelineWorker : public Nan::AsyncWorker {
bool const shouldSharpen = baton->sharpenSigma != 0.0; bool const shouldSharpen = baton->sharpenSigma != 0.0;
bool const shouldApplyMedian = baton->medianSize > 0; bool const shouldApplyMedian = baton->medianSize > 0;
bool const shouldComposite = !baton->composite.empty(); 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)) { if (shouldComposite && !HasAlpha(image)) {
image = sharp::EnsureAlpha(image); image = sharp::EnsureAlpha(image);
@ -528,6 +529,10 @@ class PipelineWorker : public Nan::AsyncWorker {
image = sharp::Recomb(image, baton->recombMatrix); image = sharp::Recomb(image, baton->recombMatrix);
} }
if (shouldModulate) {
image = sharp::Modulate(image, baton->brightness, baton->saturation, baton->hue);
}
// Sharpen // Sharpen
if (shouldSharpen) { if (shouldSharpen) {
image = sharp::Sharpen(image, baton->sharpenSigma, baton->sharpenFlat, baton->sharpenJagged); image = sharp::Sharpen(image, baton->sharpenSigma, baton->sharpenFlat, baton->sharpenJagged);
@ -1210,6 +1215,9 @@ NAN_METHOD(pipeline) {
baton->flattenBackground = AttrAsRgba(options, "flattenBackground"); baton->flattenBackground = AttrAsRgba(options, "flattenBackground");
baton->negate = AttrTo<bool>(options, "negate"); baton->negate = AttrTo<bool>(options, "negate");
baton->blurSigma = AttrTo<double>(options, "blurSigma"); baton->blurSigma = AttrTo<double>(options, "blurSigma");
baton->brightness = AttrTo<double>(options, "brightness");
baton->saturation = AttrTo<double>(options, "saturation");
baton->hue = AttrTo<int32_t>(options, "hue");
baton->medianSize = AttrTo<uint32_t>(options, "medianSize"); baton->medianSize = AttrTo<uint32_t>(options, "medianSize");
baton->sharpenSigma = AttrTo<double>(options, "sharpenSigma"); baton->sharpenSigma = AttrTo<double>(options, "sharpenSigma");
baton->sharpenFlat = AttrTo<double>(options, "sharpenFlat"); baton->sharpenFlat = AttrTo<double>(options, "sharpenFlat");

View File

@ -87,6 +87,9 @@ struct PipelineBaton {
std::vector<double> flattenBackground; std::vector<double> flattenBackground;
bool negate; bool negate;
double blurSigma; double blurSigma;
double brightness;
double saturation;
int hue;
int medianSize; int medianSize;
double sharpenSigma; double sharpenSigma;
double sharpenFlat; double sharpenFlat;
@ -189,6 +192,9 @@ struct PipelineBaton {
flattenBackground{ 0.0, 0.0, 0.0 }, flattenBackground{ 0.0, 0.0, 0.0 },
negate(false), negate(false),
blurSigma(0.0), blurSigma(0.0),
brightness(1.0),
saturation(1.0),
hue(0.0),
medianSize(0), medianSize(0),
sharpenSigma(0.0), sharpenSigma(0.0),
sharpenFlat(1.0), sharpenFlat(1.0),

BIN
test/fixtures/expected/modulate-all.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

View File

@ -120,6 +120,8 @@ module.exports = {
outputTiff: getPath('output.tiff'), outputTiff: getPath('output.tiff'),
outputZoinks: getPath('output.zoinks'), // an 'unknown' file extension outputZoinks: getPath('output.zoinks'), // an 'unknown' file extension
testPattern: getPath('test-pattern.png'),
// Path for tests requiring human inspection // Path for tests requiring human inspection
path: getPath, path: getPath,

BIN
test/fixtures/test-pattern.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

125
test/unit/modulate.js Normal file
View File

@ -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);
});
});
});
});
});