diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 5f0a30568f..a9f782b9bd 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -70,12 +70,25 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized /// colours and what noise and lighting make of them, and eight colours come within a few levels /// of every pixel; a photo's tones spread all through its range, and eight colours stay far from /// most of them (more than 6 levels, and more than a tenth of how far a single colour stays). +/// 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. pub(crate) fn continuous_tone(img: &Raster) -> bool { let hk = HistKey::new(img); let mut bins = vec![Bin::default(); 1 << 15]; + let (mut opaque, mut neutral) = (0u64, 0u64); for p in img.rgba.as_chunks::<4>().0 { if p[3] >= 128 { bins[hk.key(p[0], p[1], p[2])].add(p); + opaque += 1; + if p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) <= 8 { + neutral += 1; + } } } let used: Vec = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); @@ -91,7 +104,11 @@ pub(crate) fn continuous_tone(img: &Raster) -> bool { if n > 0.0 { (sum / n).sqrt() } else { 0.0 } }; let eight = unsaid(8); - eight > 6.0 && eight > 0.1 * unsaid(1) + if eight <= 6.0 { + return false; + } + let one = unsaid(1); + eight > 0.1 * one || (20 * neutral >= 19 * opaque && eight > 0.065 * one && unsaid(16) > 0.2 * eight) } /// Drop unused palette entries and merge duplicates. diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 4e138dfd5b..85ba8f97f6 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1310,6 +1310,77 @@ fn a_noisy_gradient_keeps_its_tones_without_specks() { assert!(res.paths.len() <= 2 * res.palette.len(), "{} paths of {} colours", res.paths.len(), res.palette.len()); } +/// A shaded ball and a lit wall on a dark mottled ground, with `noise` levels of noise per channel: +/// a photo's tones, in color or (`gray`) as their grays. +fn photo_scene(noise: f64, gray: bool) -> Raster { + Raster::from_fn(160, 128, |x, y| { + let ground = f64::from(20 + 10 * ((x / 16 + y / 16) % 3)); + let (dx, dy) = (f64::from(x) - 60.0, f64::from(y) - 64.0); + let c = if dx * dx + dy * dy < 1600.0 { + let shade = 0.25 + 0.75 * (1.0 - (dx + dy + 40.0) / 110.0).max(0.0); + [210.0 * shade + 20.0, 150.0 * shade + 20.0, 100.0 * shade + 20.0] + } else if x >= 110 { + let t = f64::from(y) / 127.0; + [200.0 - 90.0 * t, 180.0 - 40.0 * t, 70.0 + 50.0 * t] + } else { + [ground; 3] + }; + let p = [0, 1, 2].map(|k| (c[k] + noise * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8); + if gray { + let g = quantize::luma([p[0], p[1], p[2], 255]); + [g, g, g, 255] + } else { + [p[0], p[1], p[2], 255] + } + }) +} + +#[test] +fn a_gray_photo_is_continuous_tone_as_its_color_original_is() { + for noise in [4.0, 6.0] { + assert!(quantize::continuous_tone(&photo_scene(noise, false)), "color, noise {noise}"); + assert!(quantize::continuous_tone(&photo_scene(noise, true)), "gray, noise {noise}"); + } + // Three-color gray flat art stays flat art with up to 16 levels of uniform noise. (Heavier + // noise or JPEG artifacts can make gray flat art continuous tone.) + let clean = three_colour_art(0.0); + for noise in [0.0, 8.0, 16.0] { + let mut gray = three_colour_art(noise); + for p in gray.rgba.as_chunks_mut::<4>().0 { + let g = quantize::luma(*p); + p[..3].fill(g); + } + assert!(!quantize::continuous_tone(&gray), "gray flat art, noise {noise}"); + // The same noise on every channel, as a gray scan has it. + let scan = 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] + }); + assert!(!quantize::continuous_tone(&scan), "gray scan of flat art, noise {noise}"); + } + // Nine clean equal gray bands, more levels than eight colors can hold, stay flat art. + for step in [15, 30] { + let bands = Raster::from_fn(180, 60, |x, _| { + let v = (x / 20 * step) as u8; + [v, v, v, 255] + }); + assert!(!quantize::continuous_tone(&bands), "nine gray bands {step} levels apart"); + } +} + +#[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, + // leaving fewer colors than asked for, and steps the edges between them (more anchors). + for noise in [4.0, 6.0] { + let res = trace(&photo_scene(noise, true), &sixteen_colors()); + assert!(res.palette.len() >= 14, "noise {noise}: {} colors", res.palette.len()); + assert!(res.anchor_count() <= 1100, "noise {noise}: {} 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 908895ad64..d3f96b2f37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1218,6 +1218,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **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 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, 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`.