Skip to content

Commit 2a480bc

Browse files
committed
sample bilinear rotations in 8 bits again
1 parent 4004104 commit 2a480bc

7 files changed

Lines changed: 89 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Upgrading from 3.x? Upgrade to the latest 3.x release first, fix the deprecation
6666

6767
- **`renderSVG()` takes `width`, `height` or `scale`**: `{ size: 512, mode: 'width' }` is `{ width: 512 }`, `{ size: 2 }` is `{ scale: 2 }`; `SVGScaleModes` and `SVGScaleMode` are removed. More than one of them throws a `TypeError`.
6868

69-
- **`rotate()` turns clockwise**, like CSS' `rotate()` (3.x turned counterclockwise): negate the angle for the old result. Its `fast` option is removed (the precise sampling is the default now) and `mode` (`'nearest-neighbor'`, `'bilinear'` or `'bicubic'`) samples like `transform()`.
69+
- **`rotate()` turns clockwise**, like CSS' `rotate()` (3.x turned counterclockwise): negate the angle for the old result. Its `fast` option is removed (the bilinear sampling is always the 8-bit one that `fast: true` was, with the same pixels) and `mode` (`'nearest-neighbor'`, `'bilinear'` or `'bicubic'`) picks the sampling like `transform()`'s.
7070

7171
- **`dominantColor()` counts colors in a histogram, and its `threshold` is in channel values**: the colors are counted in 16 levels per channel (like sharp's `dominant`) and the average of the fullest bin is returned, instead of the average of the largest of 8 median cut clusters, so the result is a color the image has and not a mix of two hues; it can differ from 3.x's. The `threshold` is 0-256 instead of steps of 4 (0-64): the default `60` is 3.x's `15`; multiply a threshold you give by 4.
7272

‎guides/Migrating to 4.0.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ false })` grows the image again), so search for them too.
492492
| 3.x | 4.0 |
493493
| --- | --- |
494494
| `image.rotate(angle, { resize: false })` | `image.rotate(-angle, { expand: false })`, like `transform()`, `outline()` and `dropShadow()` (the angle negated, as [`rotate()` turns clockwise](#rotate-turns-clockwise)) |
495-
| `image.rotate(angle, { fast: false })`, `{ fast: true }` | `image.rotate(-angle)`: the precise bilinear sampling is the default now, `fast` is removed. `mode` picks `'nearest-neighbor'`, `'bilinear'` or `'bicubic'`, like `transform()` |
495+
| `image.rotate(angle, { fast: false })`, `{ fast: true }` | `image.rotate(-angle)`: the bilinear sampling is the `fast: true` one (the same pixels, `fast: false` is removed: it differed by a step at most). `mode` picks `'nearest-neighbor'`, `'bilinear'` or `'bicubic'`, like `transform()` |
496496
| `resize(w, h, { fast: false })`, `scale(f, { fast: false })`, `{ fast: true }` | `resize(w, h)`, `scale(f)`: `fast` is removed, resizing always works like its default `fast: true` (8-bit rounding between the passes, at most a step or two off the precise result) |
497497
| `ResizeModes.AREA` and the other `ResizeModes` | `'area'` and the other names, see [constants and modes](#constants-and-modes) |
498498
| `FrameDisposalModes.BACKGROUND` and the other `FrameDisposalModes` | `'background'` and the other names |

‎src/image.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { GainMap } from './gainmap.ts';
3535
import * as gain from './util/gain.ts';
3636
import { multiply, invert } from './util/matrix.ts';
3737
import { sampleAffine } from './util/affine.ts';
38+
import { rotateBilinear } from './util/rotate.ts';
3839
import * as gainMapCodec from './codecs/gainmap.ts';
3940

4041
/** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */
@@ -2257,6 +2258,11 @@ export class Image {
22572258
const outHeight = expand ? Math.round(Math.abs(width * sin) + Math.abs(height * cos)) : height;
22582259
const out = new Image(outWidth, outHeight);
22592260
rotateGainMap(outWidth, outHeight);
2261+
// bilinear: 8-bit sampling, about twice as fast, within a step of the precise sampling below (tests/rotate.js)
2262+
if (mode === 'bilinear' && rotateBilinear(this.bitmap, width, height, out.bitmap, outWidth, outHeight, sin, cos)) {
2263+
this.__moved__?.(1, 1, (width - outWidth) / 2, (height - outHeight) / 2);
2264+
return this.__apply__(out);
2265+
}
22602266
// the source position of each output position (pixel centers), around the middles of both images
22612267
const inverse: [number, number, number, number, number, number] = [cos, sin, -sin, cos,
22622268
width / 2 - cos * outWidth / 2 + sin * outHeight / 2, height / 2 - sin * outWidth / 2 - cos * outHeight / 2];

‎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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,30 @@ const panic = msg => {
9999
if (!(error instanceof TypeError)) panic(`rotate mode 'area' should throw a TypeError, got ${error}`);
100100
}
101101

102+
// the bilinear rotation samples 8-bit pixels (about twice as fast as the precise sampling transform() does): it
103+
// stays within a step of it for opaque pixels, translucent ones within 2.5 steps where they show (premultiplied,
104+
// alpha within 2)
105+
{
106+
const photo = (await Image.decode(await fs.readFile('./bench/inputs/photo.jpg'))).crop(700, 300, 400, 300);
107+
const transparent = photo.clone();
108+
for (let i = 0; i < transparent.bitmap.length; i += 4) transparent.bitmap[i + 3] = (i >> 2) % transparent.width * 255 / (transparent.width - 1);
109+
for (const source of [photo, transparent])
110+
for (const [angle, expand] of [[30, false], [-17.5, false], [123, false], [0.3, false]]) {
111+
// (transform's turn is clockwise as well: the same matrix as the rotation's)
112+
const rad = angle * Math.PI / 180;
113+
const a = source.clone().transform([Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 0, 0], {expand}).bitmap, b = source.clone().rotate(angle, {expand}).bitmap;
114+
let opaque = 0, shown = 0;
115+
for (let i = 0; i < a.length; i++) {
116+
if (a[i] === b[i]) continue;
117+
const al = a[i | 3], bl = b[i | 3];
118+
if ((i & 3) === 3) shown = Math.max(shown, Math.abs(a[i] - b[i]));
119+
else if (al === 255 && bl === 255) opaque = Math.max(opaque, Math.abs(a[i] - b[i]));
120+
else shown = Math.max(shown, Math.abs(a[i] * al - b[i] * bl) / 255);
121+
}
122+
if (opaque > 1 || shown > 2.5) panic(`bilinear rotate ${angle}: ${opaque} (opaque), ${shown} (shown)`);
123+
}
124+
}
125+
102126
// right angles with `expand: false` keep the size like every other angle (they used to swap width and height):
103127
// the turned image is centered, what doesn't fit is cut off
104128
{
208 Bytes
Loading

‎tests/targets/rotate-45.png‎

487 Bytes
Loading

0 commit comments

Comments
 (0)