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:
parent
7750529bbb
commit
b6b8ed21cb
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue