From c82ceeccd4e7888905bf0684e0c9e7da7d73a0fe Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 17:02:54 +0000 Subject: [PATCH 1/2] fast resizing by default: 8-bit SWAR in both passes Opaque images resized with bilinear or area resizing (down to about a third of the size, and enlarging) round to 8 bits between the passes, so the second pass shares its multiplications between two channels too. Halving a 1920x1080 photo: 15 -> 12 ms, doubling it: 80 -> 61 ms. Results stay within 1 of the previous ones (1313 corpus images). `ResizeOptions.fast: false` keeps the previous resampling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 10 ++++ src/animation.ts | 3 +- src/image.ts | 39 ++++++++++------ src/util/resample.ts | 107 +++++++++++++++++++++++++++++++++++++++++-- tests/resize.js | 19 ++++++++ 5 files changed, 158 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb6a599..325d932b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.3.0 + +### Features + +- `ResizeOptions.fast` (default `true`): opaque images resized with bilinear or area resizing (down to about a third of + the size, and enlarging) now round to 8 bits between the two passes as well, so both passes share their + multiplications between two channels. 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. `fast: false` keeps the more precise results + of 3.2.1. + ## 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..4309b0fe 100644 --- a/src/image.ts +++ b/src/image.ts @@ -47,6 +47,13 @@ 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, with more channel values a step off the exact result (10-25% instead of + * about 1%, never more than 1). Only affects opaque images with {@link Image.RESIZE_BILINEAR} and + * {@link Image.RESIZE_AREA}, down to about a third of the size. Default: `true` + */ + fast?: boolean; } /** @@ -585,15 +592,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 +623,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 +647,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 +674,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 +700,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 +728,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 +2137,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..cacb08c4 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,17 @@ 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 && (fast ? resampleOpaqueFast : resampleOpaque)(src, width, height, outWidth, outHeight, filter, out)) return; resamplePremultiplied(src, width, height, outWidth, outHeight, filter, out); } @@ -234,6 +239,102 @@ 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; +} + /** * 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..825cefce 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -13,4 +13,23 @@ 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 exact one, and leaves transparent images to it + const opaque = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300); + // one translucent pixel in the middle, found by the vertical pass + const photo = opaque.clone(); + photo.bitmap[(photo.height >> 1) * photo.width * 4 + 403] = 128; + const sizes = [[0.5, 0.5], [1 / 3, 0.4], [2, 1.5], [1, 0.7]]; + for (const source of [opaque, photo]) + 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 exact = source.clone().resize(width, height, {mode, fast: false}), fast = source.clone().resize(width, height, {mode}); + const limit = source === opaque ? 1 : 0; + if (exact.bitmap.some((value, i) => Math.abs(value - fast.bitmap[i]) > limit) + || source === opaque && mode !== Image.RESIZE_BICUBIC && sx === 0.5 && exact.bitmap.every((value, i) => value === fast.bitmap[i])) { + console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : 'transparent'); + process.exit(1); + } + } })(); From 15b78d73713542dece53fe85127011ec35b63419 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 17:10:05 +0000 Subject: [PATCH 2/2] fast resizing of transparent images: 8-bit premultiplied SWAR With `fast` (the default), transparent images are premultiplied to 8 bits, like browsers do, and both passes multiply red+blue and green+alpha as 16-bit lanes; a reciprocal table unpremultiplies. Halving a 1920x1080 photo with alpha: 43 -> 24 ms, doubling it: 260 -> 119 ms. Over 178 transparent corpus images the composited result stays within about 2 steps of `fast: false` (alpha within 2); colors of almost transparent pixels keep less precision. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 13 +++-- src/image.ts | 10 ++-- src/util/resample.ts | 119 ++++++++++++++++++++++++++++++++++++++++++- tests/resize.js | 26 ++++++---- 4 files changed, 148 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325d932b..7c37b6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ ### Features -- `ResizeOptions.fast` (default `true`): opaque images resized with bilinear or area resizing (down to about a third of - the size, and enlarging) now round to 8 bits between the two passes as well, so both passes share their - multiplications between two channels. 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. `fast: false` keeps the more precise results - of 3.2.1. +- `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 diff --git a/src/image.ts b/src/image.ts index 4309b0fe..81e6a2f6 100644 --- a/src/image.ts +++ b/src/image.ts @@ -48,10 +48,12 @@ 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, with more channel values a step off the exact result (10-25% instead of - * about 1%, never more than 1). Only affects opaque images with {@link Image.RESIZE_BILINEAR} and - * {@link Image.RESIZE_AREA}, down to about a third of the size. Default: `true` + * 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; } diff --git a/src/util/resample.ts b/src/util/resample.ts index cacb08c4..8c85a9aa 100644 --- a/src/util/resample.ts +++ b/src/util/resample.ts @@ -89,7 +89,10 @@ const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; */ 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 && (fast ? resampleOpaqueFast : 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); } @@ -335,6 +338,120 @@ function resampleOpaqueFast(src: Uint8ClampedArray, width: number, height: numbe 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 825cefce..9691942e 100644 --- a/tests/resize.js +++ b/tests/resize.js @@ -14,21 +14,27 @@ 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 exact one, and leaves transparent images to it + // 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); - // one translucent pixel in the middle, found by the vertical pass - const photo = opaque.clone(); - photo.bitmap[(photo.height >> 1) * photo.width * 4 + 403] = 128; + 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, photo]) + 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 exact = source.clone().resize(width, height, {mode, fast: false}), fast = source.clone().resize(width, height, {mode}); - const limit = source === opaque ? 1 : 0; - if (exact.bitmap.some((value, i) => Math.abs(value - fast.bitmap[i]) > limit) - || source === opaque && mode !== Image.RESIZE_BICUBIC && sx === 0.5 && exact.bitmap.every((value, i) => value === fast.bitmap[i])) { - console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : 'transparent'); + 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); } }