diff --git a/node-graph/graster-nodes/src/filter.rs b/node-graph/graster-nodes/src/filter.rs index 6274b9abf6..964bccd5dd 100644 --- a/node-graph/graster-nodes/src/filter.rs +++ b/node-graph/graster-nodes/src/filter.rs @@ -6,7 +6,7 @@ use graphene_core::raster_types::{CPU, Raster}; use graphene_core::registry::types::PixelLength; use graphene_core::table::Table; -/// Blurs the image with a Gaussian or blur kernel filter. +/// Blurs the image with a Gaussian, blur kernel or Median filter. #[node_macro::node(category("Raster: Filter"))] async fn blur( _: impl Ctx, @@ -18,6 +18,8 @@ async fn blur( radius: PixelLength, /// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts. box_blur: bool, + /// Use a median filter instead of a blur. This is good for removing noise while preserving edges, but does not produce a smooth blur effect. + median: bool, /// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software. gamma: bool, ) -> Table> { @@ -32,6 +34,8 @@ async fn blur( image.clone() } else if box_blur { Raster::new_cpu(box_blur_algorithm(image.into_data(), radius, gamma)) + } else if median { + Raster::new_cpu(median_filter_algorithm(image.into_data(), radius as u32, gamma)) } else { Raster::new_cpu(gaussian_blur_algorithm(image.into_data(), radius, gamma)) }; @@ -179,3 +183,68 @@ fn box_blur_algorithm(mut original_buffer: Image, radius: f64, gamma: boo y_axis } + +fn median_filter_algorithm(mut original_buffer: Image, radius: u32, gamma: bool) -> Image { + if gamma { + original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a())); + } else { + original_buffer.map_pixels(|px| px.to_associated_alpha(px.a())); + } + + let (width, height) = original_buffer.dimensions(); + let mut output = Image::new(width, height, Color::TRANSPARENT); + + // Pre-allocate and reuse buffers outside the loops to avoid repeated allocations. + let window_capacity = ((2 * radius + 1).pow(2)) as usize; + let mut r_vals: Vec = Vec::with_capacity(window_capacity); + let mut g_vals: Vec = Vec::with_capacity(window_capacity); + let mut b_vals: Vec = Vec::with_capacity(window_capacity); + let mut a_vals: Vec = Vec::with_capacity(window_capacity); + + for y in 0..height { + for x in 0..width { + r_vals.clear(); + g_vals.clear(); + b_vals.clear(); + a_vals.clear(); + + // Use saturating_add to avoid potential overflow in extreme cases + let y_max = y.saturating_add(radius).min(height - 1); + let x_max = x.saturating_add(radius).min(width - 1); + + for ny in y.saturating_sub(radius)..=y_max { + for nx in x.saturating_sub(radius)..=x_max { + if let Some(px) = original_buffer.get_pixel(nx, ny) { + r_vals.push(px.r()); + g_vals.push(px.g()); + b_vals.push(px.b()); + a_vals.push(px.a()); + } + } + } + + let r = median_quickselect(&mut r_vals); + let g = median_quickselect(&mut g_vals); + let b = median_quickselect(&mut b_vals); + let a = median_quickselect(&mut a_vals); + + output.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); + } + } + + if gamma { + output.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha()); + } else { + output.map_pixels(|px| px.to_unassociated_alpha()); + } + + output +} + +/// Finds the median of a slice using quickselect for efficiency. +/// This avoids the cost of full sorting (O(n log n)). +fn median_quickselect(values: &mut [f32]) -> f32 { + let mid: usize = values.len() / 2; + // nth_unstable is like quickselect: average O(n) + *values.select_nth_unstable_by(mid, |a, b| a.partial_cmp(b).unwrap()).1 +}