Add support for arbitrary rotation angle via vips_rotate (#1385)

This commit is contained in:
freezy 2018-09-27 19:00:36 +02:00 committed by Lovell Fuller
parent 37d385fafa
commit 796738da65
8 changed files with 163 additions and 36 deletions

View File

@ -5,9 +5,12 @@
Rotate the output image by either an explicit angle Rotate the output image by either an explicit angle
or auto-orient based on the EXIF `Orientation` tag. or auto-orient based on the EXIF `Orientation` tag.
If an angle is provided, it is converted to a valid 90/180/270deg rotation. If an angle is provided, it is converted to a valid positive degree rotation.
For example, `-450` will produce a 270deg rotation. For example, `-450` will produce a 270deg rotation.
If an angle that is not a multiple of 90 is provided, the color of the
background color can be provided with the `background` option.
If no angle is provided, it is determined from the EXIF data. If no angle is provided, it is determined from the EXIF data.
Mirroring is supported and may infer the use of a flip operation. Mirroring is supported and may infer the use of a flip operation.
@ -19,6 +22,8 @@ for example `rotate(x).extract(y)` will produce a different result to `extract(y
### Parameters ### Parameters
- `angle` **[Number][1]** angle of rotation, must be a multiple of 90. (optional, default `auto`) - `angle` **[Number][1]** angle of rotation, must be a multiple of 90. (optional, default `auto`)
- `options` **[Object][2]?** if present, is an Object with optional attributes.
- `options.background` **([String][3] \| [Object][2])** parsed by the [color][4] module to extract values for red, green, blue and alpha. (optional, default `"#000000"`)
### Examples ### Examples
@ -34,7 +39,7 @@ const pipeline = sharp()
readableStream.pipe(pipeline); readableStream.pipe(pipeline);
``` ```
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -45,7 +50,7 @@ The use of `flip` implies the removal of the EXIF `Orientation` tag, if any.
### Parameters ### Parameters
- `flip` **[Boolean][3]** (optional, default `true`) - `flip` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -56,7 +61,7 @@ The use of `flop` implies the removal of the EXIF `Orientation` tag, if any.
### Parameters ### Parameters
- `flop` **[Boolean][3]** (optional, default `true`) - `flop` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -74,7 +79,7 @@ Separate control over the level of sharpening in "flat" and "jagged" areas is av
- `jagged` **[Number][1]** the level of sharpening to apply to "jagged" areas. (optional, default `2.0`) - `jagged` **[Number][1]** the level of sharpening to apply to "jagged" areas. (optional, default `2.0`)
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -88,7 +93,7 @@ When used without parameters the default window is 3x3.
- `size` **[Number][1]** square mask size: size x size (optional, default `3`) - `size` **[Number][1]** square mask size: size x size (optional, default `3`)
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -103,7 +108,7 @@ When a `sigma` is provided, performs a slower, more accurate Gaussian blur.
- `sigma` **[Number][1]?** a value between 0.3 and 1000 representing the sigma of the Gaussian mask, where `sigma = 1 + radius / 2`. - `sigma` **[Number][1]?** a value between 0.3 and 1000 representing the sigma of the Gaussian mask, where `sigma = 1 + radius / 2`.
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -113,7 +118,7 @@ Merge alpha transparency channel, if any, with `background`.
### Parameters ### Parameters
- `flatten` **[Boolean][3]** (optional, default `true`) - `flatten` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -130,7 +135,7 @@ when applying a gamma correction.
- `gamma` **[Number][1]** value between 1.0 and 3.0. (optional, default `2.2`) - `gamma` **[Number][1]** value between 1.0 and 3.0. (optional, default `2.2`)
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -140,7 +145,7 @@ Produce the "negative" of the image.
### Parameters ### Parameters
- `negate` **[Boolean][3]** (optional, default `true`) - `negate` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -150,7 +155,7 @@ Enhance output image contrast by stretching its luminance to cover the full dyna
### Parameters ### Parameters
- `normalise` **[Boolean][3]** (optional, default `true`) - `normalise` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -160,7 +165,7 @@ Alternative spelling of normalise.
### Parameters ### Parameters
- `normalize` **[Boolean][3]** (optional, default `true`) - `normalize` **[Boolean][6]** (optional, default `true`)
Returns **Sharp** Returns **Sharp**
@ -170,10 +175,10 @@ Convolve the image with the specified kernel.
### Parameters ### Parameters
- `kernel` **[Object][4]** - `kernel` **[Object][2]**
- `kernel.width` **[Number][1]** width of the kernel in pixels. - `kernel.width` **[Number][1]** width of the kernel in pixels.
- `kernel.height` **[Number][1]** width of the kernel in pixels. - `kernel.height` **[Number][1]** width of the kernel in pixels.
- `kernel.kernel` **[Array][5]<[Number][1]>** Array of length `width*height` containing the kernel values. - `kernel.kernel` **[Array][7]<[Number][1]>** Array of length `width*height` containing the kernel values.
- `kernel.scale` **[Number][1]** the scale of the kernel in pixels. (optional, default `sum`) - `kernel.scale` **[Number][1]** the scale of the kernel in pixels. (optional, default `sum`)
- `kernel.offset` **[Number][1]** the offset of the kernel in pixels. (optional, default `0`) - `kernel.offset` **[Number][1]** the offset of the kernel in pixels. (optional, default `0`)
@ -193,7 +198,7 @@ sharp(input)
}); });
``` ```
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -204,12 +209,12 @@ Any pixel value greather than or equal to the threshold value will be set to 255
### Parameters ### Parameters
- `threshold` **[Number][1]** a value in the range 0-255 representing the level at which the threshold will be applied. (optional, default `128`) - `threshold` **[Number][1]** a value in the range 0-255 representing the level at which the threshold will be applied. (optional, default `128`)
- `options` **[Object][4]?** - `options` **[Object][2]?**
- `options.greyscale` **[Boolean][3]** convert to single channel greyscale. (optional, default `true`) - `options.greyscale` **[Boolean][6]** convert to single channel greyscale. (optional, default `true`)
- `options.grayscale` **[Boolean][3]** alternative spelling for greyscale. (optional, default `true`) - `options.grayscale` **[Boolean][6]** alternative spelling for greyscale. (optional, default `true`)
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -222,16 +227,16 @@ the selected bitwise boolean `operation` between the corresponding pixels of the
### Parameters ### Parameters
- `operand` **([Buffer][6] \| [String][7])** Buffer containing image data or String containing the path to an image file. - `operand` **([Buffer][8] \| [String][3])** Buffer containing image data or String containing the path to an image file.
- `operator` **[String][7]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively. - `operator` **[String][3]** one of `and`, `or` or `eor` to perform that bitwise operation, like the C logic operators `&`, `|` and `^` respectively.
- `options` **[Object][4]?** - `options` **[Object][2]?**
- `options.raw` **[Object][4]?** describes operand when using raw pixel data. - `options.raw` **[Object][2]?** describes operand when using raw pixel data.
- `options.raw.width` **[Number][1]?** - `options.raw.width` **[Number][1]?**
- `options.raw.height` **[Number][1]?** - `options.raw.height` **[Number][1]?**
- `options.raw.channels` **[Number][1]?** - `options.raw.channels` **[Number][1]?**
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
@ -245,20 +250,22 @@ Apply the linear formula a \* input + b to the image (levels adjustment)
- `b` **[Number][1]** offset (optional, default `0.0`) - `b` **[Number][1]** offset (optional, default `0.0`)
- Throws **[Error][2]** Invalid parameters - Throws **[Error][5]** Invalid parameters
Returns **Sharp** Returns **Sharp**
[1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number [1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number
[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error [2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean [3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [4]: https://www.npmjs.org/package/color
[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array [5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error
[6]: https://nodejs.org/api/buffer.html [6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[8]: https://nodejs.org/api/buffer.html

View File

@ -108,6 +108,8 @@ const Sharp = function (input, options) {
embed: 0, embed: 0,
useExifOrientation: false, useExifOrientation: false,
angle: 0, angle: 0,
rotationAngle: 0,
rotationBackground: [0, 0, 0, 255],
rotateBeforePreExtract: false, rotateBeforePreExtract: false,
flip: false, flip: false,
flop: false, flop: false,

View File

@ -1,14 +1,18 @@
'use strict'; 'use strict';
const color = require('color');
const is = require('./is'); const is = require('./is');
/** /**
* Rotate the output image by either an explicit angle * Rotate the output image by either an explicit angle
* or auto-orient based on the EXIF `Orientation` tag. * or auto-orient based on the EXIF `Orientation` tag.
* *
* If an angle is provided, it is converted to a valid 90/180/270deg rotation. * If an angle is provided, it is converted to a valid positive degree rotation.
* For example, `-450` will produce a 270deg rotation. * For example, `-450` will produce a 270deg rotation.
* *
* If an angle that is not a multiple of 90 is provided, the color of the
* background color can be provided with the `background` option.
*
* If no angle is provided, it is determined from the EXIF data. * If no angle is provided, it is determined from the EXIF data.
* Mirroring is supported and may infer the use of a flip operation. * Mirroring is supported and may infer the use of a flip operation.
* *
@ -29,16 +33,29 @@ const is = require('./is');
* readableStream.pipe(pipeline); * readableStream.pipe(pipeline);
* *
* @param {Number} [angle=auto] angle of rotation, must be a multiple of 90. * @param {Number} [angle=auto] angle of rotation, must be a multiple of 90.
* @param {Object} [options] - if present, is an Object with optional attributes.
* @param {String|Object} [options.background="#000000"] parsed by the [color](https://www.npmjs.org/package/color) module to extract values for red, green, blue and alpha.
* @returns {Sharp} * @returns {Sharp}
* @throws {Error} Invalid parameters * @throws {Error} Invalid parameters
*/ */
function rotate (angle) { function rotate (angle, options) {
if (!is.defined(angle)) { if (!is.defined(angle)) {
this.options.useExifOrientation = true; this.options.useExifOrientation = true;
} else if (is.integer(angle) && !(angle % 90)) { } else if (is.integer(angle) && !(angle % 90)) {
this.options.angle = angle; this.options.angle = angle;
} else if (is.number(angle)) {
this.options.rotationAngle = angle;
if (is.object(options) && options.background) {
const backgroundColour = color(options.background);
this.options.rotationBackground = [
backgroundColour.red(),
backgroundColour.green(),
backgroundColour.blue(),
Math.round(backgroundColour.alpha() * 255)
];
}
} else { } else {
throw new Error('Unsupported angle: angle must be a positive/negative multiple of 90 ' + angle); throw new Error('Unsupported angle: angle must be a number.');
} }
return this; return this;
} }

View File

@ -476,6 +476,13 @@ class PipelineWorker : public Nan::AsyncWorker {
} }
} }
// Rotate by degree
if (baton->rotationAngle != 0.0) {
std::vector<double> background;
std::tie(image, background) = sharp::ApplyAlpha(image, baton->rotationBackground);
image = image.rotate(baton->rotationAngle, VImage::option()->set("background", background));
}
// Post extraction // Post extraction
if (baton->topOffsetPost != -1) { if (baton->topOffsetPost != -1) {
image = image.extract_area( image = image.extract_area(
@ -1187,6 +1194,12 @@ NAN_METHOD(pipeline) {
baton->normalise = AttrTo<bool>(options, "normalise"); baton->normalise = AttrTo<bool>(options, "normalise");
baton->useExifOrientation = AttrTo<bool>(options, "useExifOrientation"); baton->useExifOrientation = AttrTo<bool>(options, "useExifOrientation");
baton->angle = AttrTo<int32_t>(options, "angle"); baton->angle = AttrTo<int32_t>(options, "angle");
baton->rotationAngle = AttrTo<double>(options, "rotationAngle");
// Rotation background colour
v8::Local<v8::Object> rotationBackground = AttrAs<v8::Object>(options, "rotationBackground");
for (unsigned int i = 0; i < 4; i++) {
baton->rotationBackground[i] = AttrTo<double>(rotationBackground, i);
}
baton->rotateBeforePreExtract = AttrTo<bool>(options, "rotateBeforePreExtract"); baton->rotateBeforePreExtract = AttrTo<bool>(options, "rotateBeforePreExtract");
baton->flip = AttrTo<bool>(options, "flip"); baton->flip = AttrTo<bool>(options, "flip");
baton->flop = AttrTo<bool>(options, "flop"); baton->flop = AttrTo<bool>(options, "flop");

View File

@ -89,6 +89,8 @@ struct PipelineBaton {
bool normalise; bool normalise;
bool useExifOrientation; bool useExifOrientation;
int angle; int angle;
double rotationAngle;
double rotationBackground[4];
bool rotateBeforePreExtract; bool rotateBeforePreExtract;
bool flip; bool flip;
bool flop; bool flop;
@ -180,6 +182,7 @@ struct PipelineBaton {
normalise(false), normalise(false),
useExifOrientation(false), useExifOrientation(false),
angle(0), angle(0),
rotationAngle(0.0),
flip(false), flip(false),
flop(false), flop(false),
extendTop(0), extendTop(0),

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@ -23,6 +23,33 @@ describe('Rotation', function () {
}); });
}); });
it('Rotate by 30 degrees with semi-transparent background', function (done) {
sharp(fixtures.inputJpg)
.rotate(30, {background: { r: 255, g: 0, b: 0, alpha: 0.5 }})
.resize(320)
.png()
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('png', info.format);
assert.strictEqual(408, info.width);
assert.strictEqual(386, info.height);
fixtures.assertSimilar(fixtures.expected('rotate-transparent-bg.png'), data, done);
});
});
it('Rotate by 30 degrees with solid background', function (done) {
sharp(fixtures.inputJpg)
.rotate(30, {background: { r: 255, g: 0, b: 0, alpha: 0.5 }})
.resize(320)
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual('jpeg', info.format);
assert.strictEqual(408, info.width);
assert.strictEqual(386, info.height);
fixtures.assertSimilar(fixtures.expected('rotate-solid-bg.jpg'), data, done);
});
});
it('Rotate by 90 degrees, respecting output input size', function (done) { it('Rotate by 90 degrees, respecting output input size', function (done) {
sharp(fixtures.inputJpg).rotate(90).resize(320, 240).toBuffer(function (err, data, info) { sharp(fixtures.inputJpg).rotate(90).resize(320, 240).toBuffer(function (err, data, info) {
if (err) throw err; if (err) throw err;
@ -34,6 +61,17 @@ describe('Rotation', function () {
}); });
}); });
it('Rotate by 30 degrees, respecting output input size', function (done) {
sharp(fixtures.inputJpg).rotate(30).resize(320, 240).toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual(true, data.length > 0);
assert.strictEqual('jpeg', info.format);
assert.strictEqual(397, info.width);
assert.strictEqual(368, info.height);
done();
});
});
[-3690, -450, -90, 90, 450, 3690].forEach(function (angle) { [-3690, -450, -90, 90, 450, 3690].forEach(function (angle) {
it('Rotate by any 90-multiple angle (' + angle + 'deg)', function (done) { it('Rotate by any 90-multiple angle (' + angle + 'deg)', function (done) {
sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) { sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) {
@ -45,6 +83,17 @@ describe('Rotation', function () {
}); });
}); });
[-3750, -510, -150, 30, 390, 3630].forEach(function (angle) {
it('Rotate by any 30-multiple angle (' + angle + 'deg)', function (done) {
sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual(397, info.width);
assert.strictEqual(368, info.height);
done();
});
});
});
[-3780, -540, 0, 180, 540, 3780].forEach(function (angle) { [-3780, -540, 0, 180, 540, 3780].forEach(function (angle) {
it('Rotate by any 180-multiple angle (' + angle + 'deg)', function (done) { it('Rotate by any 180-multiple angle (' + angle + 'deg)', function (done) {
sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) { sharp(fixtures.inputJpg320x240).rotate(angle).toBuffer(function (err, data, info) {
@ -74,6 +123,24 @@ describe('Rotation', function () {
}); });
}); });
it('Rotate by 315 degrees, square output ignoring aspect ratio', function (done) {
sharp(fixtures.inputJpg)
.resize(240, 240)
.ignoreAspectRatio()
.rotate(315)
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual(339, info.width);
assert.strictEqual(339, info.height);
sharp(data).metadata(function (err, metadata) {
if (err) throw err;
assert.strictEqual(339, metadata.width);
assert.strictEqual(339, metadata.height);
done();
});
});
});
it('Rotate by 270 degrees, rectangular output ignoring aspect ratio', function (done) { it('Rotate by 270 degrees, rectangular output ignoring aspect ratio', function (done) {
sharp(fixtures.inputJpg) sharp(fixtures.inputJpg)
.resize(320, 240) .resize(320, 240)
@ -92,6 +159,24 @@ describe('Rotation', function () {
}); });
}); });
it('Rotate by 30 degrees, rectangular output ignoring aspect ratio', function (done) {
sharp(fixtures.inputJpg)
.resize(320, 240)
.ignoreAspectRatio()
.rotate(30)
.toBuffer(function (err, data, info) {
if (err) throw err;
assert.strictEqual(397, info.width);
assert.strictEqual(368, info.height);
sharp(data).metadata(function (err, metadata) {
if (err) throw err;
assert.strictEqual(397, metadata.width);
assert.strictEqual(368, metadata.height);
done();
});
});
});
it('Input image has Orientation EXIF tag but do not rotate output', function (done) { it('Input image has Orientation EXIF tag but do not rotate output', function (done) {
sharp(fixtures.inputJpgWithExif) sharp(fixtures.inputJpgWithExif)
.resize(320) .resize(320)
@ -185,9 +270,9 @@ describe('Rotation', function () {
}); });
}); });
it('Rotate to an invalid angle, should fail', function () { it('Rotate with a string argument, should fail', function () {
assert.throws(function () { assert.throws(function () {
sharp(fixtures.inputJpg).rotate(1); sharp(fixtures.inputJpg).rotate('not-a-number');
}); });
}); });