From 2034efcf558bbb0a6936b1804c0422103d43ad69 Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Sat, 5 Mar 2016 12:29:16 +0000 Subject: [PATCH] Add experimental, entropy-based auto-crop Remove deprecated extract API --- docs/api.md | 25 +++++-- docs/changelog.md | 4 ++ index.js | 65 +++++++++--------- src/operations.cc | 78 +++++++++++++++++++++ src/operations.h | 10 +++ src/pipeline.cc | 15 +++-- src/pipeline.h | 4 +- test/fixtures/expected/crop-entropy.jpg | Bin 0 -> 8521 bytes test/fixtures/expected/crop-entropy.png | Bin 0 -> 6148 bytes test/unit/crop.js | 86 +++++++++++++++--------- test/unit/extract.js | 23 ------- 11 files changed, 214 insertions(+), 96 deletions(-) create mode 100644 test/fixtures/expected/crop-entropy.jpg create mode 100644 test/fixtures/expected/crop-entropy.png 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 0000000000000000000000000000000000000000..c1c08061cbacca3db8640b27eea743df89580811 GIT binary patch literal 8521 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 pX)