use fenestra_core::ElementFilter;
use image::RgbaImage;
#[must_use]
pub fn box_blur_rgba8(img: &RgbaImage, radius: u32) -> RgbaImage {
if radius == 0 || img.width() == 0 || img.height() == 0 {
return img.clone();
}
let mut a = img.clone();
let mut b = RgbaImage::new(img.width(), img.height());
for _ in 0..3 {
box_blur_h(&a, &mut b, radius);
box_blur_v(&b, &mut a, radius);
}
a
}
#[must_use]
pub fn apply_element_filter(img: &RgbaImage, filter: ElementFilter) -> RgbaImage {
match filter {
ElementFilter::Blur(sigma) => box_blur_rgba8(img, box_radius_for_std_dev(sigma)),
ElementFilter::Brightness(m) => map_rgb(img, |ch| f32_to_u8(f32::from(ch) * m)),
ElementFilter::Saturate(m) => saturate(img, m),
}
}
#[must_use]
pub(crate) fn box_radius_for_std_dev(sigma: f32) -> u32 {
if sigma.is_nan() || sigma <= 0.0 {
return 0;
}
let r = (((1.0 + 4.0 * sigma * sigma).sqrt() - 1.0) / 2.0).round();
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "box radius is a small, finite, non-negative integer"
)]
let radius = r as u32;
radius
}
fn box_blur_h(src: &RgbaImage, dst: &mut RgbaImage, radius: u32) {
let (w, h) = (src.width(), src.height());
let count = 2 * radius + 1;
for y in 0..h {
for c in 0..4 {
let mut sum: u32 = 0;
for i in 0..count {
let x = i.saturating_sub(radius).min(w - 1);
sum += u32::from(src.get_pixel(x, y).0[c]);
}
for x in 0..w {
dst.get_pixel_mut(x, y).0[c] = div_round(sum, count);
let leaving = x.saturating_sub(radius).min(w - 1);
let entering = (x + radius + 1).min(w - 1);
sum = sum - u32::from(src.get_pixel(leaving, y).0[c])
+ u32::from(src.get_pixel(entering, y).0[c]);
}
}
}
}
fn box_blur_v(src: &RgbaImage, dst: &mut RgbaImage, radius: u32) {
let (w, h) = (src.width(), src.height());
let count = 2 * radius + 1;
for x in 0..w {
for c in 0..4 {
let mut sum: u32 = 0;
for i in 0..count {
let y = i.saturating_sub(radius).min(h - 1);
sum += u32::from(src.get_pixel(x, y).0[c]);
}
for y in 0..h {
dst.get_pixel_mut(x, y).0[c] = div_round(sum, count);
let leaving = y.saturating_sub(radius).min(h - 1);
let entering = (y + radius + 1).min(h - 1);
sum = sum - u32::from(src.get_pixel(x, leaving).0[c])
+ u32::from(src.get_pixel(x, entering).0[c]);
}
}
}
}
fn div_round(sum: u32, count: u32) -> u8 {
let mean = (sum + count / 2) / count;
u8::try_from(mean.min(255)).unwrap_or(255)
}
fn map_rgb(img: &RgbaImage, f: impl Fn(u8) -> u8) -> RgbaImage {
let mut out = img.clone();
for px in out.pixels_mut() {
px.0[0] = f(px.0[0]);
px.0[1] = f(px.0[1]);
px.0[2] = f(px.0[2]);
}
out
}
fn saturate(img: &RgbaImage, m: f32) -> RgbaImage {
let mut out = img.clone();
for px in out.pixels_mut() {
let [r, g, b, _] = px.0;
let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
px.0[0] = f32_to_u8(luma + m * (f32::from(r) - luma));
px.0[1] = f32_to_u8(luma + m * (f32::from(g) - luma));
px.0[2] = f32_to_u8(luma + m * (f32::from(b) - luma));
}
out
}
fn f32_to_u8(v: f32) -> u8 {
let v = v.round().clamp(0.0, 255.0);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "clamped to 0..=255"
)]
let out = v as u8;
out
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgba;
#[test]
fn radius_zero_is_identity() {
let mut img = RgbaImage::new(2, 2);
img.put_pixel(0, 0, Rgba([10, 20, 30, 255]));
img.put_pixel(1, 1, Rgba([200, 100, 50, 128]));
assert_eq!(box_blur_rgba8(&img, 0), img);
}
#[test]
fn uniform_field_is_unchanged() {
let img = RgbaImage::from_pixel(5, 4, Rgba([77, 88, 99, 255]));
let out = box_blur_rgba8(&img, 2);
for px in out.pixels() {
assert_eq!(px.0, [77, 88, 99, 255]);
}
}
#[test]
fn exact_three_pass_small() {
let mut img = RgbaImage::new(3, 1);
img.put_pixel(0, 0, Rgba([0, 0, 0, 255]));
img.put_pixel(1, 0, Rgba([0, 0, 0, 255]));
img.put_pixel(2, 0, Rgba([90, 0, 0, 255]));
let out = box_blur_rgba8(&img, 1);
assert_eq!(out.get_pixel(0, 0).0, [17, 0, 0, 255]);
assert_eq!(out.get_pixel(1, 0).0, [30, 0, 0, 255]);
assert_eq!(out.get_pixel(2, 0).0, [43, 0, 0, 255]);
}
#[test]
fn is_deterministic() {
let mut img = RgbaImage::new(8, 6);
for (i, px) in img.pixels_mut().enumerate() {
#[expect(clippy::cast_possible_truncation, reason = "test pattern bytes")]
let v = (i as u32 * 37 % 256) as u8;
*px = Rgba([v, v.wrapping_mul(3), v.wrapping_add(11), 255]);
}
assert_eq!(box_blur_rgba8(&img, 3), box_blur_rgba8(&img, 3));
}
#[test]
fn radius_from_std_dev() {
assert_eq!(box_radius_for_std_dev(0.0), 0);
assert_eq!(box_radius_for_std_dev(-1.0), 0);
assert_eq!(box_radius_for_std_dev(2.0_f32.sqrt()), 1);
assert_eq!(box_radius_for_std_dev(18.0), 18);
}
#[test]
fn element_filters() {
let img = RgbaImage::from_pixel(2, 2, Rgba([100, 60, 20, 128]));
let dim = apply_element_filter(&img, ElementFilter::Brightness(0.5));
assert_eq!(dim.get_pixel(0, 0).0, [50, 30, 10, 128]);
let gray = apply_element_filter(&img, ElementFilter::Saturate(0.0));
let [r, g, b, a] = gray.get_pixel(0, 0).0;
assert_eq!((r, g, b, a), (r, r, r, 128));
assert!(g == r && b == r, "grayscale: {r} {g} {b}");
}
}