Merge #654: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/buildcmds.rs # crates/ui-egui/src/panels/image_trace.rs
This commit is contained in:
commit
eea4fe9e58
File diff suppressed because one or more lines are too long
|
|
@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", 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, snapCurvesToLines}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
|
@ -667,6 +667,30 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
|||
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
|
||||
}
|
||||
|
||||
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
|
||||
/// one as traced (sRGB) with the colour to fill with.
|
||||
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
|
||||
const C: &str = "imageTrace.make";
|
||||
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let key = params.library.as_str();
|
||||
let colors = super::swatchlib::library_colors(s, key)
|
||||
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
|
||||
if colors.is_empty() {
|
||||
return Err(bad(C, format!("`{key}` has no colours")));
|
||||
}
|
||||
let library: Vec<([u8; 3], Color)> = colors
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
([r, g, b], c)
|
||||
})
|
||||
.collect();
|
||||
params.swatches = library.iter().map(|l| l.0).collect();
|
||||
Ok(library)
|
||||
}
|
||||
|
||||
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
|
||||
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
|
||||
/// refused with advice instead (#525).
|
||||
|
|
@ -674,7 +698,7 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
|||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let params = trace_params(p)?;
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
|
|
@ -704,6 +728,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
|||
};
|
||||
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
|
||||
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// 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;
|
||||
|
|
@ -731,7 +756,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
|||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
n.appearance = fill_only(Paint::solid(color));
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let gid = d.alloc_id();
|
||||
|
|
|
|||
|
|
@ -276,11 +276,18 @@ pub const DOCUMENT_SWATCHES: &str = "document";
|
|||
/// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is
|
||||
/// no such library (or document).
|
||||
pub fn limit_palette(s: &Session, key: &str) -> Option<Palette> {
|
||||
Some(Palette::new(library_colors(s, key)?))
|
||||
}
|
||||
|
||||
/// The solid colours of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the
|
||||
/// active document's swatches and colour groups. `None` when there is no such library (or
|
||||
/// document).
|
||||
pub fn library_colors(s: &Session, key: &str) -> Option<Vec<Color>> {
|
||||
let colors = |sw: &Swatch| sw.paint.color();
|
||||
if key == DOCUMENT_SWATCHES {
|
||||
return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors)));
|
||||
return Some(s.active()?.doc.swatches_iter().filter_map(colors).collect());
|
||||
}
|
||||
Some(Palette::new(library(s, key)?.1.iter().filter_map(colors)))
|
||||
Some(library(s, key)?.1.iter().filter_map(colors).collect())
|
||||
}
|
||||
|
||||
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
|
||||
|
|
|
|||
|
|
@ -669,6 +669,49 @@ fn image_trace_make_and_expand_and_color_presets() {
|
|||
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_palettes_and_swatch_libraries() {
|
||||
let mut s = session();
|
||||
let quads = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
|
||||
(true, true) => [230, 20, 20, 255],
|
||||
(false, true) => [20, 200, 30, 255],
|
||||
(true, false) => [20, 30, 220, 255],
|
||||
(false, false) => [250, 230, 10, 255],
|
||||
});
|
||||
add_image(&mut s, &quads, Affine::IDENTITY);
|
||||
let fills = |s: &Session, id: &Value| -> Vec<Color> {
|
||||
traced_paths(&node(s, NodeId(id.as_u64().unwrap()))).iter().filter_map(|n| n.appearance.fill_paint().color()).collect()
|
||||
};
|
||||
// Document Library: every fill is one of the document's swatch colours, as it is.
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap();
|
||||
let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap();
|
||||
let got = fills(&s, &r["id"]);
|
||||
assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}");
|
||||
let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap();
|
||||
assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document")));
|
||||
assert!(t["params"].get("swatches").is_none(), "looked up again at every trace");
|
||||
// A swatch library by id: the web-safe colours.
|
||||
let r = s
|
||||
.execute(
|
||||
"imageTrace.make",
|
||||
&json!({"id": r["id"], "preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "web-safe-216", "colors": 3}}),
|
||||
)
|
||||
.unwrap();
|
||||
let got = fills(&s, &r["id"]);
|
||||
assert!((1..=3).contains(&got.len()), "{got:?}");
|
||||
assert!(got.iter().all(|c| c.to_rgba8(1.0)[..3].iter().all(|v| v % 51 == 0)), "{got:?}");
|
||||
// Full Tone and Automatic keep the four flat colours.
|
||||
let mut id = r["id"].clone();
|
||||
for palette in ["fullTone", "automatic"] {
|
||||
let r = s.execute("imageTrace.make", &json!({"id": id, "preset": "6 Colors", "params": {"palette": palette, "colorDetail": 50}})).unwrap();
|
||||
assert_eq!(r["colors"], 4, "{palette}");
|
||||
id = r["id"].clone();
|
||||
}
|
||||
let mut bad = |p: Value| s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": p})).is_err();
|
||||
assert!(bad(json!({"palette": "documentLibrary", "library": "No Such Library"})));
|
||||
assert!(bad(json!({"palette": "rainbow"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_presets_query_and_errors() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -295,7 +295,9 @@ with the {effect} effect.
|
|||
Method:
|
||||
1. open_file the image: it becomes a document of its own pixel size.
|
||||
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
|
||||
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
|
||||
color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with
|
||||
the document's swatches or a swatch `library`), then threshold, colors (colorDetail for
|
||||
fullTone and automatic), paths, corners, noise, method abutting or overlapping,
|
||||
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
|
||||
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
|
||||
near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
//! and classic colour quantisation), not from any existing implementation:
|
||||
//!
|
||||
//! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D
|
||||
//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted
|
||||
//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out.
|
||||
//! k-means over the luminance histogram (Grayscale), or, in Color, over a 15-bit colour
|
||||
//! histogram as the [`Palette`] asks: median cut to a colour count followed by weighted k-means
|
||||
//! (Limited), median cut until every colour box is tight (Full Tone), one of those two by
|
||||
//! whether the image is flat art (Automatic), or the most used colours of a swatch library
|
||||
//! (Document Library). Transparent pixels are left out.
|
||||
//! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged
|
||||
//! into their most common neighbouring colour.
|
||||
//! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and
|
||||
|
|
@ -34,7 +37,7 @@ use vectorcraft_geom::PathData;
|
|||
|
||||
pub use contour::{Component, Loop, trace_mask};
|
||||
pub use mosaic::mosaic;
|
||||
pub use quantize::{Quantized, TRANSPARENT, denoise, quantize};
|
||||
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
|
||||
|
||||
/// Errors decoding a raster.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -132,6 +135,28 @@ pub enum Method {
|
|||
Overlapping,
|
||||
}
|
||||
|
||||
/// Where Color mode's colours come from (the Palette popup).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Palette {
|
||||
/// The `colors` colours that best represent the image.
|
||||
#[default]
|
||||
#[serde(alias = "Limited")]
|
||||
Limited,
|
||||
/// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients):
|
||||
/// `color_detail` sets how close two colours may be and stay apart.
|
||||
#[serde(alias = "Full Tone")]
|
||||
FullTone,
|
||||
/// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything
|
||||
/// else as Full Tone.
|
||||
#[serde(alias = "Automatic")]
|
||||
Automatic,
|
||||
/// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of
|
||||
/// them, exactly as they are.
|
||||
#[serde(alias = "library", alias = "Document Library")]
|
||||
DocumentLibrary,
|
||||
}
|
||||
|
||||
/// Image Trace parameters (the Image Trace panel).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
|
@ -139,8 +164,20 @@ pub struct TraceParams {
|
|||
pub mode: Mode,
|
||||
/// Black and White: pixels darker than this (luminance 0–255) are black.
|
||||
pub threshold: u8,
|
||||
/// Palette size for Color, number of grays for Grayscale (2–256).
|
||||
/// Color: where the colours come from.
|
||||
pub palette: Palette,
|
||||
/// Palette size for Color (Limited; at most this many for Document Library), number of grays
|
||||
/// for Grayscale (2–256).
|
||||
pub colors: u32,
|
||||
/// Full Tone and Automatic: how many colours, 0 (few) – 100 (many).
|
||||
pub color_detail: f64,
|
||||
/// Document Library: the swatch library, `"document"` (the document's swatches) or a library id
|
||||
/// or name. The caller looks its colours up into `swatches`.
|
||||
pub library: String,
|
||||
/// Document Library: the library's colours (sRGB). Not saved: they are looked up from
|
||||
/// `library` at every trace.
|
||||
#[serde(skip)]
|
||||
pub swatches: Vec<[u8; 3]>,
|
||||
/// Paths (fidelity) 0–100: higher follows the pixels more tightly.
|
||||
pub paths: f64,
|
||||
/// Corners 0–100: higher keeps more corners.
|
||||
|
|
@ -160,7 +197,11 @@ impl Default for TraceParams {
|
|||
Self {
|
||||
mode: Mode::BlackAndWhite,
|
||||
threshold: 128,
|
||||
palette: Palette::Limited,
|
||||
colors: 6,
|
||||
color_detail: 50.0,
|
||||
library: "document".into(),
|
||||
swatches: vec![],
|
||||
paths: 50.0,
|
||||
corners: 75.0,
|
||||
noise: 25,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Colour reduction and noise removal.
|
||||
|
||||
use crate::{Mode, Raster, TraceParams};
|
||||
use crate::{Mode, Palette, Raster, TraceParams};
|
||||
|
||||
/// Label of a transparent (untraced) pixel.
|
||||
pub const TRANSPARENT: u16 = u16::MAX;
|
||||
|
|
@ -67,10 +67,8 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized {
|
|||
b.sum[2] += p[2] as u64;
|
||||
}
|
||||
}
|
||||
let used: Vec<(usize, [f64; 3], f64)> =
|
||||
bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
|
||||
let k = params.colors.clamp(2, 256) as usize;
|
||||
let centers = kmeans_rgb(&used, k);
|
||||
let used: Vec<Used> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
|
||||
let centers = color_palette(&used, params);
|
||||
let mut lut = vec![0u16; 1 << 15];
|
||||
for (i, c, _) in &used {
|
||||
lut[*i] = nearest_rgb(¢ers, *c) as u16;
|
||||
|
|
@ -187,12 +185,108 @@ fn nearest_rgb(centers: &[[f64; 3]], c: [f64; 3]) -> usize {
|
|||
best.1
|
||||
}
|
||||
|
||||
/// Median cut for the initial palette, then weighted k-means refinement over histogram bins.
|
||||
fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
|
||||
if used.is_empty() {
|
||||
return vec![[255.0; 3]];
|
||||
/// The most colours Full Tone (and Automatic on continuous-tone images) makes.
|
||||
pub const FULL_TONE_MAX: usize = 256;
|
||||
|
||||
/// A histogram bin in use: its index, mean colour and pixel count.
|
||||
type Used = (usize, [f64; 3], f64);
|
||||
|
||||
/// Color mode's palette for the histogram bins `used`, as `params.palette` asks.
|
||||
fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]> {
|
||||
let k = params.colors.clamp(2, 256) as usize;
|
||||
let detail = (params.color_detail / 100.0).clamp(0.0, 1.0);
|
||||
// Full Tone: split until no colour box is wider than 96 (few colours) … 8 (many) levels per
|
||||
// channel, which is one histogram bin.
|
||||
let full_tone = || refine(used, centers(&median_cut(used, FULL_TONE_MAX, 96.0 - 88.0 * detail)));
|
||||
match params.palette {
|
||||
Palette::Limited => kmeans_rgb(used, k),
|
||||
Palette::FullTone => full_tone(),
|
||||
Palette::Automatic => match flat_colors(used, detail) {
|
||||
Some(n) => kmeans_rgb(used, n),
|
||||
None => full_tone(),
|
||||
},
|
||||
Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, ¶ms.swatches, k),
|
||||
// No library colours to use: as Limited.
|
||||
Palette::DocumentLibrary => kmeans_rgb(used, k),
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatic: how many colours flat art is made of, or `None` for continuous tone (photos,
|
||||
/// gradients). Flat art has most of its pixels in a few tight colours (anti-aliased edges and
|
||||
/// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly
|
||||
/// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own.
|
||||
fn flat_colors(used: &[Used], detail: f64) -> Option<usize> {
|
||||
let total: f64 = used.iter().map(|u| u.2).sum();
|
||||
if total <= 0.0 {
|
||||
return Some(2);
|
||||
}
|
||||
let mut by_count: Vec<&Used> = used.iter().collect();
|
||||
by_count.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||
let mut covered = 0.0;
|
||||
let main = by_count
|
||||
.iter()
|
||||
.take_while(|u| {
|
||||
let before = covered;
|
||||
covered += u.2;
|
||||
before < 0.9 * total
|
||||
})
|
||||
.count();
|
||||
if main > FLAT_BINS {
|
||||
return None;
|
||||
}
|
||||
let boxes = median_cut(used, 64, 24.0);
|
||||
let min_share = (0.04 - 0.035 * detail) * total;
|
||||
let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect();
|
||||
let weight: f64 = colours.iter().map(|b| b.weight).sum();
|
||||
let peaks: f64 = colours.iter().map(|b| b.peak).sum();
|
||||
(weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2))
|
||||
}
|
||||
|
||||
/// The most histogram bins flat art's main colours spread over (a few bins per colour).
|
||||
const FLAT_BINS: usize = 96;
|
||||
|
||||
/// Document Library: the `k` swatches (at most) that the most pixels are nearest to.
|
||||
fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> {
|
||||
let mut all: Vec<[f64; 3]> = vec![];
|
||||
for s in swatches {
|
||||
let c = s.map(f64::from);
|
||||
if !all.contains(&c) {
|
||||
all.push(c);
|
||||
}
|
||||
}
|
||||
let mut weight = vec![0.0; all.len()];
|
||||
for (_, c, n) in used {
|
||||
if let Some(w) = weight.get_mut(nearest_rgb(&all, *c)) {
|
||||
*w += n;
|
||||
}
|
||||
}
|
||||
let mut order: Vec<usize> = (0..all.len()).collect();
|
||||
order.sort_by(|a, b| weight[*b].total_cmp(&weight[*a]).then(a.cmp(b)));
|
||||
order.into_iter().take(k.max(1)).filter_map(|i| all.get(i).copied()).collect()
|
||||
}
|
||||
|
||||
/// A median-cut box: its weighted mean colour, pixel count and the pixel count of its heaviest bin.
|
||||
struct ColorBox {
|
||||
mean: [f64; 3],
|
||||
weight: f64,
|
||||
peak: f64,
|
||||
}
|
||||
|
||||
fn centers(boxes: &[ColorBox]) -> Vec<[f64; 3]> {
|
||||
boxes.iter().map(|b| b.mean).collect()
|
||||
}
|
||||
|
||||
/// Median cut to `k` colours, then weighted k-means refinement over histogram bins.
|
||||
fn kmeans_rgb(used: &[Used], k: usize) -> Vec<[f64; 3]> {
|
||||
refine(used, centers(&median_cut(used, k, 0.0)))
|
||||
}
|
||||
|
||||
/// Median cut: repeatedly split the box with the largest weighted extent, while there are fewer
|
||||
/// than `k` boxes and some box spans more than `spread` levels of a channel.
|
||||
fn median_cut(used: &[Used], k: usize, spread: f64) -> Vec<ColorBox> {
|
||||
if used.is_empty() {
|
||||
return vec![ColorBox { mean: [255.0; 3], weight: 0.0, peak: 0.0 }];
|
||||
}
|
||||
// Median cut: repeatedly split the box with the largest weighted extent.
|
||||
let mut boxes: Vec<Vec<usize>> = vec![(0..used.len()).collect()];
|
||||
while boxes.len() < k {
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
|
|
@ -210,7 +304,7 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
|
|||
wsum += used[i].2;
|
||||
}
|
||||
let (ch, range) = (0..3).map(|c| (c, hi[c] - lo[c])).fold((0, -1.0), |a, b| if b.1 > a.1 { b } else { a });
|
||||
if range <= 0.0 {
|
||||
if range <= spread.max(0.0) {
|
||||
continue;
|
||||
}
|
||||
let score = range * wsum.sqrt();
|
||||
|
|
@ -235,21 +329,25 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> {
|
|||
boxes.push(b);
|
||||
boxes.push(tail);
|
||||
}
|
||||
let mut centers: Vec<[f64; 3]> = boxes
|
||||
boxes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut s = [0.0; 3];
|
||||
let mut w = 0.0;
|
||||
let (mut w, mut peak) = (0.0, 0.0f64);
|
||||
for &i in b {
|
||||
for (acc, v) in s.iter_mut().zip(used[i].1) {
|
||||
*acc += v * used[i].2;
|
||||
}
|
||||
w += used[i].2;
|
||||
peak = peak.max(used[i].2);
|
||||
}
|
||||
[s[0] / w, s[1] / w, s[2] / w]
|
||||
ColorBox { mean: [s[0] / w, s[1] / w, s[2] / w], weight: w, peak }
|
||||
})
|
||||
.collect();
|
||||
// Lloyd refinement.
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lloyd refinement of `centers` over the bins `used`.
|
||||
fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> {
|
||||
for _ in 0..12 {
|
||||
let mut sum = vec![[0.0; 3]; centers.len()];
|
||||
let mut cnt = vec![0.0; centers.len()];
|
||||
|
|
|
|||
|
|
@ -348,3 +348,93 @@ fn an_image_too_large_to_trace_is_refused_before_decoding() {
|
|||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
|
||||
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
|
||||
}
|
||||
|
||||
/// Four flat colours with anti-aliased (blended) edges between the quadrants.
|
||||
fn flat_quads() -> Raster {
|
||||
const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];
|
||||
Raster::from_fn(100, 100, |x, y| {
|
||||
let at = |x: u32, y: u32| C[usize::from(x >= 50) + 2 * usize::from(y >= 50)];
|
||||
let (a, b) = (at(x, y), at(x.saturating_sub(1), y.saturating_sub(1)));
|
||||
// Blend the pixels along the quadrant edges, as an anti-aliased picture has them.
|
||||
let c: Vec<u8> = a.iter().zip(b).map(|(p, q)| ((u16::from(*p) + u16::from(q)) / 2) as u8).collect();
|
||||
[c[0], c[1], c[2], 255]
|
||||
})
|
||||
}
|
||||
|
||||
/// A smooth two-dimensional colour gradient (continuous tone).
|
||||
fn gradient() -> Raster {
|
||||
Raster::from_fn(128, 128, |x, y| [(x * 2) as u8, (y * 2) as u8, (255 - x - y / 2) as u8, 255])
|
||||
}
|
||||
|
||||
fn palette(img: &Raster, palette: Palette, colors: u32, color_detail: f64) -> Vec<[u8; 3]> {
|
||||
let p = TraceParams { mode: Mode::Color, palette, colors, color_detail, noise: 4, ..TraceParams::default() };
|
||||
trace(img, &p).palette
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palettes_parse_from_their_ids_and_names() {
|
||||
for (id, want) in [
|
||||
("limited", Palette::Limited),
|
||||
("fullTone", Palette::FullTone),
|
||||
("Automatic", Palette::Automatic),
|
||||
("documentLibrary", Palette::DocumentLibrary),
|
||||
("library", Palette::DocumentLibrary),
|
||||
] {
|
||||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "palette": id })).unwrap();
|
||||
assert_eq!(p.palette, want, "{id}");
|
||||
}
|
||||
assert_eq!(TraceParams::default().palette, Palette::Limited);
|
||||
// The library's colours are looked up at every trace, not saved with the settings.
|
||||
let v = serde_json::to_value(TraceParams { swatches: vec![[1, 2, 3]], ..TraceParams::default() }).unwrap();
|
||||
assert!(v.get("swatches").is_none() && v["library"] == "document" && v["colorDetail"] == 50.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_tone_follows_the_tones_the_image_has() {
|
||||
// Flat art keeps its few colours (and the tones of its blended edges); a gradient gets many,
|
||||
// more with more detail.
|
||||
let flat = palette(&flat_quads(), Palette::FullTone, 6, 50.0);
|
||||
assert!((4..=10).contains(&flat.len()), "flat art: {flat:?}");
|
||||
let few = palette(&gradient(), Palette::FullTone, 6, 10.0).len();
|
||||
let many = palette(&gradient(), Palette::FullTone, 6, 90.0).len();
|
||||
assert!(few > 6 && many > few && many <= FULL_TONE_MAX, "{few} then {many} colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_traces_flat_art_with_its_colours_and_photos_in_full_tone() {
|
||||
let flat = palette(&flat_quads(), Palette::Automatic, 2, 50.0);
|
||||
assert_eq!(flat.len(), 4, "the four flat colours, whatever `colors` says: {flat:?}");
|
||||
assert!(flat.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60), "red is one of them: {flat:?}");
|
||||
let tone = palette(&gradient(), Palette::Automatic, 2, 50.0);
|
||||
assert_eq!(tone.len(), palette(&gradient(), Palette::FullTone, 2, 50.0).len(), "continuous tone: as Full Tone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_library_uses_the_most_used_swatches_exactly() {
|
||||
let swatches = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 0, 0], [255, 255, 255]];
|
||||
let p = |colors| TraceParams {
|
||||
mode: Mode::Color,
|
||||
palette: Palette::DocumentLibrary,
|
||||
colors,
|
||||
swatches: swatches.clone(),
|
||||
noise: 4,
|
||||
..TraceParams::default()
|
||||
};
|
||||
let hard = Raster::from_fn(100, 100, |x, y| match (x < 50, y < 50) {
|
||||
(true, true) => [220, 30, 30, 255],
|
||||
(false, true) => [30, 200, 40, 255],
|
||||
(true, false) => [30, 40, 210, 255],
|
||||
(false, false) => [240, 220, 20, 255],
|
||||
});
|
||||
let res = trace(&hard, &p(6));
|
||||
let mut got = res.palette.clone();
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, [[0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0]], "the nearest swatch of each colour, as it is");
|
||||
assert!(res.paths.iter().all(|t| swatches.contains(&t.color)));
|
||||
// At most `colors` of them: the ones the most pixels are nearest to.
|
||||
let two = trace(&hard, &p(2)).palette;
|
||||
assert!(two.len() <= 2 && two.iter().all(|c| swatches.contains(c)), "{two:?}");
|
||||
// Without library colours, it traces as Limited.
|
||||
let none = TraceParams { swatches: vec![], ..p(4) };
|
||||
assert_eq!(trace(&hard, &none).palette.len(), 4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1152,6 +1152,11 @@
|
|||
Ignore Ignorar
|
||||
Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página)
|
||||
Ignore White Ignorar blanco
|
||||
Palette: Paleta:
|
||||
Limited Limitada
|
||||
Full Tone Tono completo
|
||||
Document Library Biblioteca del documento
|
||||
Library: Biblioteca:
|
||||
Image Imagen
|
||||
Image Info Información de imagen
|
||||
Image Map: Mapa de imagen:
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1151,6 +1151,11 @@
|
|||
Ignore Ignorer
|
||||
Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page)
|
||||
Ignore White Ignorer le blanc
|
||||
Palette: Palette :
|
||||
Limited Limitée
|
||||
Full Tone Tons continus
|
||||
Document Library Bibliothèque du document
|
||||
Library: Bibliothèque :
|
||||
Image Image
|
||||
Image Info Informations sur l’image
|
||||
Image Map: Image cliquable :
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1151,6 +1151,11 @@
|
|||
Ignore Ignora
|
||||
Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina)
|
||||
Ignore White Ignora bianco
|
||||
Palette: Palette:
|
||||
Limited Limitata
|
||||
Full Tone Tono continuo
|
||||
Document Library Libreria del documento
|
||||
Library: Libreria:
|
||||
Image Immagine
|
||||
Image Info Informazioni immagine
|
||||
Image Map: Mappa immagine:
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1410,6 +1410,11 @@
|
|||
Ignore 無視
|
||||
Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う)
|
||||
Ignore White 白を無視
|
||||
Palette: パレット:
|
||||
Limited 限定
|
||||
Full Tone フルトーン
|
||||
Document Library ドキュメントライブラリ
|
||||
Library: ライブラリ:
|
||||
Image Info 画像情報
|
||||
Image Map: イメージマップ:
|
||||
Image Size イメージサイズ
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1149,6 +1149,11 @@
|
|||
Ignore Игнорировать
|
||||
Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница)
|
||||
Ignore White Игнорировать белый
|
||||
Palette: Палитра:
|
||||
Limited Ограниченная
|
||||
Full Tone Полутоновая
|
||||
Document Library Библиотека документа
|
||||
Library: Библиотека:
|
||||
Image Изображение
|
||||
Image Info Сведения об изображении
|
||||
Image Map: Карта изображения:
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1140,6 +1140,11 @@
|
|||
Ignore 忽略
|
||||
Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页)
|
||||
Ignore White 忽略白色
|
||||
Palette: 调板:
|
||||
Limited 有限
|
||||
Full Tone 全色调
|
||||
Document Library 文档库
|
||||
Library: 库:
|
||||
Image 图像
|
||||
Image Info 图像信息
|
||||
Image Map: 图像映射:
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1169,6 +1169,11 @@
|
|||
Ignore 略過
|
||||
Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面)
|
||||
Ignore White 忽略白色
|
||||
Palette: 調色盤:
|
||||
Limited 有限
|
||||
Full Tone 全色調
|
||||
Document Library 文件資料庫
|
||||
Library: 資料庫:
|
||||
Image 影像
|
||||
Image Info 影像資訊
|
||||
Image Map: 影像地圖:
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -1,19 +1,51 @@
|
|||
//! Image Trace panel: preset, view, mode, threshold or colour count, and the Advanced section's
|
||||
//! fidelity options (open or closed as it was last left). With an Image Trace object selected,
|
||||
//! changing a setting re-traces it (one undo step per change; sliders apply when released) and
|
||||
//! changing the view redraws it without tracing again. With an image selected, Trace makes a new
|
||||
//! Image Trace object.
|
||||
//! Image Trace panel: preset, view, mode, palette (Color), threshold or colour count, and the
|
||||
//! Advanced section's fidelity options (open or closed as it was last left). With an Image Trace
|
||||
//! object selected, changing a setting re-traces it (one undo step per change; sliders apply when
|
||||
//! released) and changing the view redraws it without tracing again. With an image selected, Trace
|
||||
//! makes a new Image Trace object.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind, TraceView};
|
||||
use vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES;
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use super::{first_selected, library_panel, pstate, set_pstate, swatches};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")];
|
||||
|
||||
/// Color mode's palettes (`palette` param ids), in panel order.
|
||||
const PALETTES: [&str; 4] = ["limited", "fullTone", "automatic", "documentLibrary"];
|
||||
|
||||
/// Palette `id`, in the UI language.
|
||||
fn palette_label(id: &str) -> &'static str {
|
||||
match id {
|
||||
"fullTone" => tl!("Full Tone"),
|
||||
"automatic" => tl!("Automatic"),
|
||||
"documentLibrary" => tl!("Document Library"),
|
||||
_ => tl!("Limited"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of Document Library's library `key` (the document's swatches or a swatch library); a
|
||||
/// library that is gone shows its key.
|
||||
fn library_label(app: &VectorcraftApp, key: &str) -> String {
|
||||
match swatches::limit_name(app, key) {
|
||||
Some(_) if key == DOCUMENT_SWATCHES => tl!(swatches::DOCUMENT_SWATCHES).to_string(),
|
||||
Some(name) => library_panel::library_name(key, &name).to_string(),
|
||||
None => key.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Document Library's choices: the document's swatches, then the colour libraries (`current`
|
||||
/// checked) → the key chosen.
|
||||
fn library_list(app: &VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
|
||||
let document = menu_item(ui, swatches::DOCUMENT_SWATCHES, true, current == DOCUMENT_SWATCHES).then(|| DOCUMENT_SWATCHES.to_string());
|
||||
ui.separator();
|
||||
library_panel::library_items(ui, &swatches::colour_libraries(app), Some(current)).or(document)
|
||||
}
|
||||
|
||||
/// Panel state: the preset name, the current parameters, the view and the last result's counts.
|
||||
#[derive(Clone, Default)]
|
||||
struct TraceUi {
|
||||
|
|
@ -213,16 +245,41 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
retrace = true;
|
||||
}
|
||||
});
|
||||
let bw = st.params["mode"].as_str() == Some("blackAndWhite");
|
||||
let (key, label, range) = if bw {
|
||||
("threshold", tl!("Threshold"), 0.0..=255.0)
|
||||
} else {
|
||||
("colors", if st.params["mode"] == "grayscale" { tl!("Grays") } else { tl!("Colors") }, 2.0..=256.0)
|
||||
let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string();
|
||||
let palette = st.params["palette"].as_str().unwrap_or("limited").to_string();
|
||||
if mode == "color" {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Palette:"));
|
||||
let labels = PALETTES.map(palette_label);
|
||||
if let Some(p) = widgets::dropdown_names(ui, "it-palette", palette_label(&palette), &labels, 170.0).and_then(|i| PALETTES.get(i)) {
|
||||
st.params["palette"] = json!(p);
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
});
|
||||
if palette == "documentLibrary" {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Library:"));
|
||||
let key = st.params["library"].as_str().unwrap_or(DOCUMENT_SWATCHES).to_string();
|
||||
if let Some(k) = widgets::combo(ui, "it-library", &library_label(app, &key), 170.0, false, |ui| library_list(app, ui, &key)) {
|
||||
st.params["library"] = json!(k);
|
||||
st.preset = "Custom".into();
|
||||
retrace = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Full Tone and Automatic: how many colours (Less … More); else a count or the threshold.
|
||||
let (key, label, range, suffix) = match mode.as_str() {
|
||||
"blackAndWhite" => ("threshold", tl!("Threshold"), 0.0..=255.0, ""),
|
||||
"grayscale" => ("colors", tl!("Grays"), 2.0..=256.0, ""),
|
||||
_ if matches!(palette.as_str(), "fullTone" | "automatic") => ("colorDetail", tl!("Colors"), 0.0..=100.0, "%"),
|
||||
_ => ("colors", tl!("Colors"), 2.0..=256.0, ""),
|
||||
};
|
||||
let mut v = st.params[key].as_f64().unwrap_or(0.0);
|
||||
let (changed, release) = slider(ui, label, &mut v, range, "");
|
||||
let (changed, release) = slider(ui, label, &mut v, range, suffix);
|
||||
if changed {
|
||||
st.params[key] = json!(v.round() as u64);
|
||||
st.params[key] = if key == "colorDetail" { json!(v.round()) } else { json!(v.round() as u64) };
|
||||
st.preset = "Custom".into();
|
||||
}
|
||||
retrace |= release;
|
||||
|
|
@ -373,6 +430,31 @@ mod tests {
|
|||
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Outlines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_shows_in_color_mode_and_traces_again() {
|
||||
let mut app = traced_app();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
|
||||
let palette = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["palette"].clone());
|
||||
assert!(!has(&frame(&mut app, &ctx, vec![]), "Palette:"), "Black and White has no palette");
|
||||
app.run("imageTrace.make", json!({"preset": "6 Colors"})).unwrap();
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(has(&texts, "Palette:") && has(&texts, "Limited") && !has(&texts, "Library:"), "{texts:?}");
|
||||
// Document Library traces again with the document's swatches, and names them.
|
||||
click(&mut app, &ctx, "Limited");
|
||||
click(&mut app, &ctx, "Document Library");
|
||||
assert_eq!(palette(&app), Some(json!("documentLibrary")));
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(has(&texts, "Library:") && has(&texts, "Document Swatches"), "{texts:?}");
|
||||
// Full Tone's Colors is how many, Less (0%) … More (100%).
|
||||
click(&mut app, &ctx, "Document Library");
|
||||
click(&mut app, &ctx, "Full Tone");
|
||||
assert_eq!(palette(&app), Some(json!("fullTone")));
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(has(&texts, "50") && !has(&texts, "Library:"), "{texts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_and_menu_draw_headless() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
|
|
|
|||
Loading…
Reference in New Issue