From 0de0cb7cb9f334f8eeeaea4346a98e58d87a8f46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 03:38:46 +0700 Subject: [PATCH] Image Trace: Ignore Color can leave out only the background Squashed from #3952 onto main. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/tests/build.rs | 36 +++++++++ crates/trace/src/layers.rs | 15 ++-- crates/trace/src/lib.rs | 2 + crates/trace/src/params.rs | 5 ++ crates/trace/src/pipeline.rs | 101 ++++++++++++++++++++++--- crates/trace/src/tests.rs | 116 +++++++++++++++++++++++++++++ docs/image-trace-adjustments.md | 7 ++ docs/target-app-parity.md | 1 + 9 files changed, 269 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 1b37ca96f..2aa759411 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index a94696d16..f686d80dc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +#[test] +fn image_trace_ignore_background_keeps_the_color_inside_the_art() { + // A black field, a light panel on it and a black ring on the panel. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0); + if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] } + }); + let traced = |background: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let black: Vec<_> = g + .children() + .unwrap() + .iter() + .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0))) + .map(|n| outline(n).0.bounds().unwrap()) + .collect(); + (r["colors"].clone(), black) + }; + // The whole color: the ring goes with the field. + let (colors, black) = traced(false); + assert_eq!(colors, 1); + assert!(black.is_empty(), "{black:?}"); + // The background only: the ring stays, and nothing black reaches the image's edge. + let (colors, black) = traced(true); + assert_eq!(colors, 2); + assert_eq!(black.len(), 1, "{black:?}"); + let bb = black[0]; + assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}"); + // The params doc tells agents about the option. + assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false")); +} + /// A Document Library trace made away from its command no longer applies once the library's /// colours change: the settings' JSON leaves them out (#4605 review). #[test] diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index d9b34cc54..f9c77fa67 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -1,7 +1,7 @@ use super::params::{Method, Mode, TraceParams}; #[cfg(test)] use super::pipeline::bw_tones; -use super::pipeline::{TracedPath, is_ignored}; +use super::pipeline::{TracedPath, drops_color, is_ignored}; use super::raster::Raster; use super::{ AtomicUsize, Crack, Loop, Ordering, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, centerline, contour, fit, logo, shapes, @@ -13,6 +13,9 @@ use super::{denoise, quantize}; /// What tracing one colour layer needs. pub(crate) struct Layers<'a> { pub(crate) q: &'a Quantized, + /// The label each layer traces a pixel as: `q.labels`, or with Ignore Color for the background + /// only, those with the background [`TRANSPARENT`]. Edges are placed by `q.labels`. + pub(crate) traced: &'a [u16], /// Each palette entry's layer, bottom (0) up. pub(crate) rank: &'a [usize], pub(crate) params: &'a TraceParams, @@ -36,9 +39,10 @@ pub(crate) struct Layers<'a> { impl Layers<'_> { /// Ignore White: white is no part of any layer (stacked layers reach under the layers above - /// them, and must not reach under white that is left out). + /// them, and must not reach under white that is left out). With `ignore_background` the + /// background is [`TRANSPARENT`] in `traced`, and the color's other areas are traced. fn ignored(&self, label: u16) -> bool { - self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) + drops_color(self.params) && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour. @@ -270,7 +274,7 @@ impl Layers<'_> { let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) }; - centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) + centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) }; @@ -288,7 +292,7 @@ impl Layers<'_> { let (bw, bh) = (x1 - x0, y1 - y0); let mut mask = Vec::with_capacity(bw * bh); for y in y0..y1 { - let row = self.q.labels.get(y * w + x0..y * w + x1)?; + let row = self.traced.get(y * w + x0..y * w + x1)?; mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); } // Overlapping: a part of the mask with no pixel of this color holds only colors above it, @@ -433,6 +437,7 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { let gradients = vec![None; n]; let job = Layers { q: &q, + traced: &q.labels, rank: &rank, params, img, diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index e68118d63..46c29f4e0 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -81,6 +81,8 @@ pub(crate) use layers::shift_probe; pub use params::{Method, Mode, PRESET_NAMES, Palette, TraceParams, enhanced_preset, preset, presets}; pub use pipeline::{TraceResult, TracedPath, trace, trace_watched, trace_within}; #[cfg(test)] +use pipeline::{clear_background, label_extents}; +#[cfg(test)] use raster::fit_size; pub use raster::{MAX_PIXELS, MAX_SOURCE_PIXELS, Raster, TraceError}; diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 65e5bb5c0..ce805e1c7 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -91,6 +91,10 @@ pub struct TraceParams { /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels /// of it. `None` is white. (Flat Logo mode leaves out white only.) pub ignore_color: Option, + /// With Ignore Color: leave out only the areas of that color that reach the image's edge (a + /// background), through neighbors above, below and to either side, and trace the areas of it + /// that other colors enclose. (Flat Logo mode leaves out every white area.) + pub ignore_background: bool, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool, @@ -140,6 +144,7 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None, + ignore_background: false, snap_curves_to_lines: false, shapes: false, auto_grouping: false, diff --git a/crates/trace/src/pipeline.rs b/crates/trace/src/pipeline.rs index 486eb9c0a..79a1ef8ec 100644 --- a/crates/trace/src/pipeline.rs +++ b/crates/trace/src/pipeline.rs @@ -59,6 +59,79 @@ pub(crate) fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Ignore Color leaves out every area of its color (not only the background). +pub(crate) fn drops_color(params: &TraceParams) -> bool { + params.ignore_white && !params.ignore_background +} + +/// Ignore Color, background only: clears to [`TRANSPARENT`] every area of a label `ignored` leaves +/// out that reaches the image's edge, through neighbors above, below and to either side. It fills +/// one run of a row at a time, and fills from one edge pixel at a time. +pub(crate) fn clear_background(labels: &mut [u16], w: usize, h: usize, ignored: &[bool]) { + let (last_row, last_col) = (h.saturating_sub(1) * w, w.saturating_sub(1)); + let mut seeds = Vec::new(); + for edge in (0..w).flat_map(|x| [x, last_row + x]).chain((0..h).flat_map(|y| [y * w, y * w + last_col])) { + seeds.push(edge); + fill_left_out(labels, w, h, ignored, &mut seeds); + } +} + +/// Clears to [`TRANSPARENT`] the left-out areas that `seeds` reach, and empties `seeds`. +fn fill_left_out(labels: &mut [u16], w: usize, h: usize, ignored: &[bool], seeds: &mut Vec) { + let left_out = |labels: &[u16], i: usize| labels.get(i).is_some_and(|&l| ignored.get(l as usize).copied().unwrap_or(false)); + while let Some(i) = seeds.pop() { + if !left_out(labels, i) { + continue; + } + // The run of left-out pixels along the row through `i`. + let row = i - i % w; + let (mut a, mut b) = (i, i + 1); + while a > row && left_out(labels, a - 1) { + a -= 1; + } + while b < row + w && left_out(labels, b) { + b += 1; + } + if let Some(run) = labels.get_mut(a..b) { + run.fill(TRANSPARENT); + } + // A seed at the start of each run of left-out pixels next to it in the rows above and below. + for next in [row.checked_sub(w), Some(row + w).filter(|&r| r < w * h)].into_iter().flatten() { + let mut inside = false; + for x in a - row..b - row { + let here = left_out(labels, next + x); + if here && !inside { + seeds.push(next + x); + } + inside = here; + } + } + } +} + +/// A box of pixels: `(x0, y0, x1, y1)`, the far sides excluded. +type PixelBox = (usize, usize, usize, usize); + +/// Each of `n` palette entries' pixel count in `labels`, and the box its pixels lie in within +/// `traced` (`labels` with Ignore Color's background [`TRANSPARENT`]). A layer is traced within its +/// box, so a color whose background is left out is traced within the box of its remaining areas. +pub(crate) fn label_extents(labels: &[u16], traced: &[u16], w: usize, n: usize) -> (Vec, Vec>) { + let mut counts = vec![0usize; n]; + for &l in labels { + if let Some(c) = counts.get_mut(l as usize) { + *c += 1; + } + } + let mut boxes: Vec> = vec![None; n]; + for (i, &l) in traced.iter().enumerate() { + if let Some(b) = boxes.get_mut(l as usize) { + let (x, y) = (i % w, i / w); + *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + } + } + (counts, boxes) +} + /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { @@ -160,16 +233,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( let keep: Vec = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep); go_on()?; - // Pixel count per palette entry, and the box its pixels lie in. - let mut counts = vec![0usize; q.palette.len()]; - let mut boxes: Vec> = vec![None; q.palette.len()]; - for (i, &l) in q.labels.iter().enumerate() { - if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) { - *c += 1; - let (x, y) = (i % w, i / w); - *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1)))); + // Ignore Color, background only: the labels traced, with the ignored color's areas that reach + // the edge left out. Its other areas are traced with the rest. Edges are still placed by + // `q.labels`, as when the whole color is left out. + let cleared = (params.ignore_white && params.ignore_background).then(|| { + let ignored: Vec = q.palette.iter().map(|&c| is_ignored(c, ignore)).collect(); + let mut labels = q.labels.clone(); + clear_background(&mut labels, w, h, &ignored); + labels + }); + // Whether any of a palette entry's pixels are left after the background. + let mut remains = vec![false; q.palette.len()]; + for &l in cleared.iter().flatten() { + if let Some(r) = remains.get_mut(l as usize) { + *r = true; } } + let (counts, boxes) = label_extents(&q.labels, cleared.as_deref().unwrap_or(&q.labels), w, q.palette.len()); // Layers bottom → top: largest area first. let mut order: Vec = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b))); @@ -191,10 +271,11 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &( for (r, &i) in order.iter().enumerate() { rank[i] = r; } - let layers: Vec<(usize, usize)> = - order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect(); + let dropped = |ci: usize| params.ignore_white && is_ignored(q.palette[ci], ignore) && !remains.get(ci).copied().unwrap_or(false); + let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); let job = Layers { q: &q, + traced: cleared.as_deref().unwrap_or(&q.labels), rank: &rank, params, img, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 93467b352..22090dd2d 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1444,6 +1444,122 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A 600 × 400 field of `edge` with a light panel (40..560 × 40..360) and, on the panel, a ring of +/// `line` 8 px wide and 100 px in radius around (300, 200), filled with `inside`. +fn panel_with_ring(edge: [u8; 4], line: [u8; 4], inside: [u8; 4]) -> Raster { + Raster::from_fn(600, 400, |x, y| { + let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = Point::new(fx, fy).distance(Point::new(300.0, 200.0)); + if !((40.0..560.0).contains(&fx) && (40.0..360.0).contains(&fy)) { + edge + } else if (96.0..=104.0).contains(&r) { + line + } else if r < 96.0 { + inside + } else { + [240, 240, 220, 255] + } + }) +} + +#[test] +fn ignore_background_leaves_out_only_the_areas_of_the_color_that_reach_the_edge() { + use kurbo::Shape; + let covers = |res: &TraceResult, color: [u8; 3], x: f64, y: f64| { + res.paths.iter().any(|t| t.color == color && t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0) + }; + let covered = |res: &TraceResult, x: f64, y: f64| res.paths.iter().any(|t| t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0); + // A black field, a light panel and a black ring on it. + let light = [240, 240, 220, 255]; + let img = panel_with_ring(BLACK, BLACK, light); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 2, + method, + ignore_white: true, + ignore_color: Some("#000000".into()), + ..TraceParams::default() + }; + // The whole color: the ring goes with the field. + let all = trace(&img, ¶ms); + assert!(all.paths.iter().all(|p| p.color != [0, 0, 0]), "{method:?}"); + assert!(!covered(&all, 300.0, 100.0) && !covered(&all, 10.0, 10.0), "{method:?}"); + // The background only: the field goes, the ring stays. + let res = trace(&img, &TraceParams { ignore_background: true, ..params.clone() }); + assert!(covers(&res, [0, 0, 0], 300.0, 100.0) && covers(&res, [0, 0, 0], 300.0, 300.0), "{method:?}"); + assert!(covers(&res, [240, 240, 220], 100.0, 200.0) && covers(&res, [240, 240, 220], 300.0, 200.0), "{method:?}"); + assert!(!covered(&res, 10.0, 10.0) && !covered(&res, 590.0, 390.0) && !covered(&res, 300.0, 20.0), "{method:?}"); + // Without Ignore Color, the setting has no effect. + let kept = |p: &TraceParams| trace(&img, &TraceParams { ignore_white: false, ..p.clone() }); + assert_eq!(kept(&TraceParams { ignore_background: true, ..params.clone() }), kept(¶ms), "{method:?}"); + } + // White, the default: a white field goes, the white inside a ring stays. + let img = panel_with_ring(WHITE, BLACK, WHITE); + let params = TraceParams { mode: Mode::Color, colors: 3, ignore_white: true, ignore_background: true, ..TraceParams::default() }; + let res = trace(&img, ¶ms); + assert!(covers(&res, [255, 255, 255], 300.0, 200.0) && !covered(&res, 10.0, 10.0)); + assert!(!covered(&trace(&img, &TraceParams { ignore_background: false, ..params }), 300.0, 200.0)); + // With no area of the color enclosed, the trace is the one that leaves out the whole color, + // its anti-aliased edges placed the same. + let img = three_colour_art(0.0); + for method in [Method::Abutting, Method::Overlapping] { + let params = TraceParams { colors: 3, method, ignore_white: true, ignore_color: Some("#14285a".into()), ..sixteen_colors() }; + assert_eq!(trace(&img, &TraceParams { ignore_background: true, ..params.clone() }), trace(&img, ¶ms), "{method:?}"); + } + // Old documents have no such setting: the whole color. + let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap(); + assert!(!old.ignore_background); + let on: TraceParams = serde_json::from_str(r#"{"ignoreWhite":true,"ignoreBackground":true}"#).unwrap(); + assert!(on.ignore_background); +} + +#[test] +fn ignore_background_traces_a_color_within_the_box_of_its_remaining_areas() { + // A 40 × 30 field of color 0 with a ring of color 1 around a 2 × 2 area of color 0. + let (w, h) = (40, 30); + let mut labels = vec![0u16; w * h]; + for y in 10..16 { + for x in 20..26 { + labels[y * w + x] = 1; + } + } + for y in 12..14 { + for x in 22..24 { + labels[y * w + x] = 0; + } + } + let mut cleared = labels.clone(); + clear_background(&mut cleared, w, h, &[true, false]); + let (counts, boxes) = label_extents(&labels, &cleared, w, 2); + // The counts stay those of the whole image (they order the layers); the boxes are those of + // what is traced. + assert_eq!(counts, vec![w * h - 32, 32]); + assert_eq!(boxes, vec![Some((22, 12, 24, 14)), Some((20, 10, 26, 16))]); + // Without Ignore Color, the boxes are those of every pixel. + assert_eq!(label_extents(&labels, &labels, w, 2).1, vec![Some((0, 0, w, h)), Some((20, 10, 26, 16))]); +} + +#[test] +fn ignore_background_clears_a_tall_narrow_image_from_its_edges() { + // A 1 × 200,000 column of color 0 with color 1 at rows 1,000 to 1,009: every pixel is on the + // edge, so all of color 0 is background. + let (w, h) = (1, 200_000); + let mut labels = vec![0u16; w * h]; + for l in &mut labels[1_000..1_010] { + *l = 1; + } + clear_background(&mut labels, w, h, &[true, false]); + assert_eq!(labels.iter().filter(|&&l| l == TRANSPARENT).count(), h - 10); + assert!(labels[1_000..1_010].iter().all(|&l| l == 1)); + // In a 2 × 100,000 column of color 0, every pixel is on the edge too. + let (w, h) = (2, 100_000); + let mut labels = vec![0u16; w * h]; + clear_background(&mut labels, w, h, &[true, false]); + assert!(labels.iter().all(|&l| l == TRANSPARENT)); +} + #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre. diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 92e5f42ac..7c9f0567c 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -51,6 +51,13 @@ traced fills and strokes take the library color without a link to its swatch. Th spot and tint swatches. Tints trace at the color shown by the tint swatch. Editing a swatch later does not recolor the trace. Colors from other swatch libraries are also unlinked. +**Ignore Color, background only.** Ignore Color leaves out every area of the chosen color. With +`"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or +`imageTrace.makeAndExpand`, it leaves out only the areas of that color that reach the image's edge, +joined through pixels above, below or to either side, and traces the areas of it that other colors +enclose: black line work inside a panel stays when the black around the panel is left out. Flat Logo +leaves out every white area whatever `ignoreBackground` is. + **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times as much as one in lightness, so a gray or a muted brown takes a black, white or other neutral diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7e9e7612..17b1704cc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1104,6 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. - **Image Trace, source image on hold:** while the eye button next to the Image Trace panel's View is held down, the canvas shows the selected Image Trace objects' source image whatever their View, and shows their View again on release, with no change to the document or the undo history. - **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)). +- **Image Trace, Ignore Color for the background only:** `"ignoreBackground": true` with Ignore Color leaves out only the areas of the ignored color that reach the image's edge and traces the areas of it that other colors enclose, in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger decoded raster (the 64-megapixel copy for a bigger image) is averaged down in whole-number blocks first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off.