From 7890b277a0ae0679afbf31489ca5cd423a5fe3f7 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:45:09 +0200 Subject: [PATCH] fix(adjust): absolute Selective Color scales by the room the ink has (#3793) Photoshop's absolute mode scales each range's ink change by how much the ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1 level now (8 of 72 within 3 before). CPU and GPU paths. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 43 +++++++++++++++++++++++++++++++----- crates/gpu/src/compose.wgsl | 7 +++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index a39f3307..cc96e697 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -606,10 +606,12 @@ pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] { ] } -/// Photoshop Selective Color (approximation): each range shifts the cyan, magenta and yellow ink -/// (1 − R, G, B) by its percentage, plus its black percentage on every ink, weighted by how much -/// the colour belongs to the range. Relative mode scales the shift by the ink already present -/// (so it can't tint pure white); absolute adds it outright. +/// Photoshop Selective Color: each range shifts the cyan, magenta and yellow ink (1 − R, G, B) by +/// its percentage, plus its black percentage on every ink, weighted by how much the colour belongs +/// to the range. Relative mode scales the shift by the ink already present (so it can't tint pure +/// white). Absolute mode scales it by the room the ink has: adding ink by min(1, 2 × (1 − ink)), +/// removing it by min(1, 2 × ink), so Neutrals Cyan +50 darkens red 64 by 32, not 64 (all 72 +/// absolute range × ink cases within 1 level of Photoshop 27.11). pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32; 3] { let w = selective_color_weights(c); let mut delta = [0.0f32; 3]; @@ -620,7 +622,14 @@ pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32 let a = adj[r].map(|v| v / 100.0); for i in 0..3 { let ink = 1.0 - c[i]; - let d = if relative { (a[i] + a[3]) * ink } else { a[i] + a[3] }; + let d = a[i] + a[3]; + let d = if relative { + d * ink + } else if d > 0.0 { + d * (2.0 * (1.0 - ink)).min(1.0) + } else { + d * (2.0 * ink).min(1.0) + }; delta[i] += d * wr; } } @@ -1042,6 +1051,30 @@ mod lookup_tests { } } + /// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's + /// cyan change). + #[test] + fn absolute_selective_color_matches_photoshop() { + // (range index, cyan %, grey, Photoshop's red) + let measured = [ + (7, 50.0, 64u8, 32u8), + (7, 50.0, 192, 129), + (7, -50.0, 192, 223), + (7, -50.0, 64, 128), + (6, -50.0, 192, 224), + (6, -50.0, 240, 253), + (8, 50.0, 64, 32), + (8, 50.0, 16, 2), + ]; + for (range, cyan, grey, want) in measured { + let mut adj = [[0.0f32; 4]; 9]; + adj[range][0] = cyan; + let g = grey as f32 / 255.0; + let got = selective_color([g; 3], false, &adj)[0] * 255.0; + assert!((got - want as f32).abs() <= 1.5, "range {range} cyan {cyan} grey {grey}: {got} vs {want}"); + } + } + #[test] fn selective_color_zero_is_identity_and_ranges_are_local() { let zero = [[0.0f32; 4]; 9]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index bf535b08..edfe140b 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -527,7 +527,12 @@ fn color_lookup(c: vec3, n: i32, tetra: bool) -> vec3 { fn selective_row(r: i32, w: f32, c: vec3, relative: bool) -> vec3 { let k = lut_at(r * 4 + 3) / 100.0; var d = vec3(lut_at(r * 4), lut_at(r * 4 + 1), lut_at(r * 4 + 2)) / 100.0 + k; - if (relative) { d = d * (1.0 - c); } + if (relative) { + d = d * (1.0 - c); + } else { + // Absolute: scaled by the room the ink has (adding: 2 × (1 − ink), removing: 2 × ink, capped at 1). + d = d * select(min(2.0 * (1.0 - c), vec3(1.0)), min(2.0 * c, vec3(1.0)), d > vec3(0.0)); + } return select(vec3(0.0), d * w, w > 0.0); }