Add contrast limiting adaptive histogram equalization (CLAHE) operator (#2726)
@ -243,6 +243,30 @@ Alternative spelling of normalise.
|
||||
|
||||
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 the image with the specified kernel.
|
||||
@ -252,7 +276,7 @@ Convolve the image with the specified kernel.
|
||||
* `kernel` **[Object][2]**
|
||||
|
||||
* `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.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`)
|
||||
@ -304,7 +328,7 @@ the selected bitwise boolean `operation` between the corresponding pixels of the
|
||||
|
||||
### 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.
|
||||
* `options` **[Object][2]?**
|
||||
|
||||
@ -421,4 +445,6 @@ Returns **Sharp**
|
||||
|
||||
[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
|
||||
|
@ -216,6 +216,9 @@ const Sharp = function (input, options) {
|
||||
gammaOut: 0,
|
||||
greyscale: false,
|
||||
normalise: false,
|
||||
claheWidth: 0,
|
||||
claheHeight: 0,
|
||||
claheMaxSlope: 3,
|
||||
brightness: 1,
|
||||
saturation: 1,
|
||||
hue: 0,
|
||||
|
@ -350,6 +350,46 @@ function normalize (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.
|
||||
*
|
||||
@ -368,7 +408,7 @@ function normalize (normalize) {
|
||||
*
|
||||
* @param {Object} kernel
|
||||
* @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 {number} [kernel.scale=sum] - the scale 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,
|
||||
normalise,
|
||||
normalize,
|
||||
clahe,
|
||||
convolve,
|
||||
threshold,
|
||||
boolean,
|
||||
|
@ -76,7 +76,8 @@
|
||||
"Leon Radley <leon@radley.se>",
|
||||
"alza54 <alza54@thiocod.in>",
|
||||
"Jacob Smith <jacob@frende.me>",
|
||||
"Michael Nutt <michael@nutt.im>"
|
||||
"Michael Nutt <michael@nutt.im>",
|
||||
"Brad Parham <baparham@gmail.com>"
|
||||
],
|
||||
"scripts": {
|
||||
"install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)",
|
||||
|
@ -92,6 +92,13 @@ namespace sharp {
|
||||
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
|
||||
*/
|
||||
|
@ -35,6 +35,11 @@ namespace sharp {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
@ -345,6 +345,7 @@ class PipelineWorker : public Napi::AsyncWorker {
|
||||
bool const shouldApplyMedian = baton->medianSize > 0;
|
||||
bool const shouldComposite = !baton->composite.empty();
|
||||
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)) {
|
||||
image = sharp::EnsureAlpha(image, 1);
|
||||
@ -650,6 +651,11 @@ class PipelineWorker : public Napi::AsyncWorker {
|
||||
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
|
||||
if (baton->boolean != nullptr) {
|
||||
VImage booleanImage;
|
||||
@ -1330,6 +1336,9 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
|
||||
baton->linearB = sharp::AttrAsDouble(options, "linearB");
|
||||
baton->greyscale = sharp::AttrAsBool(options, "greyscale");
|
||||
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->angle = sharp::AttrAsInt32(options, "angle");
|
||||
baton->rotationAngle = sharp::AttrAsDouble(options, "rotationAngle");
|
||||
|
@ -109,6 +109,9 @@ struct PipelineBaton {
|
||||
double gammaOut;
|
||||
bool greyscale;
|
||||
bool normalise;
|
||||
int claheWidth;
|
||||
int claheHeight;
|
||||
int claheMaxSlope;
|
||||
bool useExifOrientation;
|
||||
int angle;
|
||||
double rotationAngle;
|
||||
@ -234,6 +237,9 @@ struct PipelineBaton {
|
||||
gamma(0.0),
|
||||
greyscale(false),
|
||||
normalise(false),
|
||||
claheWidth(0),
|
||||
claheHeight(0),
|
||||
claheMaxSlope(3),
|
||||
useExifOrientation(false),
|
||||
angle(0),
|
||||
rotationAngle(0.0),
|
||||
|
BIN
test/fixtures/concert.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/clahe-100-100-0.jpg
vendored
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
test/fixtures/expected/clahe-100-50-3.jpg
vendored
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
test/fixtures/expected/clahe-11-25-14.jpg
vendored
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
test/fixtures/expected/clahe-5-5-0.jpg
vendored
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
test/fixtures/expected/clahe-5-5-5.jpg
vendored
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
test/fixtures/expected/clahe-50-50-0.jpg
vendored
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
test/fixtures/expected/clahe-50-50-14.jpg
vendored
Normal file
After Width: | Height: | Size: 21 KiB |
2
test/fixtures/index.js
vendored
@ -121,6 +121,8 @@ module.exports = {
|
||||
|
||||
inputV: getPath('vfile.v'),
|
||||
|
||||
inputJpgClahe: getPath('concert.jpg'), // public domain - https://www.flickr.com/photos/mars_/14389236779/
|
||||
|
||||
testPattern: getPath('test-pattern.png'),
|
||||
|
||||
// Path for tests requiring human inspection
|
||||
|
139
test/unit/clahe.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|