diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cb6dce..d9339478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ - Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 103 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. +- `RotateOptions.fast` (default `true`): rotations by angles that aren't multiples of 90° sample with 8-bit weights + on 8-bit premultiplied pixels, two channels per multiplication, and skip the parts of each row outside the source. + Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay + within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within + about 2.5 steps. `fast: false` keeps the precise sampling. ### Performance diff --git a/src/image.ts b/src/image.ts index 81e6a2f6..e294fb17 100644 --- a/src/image.ts +++ b/src/image.ts @@ -12,6 +12,7 @@ import { TextLayout } from './text-layout.ts'; import { Metadata, resolveMetadata, pngText, exifData } from './metadata.ts'; import { encode as utf8 } from './util/utf8.ts'; import { resample, BOX, TRIANGLE, CUBIC } from './util/resample.ts'; +import { rotateBilinear } from './util/rotate.ts'; import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts'; import { isOptions } from './util/options.ts'; @@ -170,6 +171,13 @@ export interface DominantColorOptions { export interface RotateOptions { /** Whether to resize the image so it fits all pixels or just ignore outlying pixels. Default: `true` */ resize?: boolean; + /** + * Whether to sample with 8-bit weights on 8-bit premultiplied pixels (like {@link ResizeOptions.fast}), about twice + * as fast: opaque pixels stay within a step of the precise result, what shows of translucent ones (also the + * faded edges) within about 2.5 steps (pixels that are almost transparent keep less of their color). Only affects + * angles that aren't multiples of 90°. Default: `true` + */ + fast?: boolean; } /** Options of {@link Image.trim} */ @@ -1551,7 +1559,7 @@ export class Image { */ rotate(angle: number, resize?: boolean): this; rotate(angle: number, resizeOrOptions: boolean | RotateOptions = true): this { - const { resize = true } = typeof resizeOrOptions === 'object' && resizeOrOptions !== null ? resizeOrOptions : { resize: resizeOrOptions }; + const { resize = true, fast = true } = typeof resizeOrOptions === 'object' && resizeOrOptions !== null ? resizeOrOptions : { resize: resizeOrOptions }; angle = ((angle % 360) + 360) % 360; if (angle === 0) return this; @@ -1579,6 +1587,7 @@ export class Image { const outWidth = resize ? Math.round(Math.abs(width * cos) + Math.abs(height * sin)) : width; const outHeight = resize ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height; const out = new Image(outWidth, outHeight); + if (fast && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) return this.__apply__(out); const s8 = this.bitmap, d8 = out.bitmap; // bilinear sampling of the counterclockwise rotated source, with premultiplied alpha diff --git a/src/util/alpha.ts b/src/util/alpha.ts index 93e151d0..6de5ca88 100644 --- a/src/util/alpha.ts +++ b/src/util/alpha.ts @@ -42,6 +42,11 @@ export function unpremultiply8(bitmap: Uint8ClampedArray): void { for (let i = 0; i < pixels.length; i++) { const p = pixels[i], a = p >>> 24; if (a === 255) continue; + // what the table gives at alpha 0, without looking it up + if (a === 0) { + pixels[i] = 0; + continue; + } const o = a << 8; pixels[i] = table[o | p & 255] | table[o | p >> 8 & 255] << 8 | table[o | p >> 16 & 255] << 16 | a << 24; } diff --git a/src/util/rotate.ts b/src/util/rotate.ts new file mode 100644 index 00000000..57dc784f --- /dev/null +++ b/src/util/rotate.ts @@ -0,0 +1,57 @@ +// bilinear rotation on the pixels as 32-bit words: 8-bit premultiplied pixels and 8-bit weights, so red and blue, +// green and alpha share a multiplication (like the fast resampling) + +import { littleEndian, isOpaque, premultiply8, unpremultiply8 } from './alpha.ts'; + +// the range of steps x (within from..to) where start + x * step lies within 0..limit, widened by a step on either side +function span(start: number, step: number, limit: number, from: number, to: number): [number, number] { + if (step === 0) return start >= 0 && start < limit ? [from, to] : [from, from]; + const a = -start / step, b = (limit - start) / step; + const lo = Math.floor(Math.min(a, b)) - 1, hi = Math.ceil(Math.max(a, b)) + 1; + return [Math.max(from, lo), Math.max(Math.max(from, lo), Math.min(to, hi))]; +} + +/** + * Samples the source rotated counterclockwise (by the angle of `sin` and `cos`) around its center into `out`, centered + * as well. Pixels outside the source stay transparent, edges fade like the taps outside the source were transparent. + * Returns false (without writing anything) on big endian machines + */ +export function rotateBilinear(src: Uint8ClampedArray, width: number, height: number, out: Uint8ClampedArray, outWidth: number, outHeight: number, sin: number, cos: number): boolean { + if (!littleEndian) return false; + + // premultiplied, with a transparent border of one pixel: taps outside the source need no checks + let bitmap = src; + if (!isOpaque(src)) premultiply8(src, bitmap = new Uint8ClampedArray(src.length)); + const pixels = new Int32Array(bitmap.buffer, bitmap.byteOffset, width * height); + const stride = width + 2; + const padded = new Int32Array(stride * (height + 2)); + for (let y = 0; y < height; y++) padded.set(pixels.subarray(y * width, (y + 1) * width), (y + 1) * stride + 1); + + const result = new Int32Array(out.buffer, out.byteOffset, outWidth * outHeight); + // source position (in padded coordinates) of the center of the first pixel in a row, then one step per pixel + const x0 = 0.5 - outWidth / 2, cx = width / 2 + 0.5, cy = height / 2 + 0.5; + for (let oy = 0; oy < outHeight; oy++) { + const dy = oy + 0.5 - outHeight / 2; + const sx0 = x0 * cos - dy * sin + cx, sy0 = x0 * sin + dy * cos + cy; + // the pixels of the row that hit the source (give or take one, the check below is exact) + const [xFrom, xTo] = span(sx0, cos, width + 1, 0, outWidth); + const [from, to] = span(sy0, sin, height + 1, xFrom, xTo); + let o = oy * outWidth + from, sx = sx0 + from * cos, sy = sy0 + from * sin; + for (let ox = from; ox < to; ox++, o++, sx += cos, sy += sin) { + if (!(sx >= 0 && sy >= 0 && sx < width + 1 && sy < height + 1)) continue; + const ix = sx | 0, iy = sy | 0; + // 8-bit fractions, and weights summing to 256 + const fx = (sx - ix) * 256 + 0.5 | 0, fy = (sy - iy) * 256 + 0.5 | 0; + const w11 = fx * fy + 128 >> 8, w10 = fx - w11, w01 = fy - w11, w00 = 256 - fx - fy + w11; + const i = iy * stride + ix; + const p00 = padded[i], p10 = padded[i + 1], p01 = padded[i + stride], p11 = padded[i + stride + 1]; + const rb = Math.imul(p00 & 0xff00ff, w00) + Math.imul(p10 & 0xff00ff, w10) + Math.imul(p01 & 0xff00ff, w01) + Math.imul(p11 & 0xff00ff, w11) + 0x800080; + 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; + result[o] = (rb >>> 8 & 0xff00ff) | (ga & 0xff00ff00); + } + } + + // the edges are translucent even for opaque sources + unpremultiply8(out); + return true; +} diff --git a/tests/rotate.js b/tests/rotate.js index 84d1054b..8c5f2851 100644 --- a/tests/rotate.js +++ b/tests/rotate.js @@ -79,4 +79,25 @@ const panic = msg => { if (!Buffer.from(image.bitmap).equals(Buffer.from(image.rotate(360).bitmap))) panic('rotate 360 failed'); } + + // the fast sampling (the default) stays within a step of the precise one for opaque pixels, transparent ones + // within 2.5 steps where it shows (premultiplied, alpha within 2) + { + const photo = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300); + const transparent = photo.clone(); + for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1); + for (const source of [photo, transparent]) + for (const [angle, resize] of [[30, true], [-17.5, true], [123, false], [0.3, true]]) { + const a = source.clone().rotate(angle, {resize, fast: false}).bitmap, b = source.clone().rotate(angle, {resize}).bitmap; + let opaque = 0, shown = 0; + for (let i = 0; i < a.length; i++) { + if (a[i] === b[i]) continue; + const al = a[i | 3], bl = b[i | 3]; + if ((i & 3) === 3) shown = Math.max(shown, Math.abs(a[i] - b[i])); + else if (al === 255 && bl === 255) opaque = Math.max(opaque, Math.abs(a[i] - b[i])); + else shown = Math.max(shown, Math.abs(a[i] * al - b[i] * bl) / 255); + } + if (opaque > 1 || shown > 2.5) panic(`fast rotate ${angle}: ${opaque} (opaque), ${shown} (shown)`); + } + } })(); diff --git a/tests/targets/rotate-45-noresize.png b/tests/targets/rotate-45-noresize.png index 00840faa..8090ddd0 100644 Binary files a/tests/targets/rotate-45-noresize.png and b/tests/targets/rotate-45-noresize.png differ diff --git a/tests/targets/rotate-45.png b/tests/targets/rotate-45.png index 257925be..5ad6201c 100644 Binary files a/tests/targets/rotate-45.png and b/tests/targets/rotate-45.png differ