Merge pull request #3952 from dotCooCoo/feat/trace-ignore-background-only

Image Trace: Ignore Color can leave out only the background
This commit is contained in:
BFlat
2026-11-09 04:54:36 +08:00
committed by GitHub
9 changed files with 269 additions and 16 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)
),
+36
View File
@@ -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", &params).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]
+10 -5
View File
@@ -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,
+2
View File
@@ -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};
+5
View File
@@ -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<String>,
/// 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,
+91 -10
View File
@@ -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<usize>) {
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<usize>, Vec<Option<PixelBox>>) {
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<Option<PixelBox>> = 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<bool> = 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<Option<(usize, usize, usize, usize)>> = 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<bool> = 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<usize> = (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,
+116
View File
@@ -1444,6 +1444,122 @@ fn ignore_color_leaves_out_the_colour_picked() {
assert_eq!(serde_json::to_value(&params).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, &params);
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(&params), "{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, &params);
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, &params), "{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.
+7
View File
@@ -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
+1
View File
@@ -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.