Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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} */
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/util/alpha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
57 changes: 57 additions & 0 deletions src/util/rotate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions tests/rotate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
}
}
})();
Binary file modified tests/targets/rotate-45-noresize.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/targets/rotate-45.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading