From bf877411fb5b5584152a1451e7cafa0f783326cc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:35:22 -0400 Subject: [PATCH] Render: picture brightness with contrast as PowerPoint combines them Brightness was added after the contrast stretch. Measured on PowerPoint's export of grey ramps (5 x 5 grid of -80..80% brightness and contrast): contrast scales about mid-grey (x(1+c) below zero, /(1-c) above) and brightness adds b * 255 scaled by (1 + cf) / 2, half before the stretch and half after; +40% brightness with +40% contrast maps black to 54, not 17. DeckCraft now matches the 25 ramps within 5 grey levels. Wild synthetic set: brightness/contrast grid 15.93 -> 13.36, recolour slide 8.74 -> 7.60. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/images.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 238821c..f3010da 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -327,7 +327,10 @@ fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { let s = if a.grayscale { 0.0 } else { sat }; for v in &mut rgb { *v = y + (*v - y) * s; - *v = (*v - 128.0) * cf + 128.0 + b; + // Measured on PowerPoint's export of grey ramps: contrast scales about mid-grey by + // `cf`, and brightness adds b ยท 255 scaled by (1 + cf) / 2 (half of it is applied + // before the contrast stretch, half after): +40% bright, +40% contrast maps 0 to 54. + *v = (*v - 127.5) * cf + 127.5 + b * (1.0 + cf) / 2.0; } // Duotone: the pixel's luminance picks a colour between the dark and the light one. if let Some((d, l)) = duo { @@ -368,6 +371,24 @@ mod tests { assert_ne!(adjust_key(&adj), adjust_key(&PictureAdjust::default())); } + #[test] + fn brightness_and_contrast_map_greys_like_powerpoint() { + // Grey levels PowerPoint's export gives for inputs 0, 32, 64, 96 (and 128, 160). + let run = |b: f64, c: f64, input: &[u8]| -> Vec { + let mut img = image::RgbaImage::from_fn(input.len() as u32, 1, |x, _| { + let v = input[x as usize]; + image::Rgba([v, v, v, 255]) + }); + apply_adjust(&mut img, &PictureAdjust { brightness: b, contrast: c, ..Default::default() }); + img.pixels().map(|p| p[0]).collect() + }; + let near = |got: Vec, want: &[u8]| got.iter().zip(want).all(|(g, w)| (*g as i32 - *w as i32).abs() <= 5); + assert!(near(run(0.4, 0.4, &[0, 32, 64, 96]), &[54, 105, 157, 210]), "{:?}", run(0.4, 0.4, &[0, 32, 64, 96])); + assert!(near(run(0.4, -0.4, &[0, 32, 64, 96]), &[133, 152, 171, 190])); + assert!(near(run(-0.4, 0.4, &[128, 160, 192]), &[0, 45, 97])); + assert!(near(run(0.0, -0.8, &[0, 128, 255]), &[102, 127, 152])); + } + #[test] fn opposite_brightness_and_contrast_are_cached_apart() { let a = PictureAdjust { brightness: 0.4, contrast: 0.4, ..Default::default() };