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
109 changes: 98 additions & 11 deletions node-graph/nodes/raster/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

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 at diff.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 for diff.abs() <= threshold while keeping fade only above the threshold (e.g. ramp from threshold to threshold + fade_width) if the intent is an unsharp-mask-style hard gate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/nodes/raster/src/filter.rs, line 397:

<comment>The threshold fade (`threshold * 0.75`) begins applying sharpening below the configured threshold, because the mask only reaches 0 at `diff.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 for `diff.abs() <= threshold` while keeping fade only above the threshold (e.g. ramp from `threshold` to `threshold + fade_width`) if the intent is an unsharp-mask-style hard gate.</comment>

<file context>
@@ -341,3 +384,40 @@ fn median_quickselect(values: &mut [f32]) -> f32 {
+	let amount = amount / 100.;
+	let threshold = threshold / 255.;
+	// Width of the linear transition around the threshold
+	let threshold_fade_width = threshold * 0.75;
+
+	let sharpen_channel = |orig: f32, blur: f32| -> f32 {
</file context>

Copy link
Copy Markdown
Author

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.


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 {
Comment thread
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At node-graph/nodes/raster/src/filter.rs, line 417:

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

<file context>
@@ -341,3 +384,47 @@ fn median_quickselect(values: &mut [f32]) -> f32 {
+	};
+
+	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();
+		let [blurred_r, blurred_g, blurred_b, _] = blurred.to_unpremultiplied_channels();
+
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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
}