Color: Generic CMYK separates nearby colors alike when an ink sits at 0 or 100%

The Generic CMYK solver clamps each ink to 0..1 after a step. When an
ink sits at 0 or 1 and the step pushes it further out, the clamp
removes most of the step, and the solve can stop after a step under
1e-6. Where it stopped depended on the input's last float bits. For hex
ed1e79, magenta ranged from 93.2% to 93.9% and yellow from 15.3% to
16.3% over 4,001 f32 values of its red, with 230 jumps over 0.001
between neighboring values.

An ink at 0 or 1 whose descent direction points out of the range now
stays where it is, and the step moves the other inks. The solve stops
when every ink is held. Two new tests step a channel one f32 at a time
and check that every ink stays within 0.001: the red of ed1e79 (cyan
held at 0) and the blue of sRGB (0, 0, 0.25) (cyan and magenta held at
1).

The solver still minimizes plain Lab distance to the gamut-mapped
target. The CMYK values of many RGB colors change; hex 00ff00 now
separates to C=61 M=0 Y=100 K=0 (cyan 0.6235 before, 0.6064 now), and
the swatch test that pins its name is updated.
This commit is contained in:
dotCooCoo
2026-11-08 13:23:18 +07:00
committed by bflatastic
parent b0ef9d610d
commit 1ea4eefa80
3 changed files with 56 additions and 1 deletions
+15
View File
@@ -231,6 +231,21 @@ impl GenericCmyk {
}
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
}
// An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks.
for p in 0..3 {
if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) {
a[p] = [0.0; 3];
for row in &mut a {
row[p] = 0.0;
}
a[p][p] = 1.0;
g[p] = 0.0;
}
}
// Every ink is held at a bound, or the gradient is zero: no step can lower the error.
if g == [0.0; 3] {
break;
}
let mut improved = false;
for _ in 0..8 {
let mut al = a;
+40
View File
@@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() {
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
/// in all) stays within 0.001 on every ink.
#[test]
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
let c = cms();
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -2000i32..=2000 {
let mut rgb = pink;
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
}
}
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!(lo[0], 0.0, "{lo:?}");
}
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values
/// stays within 0.001 on every ink.
#[test]
fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() {
let c = cms();
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -60i32..=60 {
let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
}
}
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+1 -1
View File
@@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
// A swatch made from a hex colour is CMYK as well; the document round-trips natively.
let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string();
assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0))));
assert_eq!(name, "C=62 M=0 Y=100 K=0");
assert_eq!(name, "C=61 M=0 Y=100 K=0");
let json = serde_json::to_string(doc(&s)).unwrap();
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
}