diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index a9f782b9bd..22e5c02af2 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -73,11 +73,13 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized /// A gray picture's tones lie along one line instead of through the whole color cube, and eight /// colors come nearer to them. A neutral picture (19 in 20 of its opaque pixels with their /// channels within 8 levels of each other) is therefore also continuous tone when eight colors -/// stay more than 0.065 as far as a single color does, unless sixteen colors stay at most a fifth -/// as far as eight do. That exception holds for gray flat art of up to sixteen clean levels, where -/// sixteen colors come within about a level of every pixel. On gray photos sixteen colors stay -/// 0.18 to 0.48 as far as eight do, and on most anti-aliased or noisy gray flat art 0.24 to 0.5 -/// as far, so the exception holds for neither. +/// stay more than 0.065 as far as a single color does. So is a sepia, single-hue or duotone +/// picture whose tones follow one curve through the color cube (19 in 20 of its opaque pixels on +/// it, see [`on_one_curve`]). Neither is when sixteen colors stay at most a fifth as far as eight +/// do. That exception holds for flat art of up to sixteen clean levels, gray or tinted, where +/// sixteen colors come within about a level of every pixel. On gray and tinted photos sixteen +/// colors stay 0.18 to 0.57 as far as eight do, and on most anti-aliased or noisy gray flat art +/// 0.24 to 0.5 as far, so the exception holds for neither. pub(crate) fn continuous_tone(img: &Raster) -> bool { let hk = HistKey::new(img); let mut bins = vec![Bin::default(); 1 << 15]; @@ -108,7 +110,118 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { return false; } let one = unsaid(1); - eight > 0.1 * one || (20 * neutral >= 19 * opaque && eight > 0.065 * one && unsaid(16) > 0.2 * eight) + if eight > 0.1 * one { + return true; + } + eight > 0.065 * one && unsaid(16) > 0.2 * eight && (20 * neutral >= 19 * opaque || 20 * on_one_curve(img) >= 19 * opaque) +} + +/// How far (levels) a pixel may lie from the tone curve of its picture and still count as on it, +/// for [`on_one_curve`]. +const CURVE_OFF: f64 = 6.0; + +/// How long (levels) each stretch along a picture's main color direction is whose median color is a +/// point of its tone curve, for [`on_one_curve`]. +const CURVE_STEP: f64 = 8.0; + +/// How far (levels) the colors of a picture can lie from their mean along any direction: more than +/// the length of the color cube's diagonal (255 × √3). +const CURVE_REACH: f64 = 448.0; + +/// How many of the opaque pixels (an alpha of 128 or more) of `img` lie on its tone curve: within +/// [`CURVE_OFF`] of it, measured across its main color direction, and within [`CURVE_OFF`] of the +/// plane its colors spread along most. The main color direction is the direction through the color +/// cube along which the opaque pixels' colors spread the most. The tone curve goes through the +/// median color (the median of each channel) of the opaque pixels in each stretch of [`CURVE_STEP`] +/// levels along that direction, which a few pixels of another color in the stretch do not move. The +/// colors of a sepia, single-hue or duotone photo follow one such curve, as a gray photo's lie along +/// the line of grays, while a color photo's spread across it. A curve with a bend, as a duotone's, +/// stays within the plane, while art in several unrelated colors has colors off it. None are on +/// the curve when there is no main color direction (no opaque pixels, or all of them one color). +fn on_one_curve(img: &Raster) -> u64 { + let opaque = || img.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] >= 128).map(|p| [p[0], p[1], p[2]].map(f64::from)); + let (mut n, mut sum, mut products) = (0.0, [0.0; 3], [[0.0; 3]; 3]); + for c in opaque() { + n += 1.0; + for i in 0..3 { + sum[i] += c[i]; + for j in 0..3 { + products[i][j] += c[i] * c[j]; + } + } + } + if n == 0.0 { + return 0; + } + let mean = sum.map(|s| s / n); + let covariance: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| products[i][j] / n - mean[i] * mean[j])); + let Some(axis) = main_direction(&covariance) else { return 0 }; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + // The direction across the main one along which the colors spread the most, and the direction + // across both: off the plane the colors spread along most. None when they lie on one line. + let first = dot(covariance.map(|row| dot(row, axis)), axis); + let rest: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| covariance[i][j] - first * axis[i] * axis[j])); + let off_plane = main_direction(&rest).and_then(|b| { + let n = [axis[1] * b[2] - axis[2] * b[1], axis[2] * b[0] - axis[0] * b[2], axis[0] * b[1] - axis[1] * b[0]]; + let len = dot(n, n).sqrt(); + (len > 1e-9).then(|| n.map(|v| v / len)) + }); + let stretches = (2.0 * CURVE_REACH / CURVE_STEP) as usize; + let stretch = |c: [f64; 3]| { + let along = dot([0, 1, 2].map(|k| c[k] - mean[k]), axis); + (((along + CURVE_REACH) / CURVE_STEP).floor().max(0.0) as usize).min(stretches - 1) + }; + // How many pixels of each stretch have each level in each channel. + let mut levels = vec![[[0u32; 256]; 3]; stretches]; + for p in img.rgba.as_chunks::<4>().0.iter().filter(|p| p[3] >= 128) { + if let Some(l) = levels.get_mut(stretch([p[0], p[1], p[2]].map(f64::from))) { + for (counts, &v) in l.iter_mut().zip(p) { + counts[usize::from(v)] += 1; + } + } + } + let middles: Vec<[f64; 3]> = levels + .iter() + .map(|l| { + l.map(|counts| { + let half = counts.iter().map(|&c| u64::from(c)).sum::().div_ceil(2); + let mut seen = 0u64; + counts + .iter() + .position(|&c| { + seen += u64::from(c); + seen >= half && seen > 0 + }) + .map_or(0.0, |v| v as f64) + }) + }) + .collect(); + opaque() + .filter(|&c| { + let Some(mid) = middles.get(stretch(c)) else { return false }; + let d = [0, 1, 2].map(|k| c[k] - mid[k]); + let along = dot(d, axis); + let off = off_plane.map_or(0.0, |o| dot([0, 1, 2].map(|k| c[k] - mean[k]), o)); + dot(d, d) - along * along <= CURVE_OFF * CURVE_OFF && off.abs() <= CURVE_OFF + }) + .count() as u64 +} + +/// The unit direction of the largest spread of a covariance matrix `m` (its main eigenvector), +/// found in a fixed 64 steps of power iteration from its longest row. None when it has no spread. +fn main_direction(m: &[[f64; 3]; 3]) -> Option<[f64; 3]> { + let norm = |v: [f64; 3]| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + let mut v = *m.iter().max_by(|a, b| norm(**a).total_cmp(&norm(**b)))?; + for _ in 0..64 { + let len = norm(v); + if len <= 1e-9 { + return None; + } + let unit = v.map(|x| x / len); + v = [0, 1, 2].map(|i| (0..3).map(|j| m[i][j] * unit[j]).sum()); + } + let len = norm(v); + (len > 1e-9).then(|| v.map(|x| x / len)) } /// Drop unused palette entries and merge duplicates. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 34192c8b43..f6176cdd6f 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1456,6 +1456,150 @@ fn a_gray_photo_is_continuous_tone_as_its_color_original_is() { } } +/// Sepia: dark brown to cream. +const SEPIA: [[f64; 3]; 2] = [[45.0, 28.0, 15.0], [250.0, 235.0, 205.0]]; +/// A single blue hue: navy to pale blue. +const BLUE: [[f64; 3]; 2] = [[12.0, 22.0, 55.0], [205.0, 225.0, 255.0]]; +/// A duotone: near black to orange to cream, two lines meeting at the orange. +const DUOTONE: [[f64; 3]; 3] = [[22.0, 18.0, 30.0], [205.0, 105.0, 35.0], [252.0, 238.0, 215.0]]; +/// The gray ramp. +const GRAY_RAMP: [[f64; 3]; 2] = [[0.0; 3], [255.0; 3]]; + +/// `img`'s grays (its red channel) as the colors of the ramp through `stops`, black going to the +/// first stop and white to the last, with `chroma` levels of noise per channel. Alpha is kept. +fn tinted(img: &Raster, stops: &[[f64; 3]], chroma: f64) -> Raster { + Raster::from_fn(img.width, img.height, |x, y| { + let i = ((y * img.width + x) * 4) as usize; + let g = f64::from(img.rgba[i]) / 255.0 * (stops.len() - 1) as f64; + let s = (g.floor() as usize).min(stops.len() - 2); + let t = g - s as f64; + let c = [0, 1, 2] + .map(|k| (stops[s][k] + t * (stops[s + 1][k] - stops[s][k]) + chroma * jitter(x, y, 7 + k as u32)).round().clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], img.rgba[i + 3]] + }) +} + +/// [`three_colour_art`] as a gray scan: its grays with `noise` levels of the same noise on every +/// channel. +fn gray_scan_of_flat_art(noise: f64) -> Raster { + let clean = three_colour_art(0.0); + Raster::from_fn(clean.width, clean.height, |x, y| { + let i = ((y * clean.width + x) * 4) as usize; + let g = f64::from(quantize::luma([clean.rgba[i], clean.rgba[i + 1], clean.rgba[i + 2], 255])); + let v = (g + noise * jitter(x, y, 0)).round().clamp(0.0, 255.0) as u8; + [v, v, v, 255] + }) +} + +/// `levels` clean gray bands, evenly spaced from black to white. +fn gray_bands(levels: u32) -> Raster { + Raster::from_fn(16 * 20, 60, |x, _| { + let v = ((x / 20).min(levels - 1) * 255 / (levels - 1)) as u8; + [v, v, v, 255] + }) +} + +/// `img` on the left of a picture three times as wide whose other pixels are transparent and of +/// every color. +fn beside_transparent_colors(img: &Raster) -> Raster { + Raster::from_fn(img.width * 3, img.height, |x, y| { + if x < img.width { + let i = ((y * img.width + x) * 4) as usize; + [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]] + } else { + let c = |k: u32| (127.5 + 127.5 * jitter(x, y, 20 + k)) as u8; + [c(0), c(1), c(2), 0] + } + }) +} + +#[test] +fn a_tinted_photo_is_continuous_tone_as_a_gray_photo_is() { + // With 0 to 4 levels of noise per channel (to 2 for the duotone). + let tints = [("sepia", &SEPIA[..], 4.0), ("blue", &BLUE[..], 4.0), ("duotone", &DUOTONE[..], 2.0)]; + for noise in [4.0, 6.0] { + let gray = photo_scene(noise, true); + assert!(quantize::continuous_tone(&gray), "gray, noise {noise}"); + assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}"); + for (name, stops, most) in tints { + for chroma in [0.0, 2.0, 4.0].into_iter().filter(|&c| c <= most) { + let photo = tinted(&gray, stops, chroma); + assert!(quantize::continuous_tone(&photo), "{name}, noise {noise}, chroma noise {chroma}"); + // Transparent pixels, whatever their colors, leave it as it is. + assert!(quantize::continuous_tone(&beside_transparent_colors(&photo)), "{name} beside transparent colors, noise {noise}"); + } + } + } +} + +#[test] +fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() { + // A sepia photo with a red and a green patch, each 2 in 100 of its pixels. + for noise in [4.0, 6.0] { + let photo = tinted(&photo_scene(noise, true), &SEPIA, 2.0); + let accented = Raster::from_fn(photo.width, photo.height, |x, y| match (x, y) { + (0..20, 0..20) => [220, 30, 40, 255], + (140..160, 108..128) => [40, 200, 60, 255], + _ => { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 255] + } + }); + assert!(quantize::continuous_tone(&accented), "noise {noise}"); + // One patch, 4 in 100 of its pixels, in a color beside the middle of the ramp. + let beside = Raster::from_fn(photo.width, photo.height, |x, y| match (x, y) { + (0..32, 0..26) => [176, 104, 110, 255], + _ => { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 255] + } + }); + assert!(quantize::continuous_tone(&beside), "noise {noise}, one patch"); + } +} + +#[test] +fn tinted_flat_art_stays_flat_art() { + let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)]; + for (name, stops) in tints { + // Clean bands, nine of them included: more than eight colors can hold. + for levels in [2, 3, 9] { + let bands = tinted(&gray_bands(levels), stops, 0.0); + assert!(!quantize::continuous_tone(&bands), "{levels} {name} bands"); + assert!(!quantize::continuous_tone(&beside_transparent_colors(&bands)), "{levels} {name} bands beside transparent colors"); + } + // Scans of flat art, with noise on the tone and on each channel. + for noise in [0.0, 8.0] { + for chroma in [0.0, 4.0] { + assert!( + !quantize::continuous_tone(&tinted(&gray_scan_of_flat_art(noise), stops, chroma)), + "{name} scan, noise {noise}, chroma noise {chroma}" + ); + } + } + } + // As with gray (see `a_gray_photo_is_continuous_tone_as_its_color_original_is`), a single-hue + // scan stays flat art with up to 16 levels of noise on its tone. + for stops in [&GRAY_RAMP, &SEPIA, &BLUE] { + assert!(!quantize::continuous_tone(&tinted(&gray_scan_of_flat_art(16.0), stops, 0.0)), "{stops:?} scan, noise 16"); + } +} + +#[test] +fn pictures_without_tones_are_not_continuous_tone() { + let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0); + let none = Raster::from_fn(0, 0, |_, _| [0, 0, 0, 0]); + let transparent = Raster::from_fn(photo.width, photo.height, |x, y| { + let i = ((y * photo.width + x) * 4) as usize; + [photo.rgba[i], photo.rgba[i + 1], photo.rgba[i + 2], 0] + }); + let one_pixel = Raster::from_fn(1, 1, |_, _| [200, 150, 100, 255]); + let one_color = Raster::from_fn(64, 64, |_, _| [200, 150, 100, 255]); + for (name, img) in [("empty", none), ("transparent", transparent), ("one pixel", one_pixel), ("one color", one_color)] { + assert!(!quantize::continuous_tone(&img), "{name}"); + } +} + #[test] fn a_gray_photo_is_smoothed_before_it_is_traced() { // As a color photo is. Traced with its noise, the noise mixes neighboring tones into one, @@ -1467,6 +1611,23 @@ fn a_gray_photo_is_smoothed_before_it_is_traced() { } } +#[test] +fn a_tinted_photo_is_smoothed_before_it_is_traced() { + // As a gray photo is, in Color and in Grayscale. Traced with its noise, the noise mixes + // neighboring tones into one, leaving 6 to 13 of the sixteen colors, and steps the edges + // between them (up to 1,402 anchors). + for noise in [4.0, 6.0] { + for (name, stops) in [("sepia", &SEPIA[..]), ("blue", &BLUE[..]), ("duotone", &DUOTONE[..])] { + let photo = tinted(&photo_scene(noise, true), stops, 2.0); + for mode in [Mode::Color, Mode::Grayscale] { + let res = trace(&photo, &TraceParams { mode, ..sixteen_colors() }); + assert!(res.palette.len() >= 12, "{name}, noise {noise}, {mode:?}: {} colors", res.palette.len()); + assert!(res.anchor_count() <= 1000, "{name}, noise {noise}, {mode:?}: {} anchors", res.anchor_count()); + } + } + } +} + #[test] fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() { // Stripes: black, white, mid gray, light gray (the widest). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ea9fe85ad..d904a57ae8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1269,6 +1269,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. +- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`.