Skip to content
Open
Changes from 8 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
100 changes: 99 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,38 @@ 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,
/// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to test if other graphics tools use gamma or linear space for their implementations.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we might not need to test this for the Median filter. Since gamma correction is a monotonic function, the sort order of pixel values doesn't change between Linear and Gamma space. Therefore, the median pixel selected will be mathematically identical in both, unlike arithmetic filters (e.g., Gaussian blur), where space matters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, please remove it then.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will differ if the median is calculated by taking the average of the middle two elements, but I guess that should usually not be an issue

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you’re right, but does it really affect the output?

gamma: bool,
) -> 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, gamma))
};

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 +211,69 @@ 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)).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By putting it at the end in parentheses, this comment is unclear to me whether the (O(n log n)) refers to this function's runtime or if it refers to the cost of full sorting.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right about the comment ambiguity. I've updated it to be clearer:

/// 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).

The original comment was confusing because the (O(n log n)) at the end could be interpreted as either this function's complexity or the complexity we're avoiding. This new version explicitly states:

This function's performance: O(n) average case

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
}