Levels: Photoshop's gamma>1 shadow toe (bounded slope 2^gamma)

Verified our levels() against Adobe Photoshop 2026 (adjustLevels on a 0..255
ramp): exact for gamma<=1 and all input/output ranges, but a pure u^(1/gamma)
has infinite slope at black while Photoshop bounds the shadow toe to initial
slope 2^gamma. Added a soft-min (p-norm) of the power curve with that line,
applied only for gamma>1; reduces to the exact power curve in the body, so
highlights/midtones and gamma<=1 are unchanged. GPU Levels LUT is built from
this fn, so GPU stays in sync (gpu parity 18/18).

Corpus oracle: levels_grayscale.psd max error 0.043 -> 0.0078 (bad px
4.1% -> 0.1%); 113 pass / 30 differ unchanged (no regressions). Golden test
pins the curve to captured Photoshop samples (gamma 2 toe within 4/255).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 09:58:22 -04:00
parent 7750529bbb
commit b6b8ed21cb
2 changed files with 43 additions and 1 deletions

View File

@ -370,7 +370,24 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
let range = (ch.in_white - ch.in_black).max(1e-6);
let t = ((v - ch.in_black) / range).clamp(0.0, 1.0).powf(1.0 / ch.gamma.max(0.01));
let u = ((v - ch.in_black) / range).clamp(0.0, 1.0);
let g = ch.gamma.max(0.01);
let t = if g > 1.0 {
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
// which Photoshop bounds, giving a soft shadow toe (initial slope 2^gamma, verified against
// the real app). Soft-min (p-norm) of the power curve with the slope-2^gamma line; reduces
// to the exact power curve in the body, so highlights are unchanged. gamma <= 1 is untouched.
let power = u.powf(1.0 / g);
let line = 2.0f32.powf(g) * u;
if power <= 1e-6 || line <= 1e-6 {
power.min(line)
} else {
let p = 1.8 + 8.8 / g; // p-norm sharpness, fit to Photoshop ground truth
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
}
} else {
u.powf(1.0 / g)
};
ch.out_black + t * (ch.out_white - ch.out_black)
}

View File

@ -848,3 +848,28 @@ fn modern_brightness_matches_photoshop() {
assert_eq!(adjust::modern_brightness(0.37, 0.0), 0.37);
assert_eq!(adjust::modern_contrast(0.37, 0.0), 0.37);
}
// Levels ground truth from Adobe Photoshop 2026 (adjustLevels on a 0..255 ramp). Verifies our
// `levels()` matches the real app, including the gamma>1 soft shadow toe (initial slope 2^gamma).
#[test]
fn levels_matches_photoshop() {
let ident = |g: f32| LevelsChannel { in_black: 0.0, in_white: 1.0, gamma: g, out_black: 0.0, out_white: 1.0 };
// gamma 2.0 (lifts shadows): x -> Photoshop out. The deep shadow is the toe-bounded region.
let g2: [(usize, u8); 8] = [(1, 4), (8, 30), (16, 55), (32, 90), (64, 128), (128, 181), (192, 221), (224, 239)];
for (x, ps) in g2 {
let got = (adjust::levels(&ident(2.0), x as f32 / 255.0) * 255.0).round();
assert!((got - ps as f32).abs() <= 4.0, "levels gamma 2.0 x={x}: got {got} want {ps}");
}
// gamma 0.5 (darkens midtones) is exact: out = (x/255)^2.
for x in [0usize, 32, 64, 128, 192, 255] {
let got = (adjust::levels(&ident(0.5), x as f32 / 255.0) * 255.0).round();
let want = ((x as f32 / 255.0).powi(2) * 255.0).round();
assert_eq!(got, want, "levels gamma 0.5 x={x}");
}
// Endpoints pinned; identity is identity. (The gamma>1 toe's soft-min leaves white within a
// few parts in 1e5 of 1.0 — invisible at any bit depth; exact at 8-bit.)
assert_eq!(adjust::levels(&ident(2.5), 0.0), 0.0);
assert_eq!((adjust::levels(&ident(2.5), 1.0) * 255.0).round(), 255.0);
assert!((adjust::levels(&ident(2.5), 1.0) - 1.0).abs() < 5e-4);
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
}