diff --git a/docs/api.md b/docs/api.md index cd186926..fd0cab2f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -125,23 +125,34 @@ Scale output to `width` x `height`. By default, the resized image is cropped to `height` is the integral Number of pixels high the resultant image should be, between 1 and 16383. Use `null` or `undefined` to auto-scale the height to match the width. -#### crop([gravity]) +#### crop([option]) Crop the resized image to the exact size specified, the default behaviour. -`gravity`, if present, is a String or an attribute of the `sharp.gravity` Object e.g. `sharp.gravity.north`. +`option`, if present, is an attribute of: -Possible values are `north`, `northeast`, `east`, `southeast`, `south`, `southwest`, `west`, `northwest`, `center` and `centre`. -The default gravity is `center`/`centre`. +* `sharp.gravity` e.g. `sharp.gravity.north`, to crop to an edge or corner, or +* `sharp.strategy` e.g. `sharp.strategy.entropy`, to crop dynamically. + +Possible attributes of `sharp.gravity` are +`north`, `northeast`, `east`, `southeast`, `south`, +`southwest`, `west`, `northwest`, `center` and `centre`. + +`sharp.strategy` currently contains only the experimental `entropy`, +which will retain the part of the image with the highest +[Shannon entropy](https://en.wikipedia.org/wiki/Entropy_%28information_theory%29) value. + +The default option is a `center`/`centre` gravity. ```javascript var transformer = sharp() - .resize(300, 200) - .crop(sharp.gravity.north) + .resize(200, 200) + .crop(sharp.strategy.entropy) .on('error', function(err) { console.log(err); }); -// Read image data from readableStream, resize and write image data to writableStream +// Read image data from readableStream +// Write 200px square auto-cropped image data to writableStream readableStream.pipe(transformer).pipe(writableStream); ``` diff --git a/docs/changelog.md b/docs/changelog.md index 4bac9062..866114d4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -14,6 +14,10 @@ [#239](https://github.com/lovell/sharp/issues/239) [@chrisriley](https://github.com/chrisriley) +* Add entropy-based strategy to determine crop region. + [#295](https://github.com/lovell/sharp/issues/295) + [@rightaway](https://github.com/rightaway) + * Expose density metadata; set density of images from vector input. [#338](https://github.com/lovell/sharp/issues/338) [@lookfirst](https://github.com/lookfirst) diff --git a/index.js b/index.js index 65ad0c09..dbb70a48 100644 --- a/index.js +++ b/index.js @@ -64,7 +64,7 @@ var Sharp = function(input, options) { width: -1, height: -1, canvas: 'crop', - gravity: 0, + crop: 0, angle: 0, rotateBeforePreExtract: false, flip: false, @@ -231,48 +231,53 @@ Sharp.prototype._write = function(chunk, encoding, callback) { } }; -// Crop this part of the resized image (Center/Centre, North, East, South, West) +// Weighting to apply to image crop module.exports.gravity = { - 'center': 0, - 'centre': 0, - 'north': 1, - 'east': 2, - 'south': 3, - 'west': 4, - 'northeast': 5, - 'southeast': 6, - 'southwest': 7, - 'northwest': 8 + center: 0, + centre: 0, + north: 1, + east: 2, + south: 3, + west: 4, + northeast: 5, + southeast: 6, + southwest: 7, + northwest: 8 }; -Sharp.prototype.crop = function(gravity) { +// Strategies for automagic behaviour +module.exports.strategy = { + entropy: 16 +}; + +/* + What part of the image should be retained when cropping? +*/ +Sharp.prototype.crop = function(crop) { this.options.canvas = 'crop'; - if (!isDefined(gravity)) { - this.options.gravity = module.exports.gravity.center; - } else if (isInteger(gravity) && inRange(gravity, 0, 8)) { - this.options.gravity = gravity; - } else if (isString(gravity) && isInteger(module.exports.gravity[gravity])) { - this.options.gravity = module.exports.gravity[gravity]; + if (!isDefined(crop)) { + // Default + this.options.crop = module.exports.gravity.center; + } else if (isInteger(crop) && inRange(crop, 0, 8)) { + // Gravity (numeric) + this.options.crop = crop; + } else if (isString(crop) && isInteger(module.exports.gravity[crop])) { + // Gravity (string) + this.options.crop = module.exports.gravity[crop]; + } else if (isInteger(crop) && crop === module.exports.strategy.entropy) { + // Strategy + this.options.crop = crop; } else { - throw new Error('Unsupported crop gravity ' + gravity); + throw new Error('Unsupported crop ' + crop); } return this; }; Sharp.prototype.extract = function(options) { - if (!options || typeof options !== 'object') { - // Legacy extract(top,left,width,height) syntax - options = { - left: arguments[1], - top: arguments[0], - width: arguments[2], - height: arguments[3] - }; - } var suffix = this.options.width === -1 && this.options.height === -1 ? 'Pre' : 'Post'; ['left', 'top', 'width', 'height'].forEach(function (name) { var value = options[name]; - if (typeof value === 'number' && !Number.isNaN(value) && value % 1 === 0 && value >= 0) { + if (isInteger(value) && value >= 0) { this.options[name + (name === 'left' || name === 'top' ? 'Offset' : '') + suffix] = value; } else { throw new Error('Non-integer value for ' + name + ' of ' + value); diff --git a/src/operations.cc b/src/operations.cc index be3c97a9..ad9f8406 100644 --- a/src/operations.cc +++ b/src/operations.cc @@ -170,4 +170,82 @@ namespace sharp { } } + /* + Calculate crop area based on image entropy + */ + std::tuple EntropyCrop(VImage image, int const outWidth, int const outHeight) { + int left = 0; + int top = 0; + int const inWidth = image.width(); + int const inHeight = image.height(); + if (inWidth > outWidth) { + // Reduce width by repeated removing slices from edge with lowest entropy + int width = inWidth; + double leftEntropy = 0.0; + double rightEntropy = 0.0; + // Max width of each slice + int const maxSliceWidth = static_cast(ceil((inWidth - outWidth) / 8.0)); + while (width > outWidth) { + // Width of current slice + int const slice = std::min(width - outWidth, maxSliceWidth); + if (leftEntropy == 0.0) { + // Update entropy of left slice + leftEntropy = Entropy(image.extract_area(left, 0, slice, inHeight)); + } + if (rightEntropy == 0.0) { + // Update entropy of right slice + rightEntropy = Entropy(image.extract_area(width - slice - 1, 0, slice, inHeight)); + } + // Keep slice with highest entropy + if (leftEntropy >= rightEntropy) { + // Discard right slice + rightEntropy = 0.0; + } else { + // Discard left slice + leftEntropy = 0.0; + left = left + slice; + } + width = width - slice; + } + } + if (inHeight > outHeight) { + // Reduce height by repeated removing slices from edge with lowest entropy + int height = inHeight; + double topEntropy = 0.0; + double bottomEntropy = 0.0; + // Max height of each slice + int const maxSliceHeight = static_cast(ceil((inHeight - outHeight) / 8.0)); + while (height > outHeight) { + // Height of current slice + int const slice = std::min(height - outHeight, maxSliceHeight); + if (topEntropy == 0.0) { + // Update entropy of top slice + topEntropy = Entropy(image.extract_area(0, top, inWidth, slice)); + } + if (bottomEntropy == 0.0) { + // Update entropy of bottom slice + bottomEntropy = Entropy(image.extract_area(0, height - slice - 1, inWidth, slice)); + } + // Keep slice with highest entropy + if (topEntropy >= bottomEntropy) { + // Discard bottom slice + bottomEntropy = 0.0; + } else { + // Discard top slice + topEntropy = 0.0; + top = top + slice; + } + height = height - slice; + } + } + return std::make_tuple(left, top); + } + + /* + Calculate the Shannon entropy for an image + */ + double Entropy(VImage image) { + return image.hist_find().hist_entropy(); + } + } // namespace sharp diff --git a/src/operations.h b/src/operations.h index 059d8188..5ece5743 100644 --- a/src/operations.h +++ b/src/operations.h @@ -33,6 +33,16 @@ namespace sharp { */ VImage Sharpen(VImage image, int const radius, double const flat, double const jagged); + /* + Calculate crop area based on image entropy + */ + std::tuple EntropyCrop(VImage image, int const outWidth, int const outHeight); + + /* + Calculate the Shannon entropy for an image + */ + double Entropy(VImage image); + } // namespace sharp #endif // SRC_OPERATIONS_H_ diff --git a/src/pipeline.cc b/src/pipeline.cc index a4f3e91d..41a992c0 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -49,6 +49,7 @@ using sharp::Normalize; using sharp::Gamma; using sharp::Blur; using sharp::Sharpen; +using sharp::EntropyCrop; using sharp::ImageType; using sharp::ImageTypeId; @@ -506,9 +507,15 @@ class PipelineWorker : public AsyncWorker { // Crop/max/min int left; int top; - std::tie(left, top) = CalculateCrop( - image.width(), image.height(), baton->width, baton->height, baton->gravity - ); + if (baton->crop < 9) { + // Gravity-based crop + std::tie(left, top) = CalculateCrop( + image.width(), image.height(), baton->width, baton->height, baton->crop + ); + } else { + // Entropy-based crop + std::tie(left, top) = EntropyCrop(image, baton->width, baton->height); + } int width = std::min(image.width(), baton->width); int height = std::min(image.height(), baton->height); image = image.extract_area(left, top, width, height); @@ -996,7 +1003,7 @@ NAN_METHOD(pipeline) { baton->overlayGravity = attrAs(options, "overlayGravity"); // Resize options baton->withoutEnlargement = attrAs(options, "withoutEnlargement"); - baton->gravity = attrAs(options, "gravity"); + baton->crop = attrAs(options, "crop"); baton->interpolator = attrAsStr(options, "interpolator"); // Operators baton->flatten = attrAs(options, "flatten"); diff --git a/src/pipeline.h b/src/pipeline.h index 4f473bfe..eba987e3 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -45,7 +45,7 @@ struct PipelineBaton { int height; int channels; Canvas canvas; - int gravity; + int crop; std::string interpolator; double background[4]; bool flatten; @@ -99,7 +99,7 @@ struct PipelineBaton { topOffsetPost(-1), channels(0), canvas(Canvas::CROP), - gravity(0), + crop(0), flatten(false), negate(false), blurSigma(0.0), diff --git a/test/fixtures/expected/crop-entropy.jpg b/test/fixtures/expected/crop-entropy.jpg new file mode 100644 index 00000000..c1c08061 Binary files /dev/null and b/test/fixtures/expected/crop-entropy.jpg differ diff --git a/test/fixtures/expected/crop-entropy.png b/test/fixtures/expected/crop-entropy.png new file mode 100644 index 00000000..1c8a3780 Binary files /dev/null and b/test/fixtures/expected/crop-entropy.png differ diff --git a/test/unit/crop.js b/test/unit/crop.js index a3228166..5463ea37 100644 --- a/test/unit/crop.js +++ b/test/unit/crop.js @@ -5,9 +5,9 @@ var assert = require('assert'); var sharp = require('../../index'); var fixtures = require('../fixtures'); -describe('Crop gravities', function() { +describe('Crop', function() { - var testSettings = [ + [ { name: 'North', width: 320, @@ -50,6 +50,13 @@ describe('Crop gravities', function() { gravity: sharp.gravity.centre, fixture: 'gravity-centre.jpg' }, + { + name: 'Default (centre)', + width: 80, + height: 320, + gravity: undefined, + fixture: 'gravity-centre.jpg' + }, { name: 'Northeast', width: 320, @@ -106,10 +113,8 @@ describe('Crop gravities', function() { gravity: sharp.gravity.northwest, fixture: 'gravity-west.jpg' } - ]; - - testSettings.forEach(function(settings) { - it(settings.name, function(done) { + ].forEach(function(settings) { + it(settings.name + ' gravity', function(done) { sharp(fixtures.inputJpg) .resize(settings.width, settings.height) .crop(settings.gravity) @@ -122,7 +127,7 @@ describe('Crop gravities', function() { }); }); - it('allows specifying the gravity as a string', function(done) { + it('Allows specifying the gravity as a string', function(done) { sharp(fixtures.inputJpg) .resize(80, 320) .crop('east') @@ -134,36 +139,57 @@ describe('Crop gravities', function() { }); }); - it('Invalid number', function() { + it('Invalid values fail', function() { assert.throws(function() { - sharp(fixtures.inputJpg).crop(9); + sharp().crop(9); + }); + assert.throws(function() { + sharp().crop(1.1); + }); + assert.throws(function() { + sharp().crop(-1); + }); + assert.throws(function() { + sharp().crop('zoinks'); }); }); - it('Invalid string', function() { - assert.throws(function() { - sharp(fixtures.inputJpg).crop('yadda'); - }); - }); - - it('does not throw if crop gravity is undefined', function() { + it('Uses default value when none specified', function() { assert.doesNotThrow(function() { - sharp(fixtures.inputJpg).crop(); + sharp().crop(); }); }); - it('defaults crop gravity to sharp.gravity.center', function(done) { - var centerGravitySettings = testSettings.filter(function (settings) { - return settings.name === 'Center'; - })[0]; - sharp(fixtures.inputJpg) - .resize(centerGravitySettings.width, centerGravitySettings.height) - .crop() - .toBuffer(function(err, data, info) { - if (err) throw err; - assert.strictEqual(centerGravitySettings.width, info.width); - assert.strictEqual(centerGravitySettings.height, info.height); - fixtures.assertSimilar(fixtures.expected(centerGravitySettings.fixture), data, done); - }); + describe('Entropy-based strategy', function() { + + it('JPEG', function(done) { + sharp(fixtures.inputJpgWithCmykProfile) + .resize(80, 320) + .crop(sharp.strategy.entropy) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('jpeg', info.format); + assert.strictEqual(3, info.channels); + assert.strictEqual(80, info.width); + assert.strictEqual(320, info.height); + fixtures.assertSimilar(fixtures.expected('crop-entropy.jpg'), data, done); + }); + }); + + it('PNG', function(done) { + sharp(fixtures.inputPngWithTransparency) + .resize(320, 80) + .crop(sharp.strategy.entropy) + .toBuffer(function(err, data, info) { + if (err) throw err; + assert.strictEqual('png', info.format); + assert.strictEqual(4, info.channels); + assert.strictEqual(320, info.width); + assert.strictEqual(80, info.height); + fixtures.assertSimilar(fixtures.expected('crop-entropy.png'), data, done); + }); + }); + }); + }); diff --git a/test/unit/extract.js b/test/unit/extract.js index 2191c618..f49c8e92 100644 --- a/test/unit/extract.js +++ b/test/unit/extract.js @@ -6,29 +6,6 @@ var sharp = require('../../index'); var fixtures = require('../fixtures'); describe('Partial image extraction', function() { - describe('using the legacy extract(top,left,width,height) syntax', function () { - it('JPEG', function(done) { - sharp(fixtures.inputJpg) - .extract(2, 2, 20, 20) - .toBuffer(function(err, data, info) { - if (err) throw err; - assert.strictEqual(20, info.width); - assert.strictEqual(20, info.height); - fixtures.assertSimilar(fixtures.expected('extract.jpg'), data, done); - }); - }); - - it('PNG', function(done) { - sharp(fixtures.inputPng) - .extract(300, 200, 400, 200) - .toBuffer(function(err, data, info) { - if (err) throw err; - assert.strictEqual(400, info.width); - assert.strictEqual(200, info.height); - fixtures.assertSimilar(fixtures.expected('extract.png'), data, done); - }); - }); - }); it('JPEG', function(done) { sharp(fixtures.inputJpg)