diff --git a/lib/constructor.js b/lib/constructor.js index e5ed61d3..62921f2a 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -235,6 +235,8 @@ const Sharp = function (input, options) { gammaOut: 0, greyscale: false, normalise: false, + normaliseLower: 1, + normaliseUpper: 99, claheWidth: 0, claheHeight: 0, claheMaxSlope: 3, diff --git a/lib/index.d.ts b/lib/index.d.ts index 1a0b672b..2c6aa585 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -447,18 +447,26 @@ declare namespace sharp { negate(negate?: boolean | NegateOptions): Sharp; /** - * Enhance output image contrast by stretching its luminance to cover the full dynamic range. - * @param normalise true to enable and false to disable (defaults to true) + * Enhance output image contrast by stretching its luminance to cover a full dynamic range. + * + * Uses a histogram-based approach, taking a default range of 1% to 99% to reduce sensitivity to noise at the extremes. + * + * Luminance values below the `lower` percentile will be underexposed by clipping to zero. + * Luminance values above the `upper` percentile will be overexposed by clipping to the max pixel value. + * + * @param normalise options + * @throws {Error} Invalid parameters * @returns A sharp instance that can be used to chain operations */ - normalise(normalise?: boolean): Sharp; + normalise(normalise?: NormaliseOptions): Sharp; /** * Alternative spelling of normalise. - * @param normalize true to enable and false to disable (defaults to true) + * @param normalize options + * @throws {Error} Invalid parameters * @returns A sharp instance that can be used to chain operations */ - normalize(normalize?: boolean): Sharp; + normalize(normalize?: NormaliseOptions): Sharp; /** * Perform contrast limiting adaptive histogram equalization (CLAHE) @@ -1218,6 +1226,13 @@ declare namespace sharp { alpha?: boolean | undefined; } + interface NormaliseOptions { + /** Percentile below which luminance values will be underexposed. */ + lower?: number | undefined; + /** Percentile above which luminance values will be overexposed. */ + upper?: number | undefined; + } + interface ResizeOptions { /** Alternative means of specifying width. If both are present this takes priority. */ width?: number | undefined; diff --git a/lib/operation.js b/lib/operation.js index b94dd470..b58583ad 100644 --- a/lib/operation.js +++ b/lib/operation.js @@ -469,16 +469,50 @@ function negate (options) { } /** - * Enhance output image contrast by stretching its luminance to cover the full dynamic range. + * Enhance output image contrast by stretching its luminance to cover a full dynamic range. + * + * Uses a histogram-based approach, taking a default range of 1% to 99% to reduce sensitivity to noise at the extremes. + * + * Luminance values below the `lower` percentile will be underexposed by clipping to zero. + * Luminance values above the `upper` percentile will be overexposed by clipping to the max pixel value. * * @example - * const output = await sharp(input).normalise().toBuffer(); + * const output = await sharp(input) + * .normalise() + * .toBuffer(); * - * @param {Boolean} [normalise=true] + * @example + * const output = await sharp(input) + * .normalise({ lower: 0, upper: 100 }) + * .toBuffer(); + * + * @param {Object} [options] + * @param {number} [options.lower=1] - Percentile below which luminance values will be underexposed. + * @param {number} [options.upper=99] - Percentile above which luminance values will be overexposed. * @returns {Sharp} */ -function normalise (normalise) { - this.options.normalise = is.bool(normalise) ? normalise : true; +function normalise (options) { + if (is.plainObject(options)) { + if (is.defined(options.lower)) { + if (is.number(options.lower) && is.inRange(options.lower, 0, 99)) { + this.options.normaliseLower = options.lower; + } else { + throw is.invalidParameterError('lower', 'number between 0 and 99', options.lower); + } + } + if (is.defined(options.upper)) { + if (is.number(options.upper) && is.inRange(options.upper, 1, 100)) { + this.options.normaliseUpper = options.upper; + } else { + throw is.invalidParameterError('upper', 'number between 1 and 100', options.upper); + } + } + } + if (this.options.normaliseLower >= this.options.normaliseUpper) { + throw is.invalidParameterError('range', 'lower to be less than upper', + `${this.options.normaliseLower} >= ${this.options.normaliseUpper}`); + } + this.options.normalise = true; return this; } @@ -486,13 +520,17 @@ function normalise (normalise) { * Alternative spelling of normalise. * * @example - * const output = await sharp(input).normalize().toBuffer(); + * const output = await sharp(input) + * .normalize() + * .toBuffer(); * - * @param {Boolean} [normalize=true] + * @param {Object} [options] + * @param {number} [options.lower=1] - Percentile below which luminance values will be underexposed. + * @param {number} [options.upper=99] - Percentile above which luminance values will be overexposed. * @returns {Sharp} */ -function normalize (normalize) { - return this.normalise(normalize); +function normalize (options) { + return this.normalise(options); } /** diff --git a/package.json b/package.json index 97d0f737..5b8e7d87 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,8 @@ "Brodan ", "Brahim Ait elhaj ", - "Mart Jansink " + "Mart Jansink ", + "Lachlan Newman " ], "scripts": { "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)", diff --git a/src/operations.cc b/src/operations.cc index 59d43b51..ea8652ec 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -6,7 +6,6 @@ #include #include #include - #include #include "common.h" @@ -46,7 +45,7 @@ namespace sharp { /* * Stretch luminance to cover full dynamic range. */ - VImage Normalise(VImage image) { + VImage Normalise(VImage image, int const lower, int const upper) { // Get original colourspace VipsInterpretation typeBeforeNormalize = image.interpretation(); if (typeBeforeNormalize == VIPS_INTERPRETATION_RGB) { @@ -56,9 +55,11 @@ namespace sharp { VImage lab = image.colourspace(VIPS_INTERPRETATION_LAB); // Extract luminance VImage luminance = lab[0]; + // Find luminance range - int const min = luminance.percent(1); - int const max = luminance.percent(99); + int const min = lower == 0 ? luminance.min() : luminance.percent(lower); + int const max = upper == 100 ? luminance.max() : luminance.percent(upper); + if (std::abs(max - min) > 1) { // Extract chroma VImage chroma = lab.extract_band(1, VImage::option()->set("n", 2)); diff --git a/src/operations.h b/src/operations.h index d5ac810f..df4d1eaf 100644 --- a/src/operations.h +++ b/src/operations.h @@ -22,7 +22,7 @@ namespace sharp { /* * Stretch luminance to cover full dynamic range. */ - VImage Normalise(VImage image); + VImage Normalise(VImage image, int const lower, int const upper); /* * Contrast limiting adapative histogram equalization (CLAHE) diff --git a/src/pipeline.cc b/src/pipeline.cc index 114f95be..7f882003 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -74,6 +74,7 @@ class PipelineWorker : public Napi::AsyncWorker { VipsAngle autoRotation = VIPS_ANGLE_D0; bool autoFlip = FALSE; bool autoFlop = FALSE; + if (baton->useExifOrientation) { // Rotate and flip image according to Exif orientation std::tie(autoRotation, autoFlip, autoFlop) = CalculateExifRotationAndFlip(sharp::ExifOrientation(image)); @@ -682,7 +683,7 @@ class PipelineWorker : public Napi::AsyncWorker { // Apply normalisation - stretch luminance to cover full dynamic range if (baton->normalise) { - image = sharp::Normalise(image); + image = sharp::Normalise(image, baton->normaliseLower, baton->normaliseUpper); } // Apply contrast limiting adaptive histogram equalization (CLAHE) @@ -1483,6 +1484,8 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->linearB = sharp::AttrAsVectorOfDouble(options, "linearB"); baton->greyscale = sharp::AttrAsBool(options, "greyscale"); baton->normalise = sharp::AttrAsBool(options, "normalise"); + baton->normaliseLower = sharp::AttrAsUint32(options, "normaliseLower"); + baton->normaliseUpper = sharp::AttrAsUint32(options, "normaliseUpper"); baton->tintA = sharp::AttrAsDouble(options, "tintA"); baton->tintB = sharp::AttrAsDouble(options, "tintB"); baton->claheWidth = sharp::AttrAsUint32(options, "claheWidth"); diff --git a/src/pipeline.h b/src/pipeline.h index fa092b6a..aa37090b 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -99,6 +99,8 @@ struct PipelineBaton { double gammaOut; bool greyscale; bool normalise; + int normaliseLower; + int normaliseUpper; int claheWidth; int claheHeight; int claheMaxSlope; @@ -262,6 +264,8 @@ struct PipelineBaton { gamma(0.0), greyscale(false), normalise(false), + normaliseLower(1), + normaliseUpper(99), claheWidth(0), claheHeight(0), claheMaxSlope(3), diff --git a/test/unit/normalize.js b/test/unit/normalize.js index 422248e2..95df8578 100644 --- a/test/unit/normalize.js +++ b/test/unit/normalize.js @@ -34,7 +34,7 @@ describe('Normalization', function () { it('spreads grayscaled image values between 0 and 255', function (done) { sharp(fixtures.inputJpgWithLowContrast) .greyscale() - .normalize(true) + .normalize() .raw() .toBuffer(function (err, data, info) { if (err) throw err; @@ -107,4 +107,58 @@ describe('Normalization', function () { done(); }); }); + + it('should handle luminance range', function (done) { + sharp(fixtures.inputJpgWithLowContrast) + .normalise({ lower: 10, upper: 70 }) + .raw() + .toBuffer(function (err, data, info) { + if (err) throw err; + assertNormalized(data); + done(); + }); + }); + + it('should allow lower without upper', function () { + assert.doesNotThrow(() => sharp().normalize({ lower: 2 })); + }); + it('should allow upper without lower', function () { + assert.doesNotThrow(() => sharp().normalize({ upper: 98 })); + }); + it('should throw when lower is out of range', function () { + assert.throws( + () => sharp().normalise({ lower: -10 }), + /Expected number between 0 and 99 for lower but received -10 of type number/ + ); + }); + it('should throw when upper is out of range', function () { + assert.throws( + () => sharp().normalise({ upper: 110 }), + /Expected number between 1 and 100 for upper but received 110 of type number/ + ); + }); + it('should throw when lower is not a number', function () { + assert.throws( + () => sharp().normalise({ lower: 'fail' }), + /Expected number between 0 and 99 for lower but received fail of type string/ + ); + }); + it('should throw when upper is not a number', function () { + assert.throws( + () => sharp().normalise({ upper: 'fail' }), + /Expected number between 1 and 100 for upper but received fail of type string/ + ); + }); + it('should throw when the lower and upper are equal', function () { + assert.throws( + () => sharp().normalise({ lower: 2, upper: 2 }), + /Expected lower to be less than upper for range but received 2 >= 2/ + ); + }); + it('should throw when the lower is greater than upper', function () { + assert.throws( + () => sharp().normalise({ lower: 3, upper: 2 }), + /Expected lower to be less than upper for range but received 3 >= 2/ + ); + }); });