mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
CMYK: Dissolve and post-adjustment rounding match the RGB compositor (#9033)
The CMYK proptests failed on main now and then (the_split_route_agrees_with_the_exact_path after #8663). Three gaps, each found by the proptests at 5000 cases: - cmyk.rs: a clipped Dissolve layer blended like Normal; it now shows each pixel fully or not at all by the compositor's noise, as the RGB compositor does since #3052. - cmyk.rs: a Dissolve adjustment layer mixed by its opacity; opacity and mask are now the chance a pixel takes the adjustment, as on the RGB compositor (#3050). - native.rs: the display/Flatten ink route didn't round to the document's levels after an adjustment layer, where the RGB compositor and cmyk.rs do; coverage in a pass-through group then differed by up to half a level. The test reference model had the same two Dissolve gaps (ref_clip passed no position, so Dissolve fell back to Normal), which is why random_documents_match_the_reference never caught the first one. Three named regression tests cover the minimal cases. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a02f2ee410
commit
0b3cab2eba
@@ -5,3 +5,5 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] }
|
||||
cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
|
||||
cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
|
||||
|
||||
@@ -402,12 +402,24 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) {
|
||||
/// mask; coverage is unchanged (the compositor's adjustment mix).
|
||||
fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) {
|
||||
let opacity = l.opacity * l.fill_opacity;
|
||||
let mv = mask_vals(l, base.rect, cx);
|
||||
let rect = base.rect;
|
||||
let w = (rect.width() as usize).max(1);
|
||||
let mv = mask_vals(l, rect, cx);
|
||||
for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() {
|
||||
let k = opacity * mask_at(&mv, i);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if l.blend == BlendMode::Dissolve {
|
||||
// As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the
|
||||
// adjustment fully, by the same noise.
|
||||
let x = rect.x0.saturating_add((i % w) as i32);
|
||||
let y = rect.y0.saturating_add((i / w) as i32);
|
||||
if super::dissolve_noise(x, y) < k {
|
||||
*p = [a[0], a[1], a[2], a[3], p[4]];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]);
|
||||
for (v, b) in p.iter_mut().zip(bl) {
|
||||
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
|
||||
@@ -416,10 +428,10 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
|
||||
}
|
||||
|
||||
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
|
||||
/// channel brightness the document stores, and coverage).
|
||||
fn quantize_inks(buf: &mut CmykBuffer, q: f32) {
|
||||
/// channel brightness the document stores, and coverage). Also used by [`super::native`].
|
||||
pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) {
|
||||
let round = |v: f32| (v * q + 0.5).floor() / q;
|
||||
for p in &mut buf.px {
|
||||
for p in px {
|
||||
for v in p.iter_mut().take(4) {
|
||||
// Whole levels of ink: exactly the stored values, rounding ties as on brightness.
|
||||
*v = (q - ((1.0 - *v) * q + 0.5).floor()) / q;
|
||||
@@ -441,11 +453,23 @@ fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) {
|
||||
let content = layer_content(c, base.rect, cx);
|
||||
let opacity = c.opacity * c.fill_opacity;
|
||||
let gamma = super::text_gamma(c);
|
||||
for (p, s) in base.px.iter_mut().zip(&content.px) {
|
||||
let rect = base.rect;
|
||||
let w = (rect.width() as usize).max(1);
|
||||
for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() {
|
||||
let alpha = p[4];
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if c.blend == BlendMode::Dissolve {
|
||||
// As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes
|
||||
// the clipped layer fully or not at all, by the same noise.
|
||||
let x = rect.x0.saturating_add((i % w) as i32);
|
||||
let y = rect.y0.saturating_add((i / w) as i32);
|
||||
if super::dissolve_noise(x, y) < s[4] * opacity {
|
||||
*p = [s[0], s[1], s[2], s[3], alpha];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma);
|
||||
*p = [r[0], r[1], r[2], r[3], alpha];
|
||||
}
|
||||
@@ -477,7 +501,7 @@ fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) {
|
||||
if base.visible {
|
||||
composite_layer(base, clipped, backdrop, cx);
|
||||
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) {
|
||||
quantize_inks(backdrop, q);
|
||||
quantize_inks(&mut backdrop.px, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32
|
||||
a = ref_clip(clipped, a, x, y);
|
||||
}
|
||||
let k = l.opacity * l.fill_opacity * ref_mask(l, x, y);
|
||||
if l.blend == BlendMode::Dissolve {
|
||||
// Opacity and mask are the chance the pixel takes the adjustment fully.
|
||||
return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p };
|
||||
}
|
||||
let mut o = p;
|
||||
for c in 0..4 {
|
||||
o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k;
|
||||
@@ -233,7 +237,7 @@ fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka {
|
||||
content = ref_adjusted(c, adj, &[], content, x, y);
|
||||
} else if content[4] > 0.0 {
|
||||
let a = content[4];
|
||||
let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, None);
|
||||
let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y)));
|
||||
content = [r[0], r[1], r[2], r[3], a];
|
||||
}
|
||||
}
|
||||
@@ -1697,3 +1701,100 @@ fn rgb_colours_become_ink_through_the_document_profile() {
|
||||
assert!(!supports(&overlay) && split::supports(&overlay));
|
||||
check(&overlay, "colour overlay");
|
||||
}
|
||||
|
||||
/// The split route and the exact path give the same inks for `s`, alpha included.
|
||||
fn assert_split_matches_exact(s: &Spec) {
|
||||
let mut d = doc(s.w as u32, s.h as u32);
|
||||
d.layers = s.layers.iter().map(build).collect();
|
||||
assert!(supports(&d) && split::supports(&d));
|
||||
let via = split_render(&d);
|
||||
let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported"));
|
||||
for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() {
|
||||
assert!((0..5).all(|c| (g[c] - w[c]).abs() <= 1e-5), "pixel {i}: {g:?} vs {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn raster_spec(blend: BlendMode, fill: (Rect, Cmyka), opacity: f32, clipped: bool) -> LayerSpec {
|
||||
LayerSpec::Pixels { kind: Kind::Raster, blend, fills: vec![fill], opacity, fill: 1.0, clipped, visible: true, mask: None }
|
||||
}
|
||||
|
||||
fn group_spec(children: Vec<LayerSpec>, pass_through: bool) -> LayerSpec {
|
||||
LayerSpec::Group { children, pass_through, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }
|
||||
}
|
||||
|
||||
/// A clipped Dissolve layer shows each pixel fully or not at all on the exact path too, as on the
|
||||
/// RGB compositor (#9033; it used to mix like Normal: K 0.81 where the noise hides the layer).
|
||||
#[test]
|
||||
fn a_clipped_dissolve_layer_dissolves_on_the_exact_path() {
|
||||
let s = Spec {
|
||||
w: 4,
|
||||
h: 5,
|
||||
layers: vec![group_spec(
|
||||
vec![
|
||||
raster_spec(BlendMode::Normal, (Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), 1.0, false),
|
||||
raster_spec(BlendMode::Dissolve, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 0.189_737_02, true),
|
||||
],
|
||||
false,
|
||||
)],
|
||||
};
|
||||
assert_split_matches_exact(&s);
|
||||
}
|
||||
|
||||
/// A Dissolve adjustment layer takes each pixel fully or not at all on the exact path too (#9033;
|
||||
/// it used to mix by opacity: cyan 0.267 where the noise picks the adjusted 0.298).
|
||||
#[test]
|
||||
fn a_dissolve_adjustment_dissolves_on_the_exact_path() {
|
||||
let levels = AdjSpec::Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] };
|
||||
let adjust = LayerSpec::Pixels {
|
||||
kind: Kind::Adjust(levels),
|
||||
blend: BlendMode::Dissolve,
|
||||
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
|
||||
opacity: 0.897_210_9,
|
||||
fill: 1.0,
|
||||
clipped: false,
|
||||
visible: true,
|
||||
mask: None,
|
||||
};
|
||||
let s = Spec {
|
||||
w: 3,
|
||||
h: 2,
|
||||
layers: vec![
|
||||
group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false)], false),
|
||||
group_spec(vec![adjust], true),
|
||||
],
|
||||
};
|
||||
assert_split_matches_exact(&s);
|
||||
}
|
||||
|
||||
/// After an adjustment layer, the display route ([`crate::native`]) rounds to the document's
|
||||
/// levels like the export path and the RGB compositor, at any opacity (#9033: in a pass-through
|
||||
/// group its coverage stayed at 41.32% where the others store 105/255).
|
||||
#[test]
|
||||
fn every_cmyk_route_rounds_after_an_adjustment_layer() {
|
||||
for opacity in [0.0, 0.5] {
|
||||
let adjust = LayerSpec::Pixels {
|
||||
kind: Kind::Adjust(AdjSpec::Invert),
|
||||
blend: BlendMode::Normal,
|
||||
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
|
||||
opacity,
|
||||
fill: 1.0,
|
||||
clipped: false,
|
||||
visible: true,
|
||||
mask: None,
|
||||
};
|
||||
let s = Spec {
|
||||
w: 8,
|
||||
h: 5,
|
||||
layers: vec![group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false), adjust], true)],
|
||||
};
|
||||
let mut d = doc(s.w as u32, s.h as u32);
|
||||
d.layers = s.layers.iter().map(build).collect();
|
||||
if let LayerContent::Group(g) = &mut d.layers[0].content {
|
||||
g.children[0].fill_opacity = 0.413_248_66;
|
||||
}
|
||||
let rect = Rect::new(0, 0, 1, 1);
|
||||
let display = crate::render(&d, rect).px[0][3];
|
||||
let export = render_cmyk(&d, rect).unwrap_or_else(|| panic!("supported")).px[0][4];
|
||||
assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,13 @@ impl<'a> InkStack<'a> {
|
||||
}
|
||||
}
|
||||
Item::Adjust(op, opacity, mask) => {
|
||||
// The result is rounded to the document's levels, as the compositor and the
|
||||
// export path do after every adjustment layer, at any opacity (#9033).
|
||||
let quantum = crate::adjustment_quantum(self.depth);
|
||||
if *opacity <= 0.0 {
|
||||
if let Some(q) = quantum {
|
||||
crate::cmyk::quantize_inks(acc, q);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
masked(*mask, mv);
|
||||
@@ -228,6 +234,9 @@ impl<'a> InkStack<'a> {
|
||||
d[c] += (v - d[c]) * mix;
|
||||
}
|
||||
}
|
||||
if let Some(q) = quantum {
|
||||
crate::cmyk::quantize_inks(acc, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user