Skip to content

Commit 0923dda

Browse files
matmenclaude
andcommitted
fast rotation: 8-bit SWAR bilinear sampling
`RotateOptions.fast` (default true): angles that aren't multiples of 90° sample 8-bit premultiplied pixels with 8-bit weights, red+blue and green+alpha sharing a multiplication (src/util/rotate.ts). The source gets a transparent border of one pixel, so taps outside it need no checks and edges fade as before; each row only visits the span that hits the source. unpremultiply8 writes fully transparent pixels directly. 1920x1080 photo by 30°: 119 -> 51 ms (alpha: 114 -> 61 ms). Over the corpus (4 angles), opaque pixels stay within 1 of `fast: false`, what shows of translucent ones (also the faded edges) within about 2.5 steps, alpha within 2. rotate-45 targets regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 1a56e22 commit 0923dda

7 files changed

Lines changed: 98 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
- Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 103
1313
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),
1414
the color of pixels that are almost transparent can differ more.
15+
- `RotateOptions.fast` (default `true`): rotations by angles that aren't multiples of 90° sample with 8-bit weights
16+
on 8-bit premultiplied pixels, two channels per multiplication, and skip the parts of each row outside the source.
17+
Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay
18+
within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within
19+
about 2.5 steps. `fast: false` keeps the precise sampling.
1520

1621
### Performance
1722

‎src/image.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TextLayout } from './text-layout.ts';
1212
import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts';
1313
import { encode as utf8 } from './util/utf8.ts';
1414
import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts';
15+
import { rotateBilinear } from './util/rotate.ts';
1516
import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts';
1617
import { isOptions } from './util/options.ts';
1718

@@ -170,6 +171,13 @@ export interface DominantColorOptions {
170171
export interface RotateOptions {
171172
/** Whether to resize the image so it fits all pixels or just ignore outlying pixels. Default: `true` */
172173
resize?: boolean;
174+
/**
175+
* Whether to sample with 8-bit weights on 8-bit premultiplied pixels (like {@link ResizeOptions.fast}), about twice
176+
* as fast: opaque pixels stay within a step of the precise result, what shows of translucent ones (also the
177+
* faded edges) within about 2.5 steps (pixels that are almost transparent keep less of their color). Only affects
178+
* angles that aren't multiples of 90°. Default: `true`
179+
*/
180+
fast?: boolean;
173181
}
174182

175183
/** Options of {@link Image.trim} */
@@ -1551,7 +1559,7 @@ export class Image {
15511559
*/
15521560
rotate(angle: number, resize?: boolean): this;
15531561
rotate(angle: number, resizeOrOptions: boolean | RotateOptions = true): this {
1554-
const { resize = true } = typeof resizeOrOptions === 'object' && resizeOrOptions !== null ? resizeOrOptions : { resize: resizeOrOptions };
1562+
const { resize = true, fast = true } = typeof resizeOrOptions === 'object' && resizeOrOptions !== null ? resizeOrOptions : { resize: resizeOrOptions };
15551563
angle = ((angle % 360) + 360) % 360;
15561564
if (angle === 0) return this;
15571565

@@ -1579,6 +1587,7 @@ export class Image {
15791587
const outWidth = resize ? Math.round(Math.abs(width * cos) + Math.abs(height * sin)) : width;
15801588
const outHeight = resize ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height;
15811589
const out = new Image(outWidth, outHeight);
1590+
if (fast && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) return this.__apply__(out);
15821591
const s8 = this.bitmap, d8 = out.bitmap;
15831592

15841593
// bilinear sampling of the counterclockwise rotated source, with premultiplied alpha

‎src/util/alpha.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export function unpremultiply8(bitmap: Uint8ClampedArray): void {
4242
for (let i = 0; i < pixels.length; i++) {
4343
const p = pixels[i], a = p >>> 24;
4444
if (a === 255) continue;
45+
// what the table gives at alpha 0, without looking it up
46+
if (a === 0) {
47+
pixels[i] = 0;
48+
continue;
49+
}
4550
const o = a << 8;
4651
pixels[i] = table[o | p & 255] | table[o | p >> 8 & 255] << 8 | table[o | p >> 16 & 255] << 16 | a << 24;
4752
}

‎src/util/rotate.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// bilinear rotation on the pixels as 32-bit words: 8-bit premultiplied pixels and 8-bit weights, so red and blue,
2+
// green and alpha share a multiplication (like the fast resampling)
3+
4+
import { littleEndian, isOpaque, premultiply8, unpremultiply8 } from './alpha.ts';
5+
6+
// the range of steps x (within from..to) where start + x * step lies within 0..limit, widened by a step on either side
7+
function span(start: number, step: number, limit: number, from: number, to: number): [number, number] {
8+
if (step === 0) return start >= 0 && start < limit ? [from, to] : [from, from];
9+
const a = -start / step, b = (limit - start) / step;
10+
const lo = Math.floor(Math.min(a, b)) - 1, hi = Math.ceil(Math.max(a, b)) + 1;
11+
return [Math.max(from, lo), Math.max(Math.max(from, lo), Math.min(to, hi))];
12+
}
13+
14+
/**
15+
* Samples the source rotated counterclockwise (by the angle of `sin` and `cos`) around its center into `out`, centered
16+
* as well. Pixels outside the source stay transparent, edges fade like the taps outside the source were transparent.
17+
* Returns false (without writing anything) on big endian machines
18+
*/
19+
export function rotateBilinear(src: Uint8ClampedArray, width: number, height: number, out: Uint8ClampedArray, outWidth: number, outHeight: number, sin: number, cos: number): boolean {
20+
if (!littleEndian) return false;
21+
22+
// premultiplied, with a transparent border of one pixel: taps outside the source need no checks
23+
let bitmap = src;
24+
if (!isOpaque(src)) premultiply8(src, bitmap = new Uint8ClampedArray(src.length));
25+
const pixels = new Int32Array(bitmap.buffer, bitmap.byteOffset, width * height);
26+
const stride = width + 2;
27+
const padded = new Int32Array(stride * (height + 2));
28+
for (let y = 0; y < height; y++) padded.set(pixels.subarray(y * width, (y + 1) * width), (y + 1) * stride + 1);
29+
30+
const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight);
31+
// source position (in padded coordinates) of the center of the first pixel in a row, then one step per pixel
32+
const x0 = 0.5 - outWidth / 2, cx = width / 2 + 0.5, cy = height / 2 + 0.5;
33+
for (let oy = 0; oy < outHeight; oy++) {
34+
const dy = oy + 0.5 - outHeight / 2;
35+
const sx0 = x0 * cos - dy * sin + cx, sy0 = x0 * sin + dy * cos + cy;
36+
// the pixels of the row that hit the source (give or take one, the check below is exact)
37+
const [xFrom, xTo] = span(sx0, cos, width + 1, 0, outWidth);
38+
const [from, to] = span(sy0, sin, height + 1, xFrom, xTo);
39+
let o = oy * outWidth + from, sx = sx0 + from * cos, sy = sy0 + from * sin;
40+
for (let ox = from; ox < to; ox++, o++, sx += cos, sy += sin) {
41+
if (!(sx >= 0 && sy >= 0 && sx < width + 1 && sy < height + 1)) continue;
42+
const ix = sx | 0, iy = sy | 0;
43+
// 8-bit fractions, and weights summing to 256
44+
const fx = (sx - ix) * 256 + 0.5 | 0, fy = (sy - iy) * 256 + 0.5 | 0;
45+
const w11 = fx * fy + 128 >> 8, w10 = fx - w11, w01 = fy - w11, w00 = 256 - fx - fy + w11;
46+
const i = iy * stride + ix;
47+
const p00 = padded[i], p10 = padded[i + 1], p01 = padded[i + stride], p11 = padded[i + stride + 1];
48+
const rb = Math.imul(p00 & 0xff00ff, w00) + Math.imul(p10 & 0xff00ff, w10) + Math.imul(p01 & 0xff00ff, w01) + Math.imul(p11 & 0xff00ff, w11) + 0x800080;
49+
const ga = Math.imul(p00 >>> 8 & 0xff00ff, w00) + Math.imul(p10 >>> 8 & 0xff00ff, w10) + Math.imul(p01 >>> 8 & 0xff00ff, w01) + Math.imul(p11 >>> 8 & 0xff00ff, w11) + 0x800080;
50+
result[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00);
51+
}
52+
}
53+
54+
// the edges are translucent even for opaque sources
55+
unpremultiply8(out);
56+
return true;
57+
}

‎tests/rotate.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,25 @@ const panic = msg => {
7979
if (!Buffer.from(image.bitmap).equals(Buffer.from(image.rotate(360).bitmap)))
8080
panic('rotate 360 failed');
8181
}
82+
83+
// the fast sampling (the default) stays within a step of the precise one for opaque pixels, transparent ones
84+
// within 2.5 steps where it shows (premultiplied, alpha within 2)
85+
{
86+
const photo = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300);
87+
const transparent = photo.clone();
88+
for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1);
89+
for (const source of [photo, transparent])
90+
for (const [angle, resize] of [[30, true], [-17.5, true], [123, false], [0.3, true]]) {
91+
const a = source.clone().rotate(angle, {resize, fast: false}).bitmap, b = source.clone().rotate(angle, {resize}).bitmap;
92+
let opaque = 0, shown = 0;
93+
for (let i = 0; i < a.length; i++) {
94+
if (a[i] === b[i]) continue;
95+
const al = a[i | 3], bl = b[i | 3];
96+
if ((i & 3) === 3) shown = Math.max(shown, Math.abs(a[i] - b[i]));
97+
else if (al === 255 && bl === 255) opaque = Math.max(opaque, Math.abs(a[i] - b[i]));
98+
else shown = Math.max(shown, Math.abs(a[i] * al - b[i] * bl) / 255);
99+
}
100+
if (opaque > 1 || shown > 2.5) panic(`fast rotate ${angle}: ${opaque} (opaque), ${shown} (shown)`);
101+
}
102+
}
82103
})();
208 Bytes
Loading

‎tests/targets/rotate-45.png‎

487 Bytes
Loading

0 commit comments

Comments
 (0)