mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 13:16:54 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685aca7769 |
No files matched your search
@@ -3,8 +3,8 @@
|
||||
//! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in
|
||||
//! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`).
|
||||
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, mat3_mul, rgb_to_gray, srgb_to_linear};
|
||||
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, lab_to_srgb, mat3_mul, rgb_to_gray, srgb_to_lab, srgb_to_linear};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace, lut3d_len};
|
||||
|
||||
@@ -92,11 +92,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
// 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 };
|
||||
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)))
|
||||
let f = legacy_brightness_contrast(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(&f))
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
|
||||
@@ -236,6 +233,99 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
|
||||
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||||
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 };
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||||
/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see
|
||||
/// [`lab_channel_adjustment`]).
|
||||
pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
|
||||
if mode == ColorMode::Lab && lab_channel_adjustment(adj) {
|
||||
apply_lab(adj, buf);
|
||||
} else {
|
||||
apply_depth(adj, buf, transfer, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Lab document evaluates `adj` on its L, a and b channels rather than on the screen
|
||||
/// colour: Invert and Posterize (L, a and b alike), Brightness/Contrast (L only), Threshold
|
||||
/// (split on L, neutral result) and Gradient Map (driven by L). Levels and Curves carry their own
|
||||
/// Lab records ([`ToneSpace::Lab`]); everything else stays on the screen colour.
|
||||
pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
matches!(
|
||||
adj,
|
||||
Adjustment::Invert
|
||||
| Adjustment::Posterize { .. }
|
||||
| Adjustment::Threshold { .. }
|
||||
| Adjustment::BrightnessContrast { .. }
|
||||
| Adjustment::GradientMap { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
|
||||
/// (b+128)/255.
|
||||
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
|
||||
let l = srgb_to_lab(c);
|
||||
[l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0]
|
||||
}
|
||||
|
||||
/// The inverse of [`lab_norm`].
|
||||
fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
|
||||
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||||
}
|
||||
|
||||
/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment).
|
||||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer) {
|
||||
match adj {
|
||||
// n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral.
|
||||
Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))),
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))),
|
||||
Adjustment::Threshold { level } => {
|
||||
// Split on L alone (on the 8-bit grid); the result is neutral black or white.
|
||||
let t = (level * 255.0).round();
|
||||
map_rgb(buf, |c| [if (lab_norm(c)[0].clamp(0.0, 1.0) * 255.0).round() >= t { 1.0 } else { 0.0 }; 3])
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||||
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
|
||||
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
|
||||
// The tone curve on L only. Added as the difference of two round trips (like
|
||||
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
|
||||
map_rgb(buf, |c| {
|
||||
let n = lab_norm(c);
|
||||
let l = curve(n[0]);
|
||||
if l == n[0] {
|
||||
return c;
|
||||
}
|
||||
let (before, after) = (lab_denorm(n), lab_denorm([l, n[1], n[2]]));
|
||||
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
|
||||
})
|
||||
}
|
||||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||||
if p[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Driven by L: equal lightness maps to the same stop colour whatever the chroma.
|
||||
let l = lab_norm([p[0], p[1], p[2]])[0].clamp(0.0, 1.0);
|
||||
let mut o = gradient(stops, if *reverse { 1.0 - l } else { l });
|
||||
if *dither {
|
||||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||||
}
|
||||
p[..3].copy_from_slice(&o);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
|
||||
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
|
||||
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
|
||||
@@ -293,7 +383,7 @@ pub fn tone_luts_depth(adj: &Adjustment, depth: Option<SampleType>) -> [Vec<f32>
|
||||
/// through the conversions the document's surfaces use. The change is added as a difference of
|
||||
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
|
||||
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab};
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
let (before, after) = match space {
|
||||
ToneSpace::Cmyk => {
|
||||
let ink = rgb_to_cmyk(c);
|
||||
@@ -1214,6 +1304,106 @@ mod tone_tests {
|
||||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
|
||||
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
|
||||
}
|
||||
|
||||
/// `adj` applied in a Lab document to one encoded sRGB colour, returned as (L, a, b).
|
||||
fn in_lab(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Lab);
|
||||
srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]])
|
||||
}
|
||||
|
||||
/// A muted, in-gamut colour from (L, a, b).
|
||||
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
|
||||
lab_to_srgb([l, a, b])
|
||||
}
|
||||
|
||||
fn near(got: [f32; 3], want: [f32; 3], tol: f32) -> bool {
|
||||
got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_invert_flips_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Invert, lab(30.0, 12.0, -18.0));
|
||||
// n -> 1 - n: L -> 100 - L, a -> -a - 1, b -> -b - 1 (mirrored on the 0..255 grid).
|
||||
assert!(near(got, [70.0, -13.0, 17.0], 0.6), "{got:?}");
|
||||
// On the screen colour it would not be a mirror in Lab.
|
||||
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
|
||||
let c = lab(30.0, 12.0, -18.0);
|
||||
rgb.px[0] = [c[0], c[1], c[2], 1.0];
|
||||
apply(&Adjustment::Invert, &mut rgb);
|
||||
assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_posterize_steps_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
|
||||
// Two levels: each normalised channel snaps to 0 or 1 (L 100, a and b at +127 / -128).
|
||||
assert!(near(got, srgb_to_lab(lab_denorm([1.0, 1.0, 0.0])), 1e-3), "{got:?}");
|
||||
let grey = in_lab(&Adjustment::Posterize { levels: 255 }, lab(50.0, 0.0, 0.0));
|
||||
assert!(near(grey, [50.0, 0.0, 0.0], 1.1), "255 levels is within a step of the identity: {grey:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_threshold_splits_on_l_with_a_neutral_result() {
|
||||
let t = Adjustment::Threshold { level: 0.5 };
|
||||
// A saturated blue (L ≈ 32) and yellow (L ≈ 97): black and white, nothing in between.
|
||||
for (c, white) in [([0.0, 0.0, 1.0], false), ([1.0, 1.0, 0.0], true), (lab(52.0, 40.0, 30.0), true), (lab(48.0, -40.0, 30.0), false)] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_in(&t, &mut b, Transfer::Srgb, None, ColorMode::Lab);
|
||||
let v = if white { 1.0 } else { 0.0 };
|
||||
assert_eq!(b.px[0], [v, v, v, 1.0], "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_brightness_contrast_moves_l_only() {
|
||||
for legacy in [false, true] {
|
||||
let adj = Adjustment::BrightnessContrast { brightness: 40.0, contrast: 30.0, legacy };
|
||||
let before = [45.0, 15.0, -20.0];
|
||||
let got = in_lab(&adj, lab(before[0], before[1], before[2]));
|
||||
assert!(got[0] > before[0] + 5.0, "{legacy}: L rises: {got:?}");
|
||||
assert!((got[1] - before[1]).abs() < 0.5 && (got[2] - before[2]).abs() < 0.5, "{legacy}: a, b kept: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_gradient_map_follows_l() {
|
||||
let stops = vec![(0.0, [1.0, 0.0, 0.0]), (1.0, [0.0, 0.0, 1.0])];
|
||||
for reverse in [false, true] {
|
||||
let map = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||||
// A grey and a saturated colour with the same L map to the same colour.
|
||||
let grey = in_lab(&map, lab(60.0, 0.0, 0.0));
|
||||
let green = in_lab(&map, lab(60.0, -50.0, 40.0));
|
||||
assert!(near(grey, green, 1e-3), "{reverse}: {grey:?} vs {green:?}");
|
||||
let t = if reverse { 0.4 } else { 0.6 };
|
||||
let want = srgb_to_lab([1.0 - t, 0.0, t]);
|
||||
assert!(near(grey, want, 0.05), "{reverse}: {grey:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_modes_and_adjustments_keep_the_screen_colour_path() {
|
||||
let c = [0.2, 0.5, 0.7, 1.0];
|
||||
let lab_only = [Adjustment::Invert, Adjustment::Threshold { level: 0.47 }, Adjustment::Posterize { levels: 4 }];
|
||||
for adj in
|
||||
lab_only.iter().chain([&Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }])
|
||||
{
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
|
||||
let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c));
|
||||
apply_in(adj, &mut a, Transfer::Srgb, None, mode);
|
||||
apply_depth(adj, &mut b, Transfer::Srgb, None);
|
||||
assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}");
|
||||
}
|
||||
}
|
||||
// Transparent pixels are left alone and odd values never panic.
|
||||
let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]);
|
||||
for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) {
|
||||
apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab);
|
||||
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
|
||||
apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab);
|
||||
}
|
||||
assert!(t.px[0][1] == 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
|
||||
|
||||
@@ -1248,7 +1248,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
advanced::composite_clipped(clipped, &mut adjusted, cx);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
@@ -1589,7 +1589,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
|
||||
@@ -2203,3 +2203,118 @@ fn outer_glow_does_not_paint_through_a_zero_fill_layer() {
|
||||
assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient
|
||||
// Map follows L. Rendered through the document compositor at every depth.
|
||||
#[test]
|
||||
fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
|
||||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||||
let c = lab_to_srgb([30.0, 12.0, -18.0]);
|
||||
for depth in SampleType::ALL {
|
||||
let render = |adj: Adjustment| {
|
||||
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2]));
|
||||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||||
let p = flatten(&d).px[5];
|
||||
srgb_to_lab([p[0], p[1], p[2]])
|
||||
};
|
||||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||||
let inv = render(Adjustment::Invert);
|
||||
assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}");
|
||||
let th = render(Adjustment::Threshold { level: 0.25 });
|
||||
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
|
||||
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
|
||||
let want = srgb_to_lab([0.3; 3]);
|
||||
assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab.
|
||||
fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] {
|
||||
let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
|
||||
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||||
let p = px(&doc, 1, 1);
|
||||
photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])
|
||||
}
|
||||
|
||||
fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}");
|
||||
}
|
||||
|
||||
/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates.
|
||||
const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
|
||||
|
||||
#[test]
|
||||
fn lab_documents_invert_and_posterize_l_a_and_b() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
for depth in SampleType::ALL {
|
||||
// Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites
|
||||
// (-a - 1 on the 0..255 grid).
|
||||
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
|
||||
near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert"));
|
||||
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
|
||||
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
|
||||
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
|
||||
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
|
||||
// step: L ≈ 50 and a, b ≈ -1, a neutral grey).
|
||||
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
|
||||
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3));
|
||||
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
|
||||
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
|
||||
near3(post, want, 1.0, &format!("{depth:?} posterize"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_brightness_contrast_moves_lightness_only() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
for depth in SampleType::ALL {
|
||||
for legacy in [true, false] {
|
||||
let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth);
|
||||
assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}");
|
||||
near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_threshold_on_lightness_with_a_neutral_result() {
|
||||
for depth in SampleType::ALL {
|
||||
// A saturated yellow is light (L ≈ 97): white, not a coloured result.
|
||||
let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||||
near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow"));
|
||||
// Pure blue has L ≈ 30: black.
|
||||
let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||||
near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue"));
|
||||
// Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance.
|
||||
let level = 0.52;
|
||||
let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth);
|
||||
near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_gradient_map_follows_lightness() {
|
||||
let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
|
||||
// A grey and a saturated colour of the same L map to the same colour.
|
||||
let red = [0.9, 0.1, 0.1];
|
||||
let l = photocraft_color::convert::srgb_to_lab(red)[0];
|
||||
let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]);
|
||||
for depth in SampleType::ALL {
|
||||
for reverse in [false, true] {
|
||||
let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||||
let a = lab_doc_adjusted(red, adj.clone(), depth);
|
||||
let b = lab_doc_adjusted(grey, adj.clone(), depth);
|
||||
near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}"));
|
||||
let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 };
|
||||
let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]);
|
||||
near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}"));
|
||||
}
|
||||
}
|
||||
// In RGB documents the map still follows RGB luminance: red and grey differ there.
|
||||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
|
||||
let adj = Adjustment::GradientMap { stops, reverse: false, dither: false };
|
||||
adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||||
let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]);
|
||||
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||||
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>,
|
||||
// 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip);
|
||||
// integer depths keep the unrounded, clipped curves.
|
||||
let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample);
|
||||
adjust::apply_depth(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth);
|
||||
adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode);
|
||||
let w = r.width() as usize;
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() {
|
||||
|
||||
@@ -241,3 +241,21 @@ fn benchmark_clear_large_selection() {
|
||||
println!("#3463 clear 8000x6000, background {background}, feather {feather}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
/// Image › Adjustments › Invert in a Lab document mirrors L, a and b, at every depth.
|
||||
#[test]
|
||||
fn invert_in_a_lab_document_mirrors_l_a_and_b() {
|
||||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||||
let c = lab_to_srgb([30.0, 12.0, -18.0]);
|
||||
for depth in SampleType::ALL {
|
||||
let fmt = PixelFormat { mode: ColorMode::Lab, sample: depth, alpha: true };
|
||||
let mut s = Surface::new(fmt);
|
||||
let r = Rect::new(0, 0, 2, 2);
|
||||
s.write_region(r, &(0..4).flat_map(|_| from_rgba(&fmt, [c[0], c[1], c[2], 1.0])).collect::<Vec<_>>());
|
||||
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab);
|
||||
let px = s.read_region(Rect::new(1, 1, 2, 2));
|
||||
let rgba = photocraft_raster::to_rgba(&fmt, &px);
|
||||
let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]);
|
||||
assert!(got.iter().zip([70.0, -13.0, 17.0]).all(|(g, w)| (g - w).abs() < 1.0), "{depth:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
+29
-5
@@ -934,7 +934,7 @@ impl<'a> Planner<'a> {
|
||||
|
||||
/// adjust::apply_with on a slot (consumes it).
|
||||
fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Result<Slot, Unsupported> {
|
||||
if !adjustment_on_gpu(adj) {
|
||||
if !adjustment_on_gpu(adj, self.cx.mode) {
|
||||
return Err(Unsupported(format!("{} on CMYK/Lab channels (evaluated on the CPU)", adj.label())));
|
||||
}
|
||||
if let Adjustment::GradientMap { stops, .. } = adj {
|
||||
@@ -1512,10 +1512,15 @@ fn to_row(t: &[f32]) -> [f32; 4096] {
|
||||
row
|
||||
}
|
||||
|
||||
/// Whether the adjustment kernel can evaluate `adj`: Levels and Curves on CMYK ink or Lab
|
||||
/// channels convert through ICC profiles per pixel, which only the CPU does.
|
||||
pub fn adjustment_on_gpu(adj: &Adjustment) -> bool {
|
||||
!matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. })
|
||||
/// Whether the adjustment kernel can evaluate `adj` in a document of colour `mode`: Levels and
|
||||
/// Curves on CMYK ink or Lab channels convert through ICC profiles per pixel, and so do the
|
||||
/// adjustments a Lab document evaluates on L, a and b (`photocraft_compose::adjust::lab_channel_adjustment`),
|
||||
/// which only the CPU does.
|
||||
pub fn adjustment_on_gpu(adj: &Adjustment, mode: photocraft_color::ColorMode) -> bool {
|
||||
let tone_in_space =
|
||||
matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. });
|
||||
let lab_channels = mode == photocraft_color::ColorMode::Lab && photocraft_compose::adjust::lab_channel_adjustment(adj);
|
||||
!tone_in_space && !lab_channels
|
||||
}
|
||||
|
||||
/// A tone transfer as the shader's `t_decode`/`t_encode` exponent (0: the sRGB curve).
|
||||
@@ -1731,6 +1736,25 @@ mod tests {
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
#[test]
|
||||
fn lab_channel_adjustments_defer_to_the_cpu_in_lab_documents() {
|
||||
let lab_rules = [
|
||||
Adjustment::Invert,
|
||||
Adjustment::Posterize { levels: 4 },
|
||||
Adjustment::Threshold { level: 0.5 },
|
||||
Adjustment::BrightnessContrast { brightness: 10.0, contrast: 5.0, legacy: false },
|
||||
Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false },
|
||||
];
|
||||
for adj in &lab_rules {
|
||||
assert!(!adjustment_on_gpu(adj, ColorMode::Lab), "{adj:?}");
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale] {
|
||||
assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}");
|
||||
}
|
||||
}
|
||||
// Screen-colour adjustments stay on the GPU in Lab documents.
|
||||
assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cmyk_fills_fall_back_only_without_a_valid_cache() {
|
||||
use photocraft_doc::{FillCache, GradientStyle};
|
||||
|
||||
Reference in new issue
Block a user