diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index eeb2c972..b4d21351 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -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; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index e0f0ddfa..884cb0f9 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -552,7 +552,7 @@ fn adjust(c: vec3) -> vec3 { 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)); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 0bf1230f..205ac06a 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -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, .. } => {