diff --git a/docs/api-operation.md b/docs/api-operation.md index 2eea9937..576b7ff9 100644 --- a/docs/api-operation.md +++ b/docs/api-operation.md @@ -18,6 +18,7 @@ - [convolve](#convolve) - [threshold](#threshold) - [boolean](#boolean) +- [linear](#linear) ## rotate @@ -321,3 +322,17 @@ the selected bitwise boolean `operation` between the corresponding pixels of the - Throws **[Error](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error)** Invalid parameters Returns **Sharp** + +## linear + +Apply the linear formula a \* input + b to the image (levels adjustment) + +**Parameters** + +- `a` **[Number](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number)?** multiplier (optional, default `1.0`) +- `b` **[Number](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number)?** offset (optional, default `0.0`) + + +- Throws **[Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error)** Invalid parameters + +Returns **Sharp** diff --git a/lib/constructor.js b/lib/constructor.js index 64026a4a..1f1d39ec 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -203,6 +203,8 @@ const Sharp = function (input, options) { tiffYres: 1.0, tileSize: 256, tileOverlap: 0, + linearA: 1, + linearB: 0, // Function to notify of libvips warnings debuglog: debuglog, // Function to notify of queue length changes diff --git a/lib/operation.js b/lib/operation.js index 85f96594..f08a2f3b 100644 --- a/lib/operation.js +++ b/lib/operation.js @@ -406,6 +406,33 @@ function boolean (operand, operator, options) { return this; } +/** + * Apply the linear formula a * input + b to the image (levels adjustment) + * @param {Number} [a=1.0] multiplier + * @param {Number} [b=0.0] offset + * @returns {Sharp} + * @throws {Error} Invalid parameters + */ +function linear (a, b) { + if (!is.defined(a)) { + this.options.linearA = 1.0; + } else if (is.number(a)) { + this.options.linearA = a; + } else { + throw new Error('Invalid linear transform multiplier ' + a); + } + + if (!is.defined(b)) { + this.options.linearB = 0.0; + } else if (is.number(b)) { + this.options.linearB = b; + } else { + throw new Error('Invalid linear transform offset ' + b); + } + + return this; +} + /** * Decorate the Sharp prototype with operation-related functions. * @private @@ -427,7 +454,8 @@ module.exports = function (Sharp) { normalize, convolve, threshold, - boolean + boolean, + linear ].forEach(function (f) { Sharp.prototype[f.name] = f; }); diff --git a/package.json b/package.json index 7647630c..e37e3f69 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "Guy Maliar ", "Nicolas Coden ", "Matt Parrish ", + "Marcel Bretschneider ", "Matthew McEachen ", "Jarda Kotěšovec ", "Kenric D'Souza ", diff --git a/src/operations.cc b/src/operations.cc index 2ab69cf5..7c3cda74 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -341,4 +341,18 @@ namespace sharp { return image.extract_area(left, top, width, height); } + /* + * Calculate (a * in + b) + */ + VImage Linear(VImage image, double const a, double const b) { + if (HasAlpha(image)) { + // Separate alpha channel + VImage imageWithoutAlpha = image.extract_band(0, + VImage::option()->set("n", image.bands() - 1)); + VImage alpha = image[image.bands() - 1]; + return imageWithoutAlpha.linear(a, b).bandjoin(alpha); + } else { + return image.linear(a, b); + } + } } // namespace sharp diff --git a/src/operations.h b/src/operations.h index 1493d519..aae0f027 100644 --- a/src/operations.h +++ b/src/operations.h @@ -92,6 +92,11 @@ namespace sharp { */ VImage Trim(VImage image, int const tolerance); + /* + * Linear adjustment (a * in + b) + */ + VImage Linear(VImage image, double const a, double const b); + } // namespace sharp #endif // SRC_OPERATIONS_H_ diff --git a/src/pipeline.cc b/src/pipeline.cc index 23e40502..74e8a3b6 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -644,6 +644,11 @@ class PipelineWorker : public Nan::AsyncWorker { image = sharp::Gamma(image, baton->gamma); } + // Linear adjustment (a * in + b) + if (baton->linearA != 1.0 || baton->linearB != 0.0) { + image = sharp::Linear(image, baton->linearA, baton->linearB); + } + // Apply normalisation - stretch luminance to cover full dynamic range if (baton->normalise) { image = sharp::Normalise(image); @@ -1185,6 +1190,8 @@ NAN_METHOD(pipeline) { baton->thresholdGrayscale = AttrTo(options, "thresholdGrayscale"); baton->trimTolerance = AttrTo(options, "trimTolerance"); baton->gamma = AttrTo(options, "gamma"); + baton->linearA = AttrTo(options, "linearA"); + baton->linearB = AttrTo(options, "linearB"); baton->greyscale = AttrTo(options, "greyscale"); baton->normalise = AttrTo(options, "normalise"); baton->useExifOrientation = AttrTo(options, "useExifOrientation"); diff --git a/src/pipeline.h b/src/pipeline.h index 40a566c6..a93c6b9f 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -79,6 +79,8 @@ struct PipelineBaton { int threshold; bool thresholdGrayscale; int trimTolerance; + double linearA; + double linearB; double gamma; bool greyscale; bool normalise; @@ -160,6 +162,8 @@ struct PipelineBaton { threshold(0), thresholdGrayscale(true), trimTolerance(0), + linearA(1.0), + linearB(0.0), gamma(0.0), greyscale(false), normalise(false), diff --git a/test/fixtures/expected/alpha-layer-1-fill-linear.png b/test/fixtures/expected/alpha-layer-1-fill-linear.png new file mode 100644 index 00000000..0b92c4f6 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-1-fill-linear.png differ diff --git a/test/fixtures/expected/alpha-layer-1-fill-offset.png b/test/fixtures/expected/alpha-layer-1-fill-offset.png new file mode 100644 index 00000000..b9602411 Binary files /dev/null and b/test/fixtures/expected/alpha-layer-1-fill-offset.png differ diff --git a/test/fixtures/expected/alpha-layer-1-fill-slope.png b/test/fixtures/expected/alpha-layer-1-fill-slope.png new file mode 100644 index 00000000..a02cd31a Binary files /dev/null and b/test/fixtures/expected/alpha-layer-1-fill-slope.png differ diff --git a/test/fixtures/expected/low-contrast-linear.jpg b/test/fixtures/expected/low-contrast-linear.jpg new file mode 100644 index 00000000..78f043cc Binary files /dev/null and b/test/fixtures/expected/low-contrast-linear.jpg differ diff --git a/test/fixtures/expected/low-contrast-offset.jpg b/test/fixtures/expected/low-contrast-offset.jpg new file mode 100644 index 00000000..c403fc32 Binary files /dev/null and b/test/fixtures/expected/low-contrast-offset.jpg differ diff --git a/test/fixtures/expected/low-contrast-slope.jpg b/test/fixtures/expected/low-contrast-slope.jpg new file mode 100644 index 00000000..0a3b4967 Binary files /dev/null and b/test/fixtures/expected/low-contrast-slope.jpg differ diff --git a/test/unit/linear.js b/test/unit/linear.js new file mode 100644 index 00000000..267356d7 --- /dev/null +++ b/test/unit/linear.js @@ -0,0 +1,79 @@ +'use strict'; + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +const assert = require('assert'); + +describe('Linear adjustment', function () { + const blackPoint = 70; + const whitePoint = 203; + const a = 255 / (whitePoint - blackPoint); + const b = -blackPoint * a; + + it('applies linear levels adjustment w/o alpha ch', function (done) { + sharp(fixtures.inputJpgWithLowContrast) + .linear(a, b) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('low-contrast-linear.jpg'), data, done); + }); + }); + + it('applies slope level adjustment w/o alpha ch', function (done) { + sharp(fixtures.inputJpgWithLowContrast) + .linear(a) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('low-contrast-slope.jpg'), data, done); + }); + }); + + it('applies offset level adjustment w/o alpha ch', function (done) { + sharp(fixtures.inputJpgWithLowContrast) + .linear(null, b) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('low-contrast-offset.jpg'), data, done); + }); + }); + + it('applies linear levels adjustment w alpha ch', function (done) { + sharp(fixtures.inputPngOverlayLayer1) + .linear(a, b) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('alpha-layer-1-fill-linear.png'), data, done); + }); + }); + + it('applies slope level adjustment w alpha ch', function (done) { + sharp(fixtures.inputPngOverlayLayer1) + .linear(a) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('alpha-layer-1-fill-slope.png'), data, done); + }); + }); + + it('applies offset level adjustment w alpha ch', function (done) { + sharp(fixtures.inputPngOverlayLayer1) + .linear(null, b) + .toBuffer(function (err, data, info) { + if (err) throw err; + fixtures.assertSimilar(fixtures.expected('alpha-layer-1-fill-offset.png'), data, done); + }); + }); + + it('Invalid linear arguments', function () { + assert.throws(function () { + sharp(fixtures.inputPngOverlayLayer1) + .linear('foo'); + }); + + assert.throws(function () { + sharp(fixtures.inputPngOverlayLayer1) + .linear(undefined, { 'bar': 'baz' }); + }); + }); +});