From 3bf7c4bcd27107db091a5e3f31bc8a0b9b4dcad2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:17:52 -0400 Subject: [PATCH] M13.9: gpu Shadow/Highlight, CC Color Neutralizer, Invert HLS / YIQ Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 28 ++++--- crates/effects/src/lib.rs | 4 +- crates/gpu/src/effects.rs | 1 + crates/gpu/src/fx_tone.rs | 66 +++++++++++++++- crates/gpu/src/shaders/fx_tone.wgsl | 115 +++++++++++++++++++++++++++- crates/gpu/src/tests_fx_tone.rs | 27 +++++++ 6 files changed, 228 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index ea6bdc65..8f929874 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -453,17 +453,29 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // Shadow/Highlight -fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Shadow/Highlight's shadow and highlight amounts for `img` (Auto Amounts measures them). +pub fn shadow_highlight_amounts(ctx: &EffectCtx, img: &Image) -> (f32, f32) { let (mut s_amt, mut h_amt) = (ctx.params.f("shadowAmount") as f32 / 100.0, ctx.params.f("highlightAmount") as f32 / 100.0); - let luma = Plane::luma(&b.img); - let src_hist = histograms(&b.img); if ctx.params.b("autoAmounts") { // Temporal Smoothing / Scene Detect average the analysis over neighbouring frames. - let mean = - if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, &b.img)[3]) as f32 } else { hist_mean(&src_hist[3]) as f32 }; + let mean = if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, img)[3]) } else { hist_mean(&histograms(img)[3]) } as f32; s_amt = ((0.6 - mean) * 1.5).clamp(0.0, 1.0) * 0.8 + 0.1; h_amt = ((mean - 0.6) * 1.5).clamp(0.0, 1.0) * 0.8; } + (s_amt, h_amt) +} + +/// The luminance values clipping `black` / `white` fractions of `img`'s alpha-weighted +/// luminance histogram (Shadow/Highlight's Black / White Clip). +pub fn luma_clip_points(img: &Image, black: f64, white: f64) -> (f32, f32) { + clip_points(&histograms(img)[3], black, white) +} + +fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (s_amt, h_amt) = shadow_highlight_amounts(ctx, &b.img); + let luma = Plane::luma(&b.img); + let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| luma_clip_points(&b.img, bc, wc)); let s_tw = (ctx.params.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01); let h_tw = (ctx.params.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01); let s_r = ctx.params.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; @@ -495,10 +507,8 @@ fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { // Black / White Clip: the adjusted image's extremes (ignoring those fractions of pixels) // are mapped back onto the original's, so lifting shadows or recovering highlights doesn't // flatten the ends of the tonal range. - let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); - if bc > 0.0 || wc > 0.0 { - let (lo0, hi0) = clip_points(&src_hist[3], bc, wc); - let (lo1, hi1) = clip_points(&histograms(&b.img)[3], bc, wc); + if let Some((lo0, hi0)) = clip0 { + let (lo1, hi1) = luma_clip_points(&b.img, bc, wc); if (lo0, hi0) != (lo1, hi1) && hi1 - lo1 > 1e-3 { let k = (hi0 - lo0) / (hi1 - lo1); map_ca(&mut b.img, |c, a| (c.map(|v| (lo0 + (v - lo1) * k).max(0.0)), a)); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index af99ec6f..d1225894 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -70,7 +70,7 @@ pub use color_fx::{ HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; -pub use color2::{Curve, auto_correct_settings, equalize_tables}; +pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use distort::transform_shutter; pub use distort2::parse_mesh; use effectcraft_keyframe::Value; @@ -850,6 +850,8 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.autocontrast", "ec.color.autocolor", "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", // effectcraft-gpu fx_stylize (stylize, distort) // // effectcraft-gpu fx_noise (noise, blur, time) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42339110..f7dc25ed 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -119,6 +119,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.color.curves" => curves(e, ctx, b), "ec.color.huesaturation" if !effectcraft_effects::huesat_ranges_identity(ctx) => crate::fx_tone::hue_saturation(e, ctx, b), "ec.color.levels" if !effectcraft_effects::levels_channels_identity(ctx) => crate::fx_tone::levels(e, ctx, b), + "ec.channel.invert" if (4..=11).contains(&ctx.params.e("channel")) => crate::fx_tone::invert(e, ctx, b), _ if id.starts_with("ec.control.") => Some(b), _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs index 91b681b2..1f829b5e 100644 --- a/crates/gpu/src/fx_tone.rs +++ b/crates/gpu/src/fx_tone.rs @@ -13,7 +13,7 @@ use effectcraft_color::rgb_to_hsl; use effectcraft_effects::EffectCtx; use crate::context::{Enc, Params}; -use crate::effects::GBuf; +use crate::effects::{GBuf, gaussian_blur}; /// Compute entry points in `fx_tone.wgsl`. pub(crate) const KERNELS: &[&str] = &["fxt_point"]; @@ -37,11 +37,63 @@ pub(crate) const IDS: &[&str] = &[ "ec.color.autocontrast", "ec.color.autocolor", "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", ]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { - point(e, id, ctx, b) + match id { + "ec.color.shadowhighlight" => shadow_highlight(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +/// Invert in the HLS and YIQ channels (the base `pointwise` kernel does RGB and Alpha). +pub(crate) fn invert(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = op(18); + p.u[0][1] = ctx.params.e("channel"); + p.f[0][0] = 1.0 - ctx.params.f("blend") as f32 / 100.0; + run(e, &p, b, None) +} + +/// Shadow/Highlight: the amounts (Auto Amounts) and the Black / White Clip ranges are measured +/// on the CPU; the luminance bases are blurred and the image adjusted on the GPU. +fn shadow_highlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let src = e.download(&b.img)?; + let (s_amt, h_amt) = effectcraft_effects::shadow_highlight_amounts(ctx, &src); + let (bc, wc) = (pr.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, pr.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| effectcraft_effects::luma_clip_points(&src, bc, wc)); + let s_r = pr.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; + let h_r = pr.f("moreOptions/highlightRadius").max(0.0) * b.scale / 2.0; + let (w, h) = (b.img.width, b.img.height); + let luma = e.image(w, h); + e.pixels("fxt_point", &op(19), &b.img, None, &luma, None); + let base_s = gaussian_blur(e, &luma, s_r, s_r, true); + let base_h = if (h_r - s_r).abs() < 1e-9 { base_s.clone() } else { gaussian_blur(e, &luma, h_r, h_r, true) }; + let bases = e.image(w, h); + e.pixels("fxt_point", &op(21), &base_s, Some(&base_h), &bases, None); + let mut p = op(20); + p.f[0] = + [s_amt, h_amt, (pr.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01), (pr.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01)]; + p.f[1] = [ + pr.f("moreOptions/colorCorrection") as f32 / 100.0, + pr.f("moreOptions/midtoneContrast") as f32 / 100.0, + (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32, + 0.0, + ]; + let out = e.image(w, h); + e.pixels("fxt_point", &p, &b.img, Some(&bases), &out, None); + let Some((lo0, hi0)) = clip0 else { return Some(GBuf { img: out, ..b }) }; + let adjusted = e.download(&out)?; + let (lo1, hi1) = effectcraft_effects::luma_clip_points(&adjusted, bc, wc); + if (lo0, hi0) == (lo1, hi1) || hi1 - lo1 <= 1e-3 { + return Some(GBuf { img: out, ..b }); + } + let mut p = op(22); + p.f[0] = [lo0, lo1, (hi0 - lo0) / (hi1 - lo1), 0.0]; + run(e, &p, GBuf { img: out, ..b }, None) } fn rgb(c: [f32; 4]) -> [f32; 4] { @@ -267,6 +319,16 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0]; p.f[1] = rgb(pr.color("gamutWarningColor")); } + "ec.color.cccolorneutralizer" => { + let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; + for (i, (u, bal)) in pairs.iter().enumerate() { + let (u, v) = (pr.color(u), pr.color(bal)); + p.f[i] = [v[0] - u[0], v[1] - u[1], v[2] - u[2], 0.0]; + } + p.u[0][0] = 23; + p.f[3] = [f("pinning") / 100.0, f("blendWOriginal") / 100.0, f("contrast") / 100.0, 0.0]; + p.f[4] = [f("darks") / 100.0, f("brights") / 100.0, 0.0, 0.0]; + } "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" | "ec.color.equalize" => { // The histograms are measured on the CPU. let img = e.download(&b.img)?; diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 35bd00f2..a6703cca 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -144,7 +144,9 @@ fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { // 5 Leave Color 6 Change Color 7 Broadcast Colors 8 Color Balance (HLS) 9 CC Toner // 10 CC Color Offset 11 CC Kernel 12 PS Arbitrary Map 13 Video Limiter // 14 Hue/Saturation with colour ranges 15 Levels with channel controls -// 16 Auto Levels / Contrast / Color apply 17 Equalize apply +// 16 Auto Levels / Contrast / Color apply 17 Equalize apply 18 Invert (HLS / YIQ channels) +// 19 luminance plane 20 Shadow/Highlight (aux = (shadow base, highlight base)) +// 21 pack src.x, aux.x 22 Shadow/Highlight clip remap 23 CC Color Neutralizer @compute @workgroup_size(16, 16) fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -586,7 +588,118 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let na = clamp(max(a, 0.0), 0.0, 1.0); o = vec4(r * na, na); } + case 18u: { + if (a <= 0.0) { + break; + } + let ch = u0.y; + var r: vec3; + if (ch <= 7u) { + var hsl = rgb_to_hsl(c); + if (ch == 4u || ch == 5u) { + hsl.x = 1.0 - hsl.x; + } + if (ch == 4u || ch == 6u) { + hsl.z = 1.0 - hsl.z; + } + if (ch == 4u || ch == 7u) { + hsl.y = 1.0 - hsl.y; + } + r = hsl_to_rgb(hsl.x, hsl.y, hsl.z); + } else { + var q = vec3( + 0.299 * c.x + 0.587 * c.y + 0.114 * c.z, + 0.596 * c.x - 0.274 * c.y - 0.322 * c.z, + 0.211 * c.x - 0.523 * c.y + 0.312 * c.z, + ); + if (ch == 8u || ch == 9u) { + q.x = 1.0 - q.x; + } + if (ch == 8u || ch == 10u) { + q.y = -q.y; + } + if (ch == 8u || ch == 11u) { + q.z = -q.z; + } + r = max( + vec3(q.x + 0.956 * q.y + 0.621 * q.z, q.x - 0.272 * q.y - 0.647 * q.z, q.x - 1.106 * q.y + 1.703 * q.z), + vec3(0.0), + ); + } + o = vec4((c + (r - c) * f0.x) * a, a); + } + case 19u: { + o = vec4(luminance(c)); + } + case 20u: { + if (a <= 0.0) { + break; + } + let bases = textureLoad(aux, p, 0); + let s_amt = f0.x; + let h_amt = f0.y; + let ws0 = clamp(1.0 - bases.x / f0.z, 0.0, 1.0); + let wh0 = clamp((bases.y - (1.0 - f0.w)) / f0.w, 0.0, 1.0); + let ws = ws0 * ws0; + let wh = wh0 * wh0; + let gain = (1.0 + s_amt * ws * 2.0) * (1.0 - h_amt * wh * 0.5); + let l = luminance(c); + let nl = l * gain; + let sat = pow(max(gain, 1e-3), P.f[1].x); + var r = nl + (c - l) * sat; + let mc = P.f[1].y; + if (mc != 0.0) { + let shift = (0.5 + (nl - 0.5) * (1.0 + mc)) - nl; + r = r + shift; + } + r = fxt_mix(max(r, vec3(0.0)), c, P.f[1].z); + o = vec4(r * a, a); + } + case 21u: { + o = vec4(px.x, textureLoad(aux, p, 0).x, 0.0, 0.0); + } + case 22u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let r = max(f0.x + (c - f0.y) * f0.z, vec3(0.0)); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 23u: { + if (a <= 0.0) { + break; + } + let l = clamp(luminance(c), 0.0, 1.0); + let ws = 1.0 - fxt_smoothstep(0.0, 0.5, l); + let wh = fxt_smoothstep(0.5, 1.0, l); + let wm = 1.0 - ws - wh; + let pin = P.f[3].x; + let keep = 1.0 - pin * clamp(1.0 - 4.0 * l * (1.0 - l), 0.0, 1.0); + let contrast = P.f[3].z; + let darks = P.f[4].x; + let brights = P.f[4].y; + var r: vec3; + for (var k = 0; k < 3; k++) { + let d = P.f[0][k] * ws + P.f[1][k] * wm + P.f[2][k] * wh; + var v = c[k] + d * keep; + v = 0.5 + (v - 0.5) * (1.0 + contrast); + v += darks * 0.25 * (1.0 - l) * (1.0 - l) + brights * 0.25 * l * l; + r[k] = max(v, 0.0); + } + r = fxt_mix(r, c, P.f[3].y); + o = vec4(r * a, a); + } default: {} } textureStore(out, p, o); } + +// util::smoothstep. +fn fxt_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs index 5958e470..50b6f55a 100644 --- a/crates/gpu/src/tests_fx_tone.rs +++ b/crates/gpu/src/tests_fx_tone.rs @@ -115,3 +115,30 @@ fn automatic_corrections() { effect_case("ec.color.equalize", &[("style", e(1)), ("amount", n(70.0))]); effect_case("ec.color.equalize", &[("style", e(2))]); } + +#[test] +fn shadow_highlight_neutralizer_invert() { + effect_case("ec.color.shadowhighlight", &[]); + effect_case( + "ec.color.shadowhighlight", + &[ + ("autoAmounts", off()), + ("shadowAmount", n(70.0)), + ("highlightAmount", n(40.0)), + ("moreOptions/shadowRadius", n(8.0)), + ("moreOptions/highlightRadius", n(15.0)), + ("moreOptions/midtoneContrast", n(30.0)), + ("moreOptions/blackClip", n(2.0)), + ("blend", n(20.0)), + ], + ); + effect_case("ec.color.shadowhighlight", &[("moreOptions/blackClip", n(0.0)), ("moreOptions/whiteClip", n(0.0)), ("moreOptions/colorCorrection", n(-40.0))]); + effect_case( + "ec.color.cccolorneutralizer", + &[("shadowsBalance", c(0.1, 0.0, 0.05)), ("midtonesUnbalance", c(0.6, 0.5, 0.4)), ("pinning", n(40.0)), ("contrast", n(20.0)), ("darks", n(-30.0))], + ); + effect_case("ec.color.cccolorneutralizer", &[("highlightsBalance", c(0.9, 1.0, 0.8)), ("brights", n(40.0)), ("blendWOriginal", n(25.0))]); + for ch in 4..=11 { + effect_case("ec.channel.invert", &[("channel", e(ch)), ("blend", n(20.0))]); + } +}