Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop (#2615)

* Legacy Brightness/Contrast: brightness first, then contrast around 128, as in Photoshop

With Use Legacy on, PhotoCraft applied contrast first and then added
the brightness, so any setting that moves both sliders came out wrong:
brightness +40 with contrast +30 mapped mid-grey 128 to 168, where
Photoshop gives 185 (up to 17 levels darker across the midtones).

Measured on Photoshop 27.11 (scripted, a gray ramp through seven
settings): Photoshop adds the brightness, then scales contrast around
128/255. Contrast 80 keeps 128 at 128; the 0.5 pivot moved it to 130.
With both changes every measured value matches within 1 level, on the
CPU and in the GPU adjustment kernel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Legacy B/C: brightness after contrast when contrast is lowered

The corpus caught it: psd-tools' brightnesscontrast_legacy_grayscale.psd
(brightness -23, contrast -26) matched Photoshop with the old order and
was 6 levels off with brightness first. Photoshop adds the brightness
before contrast when contrast is raised (measured: +40/+30 maps 128 to
185) and after it when contrast is lowered. The measured cases with
lowered contrast had brightness 0, so they could not tell.

CPU and GPU kernel (brightness after contrast goes in p0.w). The io
corpus oracle is back at its floor; the 34 GPU parity tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MrCloudy2 2026-10-10 19:15:36 +02:00 committed by GitHub
parent b077cc3a18
commit 2c163bf73d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 5 deletions

View File

@ -85,12 +85,16 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
}
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))),
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
// Legacy: contrast scales around mid-grey, then brightness is added
// (fitted to Photoshop's rendering, exact on the corpus).
// Legacy: contrast scales around 128/255, with brightness added before it when
// contrast is raised and after it when contrast is lowered. Measured on Photoshop
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
let b = brightness / 255.0;
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
map_rgb(buf, |px| px.map(|v| ((v - 0.5) * k + 0.5 + b).clamp(0.0, 1.0)))
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)))
}
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
@ -820,6 +824,32 @@ mod tone_tests {
Buffer { rect, px }
}
/// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp
/// (x = 0, 16, …, 240, 255), one row per (brightness, contrast).
#[test]
fn legacy_brightness_contrast_matches_photoshop() {
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
let measured: [(f32, f32, [u8; 17]); 7] = [
(40.0, 0.0, [40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 216, 232, 248, 255, 255, 255]),
(-40.0, 0.0, [0, 0, 0, 8, 24, 40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 215]),
(0.0, 30.0, [0, 0, 0, 14, 37, 60, 82, 105, 128, 151, 174, 196, 219, 242, 255, 255, 255]),
(0.0, -30.0, [38, 49, 61, 72, 83, 94, 106, 117, 128, 139, 150, 162, 173, 184, 195, 207, 217]),
(40.0, 30.0, [3, 25, 48, 71, 94, 117, 139, 162, 185, 208, 231, 253, 255, 255, 255, 255, 255]),
(100.0, 0.0, [100, 116, 132, 148, 164, 180, 196, 212, 228, 244, 255, 255, 255, 255, 255, 255, 255]),
(0.0, 80.0, [0, 0, 0, 0, 0, 0, 0, 48, 128, 208, 255, 255, 255, 255, 255, 255, 255]),
];
for (brightness, contrast, want) in measured {
let rect = Rect::new(0, 0, 17, 1);
let px = X.iter().map(|x| [f32::from(*x) / 255.0; 4]).map(|p| [p[0], p[1], p[2], 1.0]).collect();
let mut b = Buffer { rect, px };
apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: true }, &mut b);
for (i, (p, w)) in b.px.iter().zip(want).enumerate() {
let got = (p[0] * 255.0).round() as i32;
assert!((got - i32::from(w)).abs() <= 1, "b {brightness} c {contrast} at {}: {got} vs Photoshop {w}", X[i]);
}
}
}
#[test]
fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
@ -962,3 +992,7 @@ mod tone_tests {
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
}
}
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
/// measured on Photoshop 27.11; a 0.5 pivot moved it to 130).
pub const LEGACY_PIVOT: f32 = 128.0 / 255.0;

View File

@ -552,7 +552,7 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
return vec3(select(0.0, 1.0, round_half_up(gray(c) * 255.0) >= p0.x));
}
case 3: { return vec3(posterize(c.r, p0.x), posterize(c.g, p0.x), posterize(c.b, p0.x)); }
case 4: { return clamp((c - 0.5) * p0.y + 0.5 + p0.x, vec3(0.0), vec3(1.0)); } // B/C legacy
case 4: { return clamp((c + p0.x - p0.z) * p0.y + p0.z + p0.w, vec3(0.0), vec3(1.0)); } // B/C legacy: brightness before (p0.x) or after (p0.w) contrast around compose::adjust::LEGACY_PIVOT (p0.z)
case 5: { // B/C modern
let b = p0.x; let ct = p0.y;
return vec3(mcontrast(mbright(c.r, b), ct), mcontrast(mbright(c.g, b), ct), mcontrast(mbright(c.b, b), ct));

View File

@ -1510,7 +1510,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
let c = contrast.clamp(-100.0, 99.0);
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
p[0] = [brightness / 255.0, k, 0.0, 0.0];
// Brightness before contrast when it is raised, after it when lowered (compose::adjust).
let (pre, post) = if c >= 0.0 { (brightness / 255.0, 0.0) } else { (0.0, brightness / 255.0) };
p[0] = [pre, k, photocraft_compose::adjust::LEGACY_PIVOT, post];
(4, p, None)
}
Adjustment::BrightnessContrast { brightness, contrast, .. } => {