Image Trace: keepColors traces only the listed palette colors

Image Trace had no way to trace some of an image's colors and leave the
rest out. Ignore Color drops one color, so tracing only the line work of
a multi-color image took a full trace and then deleting the other
colors' paths by hand.

With "keepColors": ["#rrggbb", ...] in the params of imageTrace.make and
imageTrace.makeAndExpand, only the palette colors within about 30 levels
of a listed color are traced, the distance Ignore Color matches by. With
Method Abutting, the kept colors' paths are the same as in a trace of
every color. With Method Overlapping, a kept color's paths do not reach
under the colors left out, as with Ignore Color. An empty list traces
every color, and a value that is not a six-digit hex color is refused.
Flat Logo mode does not use the list.

The reply's warnings name each listed color that matches none of the
colors the trace found, and say when nothing was traced; the status bar
shows the first. The warnings are translated in every complete catalog.
The params help of imageTrace.make lists keepColors and the new
warnings, and the help of imageTrace.makeAndExpand lists the warnings
it returns.
This commit is contained in:
dotCooCoo
2026-10-24 00:19:46 -07:00
parent 50d976e1e6
commit 060e968246
29 changed files with 386 additions and 10 deletions
+25 -3
View File
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), 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), 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, white is always its own colour, at most ten; 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 unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, 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)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), 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), 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), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), 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, white is always its own colour, at most ten; 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 unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, 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; or a keepColors color matches none the trace found, or nothing was traced)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make and Expand",
["Object", "Image Trace"],
None,
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors, warnings: [text…] (as imageTrace.make)}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
@@ -1121,7 +1121,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
let mut warnings = keep_warnings(&params, &res);
warnings.extend(trace_warnings((img.width, img.height), (raster.width, raster.height)));
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
@@ -1221,6 +1222,27 @@ pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String
out
}
/// What a trace with Keep Colors tells the user: that nothing was traced because no color the
/// trace found matches a listed one, or because Ignore Color left out the ones that match; each
/// listed color that matches none of them; and that Flat Logo mode does not use the list.
pub(crate) fn keep_warnings(params: &tr::TraceParams, res: &tr::TraceResult) -> Vec<String> {
if params.keep_colors.is_empty() {
return vec![];
}
if params.mode == tr::Mode::Logo {
return vec!["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds.".to_string()];
}
if res.paths.is_empty() && res.keep_unmatched.len() == params.keep_colors.len() {
return vec![format!("Nothing was traced: none of the colors the trace found matches keepColors ({}).", params.keep_colors.join(", "))];
}
let mut out = vec![];
if res.paths.is_empty() && params.ignore_white {
out.push("Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists.".to_string());
}
out.extend(res.keep_unmatched.iter().map(|c| format!("keepColors: none of the colors the trace found matches {c}, so nothing of it is traced.")));
out
}
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let v: Vec<NodeId> = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect();
+85
View File
@@ -2047,6 +2047,91 @@ fn image_trace_make_places_paths_over_the_image() {
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
}
#[test]
fn image_trace_keep_colors_traces_only_the_colors_listed() {
// A light field with a black ring, a red square and a blue bar on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (30..70).contains(&y) {
[200, 40, 50, 255]
} else if (30..170).contains(&x) && (110..130).contains(&y) {
[40, 80, 200, 255]
} else {
[240, 240, 220, 255]
}
});
let traced = |keep: Value| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 4, "keepColors": keep}});
let r = s.execute("imageTrace.makeAndExpand", &params)?;
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let fills: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
Ok::<_, EngineError>((r["colors"].clone(), fills))
};
let (black, red, blue, light) = (Color::rgb8(0, 0, 0), Color::rgb8(200, 40, 50), Color::rgb8(40, 80, 200), Color::rgb8(240, 240, 220));
let has = |fills: &[Paint], c: Color| fills.contains(&Paint::solid(c));
// Nothing listed: every color.
let (colors, fills) = traced(json!([])).unwrap();
assert_eq!(colors, 4);
assert!([black, red, blue, light].into_iter().all(|c| has(&fills, c)), "{fills:?}");
// The black and the red only.
let (colors, fills) = traced(json!(["#000000", "#c8283c"])).unwrap();
assert_eq!(colors, 2);
assert!(has(&fills, black) && has(&fills, red), "{fills:?}");
assert!(fills.iter().all(|f| *f == Paint::solid(black) || *f == Paint::solid(red)), "{fills:?}");
// A color that is none is refused.
let e = traced(json!(["#000000", "black"])).unwrap_err().to_string();
assert!(e.contains("\"black\"") && e.contains("#rrggbb"), "{e}");
assert!(find_command("imageTrace.make").unwrap().params.contains("keepColors?: [\"#rrggbb\"…]"));
assert!(find_command("imageTrace.makeAndExpand").unwrap().params.contains("warnings: [text…] (as imageTrace.make)"));
}
#[test]
fn image_trace_keep_colors_warns_about_colors_that_match_nothing() {
// A light field with a black ring and a red square on it.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 70.0).hypot(f64::from(y) + 0.5 - 60.0);
if (34.0..=40.0).contains(&r) {
[0, 0, 0, 255]
} else if (130..170).contains(&x) && (30..70).contains(&y) {
[200, 40, 50, 255]
} else {
[240, 240, 220, 255]
}
});
let traced = |params: Value| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.makeAndExpand", &json!({ "params": params })).unwrap();
(r["paths"].as_u64().unwrap(), r["warnings"].clone())
};
let color = |keep: Value| traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": keep}));
// Every color listed is in the image: no warning.
let (paths, warnings) = color(json!(["#000000", "#c8283c"]));
assert!(paths > 0);
assert_eq!(warnings, json!([]));
// No color matches: nothing is traced, and the reply says why.
assert_eq!(
color(json!(["#ff00ff", "#00ffff"])),
(0, json!(["Nothing was traced: none of the colors the trace found matches keepColors (#ff00ff, #00ffff)."]))
);
// One color matches nothing: the others are traced, and that one is named.
let (paths, warnings) = color(json!(["#000000", "#00ffff"]));
assert!(paths > 0);
assert_eq!(warnings, json!(["keepColors: none of the colors the trace found matches #00ffff, so nothing of it is traced."]));
// Ignore Color leaves out the only color listed.
let ignored =
traced(json!({"mode": "color", "palette": "limited", "colors": 3, "keepColors": ["#000000"], "ignoreWhite": true, "ignoreColor": "#000000"}));
assert_eq!(ignored, (0, json!(["Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists."])));
// Flat Logo mode does not use the list.
let (paths, warnings) = traced(json!({"mode": "logo", "keepColors": ["#ff00ff"]}));
assert!(paths > 0);
assert_eq!(warnings, json!(["Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds."]));
}
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
/// circle as its traced path; without the option it is a plain path.
#[test]