-
-
Notifications
You must be signed in to change notification settings - Fork 989
Feature: Add median filter option to blur node #3196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 8 commits
ab88c3f
9010e07
2e1e713
0d29373
d79af07
c8e5bd6
9df9c10
a6b538a
9a6beef
d19c7ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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. | ||
| 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 | ||
|
|
@@ -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)). | ||
|
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?