Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion node-graph/graster-nodes/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Raster<CPU>> {
Expand All @@ -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))
};
Expand Down Expand Up @@ -179,3 +183,68 @@ fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: boo

y_axis
}

fn median_filter_algorithm(mut original_buffer: Image<Color>, radius: u32, gamma: bool) -> Image<Color> {
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<f32> = Vec::with_capacity(window_capacity);
let mut g_vals: Vec<f32> = Vec::with_capacity(window_capacity);
let mut b_vals: Vec<f32> = Vec::with_capacity(window_capacity);
let mut a_vals: Vec<f32> = 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
}
Loading