diff --git a/docs/api-input.md b/docs/api-input.md index 2ab3416f..5fa3f7ac 100644 --- a/docs/api-input.md +++ b/docs/api-input.md @@ -71,6 +71,7 @@ A `Promise` is returned when `callback` is not provided. - `maxY` (y-coordinate of one of the pixel where the maximum lies) - `isOpaque`: Is the image fully opaque? Will be `true` if the image has no alpha channel or if every pixel is fully opaque. - `entropy`: Histogram-based estimation of greyscale entropy, discarding alpha channel if any (experimental) +- `sharpness`: Estimation of greyscale sharpness based on the standard deviation of a Laplacian convolution, discarding alpha channel if any (experimental) ### Parameters @@ -87,6 +88,10 @@ image }); ``` +```javascript +const { entropy, sharpness } = await sharp(input).stats(); +``` + Returns **[Promise][5]<[Object][6]>** [1]: https://libvips.github.io/libvips/API/current/VipsImage.html#VipsInterpretation diff --git a/docs/changelog.md b/docs/changelog.md index aa393495..e0709faa 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,6 +21,9 @@ Requires libvips v8.9.1 * Add support for named `alpha` channel to `extractChannel` operation. [#2138](https://github.com/lovell/sharp/issues/2138) +* Add experimental `sharpness` calculation to `stats()` response. + [#2251](https://github.com/lovell/sharp/issues/2251) + ### v0.25.3 - 17th May 2020 * Ensure libvips is initialised only once, improves worker thread safety. diff --git a/lib/input.js b/lib/input.js index cbad9e1a..2a4bc105 100644 --- a/lib/input.js +++ b/lib/input.js @@ -299,6 +299,7 @@ function metadata (callback) { * - `maxY` (y-coordinate of one of the pixel where the maximum lies) * - `isOpaque`: Is the image fully opaque? Will be `true` if the image has no alpha channel or if every pixel is fully opaque. * - `entropy`: Histogram-based estimation of greyscale entropy, discarding alpha channel if any (experimental) + * - `sharpness`: Estimation of greyscale sharpness based on the standard deviation of a Laplacian convolution, discarding alpha channel if any (experimental) * * @example * const image = sharp(inputJpg); @@ -308,6 +309,9 @@ function metadata (callback) { * // stats contains the channel-wise statistics array and the isOpaque value * }); * + * @example + * const { entropy, sharpness } = await sharp(input).stats(); + * * @param {Function} [callback] - called with the arguments `(err, stats)` * @returns {Promise} */ diff --git a/src/stats.cc b/src/stats.cc index 45fd9a59..989e5620 100644 --- a/src/stats.cc +++ b/src/stats.cc @@ -75,8 +75,17 @@ class StatsWorker : public Napi::AsyncWorker { baton->isOpaque = false; } } + // Convert to greyscale + vips::VImage greyscale = image.colourspace(VIPS_INTERPRETATION_B_W)[0]; // Estimate entropy via histogram of greyscale value frequency - baton->entropy = std::abs(image.colourspace(VIPS_INTERPRETATION_B_W)[0].hist_find().hist_entropy()); + baton->entropy = std::abs(greyscale.hist_find().hist_entropy()); + // Estimate sharpness via standard deviation of greyscale laplacian + VImage laplacian = VImage::new_matrixv(3, 3, + 0.0, 1.0, 0.0, + 1.0, -4.0, 1.0, + 0.0, 1.0, 0.0); + laplacian.set("scale", 9.0); + baton->sharpness = greyscale.conv(laplacian).deviate(); } catch (vips::VError const &err) { (baton->err).append(err.what()); } @@ -123,6 +132,7 @@ class StatsWorker : public Napi::AsyncWorker { info.Set("channels", channels); info.Set("isOpaque", baton->isOpaque); info.Set("entropy", baton->entropy); + info.Set("sharpness", baton->sharpness); Callback().MakeCallback(Receiver().Value(), { env.Null(), info }); } else { Callback().MakeCallback(Receiver().Value(), { Napi::Error::New(env, baton->err).Value() }); diff --git a/src/stats.h b/src/stats.h index d2ec2e2c..eb1f1658 100644 --- a/src/stats.h +++ b/src/stats.h @@ -47,13 +47,15 @@ struct StatsBaton { std::vector channelStats; bool isOpaque; double entropy; + double sharpness; std::string err; StatsBaton(): input(nullptr), isOpaque(true), - entropy(0.0) + entropy(0.0), + sharpness(0.0) {} }; diff --git a/test/unit/stats.js b/test/unit/stats.js index bb840835..8db5abdd 100644 --- a/test/unit/stats.js +++ b/test/unit/stats.js @@ -25,6 +25,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 7.319914765248541)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 0.7883011147075762)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -84,6 +85,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 0.3409031108021736)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 9.111356137722868)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -110,6 +112,7 @@ describe('Image Stats', function () { assert.strictEqual(false, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 0.06778064835816622)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 2.522916068931278)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -185,6 +188,7 @@ describe('Image Stats', function () { assert.strictEqual(false, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 0)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 0)); // alpha channel assert.strictEqual(0, stats.channels[3].min); @@ -212,6 +216,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 0.3851250782608986)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 10.312521863719589)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -239,6 +244,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 7.51758075132966)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 9.959951636662941)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -298,6 +304,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 6.087309412541799)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 2.9250574456255682)); // red channel assert.strictEqual(35, stats.channels[0].min); @@ -357,6 +364,7 @@ describe('Image Stats', function () { assert.strictEqual(false, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 1)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 15.870619016486861)); // gray channel assert.strictEqual(0, stats.channels[0].min); @@ -410,6 +418,7 @@ describe('Image Stats', function () { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 7.319914765248541)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 0.788301114707569)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -472,6 +481,7 @@ describe('Image Stats', function () { return pipeline.stats().then(function (stats) { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 7.319914765248541)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 0.788301114707569)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -529,6 +539,7 @@ describe('Image Stats', function () { return sharp(fixtures.inputJpg).stats().then(function (stats) { assert.strictEqual(true, stats.isOpaque); assert.strictEqual(true, isInAcceptableRange(stats.entropy, 7.319914765248541)); + assert.strictEqual(true, isInAcceptableRange(stats.sharpness, 0.788301114707569)); // red channel assert.strictEqual(0, stats.channels[0].min); @@ -582,6 +593,18 @@ describe('Image Stats', function () { }); }); + it('Blurred image has lower sharpness than original', () => { + const original = sharp(fixtures.inputJpg).stats(); + const blurred = sharp(fixtures.inputJpg).blur().toBuffer().then(blur => sharp(blur).stats()); + + return Promise + .all([original, blurred]) + .then(([original, blurred]) => { + assert.strictEqual(true, isInAcceptableRange(original.sharpness, 0.7883011147075476)); + assert.strictEqual(true, isInAcceptableRange(blurred.sharpness, 0.4791559805997398)); + }); + }); + it('File input with corrupt header fails gracefully', function (done) { sharp(fixtures.inputJpgWithCorruptHeader) .stats(function (err) {