From f1c1c6551a3552749bb24bef19d430cb2e9578a8 Mon Sep 17 00:00:00 2001 From: Mathis Mensing Date: Fri, 16 Oct 2026 19:42:52 +0000 Subject: [PATCH] fast rotation: 8-bit SWAR bilinear sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ --- CHANGELOG.md | 5 +++ src/image.ts | 11 +++++- src/util/alpha.ts | 5 +++ src/util/rotate.ts | 57 +++++++++++++++++++++++++++ tests/rotate.js | 21 ++++++++++ tests/targets/rotate-45-noresize.png | Bin 2031 -> 2239 bytes tests/targets/rotate-45.png | Bin 3150 -> 3637 bytes 7 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 src/util/rotate.ts 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 00840faafadac92d654968d94ab28cca79fb3c4f..8090ddd0dbc4ec2ea302e27b0a8ea6e8593a5577 100644 GIT binary patch literal 2239 zcmX|D3pi9;8(uTBhh50*a8Sya#Dp@saSCHJE;;V}P90?AQmWr5mt4nO{O%$p#UG+S zN~D`f8jW;4&KN08kw*2CQ{#~cG!n@DhBg7r#*4IjzxsVMbV@2{!#yq0R9K#E$}2Oyjy5A-s)#- z5}ribv2@{GJp6BP+x!54y>?S6{)wou0doFFf1Jv=u(hS%E{CI1R!?NVJ)e<5rC>F* zf6~EXb*eAi%~g4B6%_mCFrf?Z#?zkdE}_&Xywqq zPIuAmp7uJEESN7ZCgvN|3&eBl0paxx{r95x;9jKfA4MPQlKCzWa<@&2>i~)MZzUeL zWjBJU_>%6zzfkuM`2KM+EOSHgB$QLd+ipb9Q5);qc0dG8^#qObTO+Dcpt-VjBFXhQ6axhq#|aen#wMrw2i=u;am!%N5@)X|d=- zeWDkb7~w@5mlJ3Fc#|G|>1xR6H7`+MJec#X_$(+FN?N1C(~<~vzdl?zmI$UgF>T=s zNo@-z^jF60Hl4Ru4S(X_H+l5oS?p}+_UrO}*$Rz~+zbqDctgNY>}*QK``7`JXegV1 z+gH%}9~A9t?PTvJ8@Ee$LPHK~O=ZDzUj_`BvTfL|SXy(EzUXAy^|i<}_hFQPW@-6F zl|OeDeba!{D;h^we!3}}v>_H5NYa+ucCl&>N!FVairfx9eh1Hiv)B9vLswiaE4SRB z#qxLDGU?d8hS}2k^Wd({kJ{2le))aT&HQ)pT@?B8Ar_nB!;%y_~pFeG{{zh=PCPV8ZV7XKc3tr!>{N5Kc80MesOq8R~4{29hv1gx4RDXV$rr z@;?JN-ATf0EG>HG{AXFT9Wouz*qPh_n*mSt9;c@mdy$9nlDwO$bkCi4GFJ9DvP*}U1+Av z4o$>HdqX$jC}QIdp5?(8SI$_iML$`FU$U3{n9?1Jc3m>Ha0cGt33i`bO!3iWJ%Nf& z=Qx;MFqGolM{A>J7jUffvAd0zIaP`oD0^|HW8Ju|N?olRB+VTOUXh3ALq+~ekPahw zE6O-R(bSPABp9t+^*Wi&*K+VRL0`^bjAl9fLp%?kHep_LhkTdGN3tA_D)kH?M6Vxu zuCHdpN@Sc1E=rja4V%<9o1(U3wS*FW``&R2toUt<6qo6%;YO%s6 z?7{s?t}qAhbD(%L;=2C($8LBP>h-N0hx@V#co*TdObfr-3FsHeqxp)@zlD5gT0q7I!`+E77X$}Af?POBGj&2Q4dY&RyKXo374v@Dh*fX z{=?+wx~x7Wsp9oNT{A~ZmB<7NE<%ZHgsVN2NNhW#ru{ht3G6I^4`F@oB?3z5eQQ!j z_5AIlxq<%gwjm>;J!>yuDM!`APeC?YsQBAGh3lp>UqZwq!gQBo&GWT>e zTepeT0FtlHvJ-M^lj#ZcHfuo8(_o`j^!G^*Gb|N}By{Vi{!f!36Lbb$5$dr8D6yKn z%n$+y)x~@8X?WsfoO~fQaKWCedrfsD)-)k_-n@m5J^#v*8Ik$_7QBF4sHYB)IxjZb zb+fUh%3($fpjKs8fdKvDQqJ}eEI=}xY0C$RyC$5+hwD*zHVt0R4|jd%6V+x02qOhm zwu7?{4YJQ5o3G1SGyAkC^7CvEj^$!b6h7ST4xrxek~hYMBsI*v{DgR`;50z)09Ig=)TuYNzjPS!lUfXS*p Vl<*hPfUEc;0lS?%sf~^__TSbv*~@qOR#_vW1q z4-KZ;xYz&ysG_d|BZxORDJtnZgs22HzF#lFvHho6d0)G#Dv35`1HEv&E1 zF^ztbtkHhSrsn5ugQIu$y=?qwXjr&=MZ#r1dM2t0s(knCD3j}RsXV0rc<-LNq%5iA zVJofSuW$b`ocs8>qz-vY={nWd9lS%Rb-zyo!6{yBkRX<=B3dv=d4#V19)C2EWMZy%CryP zg4odw{)e5`?8l>jU6bvxEw^bK^gUEpOV-DB=g6ckE%hXc3r1pn5#OXqTQr~VV!=fQW{%?;Vf)n zw^>;uHz=Hj$yKo{{H!I(dFXmf-X3K>UMoRA!Q>q=mSet{2i|A=u>Izl_iia-4Eu_|pW2?q-k~ZPqpfH8wYfujMu(H0t(v)+Y zedsb}b~XZX222^ZEji^#%cqswC%sQ+G97O_;zjDfjvQOHeb>~TKTVYPln;03p}%WA z?1mPVA$KcdY*{l~iAkxlicv#qZ{(6+96Ya^qPGQ{`*3oq``Gb=>_?T>vzJnkd20QP zD|O<1_6f()w%1M0_sG`hm{g>)Xri!Chu z(LItGuvo#?g_J-Ej~~M=Z42>pR!zT`3czCfh9NE7(L-{T__FZ3mMPc##4&-LV(@wWPqWUmn zI+x>@F04;2c&(Ty4f`slVEHOF^kUWe&!E?DsJ;<5;M%N|DOGaX>&1oiO1?b}D-U z?;@~jJ5A{h*sX6(_@8!4PXad)m^Z#bfe=_`!ecB2hrp&flT=%k6JYDkRK49w=8?0-wN_jU)H+}$s{jIp2UD(y@zB08Hw?-W2q@<v ohH`eIU&p7QJHPPZpd60x%7q4pL^j;$JV2xY(caL&>q0U9KjPAE!~g&Q diff --git a/tests/targets/rotate-45.png b/tests/targets/rotate-45.png index 257925beb9961b7d0cf72fe5c526b2923b6e4cdb..5ad6201cd1c941054223410743d5a9825a50b8f4 100644 GIT binary patch delta 3585 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 literal 3150 zcmX|D4Ls9p8{UVRuT2t)L|-I{-i(lJBB_i~6lcfODZ_BeOc|N4e1wWRj_<#%v=VkI z6q%{hahzUEMstLdybQyM`I4|L>pXL3F{W=&HbAFpTR_ zCr|KSz4}#B1izcS_zz$()qYne!pY0<4M7g%_+fG~C>r zoV=c-rIs17HHz|6Q_D<}sKMK8SM${1!F3%@PL{a5)HiwgsW;1vaw6tz<_qstcP~kw z%=b)9McI#&9rz7Vou0Z`4tUYK)OhF5p@W-Zv>IZRpXMp?P&30`NSyJ9XS<`9uLut! zDG}=(wYaE+Q?j|;!h?DsL}+pKQyxlU-JmN+1j9Qv`#_A1T=tD>RO=IjmGK82qMBo& z6$ZfqQ1Y_a*u+7N_a-8!L5u6>5^3epvE388vL5UjeG;17n9pv$OPJY? zpb^>UP-ED5a;d2o`&?S?=2JHiyz=Y^Jy;R$!=9<=43R9A8h)M1Ja;AKpHRW%&-*-T zm82S;ok;fCq+SQv6n8)_+d?$mGM3XYK)DX z(pSjuRRZ#U*5al~LW%eYJ$OOR#_|g{%G_Gkn_ z^G_Q|lwS!xhGrxm+R9$sr$JddI~-Mf(4$eNX4$MNE_8~<#fqxP=+;mLptd64YOK~f zao7szxkYr-nPuj$wRMzJmL}gmu@qaKXu8isfh(W^EIce{lDXOaRLW^xfGweceLGLb_prI zIy1T7?;yA9a3Hjp2m0T7hO-nSJ>XG}?p|yA<|9%Q)}XqsN_TCBExH&fk5v9mB1j9c zI@NXM_G=ww<1UK>*xpgH&%V}zpA=e0u)2)P+%Dq0Rz7D~MO>Kl14f&1n%iae>Ki@b zB^F&>*P{Qe{(v|D^0t1qHe1KXiYR7`Xw24TP5q7#3tqExMdhu3({ScNQE;ch*Xl^R zm;>>BJLp7XrVCxNJWWE-T!k@{qyVPN@@D7G!!!Sz4K9!*Wl3%({_O+rb?N3b&85p< zrEwxOaZWGLXINpA%edHYT@bVZ=`2GZ#|4_&lUITPBV&~c>)3{jiAa|fo>5D6q zV%+p*J-?jal6)+Oy?q}C*{rkIx0QDow<`>r{F%XNIwl@3dTvR@%D z-Q6#=BqUHY7Nu=@E4HOnCrA)xuyFGht8Z@uk=xboO_@UdR2tMrCkSiOu^@Z>+W&8Dwe$^U0%KZ%Fs1Zn_;{4-^(B1q_*Q>-0X5#2Y>xbT5Uu((go?^LsILbxtj6>r z%l=Jj%^9@@<(d<-Z}ham#Qf~;l9NX*9gNssaS?y!9jT{9`ZWF{F1<1n7hj*Hz#|*` z#_}^LQz7jIn(Q~}R7G*uuENQtW+l7Kh|wQ(Wi-dQM%qfLlK6PX!ZT%YrPp5k=at8Y zJ`MUrvnFguuFb$1hw&9rI#65yew?Gtoz)kF>EA7gXW3geBV_a1(5$kL$2bOrBU#Nz zMzcWsm8#I+4_+}}S=y}!XKECd=&|~6BM8~ZJN&rj9d@@JtUja-39!8|RiS}7s>0nf z&38&cT4Q6Uix#^Pw4niiW#1A=yLt;u>}F%vuP#0n4;D$Pz6S48ebo`thlR;C$hk#| zsxX|YP@zss_ElGq&e*69plR4=u;p}bXqHex8gF8?+Atro$gHb`5MdDsX~nD>9V}3!5yTggud0O3A?%fK6l4vIhLoSN9K}Ri ztR_=<7>N#6VM1Xwg%m5wO!ZleYk-Eiip$Kx+c{&ztUhE7e3_U66>$`4f=O5}YlU@A zxQz?f$2zyt0Bm|E2uYN5m@H2_Obr2uv5S+|*xU&?$|(|7tJs?S;A(di2QgcR4 z*&2UNe%80VkbEd_30S3`VGAIXZ zn@G`t!rc*cPcFP(M~aXYwXld-93?7&%;MXb2wmBVQg5EBaNY=Qdq%_}tAfh7Y$)dl zMtlz}JGg7pr@3$@iUF5@xJw9(*X3aV$v`%`2s8HIfGLBH4;e=8KfrBGMR_m#jXj@m zTJF&Bfho58#lt(B8;x&kN%oKe5x^c1*PtiPMF+r{TIVIS=|-}F%?!?~*)m0GO~4m- zY^YGT^4(9}7RYEF$jyNhdxQNy$z*%#zzh{1rwi$9WJPN@-wq$fK@Mud-+IT-ec8A*{6TONNQnp*Lj|2?lrcx;)}C-!Ya7rkN$vrl^I4yft`=T z>dHb+KL~l%VTbeu=n(|{TszOx`p5B!R0Vu6>U~n7b~5BCu^MOZN&!o3GHBg zR!a{r;>mYTq)cEbe!?giJwUWyzj$I>CUI= zhcBHEpa+GA8J-LZJr6rcjof2svCr^WK$zh^8*2+g3k%yXA43OV