mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 10:57:20 -06:00
The tilt-shift reference tests and tiled_clear_and_fill_match_one_pass_over_the_area cross pixel formats or channel counts with independent geometry and fill settings. Each run takes a quarter of the combinations through Sweep, a different quarter each UTC day. precheck_agrees_with_execute built all five document states for every call and kept one; it now builds only the state the call needs.
407 lines
21 KiB
Rust
407 lines
21 KiB
Rust
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||
use photocraft_doc::{Document, Layer, Size};
|
||
use photocraft_raster::from_rgba;
|
||
use serde_json::json;
|
||
use std::time::Instant;
|
||
|
||
use super::*;
|
||
|
||
/// The single pass over the content bounds that the tiled `adjust_surface` replaced (#1772).
|
||
fn one_pass(s: &Surface, adj: &Adjustment, selection: Option<&Surface>, mode: ColorMode) -> Surface {
|
||
let mut s = s.clone();
|
||
let r = s.content_bounds();
|
||
if !r.is_empty() {
|
||
let out = adjusted(&s, r, adj, selection, mode);
|
||
s.write_region(r, &out);
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Two patches over a 3×3 tile grid, not tile-aligned, with partial alpha; the bottom-left tiles
|
||
/// inside the content bounds are never allocated.
|
||
fn layer(fmt: PixelFormat) -> Surface {
|
||
let mut s = Surface::new(fmt);
|
||
for r in [Rect::new(200, 240, 300, 300), Rect::new(530, 250, 560, 530)] {
|
||
let mut data = Vec::new();
|
||
for y in r.y0..r.y1 {
|
||
for x in r.x0..r.x1 {
|
||
let a = if fmt.alpha { 0.3 + 0.7 * ((x + y) % 7) as f32 / 6.0 } else { 1.0 };
|
||
data.extend(from_rgba(&fmt, [x as f32 / 600.0, y as f32 / 540.0, 0.5 + 0.4 * (x as f32 * 0.05).sin(), a]));
|
||
}
|
||
}
|
||
s.write_region(r, &data);
|
||
}
|
||
assert!(!s.has_tiles_in(Rect::new(200, 512, 256, 530)), "a hole inside the content bounds");
|
||
s
|
||
}
|
||
|
||
/// Release-only comparison for the 5000×7073 px group-merge report (#1561). The reference path
|
||
/// models the old full-canvas composite plus a second converted float buffer; the optimized path
|
||
/// renders only the group's visible content bounds in bands.
|
||
#[test]
|
||
#[ignore = "manual 35 MP merge benchmark; run in release with --ignored --nocapture"]
|
||
fn benchmark_large_group_merge() {
|
||
let fmt = PixelFormat::RGBA8;
|
||
let mut doc = Document::new("merge benchmark", Size::new(5000, 7073), ColorMode::Rgb, SampleType::U8);
|
||
let content = Rect::new(1700, 2200, 2700, 3200);
|
||
let mut children = Vec::new();
|
||
for i in 0..4 {
|
||
let mut child = Layer::raster(format!("Layer {i}"), fmt);
|
||
if let Some(surface) = child.surface_mut() {
|
||
surface.fill_rect(content, &[(i as f32 + 1.0) / 5.0, 0.2, 0.4, 0.5]);
|
||
}
|
||
children.push(child);
|
||
}
|
||
doc.layers.push(Layer::group("Group", children));
|
||
let expected_area = photocraft_compose::layer_bounds(&doc.layers[0], doc.bounds()).intersect(&doc.bounds());
|
||
|
||
let start = Instant::now();
|
||
let reference = photocraft_compose::flatten(&doc);
|
||
let reference_data: Vec<f32> = reference.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect();
|
||
let mut reference_surface = Surface::new(fmt);
|
||
reference_surface.write_region(doc.bounds(), &reference_data);
|
||
reference_surface.prune();
|
||
let reference_time = start.elapsed();
|
||
|
||
let start = Instant::now();
|
||
let optimized = composite_layers(&doc, fmt, None);
|
||
let optimized_time = start.elapsed();
|
||
assert_eq!(reference_surface.read_region(expected_area), optimized.read_region(expected_area));
|
||
println!("#1561 5000x7073, 4x 1000x1000 layers: full-canvas {:?}, bounded/banded {:?}", reference_time, optimized_time);
|
||
}
|
||
|
||
#[test]
|
||
fn tiled_adjustment_matches_one_pass_over_the_content() {
|
||
let adjustments = [
|
||
("invert", json!({})),
|
||
("levels", json!({"inBlack": 20, "gamma": 1.3, "inWhite": 230})),
|
||
("curves", json!({"points": [[0, 10], [128, 160], [255, 240]]})),
|
||
("hueSaturation", json!({"hue": 40, "saturation": 20})),
|
||
("brightnessContrast", json!({"brightness": 30, "contrast": 20})),
|
||
("exposure", json!({"exposure": 0.8})),
|
||
("posterize", json!({"levels": 5})),
|
||
("threshold", json!({"level": 120})),
|
||
("colorBalance", json!({"midtones": [20, -10, 5]})),
|
||
("blackWhite", json!({"tint": true})),
|
||
("photoFilter", json!({"density": 50})),
|
||
("channelMixer", json!({"red": [80, 30, -10, 5]})),
|
||
("vibrance", json!({"vibrance": 40})),
|
||
("selectiveColor", json!({"reds": [10, -20, 30, 5]})),
|
||
// Dithered kinds vary by position, so they show a tile's pixels kept their coordinates.
|
||
("gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0a0"]], "dither": true})),
|
||
("colorLookup", json!({"lut": "warm", "dither": true})),
|
||
];
|
||
let mut formats = Vec::new();
|
||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale] {
|
||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
formats.push(PixelFormat { mode, sample, alpha: true });
|
||
}
|
||
}
|
||
formats.push(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: false });
|
||
formats.push(PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U16, alpha: false });
|
||
// A soft selection that ends inside the content.
|
||
let mut soft = Surface::new(PixelFormat::GRAY8);
|
||
let sr = Rect::new(0, 0, 500, 400);
|
||
soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| x as f32 / 500.0)).collect::<Vec<_>>());
|
||
for fmt in formats {
|
||
let s = layer(fmt);
|
||
for (kind, p) in &adjustments {
|
||
let adj = crate::adjust_params::from_params(kind, p, None, fmt.mode).unwrap_or_else(|e| panic!("{kind}: {e}"));
|
||
for sel in [None, Some(&soft)] {
|
||
let want = one_pass(&s, &adj, sel, fmt.mode);
|
||
let mut got = s.clone();
|
||
adjust_surface(&mut got, &adj, sel, fmt.mode);
|
||
assert!(want != s, "{kind} {fmt:?}: the adjustment changes the layer");
|
||
assert!(got == want, "{kind} {fmt:?} selection {}: tiled result differs", sel.is_some());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_layer_is_left_alone() {
|
||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||
adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Rgb);
|
||
assert_eq!(s.tile_count(), 0);
|
||
}
|
||
|
||
/// The whole-region float pass that the tiled Clear and background Fill replaced (#3463).
|
||
fn reference_fill_or_clear(s: &Surface, area: Rect, selection: Option<&Surface>, fill: Option<([f32; 4], bool)>) -> Surface {
|
||
let mut s = s.clone();
|
||
let fmt = s.format();
|
||
let n = fmt.channels();
|
||
if fill.is_none() && !fmt.alpha {
|
||
return s;
|
||
}
|
||
let w = area.width() as usize;
|
||
let mut region = s.read_region(area);
|
||
for (i, px) in region.chunks_exact_mut(n).enumerate() {
|
||
let k = selection.map_or(1.0, |sel| sel.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0));
|
||
match fill {
|
||
Some((color, lock)) => {
|
||
if k > 0.0 {
|
||
fill_pixel(&fmt, px, color, k, lock);
|
||
}
|
||
}
|
||
None => {
|
||
px[n - 1] *= 1.0 - k;
|
||
if px[n - 1] <= 0.0 {
|
||
px.fill(0.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
s.write_region(area, ®ion);
|
||
s
|
||
}
|
||
|
||
/// [`layer`] plus a fully painted tile and a transparent pixel that still carries a colour.
|
||
fn clear_layer(fmt: PixelFormat) -> Surface {
|
||
let mut s = layer(fmt);
|
||
s.fill_rect(Rect::new(0, 0, 256, 256), &from_rgba(&fmt, [0.2, 0.7, 0.4, if fmt.alpha { 0.8 } else { 1.0 }]));
|
||
s.write_pixel(260, 250, &from_rgba(&fmt, [0.9, 0.1, 0.1, 0.0]));
|
||
s
|
||
}
|
||
|
||
fn same_pixels(a: &Surface, b: &Surface) -> bool {
|
||
let r = Rect::new(-10, -10, 800, 800);
|
||
a.read_region(r) == b.read_region(r)
|
||
}
|
||
|
||
#[test]
|
||
fn tiled_clear_and_fill_match_one_pass_over_the_area() {
|
||
let mut formats = Vec::new();
|
||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale] {
|
||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||
formats.push(PixelFormat { mode, sample, alpha: true });
|
||
}
|
||
}
|
||
formats.push(PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: false });
|
||
formats.push(PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::U16, alpha: false });
|
||
// A soft (feathered) selection: fully selected over the first tile, fading out across the next.
|
||
let mut soft = Surface::new(PixelFormat::GRAY8);
|
||
let sr = Rect::new(0, 0, 600, 400);
|
||
soft.write_region(sr, &(0..sr.height()).flat_map(|_| (0..sr.width()).map(|x| (1.0 - (x as f32 - 256.0).max(0.0) / 300.0).max(0.0))).collect::<Vec<_>>());
|
||
let canvas = Rect::new(0, 0, 700, 600);
|
||
// Pixel formats and fill settings are independent: a run takes a quarter of their combinations,
|
||
// a different quarter each day.
|
||
let sweep = photocraft_testkit::Sweep::new(4);
|
||
let mut case = 0;
|
||
let mut runs = || {
|
||
case += 1;
|
||
sweep.includes(case)
|
||
};
|
||
for fmt in formats {
|
||
let s = clear_layer(fmt);
|
||
for sel in [None, Some(&soft)] {
|
||
let area = sel.map_or(canvas, |m| m.content_bounds());
|
||
let what = format!("{fmt:?} selection {}", sel.is_some());
|
||
if runs() {
|
||
let mut got = s.clone();
|
||
try_clear_surface(&mut got, area, sel).unwrap();
|
||
assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, None)), "clear {what}");
|
||
if fmt.alpha {
|
||
assert!(!same_pixels(&got, &s), "clear {what} changes the layer");
|
||
}
|
||
}
|
||
for color in [[1.0, 1.0, 1.0, 1.0], [0.3, 0.5, 0.9, 1.0], [0.3, 0.5, 0.9, 0.4]] {
|
||
for lock in [true, false].into_iter().filter(|_| runs()) {
|
||
let mut got = s.clone();
|
||
try_fill_surface(&mut got, area, color, sel, lock).unwrap();
|
||
assert!(same_pixels(&got, &reference_fill_or_clear(&s, area, sel, Some((color, lock)))), "fill {color:?} lock {lock} {what}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn clear_leaves_unselected_tiles_shared_and_drops_cleared_ones() {
|
||
let s = clear_layer(PixelFormat::RGBA8);
|
||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||
// Fully selects tile (0, 0) and part of (1, 0); the selection's bounds reach tile (2, 1),
|
||
// whose pixels it leaves alone.
|
||
sel.fill_rect(Rect::new(0, 0, 280, 256), &[1.0]);
|
||
sel.write_pixel(600, 300, &[0.0]);
|
||
sel.write_pixel(601, 301, &[0.5]);
|
||
let mut got = s.clone();
|
||
try_clear_surface(&mut got, Rect::new(0, 0, 602, 302), Some(&sel)).unwrap();
|
||
let tile = |s: &Surface, tx, ty| s.tile(photocraft_geom::TileCoord::new(tx, ty)).cloned();
|
||
assert!(tile(&got, 0, 0).is_none(), "a fully cleared tile is no tile");
|
||
let (a, b) = (tile(&s, 2, 1).unwrap(), tile(&got, 2, 1).unwrap());
|
||
assert!(std::sync::Arc::ptr_eq(&a, &b), "a tile the selection leaves alone is not copied");
|
||
assert!(!std::sync::Arc::ptr_eq(&tile(&s, 1, 0).unwrap(), &tile(&got, 1, 0).unwrap()), "a partly cleared tile is copied");
|
||
}
|
||
|
||
/// Release-only timing for Edit › Clear on a 48 MP layer (#3463): about 2 s with the old
|
||
/// whole-region float pass, tens of milliseconds tiled.
|
||
#[test]
|
||
#[ignore = "benchmark; run with --release -- --ignored --nocapture"]
|
||
fn benchmark_clear_large_selection() {
|
||
for (background, feather) in [(false, 0), (false, 200), (true, 0), (true, 200)] {
|
||
let mut s = crate::Session::new();
|
||
s.execute("file.new", json!({"width": 8000, "height": 6000, "background": "white"})).unwrap();
|
||
if !background {
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
}
|
||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 7980, "height": 5980, "ellipse": feather > 0, "feather": feather})).unwrap();
|
||
let t = Instant::now();
|
||
s.execute("edit.clear", json!({})).unwrap();
|
||
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: 8-bit bytes
|
||
/// become 255 - v (a lands one step past the mirror), 16 bits mirror a and b around neutral.
|
||
#[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]]);
|
||
let want = if depth == SampleType::U8 { [70.0, -13.0, 17.0] } else { [70.0, -12.0, 18.0] };
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.5), "{depth:?}: {got:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mixing_through_a_selection_matches_sampling_it_per_pixel() {
|
||
// A rectangle across tile edges (negative x included), a selection with fractional
|
||
// coverage, untouched tiles (default coverage) and a 16-bit as well as an 8-bit layer.
|
||
let r = Rect::new(-300, 200, 330, 290);
|
||
let gray32 = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false };
|
||
for sel_fmt in [PixelFormat::GRAY8, PixelFormat::GRAYA8, gray32] {
|
||
let mut sel = Surface::new(sel_fmt);
|
||
sel.fill_rect(Rect::new(-260, 210, 40, 280), &vec![0.6; sel_fmt.channels()]);
|
||
for x in 0..120 {
|
||
sel.write_pixel(x * 2, 230 + x % 40, &vec![x as f32 / 119.0; sel_fmt.channels()]);
|
||
}
|
||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] {
|
||
let n = (r.width() * r.height()) as usize;
|
||
let orig: Vec<[f32; 4]> = (0..n).map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.25 + (i % 3) as f32 / 4.0]).collect();
|
||
let edited: Vec<[f32; 4]> = orig.iter().map(|p| [1.0 - p[0], p[1] * 0.5, p[2], 1.0]).collect();
|
||
let w = r.width() as usize;
|
||
let mut expected = Vec::new();
|
||
for (i, (a, o)) in edited.iter().zip(&orig).enumerate() {
|
||
let k = sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0);
|
||
let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k);
|
||
let mut enc = [0.0f32; 8];
|
||
let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc);
|
||
expected.extend_from_slice(&enc[..m]);
|
||
}
|
||
assert_eq!(mix_through_selection(&fmt, r, &orig, &edited, Some(&sel)), expected, "{sel_fmt:?} over {fmt:?}");
|
||
}
|
||
}
|
||
// Without a selection the edit is taken whole; an empty rectangle mixes nothing.
|
||
let px = [[0.1, 0.2, 0.3, 1.0]];
|
||
let fmt = PixelFormat::RGBA8;
|
||
let mut enc = [0.0f32; 8];
|
||
let m = photocraft_raster::from_rgba_into(&fmt, px[0], &mut enc);
|
||
assert_eq!(mix_through_selection(&fmt, Rect::new(0, 0, 1, 1), &[[0.0; 4]], &px, None), enc[..m].to_vec());
|
||
assert!(mix_through_selection(&fmt, Rect::EMPTY, &[], &[], None).is_empty());
|
||
}
|
||
|
||
/// One `fmt` pixel holding `ch`, after `adj` through an optional `k`-strength selection.
|
||
fn adjusted_pixel(fmt: PixelFormat, ch: &[f32], adj: &Adjustment, k: Option<f32>) -> Vec<f32> {
|
||
let unit = Rect::new(0, 0, 1, 1);
|
||
let mut s = Surface::new(fmt);
|
||
s.write_region(unit, ch);
|
||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||
if let Some(k) = k {
|
||
sel.write_region(unit, &[k]);
|
||
}
|
||
adjust_surface(&mut s, adj, k.map(|_| &sel), fmt.mode);
|
||
s.read_region(unit)
|
||
}
|
||
|
||
fn assert_levels(got: &[f32], want_255: &[f32], what: &str) {
|
||
let want: Vec<f32> = want_255.iter().map(|v| v / 255.0).collect();
|
||
assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-4), "{what}: got {:?}, want {want_255:?}", got.iter().map(|v| v * 255.0).collect::<Vec<_>>());
|
||
}
|
||
|
||
/// Image › Adjustments › Invert, Posterize and Threshold change a Lab or CMYK layer's stored
|
||
/// channels exactly as Photoshop's do (photoshop corpus lab8, lab16, cmyk8 and cmyk16 invert,
|
||
/// posterize-4 and threshold-128 follow these rules on every pixel), with no trip through RGB.
|
||
#[test]
|
||
fn invert_posterize_and_threshold_change_lab_and_cmyk_channels_as_stored() {
|
||
let f = |v: [f32; 4]| v.map(|x| x / 255.0);
|
||
let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true);
|
||
let ink = [51.0, 102.0, 153.0, 26.0, 255.0].map(|x| x / 255.0);
|
||
assert_levels(&adjusted_pixel(cmyk, &ink, &Adjustment::Invert, None), &[204.0, 153.0, 102.0, 229.0, 255.0], "CMYK invert");
|
||
assert_levels(&adjusted_pixel(cmyk, &ink, &Adjustment::Posterize { levels: 4 }, None), &[0.0, 85.0, 170.0, 0.0, 255.0], "CMYK posterize");
|
||
// Threshold: the black ink alone, or paper.
|
||
let dark = [200.0, 180.0, 180.0, 200.0, 255.0].map(|x| x / 255.0);
|
||
assert_levels(&adjusted_pixel(cmyk, &dark, &Adjustment::Threshold { level: 0.5 }, None), &[0.0, 0.0, 0.0, 255.0, 255.0], "CMYK threshold dark");
|
||
let paper = [0.0, 0.0, 0.0, 0.0, 1.0];
|
||
assert_levels(&adjusted_pixel(cmyk, &paper, &Adjustment::Threshold { level: 0.5 }, None), &[0.0, 0.0, 0.0, 0.0, 255.0], "CMYK threshold paper");
|
||
// A half-strength selection mixes the stored inks.
|
||
let half = adjusted_pixel(cmyk, &paper, &Adjustment::Invert, Some(0.5));
|
||
assert!(half.iter().take(4).all(|v| (v - 0.5).abs() < 0.01) && half[4] == 1.0, "{half:?}");
|
||
|
||
let lab8 = PixelFormat::new(ColorMode::Lab, SampleType::U8, true);
|
||
let lab16 = PixelFormat::new(ColorMode::Lab, SampleType::U16, true);
|
||
let px = f([77.0, 128.0, 200.0, 255.0]);
|
||
assert_levels(&adjusted_pixel(lab8, &px, &Adjustment::Invert, None), &[178.0, 127.0, 55.0, 255.0], "Lab 8-bit invert");
|
||
assert_levels(&adjusted_pixel(lab16, &px, &Adjustment::Invert, None), &[178.0, 128.0, 56.0, 255.0], "Lab 16-bit invert");
|
||
// A neutral grey posterizes warm: a and b step to +42.
|
||
let grey = f([128.0, 128.0, 128.0, 255.0]);
|
||
assert_levels(&adjusted_pixel(lab8, &grey, &Adjustment::Posterize { levels: 4 }, None), &[170.0, 170.0, 170.0, 255.0], "Lab posterize");
|
||
// Threshold compares L alone and keeps black and white neutral.
|
||
let level = Adjustment::Threshold { level: 128.0 / 255.0 };
|
||
assert_levels(&adjusted_pixel(lab8, &f([128.0, 30.0, 220.0, 255.0]), &level, None), &[255.0, 128.0, 128.0, 255.0], "Lab threshold light");
|
||
assert_levels(&adjusted_pixel(lab16, &f([127.0, 30.0, 220.0, 255.0]), &level, None), &[0.0, 128.0, 128.0, 255.0], "Lab threshold dark");
|
||
}
|
||
|
||
/// Image › Adjustments › Brightness/Contrast on a CMYK layer curves each stored ink value
|
||
/// (1 - ink) exactly, modern and legacy, and a selection mixes the stored inks.
|
||
#[test]
|
||
fn brightness_contrast_curves_cmyk_inks_as_stored() {
|
||
let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
|
||
let ink = [0.2, 0.4, 0.6, 0.1, 1.0];
|
||
for legacy in [false, true] {
|
||
let adj = Adjustment::BrightnessContrast { brightness: 25.0, contrast: 30.0, legacy };
|
||
let table = adjust::ink_brightness_contrast_lut(25.0, 30.0, legacy);
|
||
let got = adjusted_pixel(cmyk, &ink, &adj, None);
|
||
for c in 0..4 {
|
||
let want = adjust::ink_through(&table, ink[c]);
|
||
assert!((got[c] - want).abs() < 1e-4, "legacy {legacy} ink {c}: {} vs {want}", got[c]);
|
||
}
|
||
assert_eq!(got[4], 1.0);
|
||
let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5));
|
||
for c in 0..4 {
|
||
let want = ink[c] + (adjust::ink_through(&table, ink[c]) - ink[c]) * 128.0 / 255.0;
|
||
assert!((half[c] - want).abs() < 1e-3, "legacy {legacy} ink {c} through half a selection");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Image › Adjustments › Hue/Saturation and Color Balance on a CMYK layer work on its stored C, M
|
||
/// and Y as inverted RGB, keep its black ink exactly, and mix through a selection.
|
||
#[test]
|
||
fn hue_saturation_and_color_balance_keep_a_cmyk_layers_black() {
|
||
let cmyk = PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true);
|
||
let ink = [0.2, 0.7, 0.1, 0.3, 1.0];
|
||
let hue =
|
||
Adjustment::HueSaturation { hue: 40.0, saturation: 25.0, lightness: -10.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() };
|
||
let balance =
|
||
Adjustment::ColorBalance { shadows: [20.0, -10.0, 0.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 0.0, -20.0], preserve_luminosity: true };
|
||
for adj in [hue, balance] {
|
||
let mut want = [[ink[0], ink[1], ink[2], ink[3]]];
|
||
adjust::apply_cmy(&adj, &mut want);
|
||
let got = adjusted_pixel(cmyk, &ink, &adj, None);
|
||
assert!((0..4).all(|c| (got[c] - want[0][c]).abs() < 1e-4), "{}: {got:?} vs {want:?}", adj.label());
|
||
assert!((got[3] - ink[3]).abs() < 1e-4 && got[4] == 1.0, "{}: black and alpha kept", adj.label());
|
||
let half = adjusted_pixel(cmyk, &ink, &adj, Some(0.5));
|
||
let k = 128.0 / 255.0;
|
||
assert!((0..4).all(|c| (half[c] - (ink[c] + (want[0][c] - ink[c]) * k)).abs() < 1e-3), "{}: half a selection", adj.label());
|
||
}
|
||
}
|