M13.9: gpu Shadow/Highlight, CC Color Neutralizer, Invert HLS / YIQ

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 08:17:52 -04:00
parent 7a96fab302
commit 3bf7c4bcd2
6 changed files with 228 additions and 13 deletions

View File

@ -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));

View File

@ -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)

View File

@ -119,6 +119,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
"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),

View File

@ -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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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)?;

View File

@ -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<u32>) {
let dims = out_dims();
@ -586,7 +588,118 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let na = clamp(max(a, 0.0), 0.0, 1.0);
o = vec4<f32>(r * na, na);
}
case 18u: {
if (a <= 0.0) {
break;
}
let ch = u0.y;
var r: vec3<f32>;
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<f32>(
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<f32>(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<f32>(0.0),
);
}
o = vec4<f32>((c + (r - c) * f0.x) * a, a);
}
case 19u: {
o = vec4<f32>(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<f32>(0.0)), c, P.f[1].z);
o = vec4<f32>(r * a, a);
}
case 21u: {
o = vec4<f32>(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<f32>(0.0));
let na = clamp(max(a, 0.0), 0.0, 1.0);
o = vec4<f32>(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<f32>;
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<f32>(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);
}

View File

@ -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))]);
}
}