Add contrast limiting adaptive histogram equalization (CLAHE) operator (#2726)

This commit is contained in:
Brad Parham 2021-05-23 18:36:04 +02:00 committed by GitHub
parent b69a54fc75
commit 4b6b6189bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 245 additions and 6 deletions

View File

@ -243,6 +243,30 @@ Alternative spelling of normalise.
Returns **Sharp** Returns **Sharp**
## clahe
Perform contrast limiting adaptive histogram equalization (CLAHE)
This will, in general, enhance the clarity of the image by bringing out
darker details. Please read more about CLAHE here:
[https://en.wikipedia.org/wiki/Adaptive_histogram_equalization#Contrast_Limited_AHE][8]
### Parameters
* `options` **[Object][2]**
* `options.width` **[number][1]** integer width of the region in pixels.
* `options.height` **[number][1]** integer height of the region in pixels.
* `options.maxSlope` **[number][1]** maximum value for the slope of the
cumulative histogram. A value of 0 disables contrast limiting. Valid values
are integers in the range 0-100 (inclusive) (optional, default `3`)
<!---->
* Throws **[Error][5]** Invalid parameters
Returns **Sharp**
## convolve ## convolve
Convolve the image with the specified kernel. Convolve the image with the specified kernel.
@ -252,7 +276,7 @@ Convolve the image with the specified kernel.
* `kernel` **[Object][2]** * `kernel` **[Object][2]**
* `kernel.width` **[number][1]** width of the kernel in pixels. * `kernel.width` **[number][1]** width of the kernel in pixels.
* `kernel.height` **[number][1]** width of the kernel in pixels. * `kernel.height` **[number][1]** height of the kernel in pixels.
* `kernel.kernel` **[Array][7]<[number][1]>** Array of length `width*height` containing the kernel values. * `kernel.kernel` **[Array][7]<[number][1]>** Array of length `width*height` containing the kernel values.
* `kernel.scale` **[number][1]** the scale of the kernel in pixels. (optional, default `sum`) * `kernel.scale` **[number][1]** the scale of the kernel in pixels. (optional, default `sum`)
* `kernel.offset` **[number][1]** the offset of the kernel in pixels. (optional, default `0`) * `kernel.offset` **[number][1]** the offset of the kernel in pixels. (optional, default `0`)
@ -304,7 +328,7 @@ the selected bitwise boolean `operation` between the corresponding pixels of the
### Parameters ### Parameters
* `operand` **([Buffer][8] | [string][3])** Buffer containing image data or string containing the path to an image file. * `operand` **([Buffer][9] | [string][3])** Buffer containing image data or string containing the path to an image file.
* `operator` **[string][3]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively. * `operator` **[string][3]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively.
* `options` **[Object][2]?** * `options` **[Object][2]?**
@ -421,4 +445,6 @@ Returns **Sharp**
[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[8]: https://nodejs.org/api/buffer.html [8]: https://en.wikipedia.org/wiki/Adaptive_histogram_equalization#Contrast_Limited_AHE
[9]: https://nodejs.org/api/buffer.html

File diff suppressed because one or more lines are too long

View File

@ -216,6 +216,9 @@ const Sharp = function (input, options) {
gammaOut: 0, gammaOut: 0,
greyscale: false, greyscale: false,
normalise: false, normalise: false,
claheWidth: 0,
claheHeight: 0,
claheMaxSlope: 3,
brightness: 1, brightness: 1,
saturation: 1, saturation: 1,
hue: 0, hue: 0,

View File

@ -350,6 +350,46 @@ function normalize (normalize) {
return this.normalise(normalize); return this.normalise(normalize);
} }
/**
* Perform contrast limiting adaptive histogram equalization (CLAHE)
*
* This will, in general, enhance the clarity of the image by bringing out
* darker details. Please read more about CLAHE here:
* https://en.wikipedia.org/wiki/Adaptive_histogram_equalization#Contrast_Limited_AHE
*
* @param {Object} options
* @param {number} options.width - integer width of the region in pixels.
* @param {number} options.height - integer height of the region in pixels.
* @param {number} [options.maxSlope=3] - maximum value for the slope of the
* cumulative histogram. A value of 0 disables contrast limiting. Valid values
* are integers in the range 0-100 (inclusive)
* @returns {Sharp}
* @throws {Error} Invalid parameters
*/
function clahe (options) {
if (!is.plainObject(options)) {
throw is.invalidParameterError('options', 'plain object', options);
}
if (!('width' in options) || !is.integer(options.width) || options.width <= 0) {
throw is.invalidParameterError('width', 'integer above zero', options.width);
} else {
this.options.claheWidth = options.width;
}
if (!('height' in options) || !is.integer(options.height) || options.height <= 0) {
throw is.invalidParameterError('height', 'integer above zero', options.height);
} else {
this.options.claheHeight = options.height;
}
if (!is.defined(options.maxSlope)) {
this.options.claheMaxSlope = 3;
} else if (!is.integer(options.maxSlope) || options.maxSlope < 0 || options.maxSlope > 100) {
throw is.invalidParameterError('maxSlope', 'integer 0-100', options.maxSlope);
} else {
this.options.claheMaxSlope = options.maxSlope;
}
return this;
}
/** /**
* Convolve the image with the specified kernel. * Convolve the image with the specified kernel.
* *
@ -368,7 +408,7 @@ function normalize (normalize) {
* *
* @param {Object} kernel * @param {Object} kernel
* @param {number} kernel.width - width of the kernel in pixels. * @param {number} kernel.width - width of the kernel in pixels.
* @param {number} kernel.height - width of the kernel in pixels. * @param {number} kernel.height - height of the kernel in pixels.
* @param {Array<number>} kernel.kernel - Array of length `width*height` containing the kernel values. * @param {Array<number>} kernel.kernel - Array of length `width*height` containing the kernel values.
* @param {number} [kernel.scale=sum] - the scale of the kernel in pixels. * @param {number} [kernel.scale=sum] - the scale of the kernel in pixels.
* @param {number} [kernel.offset=0] - the offset of the kernel in pixels. * @param {number} [kernel.offset=0] - the offset of the kernel in pixels.
@ -594,6 +634,7 @@ module.exports = function (Sharp) {
negate, negate,
normalise, normalise,
normalize, normalize,
clahe,
convolve, convolve,
threshold, threshold,
boolean, boolean,

View File

@ -76,7 +76,8 @@
"Leon Radley <leon@radley.se>", "Leon Radley <leon@radley.se>",
"alza54 <alza54@thiocod.in>", "alza54 <alza54@thiocod.in>",
"Jacob Smith <jacob@frende.me>", "Jacob Smith <jacob@frende.me>",
"Michael Nutt <michael@nutt.im>" "Michael Nutt <michael@nutt.im>",
"Brad Parham <baparham@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

@ -92,6 +92,13 @@ namespace sharp {
return image; return image;
} }
/*
* Contrast limiting adapative histogram equalization (CLAHE)
*/
VImage Clahe(VImage image, int const width, int const height, int const maxSlope) {
return image.hist_local(width, height, VImage::option()->set("max_slope", maxSlope));
}
/* /*
* Gamma encoding/decoding * Gamma encoding/decoding
*/ */

View File

@ -35,6 +35,11 @@ namespace sharp {
*/ */
VImage Normalise(VImage image); VImage Normalise(VImage image);
/*
* Contrast limiting adapative histogram equalization (CLAHE)
*/
VImage Clahe(VImage image, int const width, int const height, int const maxSlope);
/* /*
* Gamma encoding/decoding * Gamma encoding/decoding
*/ */

View File

@ -345,6 +345,7 @@ class PipelineWorker : public Napi::AsyncWorker {
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; bool const shouldModulate = baton->brightness != 1.0 || baton->saturation != 1.0 || baton->hue != 0.0;
bool const shouldApplyClahe = baton->claheWidth != 0 && baton->claheHeight != 0;
if (shouldComposite && !sharp::HasAlpha(image)) { if (shouldComposite && !sharp::HasAlpha(image)) {
image = sharp::EnsureAlpha(image, 1); image = sharp::EnsureAlpha(image, 1);
@ -650,6 +651,11 @@ class PipelineWorker : public Napi::AsyncWorker {
image = sharp::Normalise(image); image = sharp::Normalise(image);
} }
// Apply contrast limiting adaptive histogram equalization (CLAHE)
if (shouldApplyClahe) {
image = sharp::Clahe(image, baton->claheWidth, baton->claheHeight, baton->claheMaxSlope);
}
// Apply bitwise boolean operation between images // Apply bitwise boolean operation between images
if (baton->boolean != nullptr) { if (baton->boolean != nullptr) {
VImage booleanImage; VImage booleanImage;
@ -1330,6 +1336,9 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
baton->linearB = sharp::AttrAsDouble(options, "linearB"); baton->linearB = sharp::AttrAsDouble(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->claheWidth = sharp::AttrAsUint32(options, "claheWidth");
baton->claheHeight = sharp::AttrAsUint32(options, "claheHeight");
baton->claheMaxSlope = sharp::AttrAsUint32(options, "claheMaxSlope");
baton->useExifOrientation = sharp::AttrAsBool(options, "useExifOrientation"); baton->useExifOrientation = sharp::AttrAsBool(options, "useExifOrientation");
baton->angle = sharp::AttrAsInt32(options, "angle"); baton->angle = sharp::AttrAsInt32(options, "angle");
baton->rotationAngle = sharp::AttrAsDouble(options, "rotationAngle"); baton->rotationAngle = sharp::AttrAsDouble(options, "rotationAngle");

View File

@ -109,6 +109,9 @@ struct PipelineBaton {
double gammaOut; double gammaOut;
bool greyscale; bool greyscale;
bool normalise; bool normalise;
int claheWidth;
int claheHeight;
int claheMaxSlope;
bool useExifOrientation; bool useExifOrientation;
int angle; int angle;
double rotationAngle; double rotationAngle;
@ -234,6 +237,9 @@ struct PipelineBaton {
gamma(0.0), gamma(0.0),
greyscale(false), greyscale(false),
normalise(false), normalise(false),
claheWidth(0),
claheHeight(0),
claheMaxSlope(3),
useExifOrientation(false), useExifOrientation(false),
angle(0), angle(0),
rotationAngle(0.0), rotationAngle(0.0),

BIN
test/fixtures/concert.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
test/fixtures/expected/clahe-5-5-0.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
test/fixtures/expected/clahe-5-5-5.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
test/fixtures/expected/clahe-50-50-0.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -121,6 +121,8 @@ module.exports = {
inputV: getPath('vfile.v'), inputV: getPath('vfile.v'),
inputJpgClahe: getPath('concert.jpg'), // public domain - https://www.flickr.com/photos/mars_/14389236779/
testPattern: getPath('test-pattern.png'), testPattern: getPath('test-pattern.png'),
// Path for tests requiring human inspection // Path for tests requiring human inspection

139
test/unit/clahe.js Normal file
View File

@ -0,0 +1,139 @@
'use strict';
const assert = require('assert');
const sharp = require('../../lib');
const fixtures = require('../fixtures');
describe('Clahe', function () {
it('width 5 width 5 maxSlope 0', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 5, height: 5, maxSlope: 0 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-5-5-0.jpg'), data, { threshold: 10 }, done);
});
});
it('width 5 width 5 maxSlope 5', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 5, height: 5, maxSlope: 5 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-5-5-5.jpg'), data, done);
});
});
it('width 11 width 25 maxSlope 14', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 11, height: 25, maxSlope: 14 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-11-25-14.jpg'), data, done);
});
});
it('width 50 width 50 maxSlope 0', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 50, height: 50, maxSlope: 0 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-50-50-0.jpg'), data, done);
});
});
it('width 50 width 50 maxSlope 14', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 50, height: 50, maxSlope: 14 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-50-50-14.jpg'), data, done);
});
});
it('width 100 width 50 maxSlope 3', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 100, height: 50, maxSlope: 3 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-100-50-3.jpg'), data, done);
});
});
it('width 100 width 100 maxSlope 0', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 100, height: 100, maxSlope: 0 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-100-100-0.jpg'), data, done);
});
});
it('invalid maxSlope', function () {
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 100, maxSlope: -5 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 100, maxSlope: 110 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 100, maxSlope: 5.5 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 100, maxSlope: 'a string' });
});
});
it('invalid width', function () {
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100.5, height: 100 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: -5, height: 100 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: true, height: 100 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 'string test', height: 100 });
});
});
it('invalid height', function () {
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 100.5 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: -5 });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: true });
});
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe({ width: 100, height: 'string test' });
});
});
it('invalid options object', function () {
assert.throws(function () {
sharp(fixtures.inputJpgClahe).clahe(100, 100, 5);
});
});
it('uses default maxSlope of 3', function (done) {
sharp(fixtures.inputJpgClahe)
.clahe({ width: 100, height: 50 })
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
fixtures.assertSimilar(fixtures.expected('clahe-100-50-3.jpg'), data, done);
});
});
});