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 <noreply@anthropic.com>
This commit is contained in:
MrCloudy2 2026-10-12 14:45:09 +02:00 committed by GitHub
parent dcc9f86aa5
commit 7890b277a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 44 additions and 6 deletions

View File

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

View File

@ -527,7 +527,12 @@ fn color_lookup(c: vec3<f32>, n: i32, tetra: bool) -> vec3<f32> {
fn selective_row(r: i32, w: f32, c: vec3<f32>, relative: bool) -> vec3<f32> {
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);
}