Surface Blur: 13x to 40x+ faster on 8-bit layers (sliding histogram) (#631)

* Surface Blur: sliding 8-bit histogram instead of the full window sum

Surface Blur summed the whole (2r+1)^2 window for every pixel and channel, so radius 100 read about 40,000 samples per pixel. For 8-bit layers (every colour sample k / 255) keep a 256-bin histogram per channel that slides along the row, one column out and one in per step, and weight only the levels within the threshold, with the same weight expression as the direct sum. 16-bit and float layers, and radii where the direct sum is cheaper, keep the direct sum (now surface_direct).

* Surface Blur test: as_chunks_mut (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Armaan Sandhu 2026-10-07 16:06:23 -04:00 committed by GitHub
parent 09b6572578
commit 3a3984075a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 127 additions and 1 deletions

View File

@ -314,11 +314,102 @@ pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: Rad
})
}
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
/// [`surface`] for 8-bit samples (every colour sample in the window is `k / 255`; `None`
/// otherwise): a 256-bin histogram of each channel over the window slides along the row, adding
/// and removing one column per step, so a pixel costs O(radius + levels) instead of O(radius^2).
/// Each level is weighted with the same expression as the direct sum.
fn surface_8bit(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32, reach: usize) -> Option<Vec<f32>> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let win = out.inflate(r);
let (ww, wh) = (win.width() as usize, win.height() as usize);
// Level of every colour sample in the window; `SKIP` where the pixel is transparent (the
// direct sum skips those). Outside the source reads as 0, as `Image::get` does.
const SKIP: u16 = 256;
let mut lv = vec![0u16; ww * wh * cc];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let i = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * cc;
let skip = ctx.alpha && src.get(x, y, n - 1) <= 0.0;
for c in 0..cc {
let v = src.get(x, y, c);
let k = (v * 255.0).round();
if !(0.0..=255.0).contains(&k) || (k / 255.0).to_bits() != v.to_bits() {
return None;
}
lv[i + c] = if skip { SKIP } else { k as u16 };
}
}
}
let (ow, oh) = (out.width() as usize, out.height() as usize);
let side = (2 * r + 1) as usize;
let mut res = vec![0.0f32; ow * oh * n];
let mut hist = [0u32; 257];
for oy in 0..oh {
for c in 0..cc {
hist.fill(0);
// Window of output column 0: window rows oy..oy + side, columns 0..side.
let col = |hist: &mut [u32; 257], wx: usize, add: bool| {
for wy in oy..oy + side {
let k = lv[(wy * ww + wx) * cc + c] as usize;
if add {
hist[k] += 1;
} else {
hist[k] -= 1;
}
}
};
for wx in 0..side {
col(&mut hist, wx, true);
}
for ox in 0..ow {
let p = src.px(out.x0 + ox as i32, out.y0 + oy as i32);
let v0 = p[c];
let k0 = (v0 * 255.0).round() as usize;
let (mut acc, mut wsum) = (0.0f64, 0.0f64);
for (k, &cnt) in hist.iter().enumerate().take(256.min(k0 + reach + 1)).skip(k0.saturating_sub(reach)) {
if cnt == 0 {
continue;
}
let v = k as f32 / 255.0;
let w = (1.0 - (v - v0).abs() / t).max(0.0);
acc += f64::from(v * w) * f64::from(cnt);
wsum += f64::from(w) * f64::from(cnt);
}
res[(oy * ow + ox) * n + c] = if wsum > 0.0 { (acc / wsum) as f32 } else { v0 };
if ox + 1 < ow {
col(&mut hist, ox, false);
col(&mut hist, ox + side, true);
}
}
}
if ctx.alpha {
for ox in 0..ow {
res[(oy * ow + ox) * n + n - 1] = src.px(out.x0 + ox as i32, out.y0 + oy as i32)[n - 1];
}
}
}
Some(res)
}
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
let r = radius.max(0.0).round() as i32;
let t = (threshold.max(1.0) / 255.0) * 2.5;
// Levels within the threshold of the centre (the only ones with any weight), on each side.
let reach = ((t * 255.0).ceil() as usize).min(255);
let side = (2 * r + 1) as usize;
if side * side > 2 * reach + 1 + 4 * side
&& let Some(res) = surface_8bit(src, out, ctx, r, t, reach)
{
return res;
}
surface_direct(src, out, ctx, r, t)
}
/// [`surface`] by summing the whole (2r + 1)^2 window of every pixel.
fn surface_direct(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
@ -394,4 +485,39 @@ mod tests {
assert!(err < 1e-5, "radius {r}: max error {err}");
}
}
#[test]
fn surface_blur_8bit_histogram_matches_the_direct_sum() {
// 8-bit levels with some fully transparent pixels, which the sum skips.
let src_rect = Rect::new(-14, -14, 40, 34);
let mut img = Image::new(src_rect, 4);
let mut s = 0x6c07_8965u32;
for px in img.data.as_chunks_mut::<4>().0 {
for v in px.iter_mut() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = (s % 256) as f32 / 255.0;
}
if s.is_multiple_of(7) {
px[3] = 0.0;
}
}
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
// The output reaches the source edge, where samples read as transparent.
let out = Rect::new(-2, 0, 26, 20);
for (r, threshold) in [(1, 2.0), (4, 15.0), (12, 60.0), (12, 255.0)] {
let t = (threshold / 255.0) * 2.5;
let reach = ((t * 255.0f32).ceil() as usize).min(255);
let fast = surface_8bit(&img, out, &ctx, r, t, reach).expect("8-bit input");
let direct = surface_direct(&img, out, &ctx, r, t);
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(err < 1e-5, "r {r} threshold {threshold}: max error {err}");
}
// Not 8-bit: the histogram declines and the direct sum is used.
// (0, 0) is inside every window above.
let i = (14 * src_rect.width() as usize + 14) * 4;
img.data[i] = 0.5 / 255.0;
assert!(surface_8bit(&img, out, &ctx, 4, 0.1, 26).is_none());
}
}