Skip to content

Commit c82ceec

Browse files
matmenclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 6360109 commit c82ceec

5 files changed

Lines changed: 158 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 3.3.0
4+
5+
### Features
6+
7+
- `ResizeOptions.fast` (default `true`): opaque images resized with bilinear or area resizing (down to about a third of
8+
the size, and enlarging) now round to 8 bits between the two passes as well, so both passes share their
9+
multiplications between two channels. Halving a 1920×1080 photo takes 12 instead of 15 ms, doubling it 61 instead of
10+
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
11+
of 3.2.1.
12+
313
## 3.2.1
414

515
### Performance

‎src/animation.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ export class Animation extends Array<Frame> {
168168
*/
169169
resize(width: number, height: number, mode?: ResizeMode): this;
170170
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
171-
const mode = (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
172171
const originalWidth = this.width, originalHeight = this.height;
173172
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');
174173
if (width === Image.RESIZE_AUTO) width = originalWidth / originalHeight * height;
@@ -179,7 +178,7 @@ export class Animation extends Array<Frame> {
179178
// scale both edges so adjacent frames stay aligned
180179
const left = Math.round(frame.xOffset * sx), top = Math.round(frame.yOffset * sy);
181180
const right = Math.round((frame.xOffset + frame.width) * sx), bottom = Math.round((frame.yOffset + frame.height) * sy);
182-
Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), mode);
181+
Image.prototype.resize.call(frame, Math.max(1, right - left), Math.max(1, bottom - top), modeOrOptions as ResizeMode);
183182
frame.xOffset = left;
184183
frame.yOffset = top;
185184
}

‎src/image.ts‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export interface CompositeOptions {
4747
export interface ResizeOptions {
4848
/** The resizing mode. Default: {@link Image.RESIZE_BILINEAR} */
4949
mode?: ResizeMode;
50+
/**
51+
* Whether to round to 8 bits between the two passes, so two channels share the multiplications of both:
52+
* about 15-25% faster, with more channel values a step off the exact result (10-25% instead of
53+
* about 1%, never more than 1). Only affects opaque images with {@link Image.RESIZE_BILINEAR} and
54+
* {@link Image.RESIZE_AREA}, down to about a third of the size. Default: `true`
55+
*/
56+
fast?: boolean;
5057
}
5158

5259
/**
@@ -585,15 +592,16 @@ export class Image {
585592
*/
586593
scale(factor: number, mode?: ResizeMode): this;
587594
scale(factor: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
588-
const image = this.__scale__(factor, resizeMode(modeOrOptions));
595+
const { mode, fast } = resizeOptions(modeOrOptions);
596+
const image = this.__scale__(factor, mode, fast);
589597
return this.__apply__(image);
590598
}
591599

592600
/** @private */
593601
/** @internal */
594-
protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
602+
protected __scale__(factor: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
595603
if (factor === 1) return this;
596-
return this.__resize__(this.width * factor, this.height * factor, mode);
604+
return this.__resize__(this.width * factor, this.height * factor, mode, fast);
597605
}
598606

599607
/**
@@ -615,8 +623,8 @@ export class Image {
615623
*/
616624
resize(width: number, height: number, mode?: ResizeMode): this;
617625
resize(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
618-
const mode = resizeMode(modeOrOptions);
619-
const image = this.__resize__(width, height, mode);
626+
const { mode, fast } = resizeOptions(modeOrOptions);
627+
const image = this.__resize__(width, height, mode, fast);
620628
return this.__apply__(image);
621629
}
622630

@@ -639,12 +647,12 @@ export class Image {
639647
*/
640648
contain(width: number, height: number, mode?: ResizeMode): this;
641649
contain(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
642-
const mode = resizeMode(modeOrOptions);
650+
const options = resizeOptions(modeOrOptions);
643651
width = Math.floor(width);
644652
height = Math.floor(height);
645653
// round instead of flooring the scaled size, so the limiting axis matches the box exactly
646654
const scale = Math.min(width / this.width, height / this.height);
647-
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);
655+
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);
648656
}
649657

650658
/**
@@ -666,9 +674,9 @@ export class Image {
666674
*/
667675
fit(width: number, height: number, mode?: ResizeMode): this;
668676
fit(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
669-
const mode = resizeMode(modeOrOptions);
677+
const options = resizeOptions(modeOrOptions);
670678
const result = new Image(width, height);
671-
this.contain(width, height, mode);
679+
this.contain(width, height, options);
672680
result.composite(this, (width - this.width) / 2, (height - this.height) / 2);
673681
return this.__apply__(result);
674682
}
@@ -692,17 +700,17 @@ export class Image {
692700
*/
693701
cover(width: number, height: number, mode?: ResizeMode): this;
694702
cover(width: number, height: number, modeOrOptions?: ResizeMode | ResizeOptions): this {
695-
const mode = resizeMode(modeOrOptions);
703+
const options = resizeOptions(modeOrOptions);
696704
width = Math.floor(width);
697705
height = Math.floor(height);
698706
const scale = Math.max(width / this.width, height / this.height);
699-
this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), mode);
707+
this.resize(Math.max(width, Math.round(this.width * scale)), Math.max(height, Math.round(this.height * scale)), options);
700708
return this.crop((this.width - width) / 2, (this.height - height) / 2, width, height);
701709
}
702710

703711
/** @private */
704712
/** @internal */
705-
protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR): Image {
713+
protected __resize__(width: number, height: number, mode: ResizeMode = Image.RESIZE_BILINEAR, fast = true): Image {
706714
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');
707715
else if (width === Image.RESIZE_AUTO) width = Math.max(1, Math.round(this.width / this.height * Math.floor(height)));
708716
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 {
720728
if (!filter) throw new Error('Invalid resize mode');
721729

722730
const image = new Image(width, height);
723-
resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap);
731+
resample(this.bitmap, this.width, this.height, width, height, filter, image.bitmap, fast);
724732
return image;
725733
}
726734

@@ -2129,8 +2137,9 @@ function hue2rgb(p: number, q: number, t: number): number {
21292137
}
21302138

21312139
// the resize mode of a positional argument or options object
2132-
function resizeMode(modeOrOptions?: ResizeMode | ResizeOptions): ResizeMode {
2133-
return (isOptions(modeOrOptions) ? modeOrOptions.mode : modeOrOptions) ?? Image.RESIZE_BILINEAR;
2140+
function resizeOptions(modeOrOptions?: ResizeMode | ResizeOptions): { mode: ResizeMode, fast: boolean } {
2141+
const options = isOptions(modeOrOptions) ? modeOrOptions : { mode: modeOrOptions };
2142+
return { mode: options.mode ?? Image.RESIZE_BILINEAR, fast: options.fast ?? true };
21342143
}
21352144

21362145
// separable blend functions of normalized channel values (W3C Compositing and Blending Level 1)

‎src/util/resample.ts‎

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ interface Coefficients {
3737
taps: number;
3838
}
3939

40-
function coefficients(inSize: number, outSize: number, filter: Filter, bits: number): Coefficients {
40+
function coefficients(inSize: number, outSize: number, filter: Filter, bits: number, pad = false): Coefficients {
4141
const scale = inSize / outSize;
4242
const filterScale = Math.max(1, scale);
4343
const support = filter.support * filterScale;
@@ -51,6 +51,8 @@ function coefficients(inSize: number, outSize: number, filter: Filter, bits: num
5151
maxs[i] = Math.min(inSize, Math.floor(center + support + 0.5));
5252
taps = Math.max(taps, maxs[i] - mins[i]);
5353
}
54+
// padded to the unrolled counts (zero weights for the extra pixels)
55+
if (pad) taps = Math.min(inSize, taps <= 2 ? 2 : taps <= 4 ? 4 : taps);
5456

5557
// every output index gets `taps` weights, so the passes can unroll by the count: a shorter range starts earlier
5658
// (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
7779
return { start, weights, taps };
7880
}
7981

82+
/** most taps per pass of the fast resampling */
83+
const FAST_TAPS = 4;
84+
8085
const littleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
8186

8287
/**
8388
* Resamples RGBA pixels with the given filter, in premultiplied alpha so transparent pixels don't bleed their color
8489
*/
85-
export function resample(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): void {
90+
export function resample(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray, fast = false): void {
8691
// opaque pixels don't need premultiplying, and with non-negative weights two channels share a multiplication
87-
if (littleEndian && filter.support <= 1 && resampleOpaque(src, width, height, outWidth, outHeight, filter, out)) return;
92+
if (littleEndian && filter.support <= 1 && (fast ? resampleOpaqueFast : resampleOpaque)(src, width, height, outWidth, outHeight, filter, out)) return;
8893
resamplePremultiplied(src, width, height, outWidth, outHeight, filter, out);
8994
}
9095

@@ -234,6 +239,102 @@ function resampleOpaque(src: Uint8ClampedArray, width: number, height: number, o
234239
return true;
235240
}
236241

242+
// sums the words of `taps` pixels `step` apart with 8-bit weights, two channels per multiplication, and rounds them
243+
// back to an opaque pixel: the weights sum to 256, so a lane holds at most 255 * 256 plus the rounding
244+
function packed(pixels: Int32Array, s: number, step: number, weights: Int32Array, w: number, taps: number): number {
245+
let rb = 0x800080, g = 128;
246+
for (let k = 0; k < taps; k++, s += step) {
247+
const p = pixels[s], wk = weights[w + k];
248+
rb += Math.imul(p & 0xff00ff, wk);
249+
g += (p >> 8 & 255) * wk;
250+
}
251+
return (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
252+
}
253+
254+
/**
255+
* Like {@link resampleOpaque}, but with 8-bit weights in both passes and 8-bit pixels between them, so two channels
256+
* share every multiplication of both passes. More values end up a step off the exact result
257+
*/
258+
function resampleOpaqueFast(src: Uint8ClampedArray, width: number, height: number, outWidth: number, outHeight: number, filter: Filter, out: Uint8ClampedArray): boolean {
259+
// the rounding error of 8-bit weights adds up with the taps
260+
const vertical = coefficients(height, outHeight, filter, 8, true), horizontal = coefficients(width, outWidth, filter, 8, true);
261+
if (vertical.taps > FAST_TAPS || horizontal.taps > FAST_TAPS) return resampleOpaque(src, width, height, outWidth, outHeight, filter, out);
262+
263+
const pixels = new Int32Array(src.buffer, src.byteOffset, width * height);
264+
let all = -1;
265+
for (let x = 0, last = (height - 1) * width; x < width; x++) all &= pixels[x] & pixels[last + x];
266+
if (all >>> 24 !== 255) return false;
267+
268+
let rows = pixels;
269+
if (outHeight === height) {
270+
for (let i = 0; i < pixels.length; i++) all &= pixels[i];
271+
} else {
272+
rows = new Int32Array(width * outHeight);
273+
const { start, weights, taps } = vertical;
274+
for (let y = 0, o = 0; y < outHeight; y++) {
275+
const w = y * taps, r0 = start[y] * width;
276+
if (taps === 4) {
277+
const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
278+
const r1 = r0 + width, r2 = r1 + width, r3 = r2 + width;
279+
for (let x = 0; x < width; x++, o++) {
280+
const p0 = pixels[r0 + x], p1 = pixels[r1 + x], p2 = pixels[r2 + x], p3 = pixels[r3 + x];
281+
all &= p0 & p1 & p2 & p3;
282+
const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
283+
const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2 + (p3 >> 8 & 255) * w3 + 128;
284+
rows[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
285+
}
286+
} else if (taps === 2) {
287+
const w0 = weights[w], w1 = weights[w + 1], r1 = r0 + width;
288+
for (let x = 0; x < width; x++, o++) {
289+
const p0 = pixels[r0 + x], p1 = pixels[r1 + x];
290+
all &= p0 & p1;
291+
const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
292+
const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + 128;
293+
rows[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
294+
}
295+
} else {
296+
for (let x = 0; x < width; x++, o++) {
297+
for (let k = 0, s = r0 + x; k < taps; k++, s += width) all &= pixels[s];
298+
rows[o] = packed(pixels, r0 + x, width, weights, w, taps);
299+
}
300+
}
301+
}
302+
}
303+
if (all >>> 24 !== 255) return false;
304+
305+
const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight);
306+
if (outWidth === width) {
307+
result.set(rows);
308+
return true;
309+
}
310+
311+
const { start, weights, taps } = horizontal;
312+
for (let y = 0, o = 0; y < outHeight; y++) {
313+
const row = y * width;
314+
if (taps === 4) {
315+
for (let x = 0; x < outWidth; x++, o++) {
316+
const s = row + start[x], w = x * 4;
317+
const p0 = rows[s], p1 = rows[s + 1], p2 = rows[s + 2], p3 = rows[s + 3];
318+
const w0 = weights[w], w1 = weights[w + 1], w2 = weights[w + 2], w3 = weights[w + 3];
319+
const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + Math.imul(p2 & 0xff00ff, w2) + Math.imul(p3 & 0xff00ff, w3) + 0x800080;
320+
const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + (p2 >> 8 & 255) * w2 + (p3 >> 8 & 255) * w3 + 128;
321+
result[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
322+
}
323+
} else if (taps === 2) {
324+
for (let x = 0; x < outWidth; x++, o++) {
325+
const s = row + start[x], w = x * 2;
326+
const p0 = rows[s], p1 = rows[s + 1], w0 = weights[w], w1 = weights[w + 1];
327+
const rb = Math.imul(p0 & 0xff00ff, w0) + Math.imul(p1 & 0xff00ff, w1) + 0x800080;
328+
const g = (p0 >> 8 & 255) * w0 + (p1 >> 8 & 255) * w1 + 128;
329+
result[o] = (rb >>> 8 & 0xff00ff) | (g & 0xff00) | 0xff000000;
330+
}
331+
} else {
332+
for (let x = 0; x < outWidth; x++, o++) result[o] = packed(rows, row + start[x], 1, weights, x * taps, taps);
333+
}
334+
}
335+
return true;
336+
}
337+
237338
/**
238339
* Resamples any pixels, premultiplied: between the passes, the color channels are channel * alpha and the alpha
239340
* channel is alpha * 255 (0-65025 each, beyond with overshooting filters)

‎tests/resize.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,23 @@ const samePixels = require('./util/pixels');
1313

1414
const target = await fs.readFile('./tests/targets/resize.png');
1515
if (!await samePixels(target, encoded)) process.exit(1);
16+
17+
// the fast resampling (the default) stays within a step of the exact one, and leaves transparent images to it
18+
const opaque = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300);
19+
// one translucent pixel in the middle, found by the vertical pass
20+
const photo = opaque.clone();
21+
photo.bitmap[(photo.height >> 1) * photo.width * 4 + 403] = 128;
22+
const sizes = [[0.5, 0.5], [1 / 3, 0.4], [2, 1.5], [1, 0.7]];
23+
for (const source of [opaque, photo])
24+
for (const mode of [Image.RESIZE_BILINEAR, Image.RESIZE_AREA, Image.RESIZE_BICUBIC])
25+
for (const [sx, sy] of sizes) {
26+
const width = Math.round(source.width * sx), height = Math.round(source.height * sy);
27+
const exact = source.clone().resize(width, height, {mode, fast: false}), fast = source.clone().resize(width, height, {mode});
28+
const limit = source === opaque ? 1 : 0;
29+
if (exact.bitmap.some((value, i) => Math.abs(value - fast.bitmap[i]) > limit)
30+
|| source === opaque && mode !== Image.RESIZE_BICUBIC && sx === 0.5 && exact.bitmap.every((value, i) => value === fast.bitmap[i])) {
31+
console.error('fast resize', mode, sx, sy, source === opaque ? 'opaque' : 'transparent');
32+
process.exit(1);
33+
}
34+
}
1635
})();

0 commit comments

Comments
 (0)