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 0d2b725efb..34192c8b43 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1396,6 +1396,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 df9562f9d1..a1407a3d08 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1254,6 +1254,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **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, 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`.