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
85 changes: 84 additions & 1 deletion node-graph/nodes/raster/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use raster_types::Image;
use raster_types::{Bitmap, BitmapMut};
use raster_types::{CPU, Raster};

/// Blurs the image with a Gaussian or blur kernel filter.
/// Blurs the image with a Gaussian or box blur kernel filter.
#[node_macro::node(category("Raster: Filter"))]
async fn blur(
_: impl Ctx,
Expand Down Expand Up @@ -42,6 +42,36 @@ async fn blur(
.collect()
}

/// Applies a median filter to reduce noise while preserving edges.
#[node_macro::node(category("Raster: Filter"))]
async fn median_filter(
_: impl Ctx,
/// The image to be filtered.
image_frame: Table<Raster<CPU>>,
/// The radius of the filter kernel. Larger values remove more noise but may blur fine details.
#[range((0., 50.))]
#[hard_min(0.)]
radius: PixelLength,
) -> Table<Raster<CPU>> {
image_frame
.into_iter()
.map(|mut row| {
let image = row.element.clone();

// Apply median filter
let filtered_image = if radius < 0.5 {
// Minimum filter radius
image.clone()
} else {
Raster::new_cpu(median_filter_algorithm(image.into_data(), radius as u32))
};

row.element = filtered_image;
row
})
.collect()
}

// 1D gaussian kernel
fn gaussian_kernel(radius: f64) -> Vec<f64> {
// Given radius, compute the size of the kernel that's approximately three times the radius
Expand Down Expand Up @@ -179,3 +209,56 @@ fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: boo

y_axis
}

fn median_filter_algorithm(original_buffer: Image<Color>, radius: u32) -> Image<Color> {
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));
}
}

output
}
/// Finds the median of a slice using quickselect for O(n) average case performance.
/// This is more efficient than sorting the entire slice which would be 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)
// Use total_cmp for safe NaN handling instead of partial_cmp().unwrap()
*values.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
}