Add support to normalise for lower and upper percentiles (#3583)

This commit is contained in:
LachlanNewman 2023-03-01 19:10:44 +08:00 committed by Lovell Fuller
parent 1eefd4e562
commit d7776e3b98
9 changed files with 140 additions and 22 deletions

View File

@ -235,6 +235,8 @@ const Sharp = function (input, options) {
gammaOut: 0, gammaOut: 0,
greyscale: false, greyscale: false,
normalise: false, normalise: false,
normaliseLower: 1,
normaliseUpper: 99,
claheWidth: 0, claheWidth: 0,
claheHeight: 0, claheHeight: 0,
claheMaxSlope: 3, claheMaxSlope: 3,

25
lib/index.d.ts vendored
View File

@ -447,18 +447,26 @@ declare namespace sharp {
negate(negate?: boolean | NegateOptions): Sharp; negate(negate?: boolean | NegateOptions): Sharp;
/** /**
* 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.
* @param normalise true to enable and false to disable (defaults to true) *
* 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 * @returns A sharp instance that can be used to chain operations
*/ */
normalise(normalise?: boolean): Sharp; normalise(normalise?: NormaliseOptions): Sharp;
/** /**
* Alternative spelling of normalise. * 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 * @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) * Perform contrast limiting adaptive histogram equalization (CLAHE)
@ -1218,6 +1226,13 @@ declare namespace sharp {
alpha?: boolean | undefined; 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 { interface ResizeOptions {
/** Alternative means of specifying width. If both are present this takes priority. */ /** Alternative means of specifying width. If both are present this takes priority. */
width?: number | undefined; width?: number | undefined;

View File

@ -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 * @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} * @returns {Sharp}
*/ */
function normalise (normalise) { function normalise (options) {
this.options.normalise = is.bool(normalise) ? normalise : true; 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; return this;
} }
@ -486,13 +520,17 @@ function normalise (normalise) {
* Alternative spelling of normalise. * Alternative spelling of normalise.
* *
* @example * @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} * @returns {Sharp}
*/ */
function normalize (normalize) { function normalize (options) {
return this.normalise(normalize); return this.normalise(options);
} }
/** /**

View File

@ -85,7 +85,8 @@
"Brodan <christopher.hranj@gmail.com", "Brodan <christopher.hranj@gmail.com",
"Ankur Parihar <ankur.github@gmail.com>", "Ankur Parihar <ankur.github@gmail.com>",
"Brahim Ait elhaj <brahima@gmail.com>", "Brahim Ait elhaj <brahima@gmail.com>",
"Mart Jansink <m.jansink@gmail.com>" "Mart Jansink <m.jansink@gmail.com>",
"Lachlan Newman <lachnewman007@gmail.com>"
], ],
"scripts": { "scripts": {
"install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)", "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)",

View File

@ -6,7 +6,6 @@
#include <memory> #include <memory>
#include <tuple> #include <tuple>
#include <vector> #include <vector>
#include <vips/vips8> #include <vips/vips8>
#include "common.h" #include "common.h"
@ -46,7 +45,7 @@ namespace sharp {
/* /*
* Stretch luminance to cover full dynamic range. * Stretch luminance to cover full dynamic range.
*/ */
VImage Normalise(VImage image) { VImage Normalise(VImage image, int const lower, int const upper) {
// Get original colourspace // Get original colourspace
VipsInterpretation typeBeforeNormalize = image.interpretation(); VipsInterpretation typeBeforeNormalize = image.interpretation();
if (typeBeforeNormalize == VIPS_INTERPRETATION_RGB) { if (typeBeforeNormalize == VIPS_INTERPRETATION_RGB) {
@ -56,9 +55,11 @@ namespace sharp {
VImage lab = image.colourspace(VIPS_INTERPRETATION_LAB); VImage lab = image.colourspace(VIPS_INTERPRETATION_LAB);
// Extract luminance // Extract luminance
VImage luminance = lab[0]; VImage luminance = lab[0];
// Find luminance range // Find luminance range
int const min = luminance.percent(1); int const min = lower == 0 ? luminance.min() : luminance.percent(lower);
int const max = luminance.percent(99); int const max = upper == 100 ? luminance.max() : luminance.percent(upper);
if (std::abs(max - min) > 1) { if (std::abs(max - min) > 1) {
// Extract chroma // Extract chroma
VImage chroma = lab.extract_band(1, VImage::option()->set("n", 2)); VImage chroma = lab.extract_band(1, VImage::option()->set("n", 2));

View File

@ -22,7 +22,7 @@ namespace sharp {
/* /*
* Stretch luminance to cover full dynamic range. * 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) * Contrast limiting adapative histogram equalization (CLAHE)

View File

@ -74,6 +74,7 @@ class PipelineWorker : public Napi::AsyncWorker {
VipsAngle autoRotation = VIPS_ANGLE_D0; VipsAngle autoRotation = VIPS_ANGLE_D0;
bool autoFlip = FALSE; bool autoFlip = FALSE;
bool autoFlop = FALSE; bool autoFlop = FALSE;
if (baton->useExifOrientation) { if (baton->useExifOrientation) {
// Rotate and flip image according to Exif orientation // Rotate and flip image according to Exif orientation
std::tie(autoRotation, autoFlip, autoFlop) = CalculateExifRotationAndFlip(sharp::ExifOrientation(image)); 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 // Apply normalisation - stretch luminance to cover full dynamic range
if (baton->normalise) { if (baton->normalise) {
image = sharp::Normalise(image); image = sharp::Normalise(image, baton->normaliseLower, baton->normaliseUpper);
} }
// Apply contrast limiting adaptive histogram equalization (CLAHE) // 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->linearB = sharp::AttrAsVectorOfDouble(options, "linearB");
baton->greyscale = sharp::AttrAsBool(options, "greyscale"); baton->greyscale = sharp::AttrAsBool(options, "greyscale");
baton->normalise = sharp::AttrAsBool(options, "normalise"); 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->tintA = sharp::AttrAsDouble(options, "tintA");
baton->tintB = sharp::AttrAsDouble(options, "tintB"); baton->tintB = sharp::AttrAsDouble(options, "tintB");
baton->claheWidth = sharp::AttrAsUint32(options, "claheWidth"); baton->claheWidth = sharp::AttrAsUint32(options, "claheWidth");

View File

@ -99,6 +99,8 @@ struct PipelineBaton {
double gammaOut; double gammaOut;
bool greyscale; bool greyscale;
bool normalise; bool normalise;
int normaliseLower;
int normaliseUpper;
int claheWidth; int claheWidth;
int claheHeight; int claheHeight;
int claheMaxSlope; int claheMaxSlope;
@ -262,6 +264,8 @@ struct PipelineBaton {
gamma(0.0), gamma(0.0),
greyscale(false), greyscale(false),
normalise(false), normalise(false),
normaliseLower(1),
normaliseUpper(99),
claheWidth(0), claheWidth(0),
claheHeight(0), claheHeight(0),
claheMaxSlope(3), claheMaxSlope(3),

View File

@ -34,7 +34,7 @@ describe('Normalization', function () {
it('spreads grayscaled image values between 0 and 255', function (done) { it('spreads grayscaled image values between 0 and 255', function (done) {
sharp(fixtures.inputJpgWithLowContrast) sharp(fixtures.inputJpgWithLowContrast)
.greyscale() .greyscale()
.normalize(true) .normalize()
.raw() .raw()
.toBuffer(function (err, data, info) { .toBuffer(function (err, data, info) {
if (err) throw err; if (err) throw err;
@ -107,4 +107,58 @@ describe('Normalization', function () {
done(); 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/
);
});
}); });