Skip to content

Commit 3a9fb0d

Browse files
matmenclaude
andcommitted
faster blur: box blurs on 32-bit words
blur() premultiplied the image into floats, ran the three box blurs on four float channels (clamping the window to the image for every pixel) and converted back: 205 ms for a 1920x1080 photo, slower than stackblur (127 ms, 83 ms without alpha). The box blurs now run on the pixels as 32-bit words: red and blue share a sum as two 16-bit lanes, green and alpha another (a window of up to 257 pixels fits), and the average is a multiplication by 65536 / size. Each box rounds to 8 bits. Opaque images need no conversion, transparent ones are premultiplied in 8 bits first, as browsers (Skia) blur them. Radii below 2 keep the exact gaussian kernel on floats, as does sharpen(). 86 ms for the photo. Over 1298 corpus images at 5 radii: opaque results within 1 of before (2 above radius 10); with alpha, the premultiplied values (what compositing shows) within 2, the unpremultiplied color of almost transparent pixels further off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent 7a84f55 commit 3a9fb0d

4 files changed

Lines changed: 111 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
images two channels share a multiplication. Results differ from 3.2.0 by at most 1 per channel (more only in the
1010
invisible color of pixels that are almost fully transparent).
1111
- `clone()` is about 50% faster.
12+
- `blur()` is about 2.4 times faster (1920×1080 photo: 86 instead of 205 ms): the three box blurs run on the pixels as
13+
32-bit words, two channels per sum. Results differ from 3.2.0 by at most 1 per channel (2 for radii above 10).
14+
Transparent images are blurred premultiplied in 8 bits, like browsers do: the color of pixels that are almost
15+
transparent can differ more, what shows when they're composited stays within 2 steps.
1216

1317
### Fixes
1418

‎src/image.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +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 { gaussianBlur, premultiply, unpremultiply } from './util/blur.ts';
15+
import { gaussianBlur, boxBlurRGBA, premultiply, unpremultiply } from './util/blur.ts';
1616
import { isOptions } from './util/options.ts';
1717

1818
/** A color as a 32-bit RGBA integer (`0xRRGGBBAA`) */
@@ -1329,6 +1329,7 @@ export class Image {
13291329
if (isNaN(radius) || radius < 0)
13301330
throw new RangeError('Invalid blur radius');
13311331

1332+
if (boxBlurRGBA(this.bitmap, this.width, this.height, radius)) return this;
13321333
const pixels = premultiply(this.bitmap);
13331334
gaussianBlur(pixels, this.width, this.height, radius);
13341335
unpremultiply(pixels, this.bitmap);

‎src/util/blur.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,111 @@ export function gaussianBlur(pixels: Float32Array, width: number, height: number
146146
}
147147
}
148148

149+
// box blurs the pixels of one row (32-bit words) from src into dst, the edge pixels repeat. Red and blue share an
150+
// int as two 16-bit lanes, green and alpha another: a window of up to 257 pixels fits (255 * 257 < 65536).
151+
// `mult` divides by the window size (65536 / size)
152+
function boxRowWords(src: Int32Array, dst: Int32Array, s: number, length: number, radius: number, mult: number): void {
153+
const last = length - 1;
154+
const first = src[s], end = src[s + last];
155+
let rb = (radius + 1) * (first & 0xff00ff), ga = (radius + 1) * (first >>> 8 & 0xff00ff);
156+
for (let k = 1; k <= radius; k++) {
157+
const p = src[s + (k < last ? k : last)];
158+
rb += p & 0xff00ff;
159+
ga += p >>> 8 & 0xff00ff;
160+
}
161+
162+
for (let x = 0; x < length; x++) {
163+
dst[s + x] = (Math.imul(rb & 0xffff, mult) + 32768 >>> 16) | (Math.imul(ga & 0xffff, mult) + 32768 >>> 16) << 8
164+
| (Math.imul(rb >>> 16, mult) + 32768 >>> 16) << 16 | (Math.imul(ga >>> 16, mult) + 32768 >>> 16) << 24;
165+
const add = x + radius + 1, remove = x - radius;
166+
const entering = add < last ? src[s + add] : end, leaving = remove > 0 ? src[s + remove] : first;
167+
rb += (entering & 0xff00ff) - (leaving & 0xff00ff);
168+
ga += (entering >>> 8 & 0xff00ff) - (leaving >>> 8 & 0xff00ff);
169+
}
170+
}
171+
172+
// box blurs the columns of src into dst, sliding a window of whole rows (sums per column, in lanes as above)
173+
function boxColumnsWords(src: Int32Array, dst: Int32Array, width: number, height: number, radius: number, mult: number, rbs: Int32Array, gas: Int32Array): void {
174+
for (let x = 0; x < width; x++) {
175+
const p = src[x];
176+
rbs[x] = (radius + 1) * (p & 0xff00ff);
177+
gas[x] = (radius + 1) * (p >>> 8 & 0xff00ff);
178+
}
179+
for (let k = 1; k <= radius; k++) {
180+
const o = Math.min(k, height - 1) * width;
181+
for (let x = 0; x < width; x++) {
182+
const p = src[o + x];
183+
rbs[x] += p & 0xff00ff;
184+
gas[x] += p >>> 8 & 0xff00ff;
185+
}
186+
}
187+
188+
for (let y = 0; y < height; y++) {
189+
const o = y * width, add = Math.min(height - 1, y + radius + 1) * width, remove = Math.max(0, y - radius) * width;
190+
for (let x = 0; x < width; x++) {
191+
const rb = rbs[x], ga = gas[x];
192+
dst[o + x] = (Math.imul(rb & 0xffff, mult) + 32768 >>> 16) | (Math.imul(ga & 0xffff, mult) + 32768 >>> 16) << 8
193+
| (Math.imul(rb >>> 16, mult) + 32768 >>> 16) << 16 | (Math.imul(ga >>> 16, mult) + 32768 >>> 16) << 24;
194+
const entering = src[add + x], leaving = src[remove + x];
195+
rbs[x] = rb + (entering & 0xff00ff) - (leaving & 0xff00ff);
196+
gas[x] = ga + (entering >>> 8 & 0xff00ff) - (leaving >>> 8 & 0xff00ff);
197+
}
198+
}
199+
}
200+
201+
/**
202+
* Blurs 8-bit RGBA in place with the three box blurs of {@link gaussianBlur}, on the pixels as 32-bit words: two
203+
* channels per sum, rounded to 8 bits after each box (within a step of blurring floats, several times faster).
204+
* Transparent images are premultiplied to 8 bits first: that rounds the color of pixels that are almost
205+
* transparent, which only shows unpremultiplied (composited, they stay within half a step). Returns false (and
206+
* does nothing) for radii the box approximation doesn't cover (below 2) or that don't fit the lanes
207+
*/
208+
export function boxBlurRGBA(bitmap: Uint8ClampedArray, width: number, height: number, sigma: number): boolean {
209+
const sizes = boxSizes(sigma);
210+
if (!(sigma >= 2) || Math.max(...sizes) > 257) return false;
211+
212+
let opaque = true;
213+
for (let i = 3; i < bitmap.length; i += 4) if (bitmap[i] !== 255) {
214+
opaque = false;
215+
break;
216+
}
217+
if (!opaque) {
218+
for (let i = 0; i < bitmap.length; i += 4) {
219+
const a = bitmap[i + 3];
220+
// x * a / 255, rounded
221+
bitmap[i] = (bitmap[i] * a + 128) * 257 >> 16;
222+
bitmap[i + 1] = (bitmap[i + 1] * a + 128) * 257 >> 16;
223+
bitmap[i + 2] = (bitmap[i + 2] * a + 128) * 257 >> 16;
224+
}
225+
}
226+
227+
const pixels = new Int32Array(bitmap.buffer, bitmap.byteOffset, width * height);
228+
const tmp = new Int32Array(pixels.length), rbs = new Int32Array(width), gas = new Int32Array(width);
229+
for (const size of sizes) {
230+
const radius = (size - 1) / 2;
231+
if (radius < 1) continue;
232+
const mult = Math.round(65536 / size);
233+
for (let y = 0; y < height; y++) boxRowWords(pixels, tmp, y * width, width, radius, mult);
234+
boxColumnsWords(tmp, pixels, width, height, radius, mult, rbs, gas);
235+
}
236+
237+
if (!opaque) {
238+
for (let i = 0; i < bitmap.length; i += 4) {
239+
const a = bitmap[i + 3];
240+
if (a === 0) {
241+
bitmap[i] = bitmap[i + 1] = bitmap[i + 2] = 0;
242+
continue;
243+
}
244+
// the clamped bitmap rounds and clamps
245+
const f = 255 / a;
246+
bitmap[i] *= f;
247+
bitmap[i + 1] *= f;
248+
bitmap[i + 2] *= f;
249+
}
250+
}
251+
return true;
252+
}
253+
149254
/**
150255
* Converts 8-bit RGBA to premultiplied floats
151256
*/

‎tests/targets/effects.png‎

25 Bytes
Loading

0 commit comments

Comments
 (0)