From 60831b87d9d8b7f86732e5c4c363084a9079209c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 12:03:13 +1100 Subject: [PATCH] Preserve edge color and brightness through glow blur --- crates/effects/src/pixel/edges.rs | 61 +++++++++++++++++++++---------- crates/effects/src/pixel/tests.rs | 15 ++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/pixel/edges.rs b/crates/effects/src/pixel/edges.rs index afed97da2..d63b24211 100644 --- a/crates/effects/src/pixel/edges.rs +++ b/crates/effects/src/pixel/edges.rs @@ -2,26 +2,31 @@ use super::blur_plane; -/// Find Sobel edges using alpha-weighted straight colour, smooth and expand them, then draw -/// coloured outlines on black. Samples outside the raster are transparent. +/// Find Sobel edges, smooth and expand them, then draw coloured outlines on black. Samples outside +/// the raster are transparent. pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightness: f64, smoothness: f64) { if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) { return; } let src = px.to_vec(); - let signal = |x: isize, y: isize| -> f32 { + let pixel = |x: isize, y: isize| -> ([f32; 3], f32) { // Beyond the raster's edges is transparency (not the next row's first pixel). - let (Ok(x), Ok(y)) = (usize::try_from(x), usize::try_from(y)) else { return 0.0 }; + let (Ok(x), Ok(y)) = (usize::try_from(x), usize::try_from(y)) else { return ([0.0; 3], 0.0) }; if x >= w || y >= h { - return 0.0; + return ([0.0; 3], 0.0); } - let Some(p) = src.get(y * w + x) else { return 0.0 }; + let Some(p) = src.get(y * w + x) else { return ([0.0; 3], 0.0) }; let alpha = f32::from(p[3]) / 255.0; let straight = if alpha > 0.0 { [f32::from(p[0]) / 255.0 / alpha, f32::from(p[1]) / 255.0 / alpha, f32::from(p[2]) / 255.0 / alpha] } else { [0.0; 3] }; - (0.2126 * straight[0] + 0.7152 * straight[1] + 0.0722 * straight[2]) * alpha + (straight, alpha) + }; + let signal = |x: isize, y: isize| { + let (rgb, alpha) = pixel(x, y); + (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) * alpha }; let mut edges = vec![0.0f32; px.len()]; + let mut colours: [Vec; 3] = std::array::from_fn(|_| vec![0.0; px.len()]); for y in 0..h { for x in 0..w { let x = x as isize; @@ -32,8 +37,22 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes + signal(x - 1, y + 1) + 2.0 * signal(x, y + 1) + signal(x + 1, y + 1); - if let Some(edge) = edges.get_mut(y as usize * w + x as usize) { - *edge = gx.hypot(gy).clamp(0.0, 1.0); + let alpha_signal = |x, y| pixel(x, y).1; + let agx = -alpha_signal(x - 1, y - 1) + alpha_signal(x + 1, y - 1) - 2.0 * alpha_signal(x - 1, y) + 2.0 * alpha_signal(x + 1, y) + - alpha_signal(x - 1, y + 1) + + alpha_signal(x + 1, y + 1); + let agy = -alpha_signal(x - 1, y - 1) - 2.0 * alpha_signal(x, y - 1) - alpha_signal(x + 1, y - 1) + + alpha_signal(x - 1, y + 1) + + 2.0 * alpha_signal(x, y + 1) + + alpha_signal(x + 1, y + 1); + let index = y as usize * w + x as usize; + let edge = gx.hypot(gy).max(agx.hypot(agy)).clamp(0.0, 1.0); + if let Some(value) = edges.get_mut(index) { + *value = edge; + let (rgb, alpha) = pixel(x, y); + for channel in 0..3 { + colours[channel][index] = rgb[channel] * alpha * edge; + } } } } @@ -41,18 +60,20 @@ pub(super) fn glow(px: &mut [[u8; 4]], w: usize, h: usize, width: f64, brightnes if width > 0.5 { blur_plane(&mut edges, w, h, width * 0.5); } - for (out, (original, edge)) in px.iter_mut().zip(src.iter().zip(edges)) { + let peak = edges.iter().copied().fold(0.0f32, f32::max); + if peak > 0.0 { + for colour in &mut colours { + blur_plane(colour, w, h, smoothness.max(0.0)); + if width > 0.5 { + blur_plane(colour, w, h, width * 0.5); + } + } + } + for (index, (out, (original, edge))) in px.iter_mut().zip(src.iter().zip(edges)).enumerate() { let alpha = f32::from(original[3]) / 255.0; - let glow = (edge * brightness as f32 / 6.0).clamp(0.0, 1.0); - let straight = if original[3] > 0 { - [ - f32::from(original[0]) / 255.0 / alpha.max(1e-6), - f32::from(original[1]) / 255.0 / alpha.max(1e-6), - f32::from(original[2]) / 255.0 / alpha.max(1e-6), - ] - } else { - [1.0, 1.0, 1.0] - }; + let glow = if peak > 0.0 { (edge / peak * brightness as f32 / 6.0).clamp(0.0, 1.0) } else { 0.0 }; + let colour_weight = colours[0][index].max(colours[1][index]).max(colours[2][index]); + let straight = if colour_weight > 1e-6 { std::array::from_fn(|channel| colours[channel][index] / colour_weight) } else { [0.0; 3] }; let a = alpha.max(glow); *out = [ (straight[0] * glow * a * 255.0).round().clamp(0.0, 255.0) as u8, diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 120e7a1c4..883a5cbc2 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -143,6 +143,21 @@ fn glowing_edges_glow_alike_on_every_side() { assert!(at(&out, w, 4, 4)[0] < left[0], "the inside stays darker than the edges"); } +#[test] +fn glowing_edges_defaults_keep_the_outline_bright_and_use_source_colour() { + let (w, h) = (17, 17); + let src = image(w, h, |x, y| if (4..13).contains(&x) && (4..13).contains(&y) { [180, 0, 0, 255] } else { [0; 4] }); + let mut out = src.clone(); + fx("stylize.glowingEdges", json!({})).apply(&mut out, w, h, &space(w, h)); + + let outline = at(&out, w, 4, 8); + assert!(outline[0] >= 170, "default outline should retain a near-full-color peak: {outline:?}"); + let outside = at(&out, w, 3, 8); + assert_eq!(outside[1], 0, "transparent halo must not be grey: {outside:?}"); + assert_eq!(outside[2], 0, "transparent halo must retain source hue: {outside:?}"); + assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3])); +} + #[test] fn spin_blur_smears_along_arcs_and_keeps_the_centre() { let (w, h) = (41, 41);