diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fb6a599..7c37b6ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## 3.3.0
+
+### Features
+
+- `ResizeOptions.fast` (default `true`): bilinear and area resizing (down to about a third of the size, and
+ enlarging) round to 8 bits between the two passes, so both passes share their multiplications between two channels.
+ `fast: false` keeps the more precise results of 3.2.1.
+ - Opaque images: halving a 1920×1080 photo takes 12 instead of 15 ms, doubling it 61 instead of 80 ms. About 10-25%
+ of the channel values differ from 3.2.1, by at most 1.
+ - Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 119
+ instead of 260 ms. What shows once they're composited differs from 3.2.1 by up to about 2 steps (alpha by at most 2),
+ the color of pixels that are almost transparent can differ more.
+
## 3.2.1
### Performance
diff --git a/src/animation.ts b/src/animation.ts
index 78b9abea..d47cfbcc 100644
--- a/src/animation.ts
+++ b/src/animation.ts
@@ -168,7 +168,6 @@ export class Animation extends Array {
*/
resize(width: number, height: number, mode?: ResizeMode): this;
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const mode = (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
const originalWidth = this.width, originalHeight = this.height;
if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both');
if (width === Image.RESIZE_AUTO) width = originalWidth / originalHeight * height;
@@ -179,7 +178,7 @@ export class Animation extends Array {
// scale both edges so adjacent frames stay aligned
const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy);
const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy);
- Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), mode);
+ Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), modeOrOptions as ResizeMode);
frame.xOffset = left;
frame.yOffset = top;
}
diff --git a/src/image.ts b/src/image.ts
index 3f0fecf4..81e6a2f6 100644
--- a/src/image.ts
+++ b/src/image.ts
@@ -47,6 +47,15 @@ export interface CompositeOptions {
export interface ResizeOptions {
/** The resizing mode. Default: {@link Image.RESIZE_BILINEAR} */
mode?: ResizeMode;
+ /**
+ * Whether to round to 8 bits between the two passes, so two channels share the multiplications of both: about
+ * 15-25% faster for opaque images, with 10-25% of the channel values a step off the precise result (instead of
+ * about 1%, never more than 1). Transparent images are premultiplied to 8 bits like browsers do, which makes them
+ * about twice as fast: what shows once they're composited differs by up to about 2 steps, pixels that are almost
+ * transparent keep less of their color. Only affects {@link Image.RESIZE_BILINEAR} and {@link Image.RESIZE_AREA},
+ * down to about a third of the size. Default: `true`
+ */
+ fast?: boolean;
}
/**
@@ -585,15 +594,16 @@ export class Image {
*/
scale(factor: number, mode?: ResizeMode): this;
scale(factor: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const image = this.__scale__(factor, resizeMode(modeOrOptions));
+ const { mode, fast } = resizeOptions(modeOrOptions);
+ const image = this.__scale__(factor, mode, fast);
return this.__apply__(image);
}
/** @private */
/** @internal */
- protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
+ protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
if (factor === 1) return this;
- return this.__resize__(this.width * factor, this.height * factor, mode);
+ return this.__resize__(this.width * factor, this.height * factor, mode, fast);
}
/**
@@ -615,8 +625,8 @@ export class Image {
*/
resize(width: number, height: number, mode?: ResizeMode): this;
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const mode = resizeMode(modeOrOptions);
- const image = this.__resize__(width, height, mode);
+ const { mode, fast } = resizeOptions(modeOrOptions);
+ const image = this.__resize__(width, height, mode, fast);
return this.__apply__(image);
}
@@ -639,12 +649,12 @@ export class Image {
*/
contain(width: number, height: number, mode?: ResizeMode): this;
contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const mode = resizeMode(modeOrOptions);
+ const options = resizeOptions(modeOrOptions);
width = Math.floor(width);
height = Math.floor(height);
// round instead of flooring the scaled size, so the limiting axis matches the box exactly
const scale = Math.min(width / this.width, height / this.height);
- return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), mode);
+ return this.resize(Math.max(1, Math.min(width, Math.round(this.width * scale))), Math.max(1, Math.min(height, Math.round(this.height * scale))), options);
}
/**
@@ -666,9 +676,9 @@ export class Image {
*/
fit(width: number, height: number, mode?: ResizeMode): this;
fit(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const mode = resizeMode(modeOrOptions);
+ const options = resizeOptions(modeOrOptions);
const result = new Image(width, height);
- this.contain(width, height, mode);
+ this.contain(width, height, options);
result.composite(this, (width - this.width) / 2, (height - this.height) / 2);
return this.__apply__(result);
}
@@ -692,17 +702,17 @@ export class Image {
*/
cover(width: number, height: number, mode?: ResizeMode): this;
cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
- const mode = resizeMode(modeOrOptions);
+ const options = resizeOptions(modeOrOptions);
width = Math.floor(width);
height = Math.floor(height);
const scale = Math.max(width / this.width, height / this.height);
- this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), mode);
+ this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), options);
return this.crop((this.width - width) / 2, (this.height - height) / 2, width, height);
}
/** @private */
/** @internal */
- protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
+ protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
if (width === Image.RESIZE_AUTO && height === Image.RESIZE_AUTO) throw new Error('RESIZE_AUTO can only be used for either width or height, not for both');
else if (width === Image.RESIZE_AUTO) width = Math.max(1, Math.round(this.width / this.height * Math.floor(height)));
else if (height === Image.RESIZE_AUTO) height = Math.max(1, Math.round(this.height / this.width * Math.floor(width)));
@@ -720,7 +730,7 @@ export class Image {
if (!filter) throw new Error('Invalid resize mode');
const image = new Image(width, height);
- resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap);
+ resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap, fast);
return image;
}
@@ -2129,8 +2139,9 @@ function hue2rgb(p: number, q: number, t: number): number {
}
// the resize mode of a positional argument or options object
-function resizeMode(modeOrOptions?: ResizeMode | ResizeOptions): ResizeMode {
- return (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
+function resizeOptions(modeOrOptions?: ResizeMode | ResizeOptions): { mode: ResizeMode, fast: boolean } {
+ const options = isOptions(modeOrOptions) ? modeOrOptions : { mode: modeOrOptions };
+ return { mode: options.mode ?? Image.RESIZE_BILINEAR, fast: options.fast ?? true };
}
// separable blend functions of normalized channel values (W3C Compositing and Blending Level 1)
diff --git a/src/util/resample.ts b/src/util/resample.ts
index c07780b0..8c85a9aa 100644
--- a/src/util/resample.ts
+++ b/src/util/resample.ts
@@ -37,7 +37,7 @@ interface Coefficients {
taps: number;
}
-function coefficients(inSize: number, outSize: number, filter: Filter, bits: number): Coefficients {
+function coefficients(inSize: number, outSize: number, filter: Filter, bits: number, pad = false): Coefficients {
const scale = inSize / outSize;
const filterScale = Math.max(1, scale);
const support = filter.support * filterScale;
@@ -51,6 +51,8 @@ function coefficients(inSize: number, outSize: number, filter: Filter, bits: num
maxs[i] = Math.min(inSize, Math.floor(center + support + 0.5));
taps = Math.max(taps, maxs[i] - mins[i]);
}
+ // padded to the unrolled counts (zero weights for the extra pixels)
+ if (pad) taps = Math.min(inSize, taps <= 2 ? 2 : taps <= 4 ? 4 : taps);
// every output index gets `taps` weights, so the passes can unroll by the count: a shorter range starts earlier
// (zero weights for the pixels before it) where it would read past the end
@@ -77,14 +79,20 @@ function coefficients(inSize: number, outSize: number, filter: Filter, bits: num
return { start, weights, taps };
}
+/** most taps per pass of the fast resampling */
+const FAST_TAPS = 4;
+
const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
/**
* Resamples RGBA pixels with the given filter, in premultiplied alpha so transparent pixels don't bleed their color
*/
-export function resample(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): void {
+export function resample(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray, fast = false): void {
// opaque pixels don't need premultiplying, and with non-negative weights two channels share a multiplication
- if (littleEndian && filter.support <= 1 && resampleOpaque(src, width, height, outWidth, outHeight, filter, out)) return;
+ if (littleEndian && filter.support <= 1) {
+ if (!fast && resampleOpaque(src, width, height, outWidth, outHeight, filter, out)) return;
+ if (fast && (resampleOpaqueFast(src, width, height, outWidth, outHeight, filter, out) || resampleTransparentFast(src, width, height, outWidth, outHeight, filter, out))) return;
+ }
resamplePremultiplied(src, width, height, outWidth, outHeight, filter, out);
}
@@ -234,6 +242,216 @@ function resampleOpaque(src: Uint8ClampedArray, width: number, height: number, o
return true;
}
+// sums the words of `taps` pixels `step` apart with 8-bit weights, two channels per multiplication, and rounds them
+// back to an opaque pixel: the weights sum to 256, so a lane holds at most 255 * 256 plus the rounding
+function packed(pixels: Int32Array, s: number, step: number, weights: Int32Array, w: number, taps: number): number {
+ let rb = 0x800080, g = 128;
+ for (let k = 0; k < taps; k++, s += step) {
+ const p = pixels[s], wk = weights[w + k];
+ rb += Math.imul(p & 0xff00ff, wk);
+ g += (p >> 8 & 255) * wk;
+ }
+ return (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
+}
+
+/**
+ * Like {@link resampleOpaque}, but with 8-bit weights in both passes and 8-bit pixels between them, so two channels
+ * share every multiplication of both passes. More values end up a step off the exact result
+ */
+function resampleOpaqueFast(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): boolean {
+ // the rounding error of 8-bit weights adds up with the taps
+ const vertical = coefficients(height, outHeight, filter, 8, true), horizontal = coefficients(width, outWidth, filter, 8, true);
+ if (vertical.taps > FAST_TAPS || horizontal.taps > FAST_TAPS) return resampleOpaque(src, width, height, outWidth, outHeight, filter, out);
+
+ const pixels = new Int32Array(src.buffer, src.byteOffset, width * height);
+ let all = -1;
+ for (let x = 0, last = (height - 1) * width; x < width; x++) all &= pixels[x] & pixels[last + x];
+ if (all >>> 24 !== 255) return false;
+
+ let rows = pixels;
+ if (outHeight === height) {
+ for (let i = 0; i < pixels.length; i++) all &= pixels[i];
+ } else {
+ rows = new Int32Array(width * outHeight);
+ const { start, weights, taps } = vertical;
+ for (let y = 0, o = 0; y < outHeight; y++) {
+ const w = y * taps, r0 = start[y] * width;
+ if (taps === 4) {
+ const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
+ const r1 = r0 + width, r2 = r1 + width, r3 = r2 + width;
+ for (let x = 0; x < width; x++, o++) {
+ const p0 = pixels[r0 + x], p1 = pixels[r1 + x], p2 = pixels[r2 + x], p3 = pixels[r3 + x];
+ all &= p0 & p1 & p2 & p3;
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
+ const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2 + (p3 >> 8 & 255) * w3 + 128;
+ rows[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
+ }
+ } else if (taps === 2) {
+ const w0 = weights[w], w1 = weights[w + 1], r1 = r0 + width;
+ for (let x = 0; x < width; x++, o++) {
+ const p0 = pixels[r0 + x], p1 = pixels[r1 + x];
+ all &= p0 & p1;
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
+ const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + 128;
+ rows[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
+ }
+ } else {
+ for (let x = 0; x < width; x++, o++) {
+ for (let k = 0, s = r0 + x; k < taps; k++, s += width) all &= pixels[s];
+ rows[o] = packed(pixels, r0 + x, width, weights, w, taps);
+ }
+ }
+ }
+ }
+ if (all >>> 24 !== 255) return false;
+
+ const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight);
+ if (outWidth === width) {
+ result.set(rows);
+ return true;
+ }
+
+ const { start, weights, taps } = horizontal;
+ for (let y = 0, o = 0; y < outHeight; y++) {
+ const row = y * width;
+ if (taps === 4) {
+ for (let x = 0; x < outWidth; x++, o++) {
+ const s = row + start[x], w = x * 4;
+ const p0 = rows[s], p1 = rows[s + 1], p2 = rows[s + 2], p3 = rows[s + 3];
+ const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
+ const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2 + (p3 >> 8 & 255) * w3 + 128;
+ result[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
+ }
+ } else if (taps === 2) {
+ for (let x = 0; x < outWidth; x++, o++) {
+ const s = row + start[x], w = x * 2;
+ const p0 = rows[s], p1 = rows[s + 1], w0 = weights[w], w1 = weights[w + 1];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
+ const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + 128;
+ result[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
+ }
+ } else {
+ for (let x = 0; x < outWidth; x++, o++) result[o] = packed(rows, row + start[x], 1, weights, x * taps, taps);
+ }
+ }
+ return true;
+}
+
+// 255 / alpha with 16 fractional bits, for unpremultiplying
+let reciprocals: Int32Array | undefined;
+
+// sums the rows `taps` words apart of packed premultiplied pixels with 8-bit weights: red and blue, green and alpha
+// share a multiplication each
+function packedTransparent(pixels: Int32Array, s: number, step: number, weights: Int32Array, w: number, taps: number): number {
+ let rb = 0x800080, ga = 0x800080;
+ for (let k = 0; k < taps; k++, s += step) {
+ const p = pixels[s], wk = weights[w + k];
+ rb += Math.imul(p & 0xff00ff, wk);
+ ga += Math.imul(p >>> 8 & 0xff00ff, wk);
+ }
+ return (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
+}
+
+/**
+ * Like {@link resampleOpaqueFast} for any pixels: they are premultiplied to 8 bits (as browsers blur and scale), both
+ * passes take two multiplications per pixel, and the result is unpremultiplied. Pixels that are almost transparent
+ * keep less of their color. Returns false (without writing anything) for too many taps
+ */
+function resampleTransparentFast(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): boolean {
+ const vertical = coefficients(height, outHeight, filter, 8, true), horizontal = coefficients(width, outWidth, filter, 8, true);
+ if (vertical.taps > FAST_TAPS || horizontal.taps > FAST_TAPS) return false;
+
+ // premultiplied: channel * alpha / 255, rounded
+ const source = new Int32Array(src.buffer, src.byteOffset, width * height);
+ const pixels = new Int32Array(width * height);
+ for (let i = 0; i < pixels.length; i++) {
+ const p = source[i], a = p >>> 24;
+ if (a === 255) { pixels[i] = p; continue; }
+ let rb = Math.imul(p & 0xff00ff, a) + 0x800080;
+ rb = (rb + (rb >>> 8 & 0xff00ff)) >>> 8 & 0xff00ff;
+ let g = (p >> 8 & 255) * a + 128;
+ g = g + (g >> 8) & 0xff00;
+ pixels[i] = rb | g | a << 24;
+ }
+
+ let rows = pixels;
+ if (outHeight !== height) {
+ rows = new Int32Array(width * outHeight);
+ const { start, weights, taps } = vertical;
+ for (let y = 0, o = 0; y < outHeight; y++) {
+ const w = y * taps, r0 = start[y] * width;
+ if (taps === 4) {
+ const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
+ const r1 = r0 + width, r2 = r1 + width, r3 = r2 + width;
+ for (let x = 0; x < width; x++, o++) {
+ const p0 = pixels[r0 + x], p1 = pixels[r1 + x], p2 = pixels[r2 + x], p3 = pixels[r3 + x];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
+ const ga = Math.imul(p0 >>> 8 & 0xff00ff, w0) + Math.imul(p1 >>> 8 & 0xff00ff, w1) + Math.imul(p2 >>> 8 & 0xff00ff, w2) + Math.imul(p3 >>> 8 & 0xff00ff, w3) + 0x800080;
+ rows[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
+ }
+ } else if (taps === 2) {
+ const w0 = weights[w], w1 = weights[w + 1], r1 = r0 + width;
+ for (let x = 0; x < width; x++, o++) {
+ const p0 = pixels[r0 + x], p1 = pixels[r1 + x];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
+ const ga = Math.imul(p0 >>> 8 & 0xff00ff, w0) + Math.imul(p1 >>> 8 & 0xff00ff, w1) + 0x800080;
+ rows[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
+ }
+ } else {
+ for (let x = 0; x < width; x++, o++) rows[o] = packedTransparent(pixels, r0 + x, width, weights, w, taps);
+ }
+ }
+ }
+
+ const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight);
+ if (outWidth === width) {
+ result.set(rows);
+ } else {
+ const { start, weights, taps } = horizontal;
+ for (let y = 0, o = 0; y < outHeight; y++) {
+ const row = y * width;
+ if (taps === 4) {
+ for (let x = 0; x < outWidth; x++, o++) {
+ const s = row + start[x], w = x * 4;
+ const p0 = rows[s], p1 = rows[s + 1], p2 = rows[s + 2], p3 = rows[s + 3];
+ const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
+ const ga = Math.imul(p0 >>> 8 & 0xff00ff, w0) + Math.imul(p1 >>> 8 & 0xff00ff, w1) + Math.imul(p2 >>> 8 & 0xff00ff, w2) + Math.imul(p3 >>> 8 & 0xff00ff, w3) + 0x800080;
+ result[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
+ }
+ } else if (taps === 2) {
+ for (let x = 0; x < outWidth; x++, o++) {
+ const s = row + start[x], w = x * 2;
+ const p0 = rows[s], p1 = rows[s + 1], w0 = weights[w], w1 = weights[w + 1];
+ const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
+ const ga = Math.imul(p0 >>> 8 & 0xff00ff, w0) + Math.imul(p1 >>> 8 & 0xff00ff, w1) + 0x800080;
+ result[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
+ }
+ } else {
+ for (let x = 0; x < outWidth; x++, o++) result[o] = packedTransparent(rows, row + start[x], 1, weights, x * taps, taps);
+ }
+ }
+ }
+
+ // unpremultiplied: channel * 255 / alpha, clamped (the weighted sums can round a channel above its alpha)
+ if (!reciprocals) {
+ reciprocals = new Int32Array(256);
+ for (let a = 1; a < 256; a++) reciprocals[a] = Math.round(255 * 65536 / a);
+ }
+ for (let i = 0; i < result.length; i++) {
+ const p = result[i], a = p >>> 24;
+ if (a === 255) continue;
+ if (a === 0) { result[i] = 0; continue; }
+ const f = reciprocals[a];
+ const r = Math.min(255, Math.imul(p & 255, f) + 32768 >>> 16);
+ const g = Math.min(255, Math.imul(p >> 8 & 255, f) + 32768 >>> 16);
+ const b = Math.min(255, Math.imul(p >> 16 & 255, f) + 32768 >>> 16);
+ result[i] = r | g << 8 | b << 16 | a << 24;
+ }
+ return true;
+}
+
/**
* Resamples any pixels, premultiplied: between the passes, the color channels are channel * alpha and the alpha
* channel is alpha * 255 (0-65025 each, beyond with overshooting filters)
diff --git a/tests/resize.js b/tests/resize.js
index 2c867e2e..9691942e 100644
--- a/tests/resize.js
+++ b/tests/resize.js
@@ -13,4 +13,29 @@ const samePixels = require('./util/pixels');
const target = await fs.readFile('./tests/targets/resize.png');
if (!await samePixels(target, encoded)) process.exit(1);
+
+ // the fast resampling (the default) stays within a step of the precise one for opaque images, transparent ones
+ // within 2.5 steps where it shows (premultiplied, alpha within 2), bicubic resizing is left to the precise one
+ const opaque = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300);
+ const transparent = opaque.clone();
+ for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1);
+ const sizes = [[0.5, 0.5], [1 / 3, 0.4], [2, 1.5], [1, 0.7]];
+ for (const source of [opaque, transparent])
+ for (const mode of [Image.RESIZE_BILINEAR, Image.RESIZE_AREA, Image.RESIZE_BICUBIC])
+ for (const [sx, sy] of sizes) {
+ const width = Math.round(source.width * sx), height = Math.round(source.height * sy);
+ const a = source.clone().resize(width, height, {mode, fast: false}).bitmap, b = source.clone().resize(width, height, {mode}).bitmap;
+ const limit = mode === Image.RESIZE_BICUBIC ? 0 : source === opaque ? 1 : 2.5;
+ let max = 0, differ = false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] === b[i]) continue;
+ differ = true;
+ const alpha = (i & 3) === 3, al = a[i | 3], bl = b[i | 3];
+ max = Math.max(max, source === opaque || alpha ? Math.abs(a[i] - b[i]) : Math.abs(a[i] * al - b[i] * bl) / 255);
+ }
+ if (max > limit || mode !== Image.RESIZE_BICUBIC && sx === 0.5 && !differ) {
+ console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : 'transparent', max);
+ process.exit(1);
+ }
+ }
})();