vectorcraft/crates/trace/src/lib.rs

883 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! VectorCraft Image Trace: raster → vector.
//!
//! A clean-room tracer built from the published ideas behind bitmap tracers (the potrace paper
//! 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, 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. The palette is made of the picture's own
//! colours: the blends that anti-aliasing and blur leave along edges take none of it, colours that
//! noise made of one colour are merged, and a blend pixel goes to one of the two colours it lies
//! between (see `quantize`).
//! A continuous-tone image (a photo) is first smoothed within its tones, edges kept (see
//! `smooth`): otherwise its noise decides, pixel by pixel, which of two neighbouring tones a
//! pixel gets, and every such pixel is a speck.
//! 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
//! outside are linked into closed loops (inside on the right, right turns at saddles, so
//! diagonal pixels stay separate, matching 4-connectivity). Outer loops run clockwise and
//! holes counter-clockwise (y down), so the result fills correctly with either fill rule.
//! 4. **Sub-pixel edges**: each crack of a loop is moved to where the image says the edge really
//! is (an anti-aliased or blurred edge pixel is part one tone, part the other), so the outline
//! is right to a fraction of a pixel before any fitting; hard-edged images are read as
//! staircases instead (see `subpixel`).
//! Outlines are cut at their junctions (where three regions meet, and at the image's border)
//! and each piece between two is fitted on its own, the same way from either side, so that two
//! abutting regions have one and the same curve along the edge they share.
//! 5. **Curves**: the outline is fitted as Flat Logo mode fits its own (`logo::fit_outline`):
//! corners the pixels rounded are put back, straight runs become lines, a round outline a
//! true circle, and the rest as few cubic Béziers as the Paths tolerance allows; Snap Curves To
//! Lines also straightens slightly curved runs and makes arcs of parts of circles.
//!
//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced
//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked
//! outlines.
//!
//! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or
//! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps).
#![forbid(unsafe_code)]
mod centerline;
mod contour;
mod fit;
mod logo;
mod mosaic;
mod quantize;
mod smooth;
mod subpixel;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::PathData;
pub use contour::{Component, Loop, trace_mask};
pub use mosaic::mosaic;
pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize};
use subpixel::Crack;
/// Errors decoding a raster.
#[derive(Debug, thiserror::Error)]
pub enum TraceError {
#[error("could not decode image: {0}")]
Decode(String),
#[error("image is empty")]
Empty,
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
TooLarge { width: u32, height: u32, max: u64 },
#[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())]
NotFlat { fraction: f64 },
#[error("\"{0}\" is not a colour: write it as #rrggbb")]
BadPalette(String),
#[error(
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
)]
TooComplex { max: usize },
}
/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are
/// refused instead of running out of memory.
pub const MAX_PIXELS: u64 = 64 << 20;
/// The most pixels an image may have for [`Raster::decode_to_fit`] to decode it and scale it down
/// to [`MAX_PIXELS`] (192 megapixels: 768 MB decoded); larger images are refused.
pub const MAX_SOURCE_PIXELS: u64 = 192 << 20;
/// The largest size with `w : h`'s aspect of at most `max` pixels (each side at least 1).
fn fit_size(w: u32, h: u32, max: u64) -> (u32, u32) {
let pixels = u64::from(w) * u64::from(h);
if pixels <= max || pixels == 0 {
return (w, h);
}
let k = (max as f64 / pixels as f64).sqrt();
let side = |v: u32| ((f64::from(v) * k).floor() as u32).clamp(1, v);
let (mut nw, mut nh) = (side(w), side(h));
// A side held at 1 pixel leaves the other too long: cut it to what fits.
if u64::from(nw) * u64::from(nh) > max {
if nw >= nh {
nw = u32::try_from(max / u64::from(nh)).unwrap_or(nw).clamp(1, nw);
} else {
nh = u32::try_from(max / u64::from(nw)).unwrap_or(nh).clamp(1, nh);
}
}
(nw, nh)
}
/// An RGBA8 raster (row-major, top row first).
#[derive(Clone, Debug, PartialEq)]
pub struct Raster {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
impl Raster {
/// A raster from RGBA bytes (`width * height * 4` of them).
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
Self { width, height, rgba }
}
/// A raster whose pixel `(x, y)` is `f(x, y)`.
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
for y in 0..height {
for x in 0..width {
rgba.extend_from_slice(&f(x, y));
}
}
Self { width, height, rgba }
}
/// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding).
pub fn decode(bytes: &[u8]) -> Result<Self, TraceError> {
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 });
}
let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8();
let (w, h) = img.dimensions();
Ok(Self { width: w, height: h, rgba: img.into_raw() })
}
/// Decode as [`Raster::decode`], but an image of more than [`MAX_PIXELS`] (up to
/// [`MAX_SOURCE_PIXELS`]) is scaled down, aspect kept, to the largest size within
/// [`MAX_PIXELS`] instead of being refused: a 300 ppi scan of a poster traces at slightly
/// less than its own resolution. The caller maps the result back with `width` / `height`.
pub fn decode_to_fit(bytes: &[u8]) -> Result<Self, TraceError> {
let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string());
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let (w, h) = reader.into_dimensions().map_err(decode_err)?;
if w == 0 || h == 0 {
return Err(TraceError::Empty);
}
let pixels = u64::from(w) * u64::from(h);
if pixels <= MAX_PIXELS {
return Self::decode(bytes);
}
if pixels > MAX_SOURCE_PIXELS {
return Err(TraceError::TooLarge { width: w, height: h, max: MAX_SOURCE_PIXELS >> 20 });
}
// Decoded within the allocation the source limit needs; the web keeps file import's 512 MB.
// A deeper image (16 bits a channel) that needs more fails with a decode error.
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(if cfg!(target_arch = "wasm32") { 512 << 20 } else { MAX_SOURCE_PIXELS * 4 });
reader.limits(limits);
let img = reader.decode().map_err(decode_err)?.into_rgba8();
let (w, h) = img.dimensions();
let (nw, nh) = fit_size(w, h, MAX_PIXELS);
// Box-filtered: every source pixel counts, so thin lines and anti-aliased edges survive.
let small = image::imageops::thumbnail(&img, nw, nh);
Ok(Self { width: nw, height: nh, rgba: small.into_raw() })
}
/// Encode as PNG.
pub fn encode_png(&self) -> Vec<u8> {
let mut out = Vec::new();
if let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.rgba.clone()) {
let _ = img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png);
}
out
}
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = (y as usize * self.width as usize + x as usize) * 4;
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
}
}
/// Tracing mode (Illustrator's Mode popup).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Mode {
#[default]
#[serde(alias = "bw", alias = "blackWhite", alias = "Black and White")]
BlackAndWhite,
#[serde(alias = "gray", alias = "Grayscale")]
Grayscale,
#[serde(alias = "Color")]
Color,
/// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles.
#[serde(alias = "Logo", alias = "flat", alias = "flatLogo")]
Logo,
}
/// How colour layers relate (Advanced → Method).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Method {
/// Each colour traced on its own; neighbouring shapes share their edge.
#[default]
Abutting,
/// Stacked: every layer extends under the layers above it.
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")]
pub struct TraceParams {
pub mode: Mode,
/// Black and White: pixels darker than this (luminance 0–255) are black.
pub threshold: u8,
/// 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.
pub corners: f64,
/// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than
/// `noise / 10` pixels of the source image.)
pub noise: u32,
pub method: Method,
/// Ignore Color: drop the areas of one colour (no background shape): white, unless
/// `ignore_color` names another.
pub ignore_white: bool,
/// 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>,
/// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs
/// to true circles.
pub snap_curves_to_lines: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
pub strokes: bool,
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
/// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is
/// always traced on its own and cannot be named, colours closer than about 12 (RGB distance)
/// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.)
pub logo_colors: Vec<String>,
}
impl Default for TraceParams {
/// Illustrator's [Default]: black and white, threshold 128.
fn default() -> Self {
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,
method: Method::Abutting,
ignore_white: false,
ignore_color: None,
snap_curves_to_lines: false,
fills: true,
strokes: false,
stroke_width: 10.0,
logo_colors: Vec::new(),
}
}
}
impl TraceParams {
/// Maximum distance (px) of polygon vertices from the pixel boundary.
fn polygon_tolerance(&self) -> f64 {
let f = (self.paths / 100.0).clamp(0.0, 1.0);
0.55 + 0.75 * (1.0 - f)
}
/// Curve fitting tolerance (px).
fn fit_tolerance(&self) -> f64 {
let f = (self.paths / 100.0).clamp(0.0, 1.0);
0.2 + 1.6 * (1.0 - f)
}
/// How outlines are fitted: Paths sets how closely the curves follow the edge (0.08 px at 100,
/// 0.33 px at 50, 0.78 px at 0), Corners the turn that makes a corner (35° at 100, 90° at 0).
/// Straight edges are always lines; Snap Curves To Lines also straightens slightly curved ones,
/// makes parts of circles true arcs, and makes lines within 2.5° of level or upright exactly so.
fn outline_style(&self) -> logo::Style {
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
let snap = self.snap_curves_to_lines;
logo::Style {
step: 0.25,
tol: 0.08 + 0.7 * (1.0 - unit(self.paths, 0.5)).powf(1.5),
corner_deg: 90.0 - 55.0 * unit(self.corners, 0.75),
snap: true,
arcs: snap,
line_tol: if snap { 0.5 } else { 0.2 },
circle_tol: if snap { 0.3 } else { 0.15 },
restore: Some(logo::RestoreParams::default()),
check_step: 0.15,
upright: snap,
..logo::Style::default()
}
}
/// Turn angle (degrees) above which a polygon vertex stays a corner.
fn corner_angle(&self) -> f64 {
let c = (self.corners / 100.0).clamp(0.0, 1.0);
150.0 - 115.0 * c
}
}
/// Built-in preset names in panel order (our own parameter sets).
pub const PRESET_NAMES: &[&str] = &[
"Default",
"Auto Color",
"High Fidelity Photo",
"Low Fidelity Photo",
"3 Colors",
"6 Colors",
"16 Colors",
"Shades of Gray",
"Black and White Logo",
"Sketched Art",
"Silhouettes",
"Line Art",
"Technical Drawing",
"Outline",
"Flat Logo",
];
/// A built-in preset by name (case-insensitive; `[Default]` is accepted).
pub fn preset(name: &str) -> Option<TraceParams> {
let key = name.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
let d = TraceParams::default();
let color = |colors: u32, paths: f64, corners: f64, noise: u32, method: Method| TraceParams {
mode: Mode::Color,
colors,
paths,
corners,
noise,
method,
..TraceParams::default()
};
let bw = |threshold: u8, paths: f64, corners: f64, noise: u32, snap: bool| TraceParams {
mode: Mode::BlackAndWhite,
threshold,
paths,
corners,
noise,
ignore_white: true,
snap_curves_to_lines: snap,
..TraceParams::default()
};
Some(match key.as_str() {
"default" => d,
// A posterised image of a photo or of artwork: its own colours where it is flat art, as
// many as its tones need where it is not.
// (Color detail 85: a colour on a hundredth of the picture is still one of its colours.)
"auto color" => TraceParams { palette: Palette::Automatic, color_detail: 85.0, ..color(6, 65.0, 60.0, 16, Method::Abutting) },
"high fidelity photo" => color(64, 90.0, 25.0, 4, Method::Overlapping),
"low fidelity photo" => color(20, 60.0, 50.0, 12, Method::Overlapping),
"3 colors" => color(3, 60.0, 60.0, 20, Method::Abutting),
"6 colors" => color(6, 65.0, 60.0, 16, Method::Abutting),
"16 colors" => color(16, 70.0, 55.0, 10, Method::Abutting),
"shades of gray" => TraceParams { mode: Mode::Grayscale, colors: 8, paths: 60.0, corners: 50.0, noise: 10, method: Method::Overlapping, ..d },
"black and white logo" => bw(128, 95.0, 80.0, 8, true),
"sketched art" => bw(150, 50.0, 50.0, 100, false),
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
"line art" => bw(128, 80.0, 70.0, 10, false),
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
// Black outlines: lines as stroked centre lines, wider areas as stroked outlines.
"outline" => TraceParams { fills: false, strokes: true, ..bw(128, 80.0, 70.0, 10, false) },
// Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel.
"flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d },
_ => return None,
})
}
/// All built-in presets in panel order.
pub fn presets() -> Vec<(&'static str, TraceParams)> {
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
}
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
/// lines stroked with it.
#[derive(Clone, Debug, PartialEq)]
pub struct TracedPath {
/// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`).
pub path: PathData,
pub color: [u8; 3],
/// Filled pixel count of the component the path came from.
pub pixels: usize,
/// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the
/// outlines of wider areas without Fills).
pub stroke: Option<f64>,
}
/// The traced result, bottom-most path first.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TraceResult {
pub paths: Vec<TracedPath>,
/// Colours actually used.
pub palette: Vec<[u8; 3]>,
}
impl TraceResult {
pub fn anchor_count(&self) -> usize {
self.paths.iter().map(|p| p.path.anchor_count()).sum()
}
}
fn is_white(c: [u8; 3]) -> bool {
c.iter().all(|&v| v >= 245)
}
/// Whether Ignore Color leaves palette colour `c` out: `target` is the colour picked (`None`: white).
fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool {
match target {
None => is_white(c),
Some(t) => (0..3).map(|k| (f64::from(c[k]) - f64::from(t[k])).powi(2)).sum::<f64>() <= 30.0 * 30.0 || (is_white(t) && is_white(c)),
}
}
/// 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 {
// No trace has more than `usize::MAX` anchors.
trace_within(img, params, usize::MAX).unwrap_or_default()
}
/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more
/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The
/// colour layers are traced in parallel where threads are available.
pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
let (w, h) = (img.width as usize, img.height as usize);
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
return Ok(TraceResult::default());
}
if params.mode == Mode::Logo {
return logo::trace_logo(img, params, max_anchors);
}
// The colour Ignore Color leaves out, if not white.
let ignore = match &params.ignore_color {
Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?),
_ => None,
};
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
let img = smoothed.as_ref().unwrap_or(img);
let mut q = quantize(img, params);
denoise(&mut q.labels, w, h, params.noise as usize);
// 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))));
}
}
// 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)));
if params.method == Method::Overlapping {
// Stacked layers go by lightness instead, from the largest colour's end of the scale: each
// layer reaches under all those above it, so where two colours meet, every layer between
// them in the stack ends on that same edge and shows through its anti-aliasing. Neighbours
// in a picture are mostly neighbours in tone, and in this order they are neighbours in the
// stack too (by area, an unrelated colour's edge would shine through as a hairline).
let light = |i: usize| q.palette.get(i).map_or(0, |c| u32::from(c[0]) * 299 + u32::from(c[1]) * 587 + u32::from(c[2]) * 114);
let first = order.first().copied();
order.sort_by(|a, b| light(*a).cmp(&light(*b)).then(a.cmp(b)));
if first.is_some_and(|f| order.iter().position(|&i| i == f).is_some_and(|at| at * 2 >= order.len())) {
order.reverse();
}
}
let mut rank = vec![usize::MAX; q.palette.len()];
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 job = Layers {
q: &q,
rank: &rank,
params,
img,
ignore,
boxes,
bw_tones: bw_tones(img, &q),
style: params.outline_style(),
opts: fit::FitOptions {
polygon_tol: params.polygon_tolerance(),
fit_tol: params.fit_tolerance(),
corner_angle: params.corner_angle(),
snap_lines: params.snap_curves_to_lines,
},
w,
h,
anchors: AtomicUsize::new(0),
max_anchors,
};
let mut out = TraceResult::default();
// A layer takes about ten bytes a pixel of its box while it is traced (its mask, the labels
// of its components, the stack that fills them, its edges): as many layers at a time as fit
// in half a gigabyte, so that a large image is not traced at the cost of gigabytes.
let largest = job.boxes.iter().flatten().map(|b| (b.2 - b.0) * (b.3 - b.1)).max().unwrap_or(1).max(1);
let at_a_time = ((512 << 20) / (largest * 10)).max(1);
for (paths, &(_, ci)) in parallel_map_at_most(at_a_time, &layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) {
out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?);
let color = q.palette[ci];
if !out.palette.contains(&color) {
out.palette.push(color);
}
}
Ok(out)
}
/// The luminance of white and of black in a Black and White quantisation: what its lightest
/// white pixels and its darkest black ones have (the 98th and 2nd percentiles; pure white and
/// black where a side has no pixel). Not the means: where the black is thin lines, blurred or
/// scanned, most black pixels are part paper, their mean is a gray, and read against it the middle
/// of every line would count as more than all ink, now more and now less as the line crosses the
/// pixel rows, which put a ripple on its edges.
fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] {
let mut hist = [[0u64; 256]; 2];
for (p, &l) in img.rgba.as_chunks::<4>().0.iter().zip(&q.labels) {
if let Some(h) = hist.get_mut(l as usize) {
h[((u32::from(p[0]) * 299 + u32::from(p[1]) * 587 + u32::from(p[2]) * 114 + 500) / 1000).min(255) as usize] += 1;
}
}
// The luminance a share `at` of the side's pixels are darker than.
let percentile = |h: &[u64; 256], at: f64, none: f64| {
let total: u64 = h.iter().sum();
let mut seen = 0;
for (v, n) in h.iter().enumerate() {
seen += n;
if total > 0 && seen as f64 >= at * total as f64 {
return v as f64;
}
}
none
};
[percentile(&hist[0], 0.98, 255.0), percentile(&hist[1], 0.02, 0.0)]
}
/// What tracing one colour layer needs.
struct Layers<'a> {
q: &'a Quantized,
/// Each palette entry's layer, bottom (0) up.
rank: &'a [usize],
params: &'a TraceParams,
img: &'a Raster,
/// The colour Ignore Color leaves out, if not white.
ignore: Option<[u8; 3]>,
/// The box each palette entry's pixels lie in: `(x0, y0, x1, y1)`, the far sides excluded.
boxes: Vec<Option<(usize, usize, usize, usize)>>,
/// Black and White: the luminance of white and of black (see `bw_tones`).
bw_tones: [f64; 2],
/// How outlines are fitted.
style: logo::Style,
/// How centre lines are fitted.
opts: fit::FitOptions,
w: usize,
h: usize,
/// Anchor points made so far, by every layer.
anchors: AtomicUsize,
max_anchors: usize,
}
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).
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))
}
/// 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.
fn neighbour(&self, c: Crack) -> u32 {
if self.params.method == Method::Overlapping {
return 0;
}
c.at(1).and_then(|i| self.q.labels.get(i)).map_or(u32::from(TRANSPARENT), |&l| u32::from(l))
}
/// The junction at pixel corner `(x, y)`, if there is one: where three or four regions
/// meet, or two parts of a region touch by the corner only, or an edge reaches the image's
/// border. It lies at the corner, moved across by what the edges that run up and down from it
/// say, and up or down by what those to its left and right say (the mean, where there are
/// two). It depends on the corner alone, so every region around it gets the same point; on the
/// image's border it stays on the border.
fn junction(&self, x: i32, y: i32) -> Option<vectorcraft_geom::Point> {
let label = |px: i32, py: i32| Crack::new((px, py), (1, 0), self.w, self.h).at(0).and_then(|i| self.q.labels.get(i).copied());
// The four pixels around the corner (`None` off the image), and the edges between them.
let around = [label(x - 1, y - 1), label(x, y - 1), label(x, y), label(x - 1, y)];
let edges = (0..4).filter(|&i| around[i] != around[(i + 1) % 4]).count();
let apart = |a: usize, b: usize, c: usize, d: usize| around[a] == around[c] && around[b] != around[a] && around[d] != around[a];
let touching = apart(0, 1, 2, 3) || apart(1, 2, 3, 0);
// (Stacked layers share no edges: they are cut at the image's border only.)
if edges < 3 && !touching || self.params.method == Method::Overlapping {
return None;
}
// The shift of the crack between `a` and the pixel `step` from it, if both are in the
// image and of different regions.
let shift = |a: (i32, i32), step: (i32, i32)| {
let (la, lb) = (label(a.0, a.1)?, label(a.0 + step.0, a.1 + step.1)?);
(la != lb).then(|| self.shift(Crack::new(a, step, self.w, self.h)).clamp(-0.5, 0.5))
};
let mean = |a: Option<f64>, b: Option<f64>| match (a, b) {
(Some(a), Some(b)) => (a + b) / 2.0,
(Some(v), None) | (None, Some(v)) => v,
(None, None) => 0.0,
};
let dx = mean(shift((x - 1, y - 1), (1, 0)), shift((x - 1, y), (1, 0)));
let dy = mean(shift((x - 1, y - 1), (0, 1)), shift((x, y - 1), (0, 1)));
Some(vectorcraft_geom::Point::new(f64::from(x) + dx, f64::from(y) + dy))
}
/// How far the edge lies from a crack between two pixels, towards the outside one (−0.5 to 0.5).
///
/// Each pixel of the column across the crack is read as a blend of the tones on its two sides
/// (the two pixels' palette colours; in Black and White the luminance of the image's black and of
/// the white pixels). The edge is where the blends, summed, put it: as far out as there is
/// inside tone in the outside pixels, less the outside tone in the inside ones (two pixels each
/// way, while they are on the same side and no more blended than the pixel before: more would be
/// another edge's doing).
///
/// Where the inside is at most three pixels deep with the outside's colour behind it (a thin
/// line), its two edges share those pixels and cannot be read apart; the inside tone of the
/// whole column gives the line's middle (its centroid) and width (its sum) instead. A thin gap
/// between two parts of the inside is read the same way, with the tones swapped.
///
/// Black and White then moves the edge to where the luminance crosses the threshold: by the
/// threshold's distance from the middle tone, over how steep the edge is (nothing for a
/// threshold in the middle, more the softer the edge).
fn shift(&self, c: Crack) -> f64 {
let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT);
let (Some(la), Some(lb)) = (label(0), label(1)) else { return 0.0 };
if la == lb {
return 0.0;
}
let bw = self.params.mode == Mode::BlackAndWhite;
// Black and White: black is the image's black; white is the lightest pixel within a few
// pixels of the crack, the paper as it is here (a scan's paper is darker in one corner than
// in another, and read against the image's lightest white it would count as part ink).
let black = self.bw_tones[1];
let white = if bw {
let light = (-3..=4)
.filter_map(|k| c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)))
.map(|p| (f64::from(p[0]) * 299.0 + f64::from(p[1]) * 587.0 + f64::from(p[2]) * 114.0) / 1000.0);
light.fold(f64::NEG_INFINITY, f64::max).clamp((black + 32.0).min(self.bw_tones[0]), self.bw_tones[0])
} else {
self.bw_tones[0]
};
let (ca, cb) = if bw {
let tone = |l: u16| [if l == 1 { black } else { white }; 3];
(tone(la), tone(lb))
} else {
let (Some(ca), Some(cb)) = (self.q.palette.get(la as usize), self.q.palette.get(lb as usize)) else { return 0.0 };
(ca.map(f64::from), cb.map(f64::from))
};
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
if len2 <= 0.0 {
return 0.0;
}
// How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image.
let inside = |k: i32| {
let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 };
let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])];
let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p };
1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0)
};
// The edge from a thin run of `n` pixels starting at pixel `from` and going `dir`, by the
// amounts `tone` of the run's own tone in it and in the two pixels at either end of it.
// Returns the run's middle and width, measured from the crack towards the outside.
let thin = |from: i32, dir: i32, n: i32, tone: &dyn Fn(i32) -> f64| {
let (near, far) = (from - dir, from + n * dir);
let mut cells: Vec<(i32, f64)> = (0..n).map(|j| from + j * dir).chain([near, far]).map(|k| (k, tone(k))).collect();
for (edge, out) in [(near, -dir), (far, dir)] {
if label(edge + out) == label(edge) {
cells.push((edge + out, tone(edge + out).min(tone(edge))));
}
}
let width: f64 = cells.iter().map(|c| c.1).sum();
// Pixel `k`'s centre is `k - 0.5` from the crack.
(width > 1e-6).then(|| (cells.iter().map(|c| c.1 * (f64::from(c.0) - 0.5)).sum::<f64>() / width, width))
};
// How deep each side is here: up to three pixels of its own label, then the other's.
let depth = |dir: i32, own: u16, other: u16| {
let start = if dir < 0 { 0 } else { 1 };
let n = (1..=3).take_while(|j| label(start + j * dir) == Some(own)).count() as i32 + 1;
(n <= 3 && label(start + n * dir) == Some(other)).then_some(n)
};
let mut steep = 1.0;
let mut shift = if let Some((middle, width)) = depth(-1, la, lb).and_then(|n| thin(0, -1, n, &inside)) {
middle + width / 2.0
} else if let Some((middle, width)) = depth(1, lb, la).and_then(|n| thin(1, 1, n, &|k| 1.0 - inside(k))) {
middle - width / 2.0
} else {
let (a1, b1) = (1.0 - inside(0), inside(1));
let a2 = (label(-1) == Some(la)).then(|| (1.0 - inside(-1)).min(a1));
let b2 = (label(2) == Some(lb)).then(|| inside(2).min(b1));
// The edge's steepness per pixel: the square of the change over two pixels (1 for an
// anti-aliased edge, less for a blurred one).
let change = [a2.map(|v| 1.0 - v - b1), b2.map(|v| 1.0 - a1 - v)].into_iter().flatten().fold(f64::NAN, f64::max);
if !change.is_nan() {
steep = (change * change).clamp(0.35, 1.0);
}
b1 + b2.unwrap_or(0.0) - a1 - a2.unwrap_or(0.0)
};
if bw {
// The share of black at which a pixel turns black.
let turn = ((white - f64::from(self.params.threshold)) / (white - black).max(1.0)).clamp(0.02, 0.98);
shift += if la == 1 { 1.0 } else { -1.0 } * (0.5 - turn) / steep;
}
shift
}
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
/// maximum.
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
let (params, opts) = (self.params, &self.opts);
let color = *self.q.palette.get(ci)?;
let overlapping = params.method == Method::Overlapping;
let (w, h) = (self.w, self.h);
// Create Strokes: this colour's lines, left out of its areas.
let (thin, lines) = if params.strokes {
centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, opts)
} else {
(vec![], vec![])
};
// The layer is traced within the box its pixels lie in, not the whole image.
let part = |l: u16| if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) && !self.ignored(l) } else { l as usize == ci };
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (l, b) in self.boxes.iter().enumerate() {
if let Some(b) = b.filter(|_| part(l as u16)) {
(x0, y0, x1, y1) = (x0.min(b.0), y0.min(b.1), x1.max(b.2), y1.max(b.3));
}
}
if x0 >= x1 || y0 >= y1 {
return Some(vec![]);
}
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)?;
mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l)));
}
// Without Fills, areas are outlined with a 1 px stroke.
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
let min_hole = params.noise.max(1) as i64;
let mut paths = vec![];
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>| {
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return false;
}
paths.push(TracedPath { path, color, pixels, stroke });
true
};
let mut comps = trace_mask(&mask, bw, bh);
drop(mask);
for comp in &mut comps {
for l in std::iter::once(&mut comp.outer).chain(&mut comp.holes) {
for p in &mut l.pts {
*p = (p.0 + x0 as i32, p.1 + y0 as i32);
}
}
}
for comp in comps {
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
let Some(outer) = fit(&comp.outer) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
continue;
}
subs.extend(fit(hole));
}
if !add(PathData::new(subs), comp.pixels, area_stroke) {
return None;
}
}
for line in lines {
if !add(line.path, line.pixels, Some(line.width)) {
return None;
}
}
Some(paths)
}
}
/// `f` of each of `items`, in order, spread over the available cores.
fn parallel_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
parallel_map_at_most(usize::MAX, items, f)
}
/// `f` of each of `items`, in order, spread over the available cores, at most `at_a_time` at once.
#[cfg(not(target_arch = "wasm32"))]
fn parallel_map_at_most<T: Sync, R: Send>(at_a_time: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()).min(at_a_time);
if threads <= 1 {
return items.iter().map(f).collect();
}
let next = AtomicUsize::new(0);
let mut done: Vec<(usize, R)> = std::thread::scope(|s| {
let workers: Vec<_> = (0..threads)
.map(|_| {
s.spawn(|| {
let mut mine = vec![];
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(item) = items.get(i) else { break };
mine.push((i, f(item)));
}
mine
})
})
.collect();
// A worker's panic goes on in this thread, as if it had run here (the engine's guard
// reports it).
workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect()
});
done.sort_by_key(|(i, _)| *i);
done.into_iter().map(|(_, r)| r).collect()
}
/// `f` of each of `items`, in order (no threads on the web).
#[cfg(target_arch = "wasm32")]
fn parallel_map_at_most<T: Sync, R: Send>(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
items.iter().map(f).collect()
}
#[cfg(test)]
mod tests;