diff --git a/lib/constructor.js b/lib/constructor.js index c49394aa..73f37d45 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -149,6 +149,7 @@ const Sharp = function (input, options) { kernel: 'lanczos3', interpolator: 'bicubic', centreSampling: false, + fastShrinkOnLoad: true, // operations background: [0, 0, 0, 255], flatten: false, diff --git a/lib/resize.js b/lib/resize.js index dc8a67ec..b51050d4 100644 --- a/lib/resize.js +++ b/lib/resize.js @@ -142,6 +142,11 @@ function resize (width, height, options) { if (is.defined(options.centreSampling)) { this._setBooleanOption('centreSampling', options.centreSampling); } + + // Shrink on load + if (is.defined(options.fastShrinkOnLoad)) { + this._setBooleanOption('fastShrinkOnLoad', options.fastShrinkOnLoad); + } } return this; } diff --git a/src/pipeline.cc b/src/pipeline.cc index 23863f54..16a8870d 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -222,20 +222,27 @@ class PipelineWorker : public Nan::AsyncWorker { // If integral x and y shrink are equal, try to use shrink-on-load for JPEG and WebP, // but not when applying gamma correction, pre-resize extract or trim int shrink_on_load = 1; + + int shrink_on_load_factor = 1; + // Leave at least a factor of two for the final resize step, when fastShrinkOnLoad: false + // for more consistent results and avoid occasional small image shifting + if (!baton->fastShrinkOnLoad) { + shrink_on_load_factor = 2; + } if ( - xshrink == yshrink && xshrink >= 2 && + xshrink == yshrink && xshrink >= 2 * shrink_on_load_factor && (inputImageType == ImageType::JPEG || inputImageType == ImageType::WEBP) && baton->gamma == 0 && baton->topOffsetPre == -1 && baton->trimTolerance == 0 ) { - if (xshrink >= 8) { + if (xshrink >= 8 * shrink_on_load_factor) { xfactor = xfactor / 8; yfactor = yfactor / 8; shrink_on_load = 8; - } else if (xshrink >= 4) { + } else if (xshrink >= 4 * shrink_on_load_factor) { xfactor = xfactor / 4; yfactor = yfactor / 4; shrink_on_load = 4; - } else if (xshrink >= 2) { + } else if (xshrink >= 2 * shrink_on_load_factor) { xfactor = xfactor / 2; yfactor = yfactor / 2; shrink_on_load = 2; @@ -282,23 +289,6 @@ class PipelineWorker : public Nan::AsyncWorker { } xfactor = static_cast(shrunkOnLoadWidth) / static_cast(targetResizeWidth); yfactor = static_cast(shrunkOnLoadHeight) / static_cast(targetResizeHeight); - xshrink = std::max(1, static_cast(floor(xfactor))); - yshrink = std::max(1, static_cast(floor(yfactor))); - xresidual = static_cast(xshrink) / xfactor; - yresidual = static_cast(yshrink) / yfactor; - if ( - !baton->rotateBeforePreExtract && - (rotation == VIPS_ANGLE_D90 || rotation == VIPS_ANGLE_D270) - ) { - std::swap(xresidual, yresidual); - } - } - // Help ensure a final kernel-based reduction to prevent shrink aliasing - if (xshrink > 1 && yshrink > 1 && (xresidual == 1.0 || yresidual == 1.0)) { - xshrink = xshrink / 2; - yshrink = yshrink / 2; - xresidual = static_cast(xshrink) / xfactor; - yresidual = static_cast(yshrink) / yfactor; } // Ensure we're using a device-independent colour space @@ -364,13 +354,12 @@ class PipelineWorker : public Nan::AsyncWorker { } } - bool const shouldShrink = xshrink > 1 || yshrink > 1; - bool const shouldReduce = xresidual != 1.0 || yresidual != 1.0; + bool const shouldResize = xfactor != 1.0 || yfactor != 1.0; bool const shouldBlur = baton->blurSigma != 0.0; bool const shouldConv = baton->convKernelWidth * baton->convKernelHeight > 0; bool const shouldSharpen = baton->sharpenSigma != 0.0; bool const shouldPremultiplyAlpha = HasAlpha(image) && - (shouldShrink || shouldReduce || shouldBlur || shouldConv || shouldSharpen || shouldOverlayWithAlpha); + (shouldResize || shouldBlur || shouldConv || shouldSharpen || shouldOverlayWithAlpha); // Premultiply image alpha channel before all transformations to avoid // dark fringing around bright pixels @@ -379,79 +368,21 @@ class PipelineWorker : public Nan::AsyncWorker { image = image.premultiply(); } - // Fast, integral box-shrink - if (shouldShrink) { - if (yshrink > 1) { - image = image.shrinkv(yshrink); - } - if (xshrink > 1) { - image = image.shrinkh(xshrink); - } - // Recalculate residual float based on dimensions of required vs shrunk images - int shrunkWidth = image.width(); - int shrunkHeight = image.height(); - if (!baton->rotateBeforePreExtract && - (rotation == VIPS_ANGLE_D90 || rotation == VIPS_ANGLE_D270)) { - // Swap input output width and height when rotating by 90 or 270 degrees - std::swap(shrunkWidth, shrunkHeight); - } - xresidual = static_cast(targetResizeWidth) / static_cast(shrunkWidth); - yresidual = static_cast(targetResizeHeight) / static_cast(shrunkHeight); + // Resize + if (shouldResize) { + VipsKernel kernel = static_cast( + vips_enum_from_nick(nullptr, VIPS_TYPE_KERNEL, baton->kernel.data())); if ( - !baton->rotateBeforePreExtract && - (rotation == VIPS_ANGLE_D90 || rotation == VIPS_ANGLE_D270) + kernel != VIPS_KERNEL_NEAREST && kernel != VIPS_KERNEL_CUBIC && kernel != VIPS_KERNEL_LANCZOS2 && + kernel != VIPS_KERNEL_LANCZOS3 ) { - std::swap(xresidual, yresidual); + throw vips::VError("Unknown kernel"); } - } - // Use affine increase or kernel reduce with the remaining float part - if (xresidual != 1.0 || yresidual != 1.0) { - // Insert tile cache to prevent over-computation of previous operations - if (baton->accessMethod == VIPS_ACCESS_SEQUENTIAL) { - image = sharp::TileCache(image, yresidual); - } - // Perform kernel-based reduction - if (yresidual < 1.0 || xresidual < 1.0) { - VipsKernel kernel = static_cast( - vips_enum_from_nick(nullptr, VIPS_TYPE_KERNEL, baton->kernel.data())); - if ( - kernel != VIPS_KERNEL_NEAREST && kernel != VIPS_KERNEL_CUBIC && kernel != VIPS_KERNEL_LANCZOS2 && - kernel != VIPS_KERNEL_LANCZOS3 - ) { - throw vips::VError("Unknown kernel"); - } - if (yresidual < 1.0) { - image = image.reducev(1.0 / yresidual, VImage::option() - ->set("kernel", kernel) - ->set("centre", baton->centreSampling)); - } - if (xresidual < 1.0) { - image = image.reduceh(1.0 / xresidual, VImage::option() - ->set("kernel", kernel) - ->set("centre", baton->centreSampling)); - } - } - // Perform enlargement - if (yresidual > 1.0 || xresidual > 1.0) { - if (trunc(xresidual) == xresidual && trunc(yresidual) == yresidual && baton->interpolator == "nearest") { - // Fast, integral nearest neighbour enlargement - image = image.zoom(static_cast(xresidual), static_cast(yresidual)); - } else { - // Floating point affine transformation - vips::VInterpolate interpolator = vips::VInterpolate::new_from_name(baton->interpolator.data()); - if (yresidual > 1.0 && xresidual > 1.0) { - image = image.affine({xresidual, 0.0, 0.0, yresidual}, VImage::option() - ->set("interpolate", interpolator)); - } else if (yresidual > 1.0) { - image = image.affine({1.0, 0.0, 0.0, yresidual}, VImage::option() - ->set("interpolate", interpolator)); - } else if (xresidual > 1.0) { - image = image.affine({xresidual, 0.0, 0.0, 1.0}, VImage::option() - ->set("interpolate", interpolator)); - } - } - } + image = image.resize(1.0 / xfactor, VImage::option() + ->set("vscale", 1.0 / yfactor) + ->set("kernel", kernel) + ->set("centre", baton->centreSampling)); } // Rotate @@ -1211,6 +1142,7 @@ NAN_METHOD(pipeline) { baton->kernel = AttrAsStr(options, "kernel"); baton->interpolator = AttrAsStr(options, "interpolator"); baton->centreSampling = AttrTo(options, "centreSampling"); + baton->fastShrinkOnLoad = AttrTo(options, "fastShrinkOnLoad"); // Join Channel Options if (HasAttr(options, "joinChannelIn")) { v8::Local joinChannelObject = Nan::Get(options, Nan::New("joinChannelIn").ToLocalChecked()) diff --git a/src/pipeline.h b/src/pipeline.h index 7d7455d3..6146f479 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -69,6 +69,7 @@ struct PipelineBaton { std::string kernel; std::string interpolator; bool centreSampling; + bool fastShrinkOnLoad; double background[4]; bool flatten; bool negate; diff --git a/test/fixtures/centered_image.jpeg b/test/fixtures/centered_image.jpeg new file mode 100644 index 00000000..4c3afa6b Binary files /dev/null and b/test/fixtures/centered_image.jpeg differ diff --git a/test/fixtures/expected/embed-4-into-4.png b/test/fixtures/expected/embed-4-into-4.png index 79cf1802..071909cb 100644 Binary files a/test/fixtures/expected/embed-4-into-4.png and b/test/fixtures/expected/embed-4-into-4.png differ diff --git a/test/fixtures/expected/fast-shrink-on-load-false.png b/test/fixtures/expected/fast-shrink-on-load-false.png new file mode 100644 index 00000000..7d506d55 Binary files /dev/null and b/test/fixtures/expected/fast-shrink-on-load-false.png differ diff --git a/test/fixtures/expected/fast-shrink-on-load-true.png b/test/fixtures/expected/fast-shrink-on-load-true.png new file mode 100644 index 00000000..e1d28756 Binary files /dev/null and b/test/fixtures/expected/fast-shrink-on-load-true.png differ diff --git a/test/fixtures/index.js b/test/fixtures/index.js index ee9aaa02..e82a20d2 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -67,6 +67,7 @@ module.exports = { inputJpg320x240: getPath('320x240.jpg'), // http://www.andrewault.net/2010/01/26/create-a-test-pattern-video-with-perl/ inputJpgOverlayLayer2: getPath('alpha-layer-2-ink.jpg'), inputJpgTruncated: getPath('truncated.jpg'), // head -c 10000 2569067123_aca715a2ee_o.jpg > truncated.jpg + inputJpgCenteredImage: getPath('centered_image.jpeg'), inputPng: getPath('50020484-00001.png'), // http://c.searspartsdirect.com/lis_png/PLDM/50020484-00001.png inputPngWithTransparency: getPath('blackbug.png'), // public domain diff --git a/test/unit/extract.js b/test/unit/extract.js index 22a70b74..8d980ee2 100644 --- a/test/unit/extract.js +++ b/test/unit/extract.js @@ -98,6 +98,7 @@ describe('Partial image extraction', function () { sharp(fixtures.inputPngWithGreyAlpha) .extract({ left: 20, top: 10, width: 380, height: 280 }) .rotate(90) + .jpeg() .toBuffer(function (err, data, info) { if (err) throw err; assert.strictEqual(280, info.width); diff --git a/test/unit/gamma.js b/test/unit/gamma.js index f5c3e45c..b5c56e1b 100644 --- a/test/unit/gamma.js +++ b/test/unit/gamma.js @@ -48,11 +48,12 @@ describe('Gamma correction', function () { sharp(fixtures.inputPngOverlayLayer1) .resize(320) .gamma() + .jpeg() .toBuffer(function (err, data, info) { if (err) throw err; - assert.strictEqual('png', info.format); + assert.strictEqual('jpeg', info.format); assert.strictEqual(320, info.width); - fixtures.assertSimilar(fixtures.expected('gamma-alpha.jpg'), data, { threshold: 20 }, done); + fixtures.assertSimilar(fixtures.expected('gamma-alpha.jpg'), data, done); }); }); diff --git a/test/unit/resize.js b/test/unit/resize.js index 47958dad..4325090d 100644 --- a/test/unit/resize.js +++ b/test/unit/resize.js @@ -448,4 +448,34 @@ describe('Resize dimensions', function () { }); }); }); + + it('fastShrinkOnLoad: false ensures image is not shifted', function (done) { + return sharp(fixtures.inputJpgCenteredImage) + .resize(9, 8, { + fastShrinkOnLoad: false, + centreSampling: true + }) + .png() + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(9, info.width); + assert.strictEqual(8, info.height); + // higher threshold makes it pass for both jpeg and jpeg-turbo libs + fixtures.assertSimilar(fixtures.expected('fast-shrink-on-load-false.png'), data, { threshold: 7 }, done); + }); + }); + + it('fastShrinkOnLoad: true (default) might result in shifted image', function (done) { + return sharp(fixtures.inputJpgCenteredImage) + .resize(9, 8, { + centreSampling: true + }) + .png() + .toBuffer(function (err, data, info) { + if (err) throw err; + assert.strictEqual(9, info.width); + assert.strictEqual(8, info.height); + fixtures.assertSimilar(fixtures.expected('fast-shrink-on-load-true.png'), data, done); + }); + }); });