diff --git a/README.md b/README.md index 6c64cc2c..7cd9f012 100755 --- a/README.md +++ b/README.md @@ -486,6 +486,10 @@ This is a linear operation. If the input image is in a non-linear colour space s The output image will still be web-friendly sRGB and contain three (identical) channels. +#### normalize() / normalise() + +Stretch histogram to cover full dynamic range before output to enhance contrast. + ### Output options #### jpeg() diff --git a/index.js b/index.js index 04470678..c4fc9fdf 100755 --- a/index.js +++ b/index.js @@ -59,6 +59,7 @@ var Sharp = function(input) { sharpenJagged: 2, gamma: 0, greyscale: false, + normalize: 0, // output options output: '__input', progressive: false, @@ -333,6 +334,15 @@ Sharp.prototype.gamma = function(gamma) { return this; }; +/* + Normalize histogram +*/ +Sharp.prototype.normalize = function(normalize) { + this.options.normalize = (typeof normalize === 'boolean') ? normalize : true; + return this; +}; +Sharp.prototype.normalise = Sharp.prototype.normalize; + /* Convert to greyscale */ diff --git a/package.json b/package.json index 5d148d54..3cf06845 100755 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "Maurus Cuelenaere ", "Linus Unnebäck ", "Victor Mateevitsi ", - "Alaric Holloway " + "Alaric Holloway ", + "Bernhard K. Weisshuhn " ], "description": "High performance Node.js module to resize JPEG, PNG, WebP and TIFF images using the libvips library", "scripts": { diff --git a/src/resize.cc b/src/resize.cc index 3a0924d3..afad0564 100755 --- a/src/resize.cc +++ b/src/resize.cc @@ -83,6 +83,7 @@ struct ResizeBaton { double sharpenJagged; double gamma; bool greyscale; + bool normalize; int angle; bool rotateBeforePreExtract; bool flip; @@ -115,6 +116,7 @@ struct ResizeBaton { sharpenJagged(2.0), gamma(0.0), greyscale(false), + normalize(false), angle(0), flip(false), flop(false), @@ -694,6 +696,86 @@ class ResizeWorker : public NanAsyncWorker { image = gammaDecoded; } + // Apply normalization + if (baton->normalize) { + VipsInterpretation typeBeforeNormalize = image->Type; + if (typeBeforeNormalize == VIPS_INTERPRETATION_RGB) { + typeBeforeNormalize = VIPS_INTERPRETATION_sRGB; + } + + // normalize the luminance band in LAB space: + VipsImage *lab; + if (vips_colourspace(image, &lab, VIPS_INTERPRETATION_LAB, NULL)) { + return Error(); + } + vips_object_local(hook, lab); + + VipsImage *luminance; + if (vips_extract_band(lab, &luminance, 0, "n", 1, NULL)) { + return Error(); + } + vips_object_local(hook, luminance); + + VipsImage *chroma; + if (vips_extract_band(lab, &chroma, 1, "n", 2, NULL)) { + return Error(); + } + vips_object_local(hook, chroma); + + VipsImage *stats; + if (vips_stats(luminance, &stats, NULL)) { + return Error(); + } + vips_object_local(hook, stats); + double min = *VIPS_MATRIX(stats, 0, 0); + double max = *VIPS_MATRIX(stats, 1, 0); + + VipsImage *normalized; + if (min == max) { + // Range of zero: create black image + if (vips_black(&normalized, image->Xsize, image->Ysize, "bands", 1, NULL )) { + return Error(); + } + vips_object_local(hook, normalized); + } else { + double f = 100.0 / (max - min); + double a = -(min * f); + + VipsImage *luminance100; + if (vips_linear1(luminance, &luminance100, f, a, NULL)) { + return Error(); + } + vips_object_local(hook, luminance100); + + VipsImage *normalizedLab; + if (vips_bandjoin2(luminance100, chroma, &normalizedLab, NULL)) { + return Error(); + } + vips_object_local(hook, normalizedLab); + if (vips_colourspace(normalizedLab, &normalized, typeBeforeNormalize, NULL)) { + return Error(); + } + vips_object_local(hook, normalized); + } + + if (HasAlpha(image)) { + VipsImage *alpha; + if (vips_extract_band(image, &alpha, image->Bands - 1, "n", 1, NULL)) { + return Error(); + } + vips_object_local(hook, alpha); + + VipsImage *normalizedAlpha; + if (vips_bandjoin2(normalized, alpha, &normalizedAlpha, NULL)) { + return Error(); + } + vips_object_local(hook, normalizedAlpha); + image = normalizedAlpha; + } else { + image = normalized; + } + } + // Convert image to sRGB, if not already if (image->Type != VIPS_INTERPRETATION_sRGB) { // Switch intrepretation to sRGB @@ -762,10 +844,10 @@ class ResizeWorker : public NanAsyncWorker { #if (VIPS_MAJOR_VERSION >= 8 || (VIPS_MAJOR_VERSION >= 7 && VIPS_MINOR_VERSION >= 42)) } else if (baton->output == "__raw") { // Write raw, uncompressed image data to buffer - if (baton->greyscale) { + if (baton->greyscale || image->Type == VIPS_INTERPRETATION_B_W) { // Extract first band for greyscale image VipsImage *grey; - if (vips_extract_band(image, &grey, 1, NULL)) { + if (vips_extract_band(image, &grey, 0, NULL)) { return Error(); } vips_object_local(hook, grey); @@ -1082,6 +1164,7 @@ NAN_METHOD(resize) { baton->sharpenJagged = options->Get(NanNew("sharpenJagged"))->NumberValue(); baton->gamma = options->Get(NanNew("gamma"))->NumberValue(); baton->greyscale = options->Get(NanNew("greyscale"))->BooleanValue(); + baton->normalize = options->Get(NanNew("normalize"))->BooleanValue(); baton->angle = options->Get(NanNew("angle"))->Int32Value(); baton->rotateBeforePreExtract = options->Get(NanNew("rotateBeforePreExtract"))->BooleanValue(); baton->flip = options->Get(NanNew("flip"))->BooleanValue(); diff --git a/test/fixtures/2x2_fdcce6.png b/test/fixtures/2x2_fdcce6.png new file mode 100644 index 00000000..fc82a882 Binary files /dev/null and b/test/fixtures/2x2_fdcce6.png differ diff --git a/test/fixtures/grey-8bit-alpha.png b/test/fixtures/grey-8bit-alpha.png new file mode 100644 index 00000000..9c062030 Binary files /dev/null and b/test/fixtures/grey-8bit-alpha.png differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index a0daf0c3..6a47a5c6 100755 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -15,9 +15,12 @@ module.exports = { inputJpgWithCmykProfile: getPath('Channel_digital_image_CMYK_color.jpg'), // http://en.wikipedia.org/wiki/File:Channel_digital_image_CMYK_color.jpg inputJpgWithCmykNoProfile: getPath('Channel_digital_image_CMYK_color_no_profile.jpg'), inputJpgWithCorruptHeader: getPath('corrupt-header.jpg'), + inputJpgWithLowContrast: getPath('low-contrast.jpg'), // http://www.flickr.com/photos/grizdave/2569067123/ inputPng: getPath('50020484-00001.png'), // http://c.searspartsdirect.com/lis_png/PLDM/50020484-00001.png inputPngWithTransparency: getPath('blackbug.png'), // public domain + inputPngWithGreyAlpha: getPath('grey-8bit-alpha.png'), + inputPngWithOneColor: getPath('2x2_fdcce6.png'), inputWebP: getPath('4.webp'), // http://www.gstatic.com/webp/gallery/4.webp inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm diff --git a/test/fixtures/low-contrast.jpg b/test/fixtures/low-contrast.jpg new file mode 100644 index 00000000..c519d10e Binary files /dev/null and b/test/fixtures/low-contrast.jpg differ diff --git a/test/unit/normalize.js b/test/unit/normalize.js new file mode 100755 index 00000000..4d96781e --- /dev/null +++ b/test/unit/normalize.js @@ -0,0 +1,136 @@ +'use strict'; + +var assert = require('assert'); + +var sharp = require('../../index'); +var fixtures = require('../fixtures'); + +sharp.cache(0); + +describe('Normalization', function () { + + it('uses the same prototype for both spellings', function () { + assert.strictEqual(sharp.prototype.normalize, sharp.prototype.normalise); + }); + + it('spreads rgb image values between 0 and 255', function(done) { + sharp(fixtures.inputJpgWithLowContrast) + .normalize() + .raw() + .toBuffer(function (err, data, info) { + if (err) throw err; + var min = 255, max = 0, i; + for (i = 0; i < data.length; i += 3) { + min = Math.min(min, data[i], data[i + 1], data[i + 2]); + max = Math.max(max, data[i], data[i + 1], data[i + 2]); + } + assert.strictEqual(0, min); + assert.strictEqual(255, max); + return done(); + }); + }); + + it('spreads grayscaled image values between 0 and 255', function(done) { + sharp(fixtures.inputJpgWithLowContrast) + .gamma() + .greyscale() + .normalize() + .raw() + .toBuffer(function (err, data, info) { + if (err) throw err; + var min = 255, max = 0, i; + for (i = 0; i < data.length; i++) { + min = Math.min(min, data[i]); + max = Math.max(max, data[i]); + } + assert.strictEqual(0, min); + assert.strictEqual(255, max); + return done(); + }); + }); + + it('stretches greyscale images with alpha channel', function (done) { + sharp(fixtures.inputPngWithGreyAlpha) + .normalize() + .raw() + .toBuffer(function (err, data, info) { + // raw toBuffer does not return the alpha channel (yet?) + var min = 255, max = 0, i; + for (i = 0; i < data.length; i++) { + min = Math.min(min, data[i]); + max = Math.max(max, data[i]); + } + assert.strictEqual(0, min); + assert.strictEqual(255, max); + return done(); + }); + }); + + it('keeps an existing alpha channel', function (done) { + sharp(fixtures.inputPngWithTransparency) + .normalize() + .toBuffer(function (err, data, info) { + sharp(data) + .metadata() + .then(function (metadata) { + assert.strictEqual(4, metadata.channels); + assert.strictEqual(true, metadata.hasAlpha); + assert.strictEqual('srgb', metadata.space); + }) + .finally(done); + }); + }); + + it('keeps the alpha channel of greyscale images intact', function (done) { + sharp(fixtures.inputPngWithGreyAlpha) + .normalize() + .toBuffer(function (err, data, info) { + sharp(data) + .metadata() + .then(function (metadata) { + assert.strictEqual(true, metadata.hasAlpha); + // because of complications with greyscale + // we return everything in srgb for now. + // + // assert.strictEqual(2, metadata.channels); + // assert.strictEqual('b-w', metadata.space); + assert.strictEqual(4, metadata.channels); + assert.strictEqual('srgb', metadata.space); + }) + .finally(done); + }); + }); + + it('returns a black image for images with only one color', function (done) { + sharp(fixtures.inputPngWithOneColor) + .normalize() + .toBuffer() + .bind({}) + .then(function (imageData) { + this.imageData = imageData; + return sharp(imageData) + .metadata(); + }) + .then(function (metadata) { + assert.strictEqual(false, metadata.hasAlpha); + // because of complications with greyscale + // we return everything in srgb for now. + // + // assert.strictEqual(1, metadata.channels); + // assert.strictEqual('b-w', metadata.space); + assert.strictEqual(3, metadata.channels); + assert.strictEqual('srgb', metadata.space); + }) + .then(function () { + return sharp(this.imageData) + .raw() + .toBuffer(); + }) + .then(function (rawData) { + // var blackBuffer = new Buffer([0,0,0,0]); + var blackBuffer = new Buffer([0,0,0, 0,0,0, 0,0,0, 0,0,0]); + assert.strictEqual(blackBuffer.toString(), rawData.toString()); + }) + .finally(done); + }); +});