-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New Node: Sharpen node #4516
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?
New Node: Sharpen node #4516
Changes from all commits
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 |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ use bytemuck::{Pod, Zeroable}; | |
| use core_types::color::{Alpha, Color, Pixel, RGB}; | ||
| use core_types::context::Ctx; | ||
| use core_types::list::Item; | ||
| use core_types::registry::types::PixelLength; | ||
| use core_types::registry::types::{Percentage, PixelLength}; | ||
| use raster_types::Image; | ||
| use raster_types::{Bitmap, BitmapMut}; | ||
| use raster_types::{CPU, Raster}; | ||
|
|
@@ -18,6 +18,17 @@ struct PremultipliedGammaPixel { | |
| a: f32, | ||
| } | ||
|
|
||
| impl PremultipliedGammaPixel { | ||
| fn to_unpremultiplied_channels(self) -> [f32; 4] { | ||
| if self.a > 0. { | ||
| let inv_a = 1. / self.a; | ||
| [self.r * inv_a, self.g * inv_a, self.b * inv_a, self.a] | ||
| } else { | ||
| [0., 0., 0., 0.] | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl Pixel for PremultipliedGammaPixel {} | ||
|
|
||
| impl RGB for PremultipliedGammaPixel { | ||
|
|
@@ -49,13 +60,13 @@ impl Alpha for PremultipliedGammaPixel { | |
| } | ||
| } | ||
|
|
||
| fn premultiply_gamma(buffer: Image<Color>) -> Image<PremultipliedGammaPixel> { | ||
| fn premultiply_gamma(buffer: &Image<Color>) -> Image<PremultipliedGammaPixel> { | ||
| Image { | ||
| width: buffer.width, | ||
| height: buffer.height, | ||
| data: buffer | ||
| .data | ||
| .into_iter() | ||
| .iter() | ||
| .map(|px| { | ||
| let [r, g, b, a] = px.to_gamma_srgb_channels(); | ||
| PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a } | ||
|
|
@@ -73,12 +84,8 @@ fn unpremultiply_gamma_to_linear(buffer: Image<PremultipliedGammaPixel>) -> Imag | |
| .data | ||
| .into_iter() | ||
| .map(|px| { | ||
| if px.a > 0. { | ||
| let inv_a = 1. / px.a; | ||
| Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a) | ||
| } else { | ||
| Color::TRANSPARENT | ||
| } | ||
| let [r, g, b, a] = px.to_unpremultiplied_channels(); | ||
| Color::from_gamma_srgb_channels(r, g, b, a) | ||
| }) | ||
| .collect(), | ||
| base64_string: None, | ||
|
|
@@ -143,6 +150,42 @@ async fn median_filter( | |
| Item::from_parts(filtered_image, attributes) | ||
| } | ||
|
|
||
| /// Sharpens the image using unsharp mask. | ||
| #[node_macro::node(category("Raster: Filter"))] | ||
| async fn sharpen( | ||
| _: impl Ctx, | ||
| /// The image to be sharpened. | ||
| image_frame: Item<Raster<CPU>>, | ||
| /// The strength of the sharpening effect. | ||
| #[range] | ||
| #[hard(0..)] | ||
| #[soft(..100)] | ||
| amount: Item<Percentage>, | ||
| /// Sets how many pixels around edges are affected. | ||
| #[range] | ||
| #[hard(0..)] | ||
| #[soft(..50)] | ||
| radius: Item<PixelLength>, | ||
| /// Sets how many different pixels must be from surrounding area before sharpening is applied. | ||
| #[range] | ||
| #[hard(0..255)] | ||
| #[soft(..30)] | ||
| threshold: Item<u32>, | ||
| ) -> Item<Raster<CPU>> { | ||
| let (amount, radius, threshold) = (*amount.element(), *radius.element(), *threshold.element()); | ||
|
|
||
| let (image, attributes) = image_frame.into_parts(); | ||
|
|
||
| let sharpened_image = if radius < 0.1 || amount == 0. { | ||
| // Minimum sharpen radius and amount | ||
| image | ||
| } else { | ||
| Raster::new_cpu(sharpen_algorithm(image.into_data(), amount as f32, radius, threshold as f32)) | ||
| }; | ||
|
|
||
| Item::from_parts(sharpened_image, attributes) | ||
| } | ||
|
|
||
| // 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 | ||
|
|
@@ -172,7 +215,7 @@ fn gaussian_kernel(radius: f64) -> Vec<f64> { | |
| fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> { | ||
| let kernel = gaussian_kernel(radius); | ||
| if gamma { | ||
| let working = premultiply_gamma(buffer); | ||
| let working = premultiply_gamma(&buffer); | ||
| let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); | ||
| unpremultiply_gamma_to_linear(blurred) | ||
| } else { | ||
|
|
@@ -186,7 +229,7 @@ fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Im | |
|
|
||
| fn box_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> { | ||
| if gamma { | ||
| let working = premultiply_gamma(buffer); | ||
| let working = premultiply_gamma(&buffer); | ||
| let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); | ||
| unpremultiply_gamma_to_linear(blurred) | ||
| } else { | ||
|
|
@@ -341,3 +384,47 @@ fn median_quickselect(values: &mut [f32]) -> f32 { | |
| // 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 | ||
| } | ||
|
|
||
| fn sharpen_algorithm(mut buffer: Image<Color>, amount: f32, radius: f64, threshold: f32) -> Image<Color> { | ||
| // Normalize threshold and amount | ||
| let amount = amount / 100.; | ||
| let threshold = threshold / 255.; | ||
|
|
||
| if threshold >= 1. { | ||
| return buffer; | ||
| } | ||
|
|
||
| let kernel = gaussian_kernel(radius); | ||
| let working = premultiply_gamma(&buffer); | ||
|
|
||
| let blurred_image = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); | ||
|
|
||
| // Width of the linear transition around the threshold | ||
| let threshold_fade_width = threshold * 0.75; | ||
|
|
||
| let sharpen_channel = |orig: f32, blur: f32| -> f32 { | ||
| // This operates on normalized sRGB values | ||
| let diff = orig - blur; | ||
| let mask = if threshold_fade_width > 0.0 { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| ((diff.abs() - threshold + threshold_fade_width) / (threshold_fade_width * 2.)).clamp(0., 1.) | ||
| } else { | ||
| 1.0 | ||
| }; | ||
| (orig + diff * amount * mask).clamp(0., 1.) | ||
| }; | ||
|
|
||
| for (original, blurred) in buffer.data.iter_mut().zip(&blurred_image.data) { | ||
| let [original_r, original_g, original_b, original_a] = original.to_gamma_srgb_channels(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The sharpening compares gamma values of the premultiplied original against un-premultiplied blur values. original.to_gamma_srgb_channels() gamma-encodes the premultiplied linear RGB stored in Color, while blurred.to_unpremultiplied_channels() is un-premultiplied. For semi-transparent pixels these representations diverge (the original is scaled by alpha, the blur is not), so the diff and threshold mask are computed on mismatched data, which can produce fringing/halos at soft alpha edges. Un-premultiply the original channels as well before computing the diff, so both sides are unassociated. Prompt for AI agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is how it is handled in gaussian blur. so fixing this here would require a fix there as well. I dont know enough about this particular issue and the chances it will occur while using normally. |
||
| let [blurred_r, blurred_g, blurred_b, _] = blurred.to_unpremultiplied_channels(); | ||
|
|
||
| // Sharpens RGB channels while preserving alpha channel | ||
| let final_r = sharpen_channel(original_r, blurred_r); | ||
| let final_g = sharpen_channel(original_g, blurred_g); | ||
| let final_b = sharpen_channel(original_b, blurred_b); | ||
|
|
||
| let unassociated = Color::from_gamma_srgb_channels(final_r, final_g, final_b, original_a); | ||
| *original = Color::from_rgbaf32_unchecked(unassociated.r() * original_a, unassociated.g() * original_a, unassociated.b() * original_a, original_a); | ||
| } | ||
|
|
||
| buffer | ||
| } | ||
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.
P2: The threshold fade (
threshold * 0.75) begins applying sharpening below the configured threshold, because the mask only reaches 0 atdiff.abs() <= threshold - fade_width. At the default threshold of 30 this sharpens pixels that differ by ~8/255, and at high threshold values the deviation grows — contradicting the parameter's documented 'before sharpening is applied' behavior. Consider gating the mask at 0 fordiff.abs() <= thresholdwhile keeping fade only above the threshold (e.g. ramp fromthresholdtothreshold + fade_width) if the intent is an unsharp-mask-style hard gate.Prompt for AI agents
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.
The unsharp mask style hard gate mentioned here produces output that differs from the output of other software like photoshop and gimp. The threshold_fade_width is a hacky way to get output that looks similar.