Add experimental, entropy-based auto-crop

Remove deprecated extract API
This commit is contained in:
Lovell Fuller 2016-03-05 12:29:16 +00:00
parent 38ddb3b866
commit 2034efcf55
11 changed files with 214 additions and 96 deletions

View File

@ -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. `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. 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`. * `sharp.gravity` e.g. `sharp.gravity.north`, to crop to an edge or corner, or
The default gravity is `center`/`centre`. * `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 ```javascript
var transformer = sharp() var transformer = sharp()
.resize(300, 200) .resize(200, 200)
.crop(sharp.gravity.north) .crop(sharp.strategy.entropy)
.on('error', function(err) { .on('error', function(err) {
console.log(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); readableStream.pipe(transformer).pipe(writableStream);
``` ```

View File

@ -14,6 +14,10 @@
[#239](https://github.com/lovell/sharp/issues/239) [#239](https://github.com/lovell/sharp/issues/239)
[@chrisriley](https://github.com/chrisriley) [@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. * Expose density metadata; set density of images from vector input.
[#338](https://github.com/lovell/sharp/issues/338) [#338](https://github.com/lovell/sharp/issues/338)
[@lookfirst](https://github.com/lookfirst) [@lookfirst](https://github.com/lookfirst)

View File

@ -64,7 +64,7 @@ var Sharp = function(input, options) {
width: -1, width: -1,
height: -1, height: -1,
canvas: 'crop', canvas: 'crop',
gravity: 0, crop: 0,
angle: 0, angle: 0,
rotateBeforePreExtract: false, rotateBeforePreExtract: false,
flip: 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 = { module.exports.gravity = {
'center': 0, center: 0,
'centre': 0, centre: 0,
'north': 1, north: 1,
'east': 2, east: 2,
'south': 3, south: 3,
'west': 4, west: 4,
'northeast': 5, northeast: 5,
'southeast': 6, southeast: 6,
'southwest': 7, southwest: 7,
'northwest': 8 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'; this.options.canvas = 'crop';
if (!isDefined(gravity)) { if (!isDefined(crop)) {
this.options.gravity = module.exports.gravity.center; // Default
} else if (isInteger(gravity) && inRange(gravity, 0, 8)) { this.options.crop = module.exports.gravity.center;
this.options.gravity = gravity; } else if (isInteger(crop) && inRange(crop, 0, 8)) {
} else if (isString(gravity) && isInteger(module.exports.gravity[gravity])) { // Gravity (numeric)
this.options.gravity = module.exports.gravity[gravity]; 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 { } else {
throw new Error('Unsupported crop gravity ' + gravity); throw new Error('Unsupported crop ' + crop);
} }
return this; return this;
}; };
Sharp.prototype.extract = function(options) { 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'; var suffix = this.options.width === -1 && this.options.height === -1 ? 'Pre' : 'Post';
['left', 'top', 'width', 'height'].forEach(function (name) { ['left', 'top', 'width', 'height'].forEach(function (name) {
var value = options[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; this.options[name + (name === 'left' || name === 'top' ? 'Offset' : '') + suffix] = value;
} else { } else {
throw new Error('Non-integer value for ' + name + ' of ' + value); throw new Error('Non-integer value for ' + name + ' of ' + value);

View File

@ -170,4 +170,82 @@ namespace sharp {
} }
} }
/*
Calculate crop area based on image entropy
*/
std::tuple<int, int> 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<int>(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<int>(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 } // namespace sharp

View File

@ -33,6 +33,16 @@ namespace sharp {
*/ */
VImage Sharpen(VImage image, int const radius, double const flat, double const jagged); VImage Sharpen(VImage image, int const radius, double const flat, double const jagged);
/*
Calculate crop area based on image entropy
*/
std::tuple<int, int> EntropyCrop(VImage image, int const outWidth, int const outHeight);
/*
Calculate the Shannon entropy for an image
*/
double Entropy(VImage image);
} // namespace sharp } // namespace sharp
#endif // SRC_OPERATIONS_H_ #endif // SRC_OPERATIONS_H_

View File

@ -49,6 +49,7 @@ using sharp::Normalize;
using sharp::Gamma; using sharp::Gamma;
using sharp::Blur; using sharp::Blur;
using sharp::Sharpen; using sharp::Sharpen;
using sharp::EntropyCrop;
using sharp::ImageType; using sharp::ImageType;
using sharp::ImageTypeId; using sharp::ImageTypeId;
@ -506,9 +507,15 @@ class PipelineWorker : public AsyncWorker {
// Crop/max/min // Crop/max/min
int left; int left;
int top; int top;
if (baton->crop < 9) {
// Gravity-based crop
std::tie(left, top) = CalculateCrop( std::tie(left, top) = CalculateCrop(
image.width(), image.height(), baton->width, baton->height, baton->gravity 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 width = std::min(image.width(), baton->width);
int height = std::min(image.height(), baton->height); int height = std::min(image.height(), baton->height);
image = image.extract_area(left, top, width, height); image = image.extract_area(left, top, width, height);
@ -996,7 +1003,7 @@ NAN_METHOD(pipeline) {
baton->overlayGravity = attrAs<int32_t>(options, "overlayGravity"); baton->overlayGravity = attrAs<int32_t>(options, "overlayGravity");
// Resize options // Resize options
baton->withoutEnlargement = attrAs<bool>(options, "withoutEnlargement"); baton->withoutEnlargement = attrAs<bool>(options, "withoutEnlargement");
baton->gravity = attrAs<int32_t>(options, "gravity"); baton->crop = attrAs<int32_t>(options, "crop");
baton->interpolator = attrAsStr(options, "interpolator"); baton->interpolator = attrAsStr(options, "interpolator");
// Operators // Operators
baton->flatten = attrAs<bool>(options, "flatten"); baton->flatten = attrAs<bool>(options, "flatten");

View File

@ -45,7 +45,7 @@ struct PipelineBaton {
int height; int height;
int channels; int channels;
Canvas canvas; Canvas canvas;
int gravity; int crop;
std::string interpolator; std::string interpolator;
double background[4]; double background[4];
bool flatten; bool flatten;
@ -99,7 +99,7 @@ struct PipelineBaton {
topOffsetPost(-1), topOffsetPost(-1),
channels(0), channels(0),
canvas(Canvas::CROP), canvas(Canvas::CROP),
gravity(0), crop(0),
flatten(false), flatten(false),
negate(false), negate(false),
blurSigma(0.0), blurSigma(0.0),

BIN
test/fixtures/expected/crop-entropy.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
test/fixtures/expected/crop-entropy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -5,9 +5,9 @@ var assert = require('assert');
var sharp = require('../../index'); var sharp = require('../../index');
var fixtures = require('../fixtures'); var fixtures = require('../fixtures');
describe('Crop gravities', function() { describe('Crop', function() {
var testSettings = [ [
{ {
name: 'North', name: 'North',
width: 320, width: 320,
@ -50,6 +50,13 @@ describe('Crop gravities', function() {
gravity: sharp.gravity.centre, gravity: sharp.gravity.centre,
fixture: 'gravity-centre.jpg' fixture: 'gravity-centre.jpg'
}, },
{
name: 'Default (centre)',
width: 80,
height: 320,
gravity: undefined,
fixture: 'gravity-centre.jpg'
},
{ {
name: 'Northeast', name: 'Northeast',
width: 320, width: 320,
@ -106,10 +113,8 @@ describe('Crop gravities', function() {
gravity: sharp.gravity.northwest, gravity: sharp.gravity.northwest,
fixture: 'gravity-west.jpg' fixture: 'gravity-west.jpg'
} }
]; ].forEach(function(settings) {
it(settings.name + ' gravity', function(done) {
testSettings.forEach(function(settings) {
it(settings.name, function(done) {
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(settings.width, settings.height) .resize(settings.width, settings.height)
.crop(settings.gravity) .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) sharp(fixtures.inputJpg)
.resize(80, 320) .resize(80, 320)
.crop('east') .crop('east')
@ -134,36 +139,57 @@ describe('Crop gravities', function() {
}); });
}); });
it('Invalid number', function() { it('Invalid values fail', function() {
assert.throws(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() { it('Uses default value when none specified', function() {
assert.throws(function() {
sharp(fixtures.inputJpg).crop('yadda');
});
});
it('does not throw if crop gravity is undefined', function() {
assert.doesNotThrow(function() { assert.doesNotThrow(function() {
sharp(fixtures.inputJpg).crop(); sharp().crop();
}); });
}); });
it('defaults crop gravity to sharp.gravity.center', function(done) { describe('Entropy-based strategy', function() {
var centerGravitySettings = testSettings.filter(function (settings) {
return settings.name === 'Center'; it('JPEG', function(done) {
})[0]; sharp(fixtures.inputJpgWithCmykProfile)
sharp(fixtures.inputJpg) .resize(80, 320)
.resize(centerGravitySettings.width, centerGravitySettings.height) .crop(sharp.strategy.entropy)
.crop()
.toBuffer(function(err, data, info) { .toBuffer(function(err, data, info) {
if (err) throw err; if (err) throw err;
assert.strictEqual(centerGravitySettings.width, info.width); assert.strictEqual('jpeg', info.format);
assert.strictEqual(centerGravitySettings.height, info.height); assert.strictEqual(3, info.channels);
fixtures.assertSimilar(fixtures.expected(centerGravitySettings.fixture), data, done); 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);
});
});
});
}); });

View File

@ -6,29 +6,6 @@ var sharp = require('../../index');
var fixtures = require('../fixtures'); var fixtures = require('../fixtures');
describe('Partial image extraction', function() { 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) { it('JPEG', function(done) {
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)