mirror of
https://github.com/lovell/sharp.git
synced 2025-07-09 18:40:16 +02:00
Add experimental, entropy-based auto-crop
Remove deprecated extract API
This commit is contained in:
parent
38ddb3b866
commit
2034efcf55
25
docs/api.md
25
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);
|
||||
```
|
||||
|
||||
|
@ -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)
|
||||
|
65
index.js
65
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);
|
||||
|
@ -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
|
||||
|
@ -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<int, int> 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_
|
||||
|
@ -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<int32_t>(options, "overlayGravity");
|
||||
// Resize options
|
||||
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");
|
||||
// Operators
|
||||
baton->flatten = attrAs<bool>(options, "flatten");
|
||||
|
@ -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),
|
||||
|
BIN
test/fixtures/expected/crop-entropy.jpg
vendored
Normal file
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
BIN
test/fixtures/expected/crop-entropy.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.0 KiB |
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user