mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Merge pull request #4075 from dotCooCoo/fix/trace-cut-edge-palette
Image Trace: the blended edge of a cut-out image adds no color to the palette
This commit is contained in:
+358
-52
@@ -45,11 +45,15 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
(Quantized { labels, palette: vec![[255, 255, 255], [0, 0, 0]] }, vec![])
|
||||
}
|
||||
Mode::Grayscale => {
|
||||
// The pixels that choose the grays: the opaque ones, except the semi-transparent ones
|
||||
// along the cut edge (see `choosing_pixels`).
|
||||
let choose = choosing_pixels(img, true);
|
||||
let own = own_colours(img);
|
||||
let own_choosing = own_colours_among(img, &choose);
|
||||
let histogram = |only_own: bool| {
|
||||
let mut hist = [0u64; 256];
|
||||
for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) {
|
||||
if p[3] >= 128 && (f || !only_own) {
|
||||
for ((p, &f), &c) in img.rgba.as_chunks::<4>().0.iter().zip(&own_choosing).zip(&choose) {
|
||||
if p[3] >= 128 && c && (f || !only_own) {
|
||||
hist[luma([p[0], p[1], p[2], p[3]]) as usize] += 1;
|
||||
}
|
||||
}
|
||||
@@ -74,20 +78,28 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
// 15-bit colour histogram with per-bin sums (exact bin means), fitted to the image's
|
||||
// range per channel so that close tones (a dark, low-contrast photo) stay apart.
|
||||
let hk = HistKey::new(img);
|
||||
let pixels = img.rgba.as_chunks::<4>().0;
|
||||
// The pixels that choose the colors: the opaque ones, except the semi-transparent ones
|
||||
// along the cut edge (see `choosing_pixels`).
|
||||
let choose = choosing_pixels(img, false);
|
||||
let own = own_colours(img);
|
||||
let (mut every, mut calm) = (vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15]);
|
||||
for (p, &f) in img.rgba.as_chunks::<4>().0.iter().zip(&own) {
|
||||
let own_choosing = own_colours_among(img, &choose);
|
||||
let (mut all, mut every, mut calm) = (vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15], vec![Bin::default(); 1 << 15]);
|
||||
for ((p, &f), &c) in pixels.iter().zip(&own_choosing).zip(&choose) {
|
||||
if p[3] >= 128 {
|
||||
let k = hk.key(p[0], p[1], p[2]);
|
||||
every[k].add(p);
|
||||
if f {
|
||||
calm[k].add(p);
|
||||
all[k].add(p);
|
||||
if c {
|
||||
every[k].add(p);
|
||||
if f {
|
||||
calm[k].add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let used =
|
||||
|bins: &[Bin]| -> Vec<Used> { bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect() };
|
||||
let (every, calm) = (used(&every), used(&calm));
|
||||
let (all, every, calm) = (used(&all), used(&every), used(&calm));
|
||||
let count = |u: &[Used]| u.iter().map(|b| b.2).sum::<f64>();
|
||||
let from = if palette_from_own(count(&calm), count(&every)) { &calm } else { &every };
|
||||
// Automatic on flat art: colours too rare for its count, but distinct and drawn as
|
||||
@@ -99,21 +111,36 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
// when noise made several of one (see `merge_mixed`).
|
||||
let mut reduce = |colors: u32| {
|
||||
let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() });
|
||||
// The anti-aliasing beside a rare color along the cut edge, with the indexes of
|
||||
// that color and of the color on the other side of it (see `rare_colours`).
|
||||
let mut traced_as: Vec<TracedAs> = vec![];
|
||||
if flat_auto {
|
||||
let found = rare_colours(img, &own, &hk, ¢ers);
|
||||
let (found, blends) = rare_colours(img, &own_choosing, &choose, &hk, ¢ers);
|
||||
if !found.is_empty() {
|
||||
let common = centers.len();
|
||||
traced_as = blends;
|
||||
centers.extend(found);
|
||||
centers = refine_around(from, centers, common);
|
||||
rare = centers.iter().skip(common).map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
|
||||
}
|
||||
}
|
||||
let mut lut = vec![0u16; 1 << 15];
|
||||
let center_labs = labs(¢ers);
|
||||
let center_labs = labs(¢ers.iter().copied().chain(traced_as.iter().map(|t| t.0)).collect::<Vec<_>>());
|
||||
let library = params.palette == Palette::DocumentLibrary && !params.swatches.is_empty();
|
||||
let nearest = if library { nearest_library } else { nearest_lab };
|
||||
for (i, c, _) in &every {
|
||||
lut[*i] = nearest(¢er_labs, oklab(*c)) as u16;
|
||||
// Every opaque pixel takes a color, the cut edge's too. A bin takes the color
|
||||
// nearest to the mean of the pixels in it that choose the colors, and a bin with
|
||||
// none of them the color nearest to the mean of all its pixels. A bin nearest to
|
||||
// the anti-aliasing beside a rare color takes that rare color when it is at least
|
||||
// halfway from the color on the other side to it, and that color otherwise.
|
||||
for (i, c, _) in all.iter().chain(&every) {
|
||||
let n = nearest(¢er_labs, oklab(*c));
|
||||
lut[*i] = n.checked_sub(centers.len()).and_then(|t| traced_as.get(t)).map_or(n, |&(_, rare, other)| {
|
||||
let (Some(r), Some(o)) = (centers.get(rare), centers.get(other)) else { return rare };
|
||||
let d2: f64 = (0..3).map(|q| (r[q] - o[q]).powi(2)).sum();
|
||||
let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - o[q]) * (r[q] - o[q])).sum::<f64>() / d2 } else { 1.0 };
|
||||
if t >= 0.5 { rare } else { other }
|
||||
}) as u16;
|
||||
}
|
||||
let labels =
|
||||
img.rgba.as_chunks::<4>().0.iter().map(|p| if p[3] < 128 { TRANSPARENT } else { lut[hk.key(p[0], p[1], p[2])] }).collect();
|
||||
@@ -136,11 +163,19 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
/// leave along an edge. A line one pixel wide is no blend (the pixels on its two sides are alike),
|
||||
/// nor is noise.
|
||||
fn own_colours(img: &Raster) -> Vec<bool> {
|
||||
let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect();
|
||||
own_colours_among(img, &opaque)
|
||||
}
|
||||
|
||||
/// [`own_colours`] among the pixels that `among` marks: the others are neither own colours nor
|
||||
/// the neighbours a pixel is a blend of.
|
||||
fn own_colours_among(img: &Raster, among: &[bool]) -> Vec<bool> {
|
||||
let (w, h) = (img.width as isize, img.height as isize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let at = |x: isize, y: isize| {
|
||||
let p = (x >= 0 && y >= 0 && x < w && y < h).then(|| px.get((y * w + x) as usize)).flatten()?;
|
||||
(p[3] >= 128).then(|| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])])
|
||||
let i = (x >= 0 && y >= 0 && x < w && y < h).then_some((y * w + x) as usize)?;
|
||||
let p = px.get(i).filter(|_| among.get(i).copied().unwrap_or(false))?;
|
||||
Some([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])])
|
||||
};
|
||||
let mut out = Vec::with_capacity(px.len());
|
||||
for y in 0..h {
|
||||
@@ -413,38 +448,24 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) {
|
||||
if q.labels.len() != w * h || px.len() != w * h {
|
||||
return;
|
||||
}
|
||||
let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]);
|
||||
let color = |p: &[u8; 4]| if gray { [luma(*p); 3] } else { [p[0], p[1], p[2]] };
|
||||
let edge = cut_edge(img);
|
||||
let mut changed = false;
|
||||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||||
for i in 0..w * h {
|
||||
let mine = q.labels[i];
|
||||
if mine == TRANSPARENT || !(128..=254).contains(&alpha(i)) {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
let cut =
|
||||
(x > 0 && alpha(i - 1) < 128) || (x + 1 < w && alpha(i + 1) < 128) || (y > 0 && alpha(i - w) < 128) || (y + 1 < h && alpha(i + w) < 128);
|
||||
if !cut {
|
||||
if mine == TRANSPARENT || !edge.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = px.get(i) else { continue };
|
||||
let own = oklab(color(p).map(f64::from));
|
||||
// The colors of the fully opaque pixels within two pixels that it reaches and is a blend
|
||||
// of, and how many have each.
|
||||
let (x0, y0, x1, y1) = (x.saturating_sub(2), y.saturating_sub(2), (x + 2).min(w - 1), (y + 2).min(h - 1));
|
||||
let mut reached = [[false; 5]; 5];
|
||||
let mut near: Vec<(u16, usize)> = Vec::new();
|
||||
let mut keep = false;
|
||||
stack.clear();
|
||||
stack.push((x, y));
|
||||
if let Some(r) = reached.get_mut(y - y0).and_then(|row| row.get_mut(x - x0)) {
|
||||
*r = true;
|
||||
}
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
let j = cy * w + cx;
|
||||
reached_opaque(img, (i % w, i / w), &mut stack, |j| {
|
||||
let l = q.labels[j];
|
||||
if alpha(j) == 255 && l != TRANSPARENT {
|
||||
if l != TRANSPARENT {
|
||||
keep |= l == mine;
|
||||
if px.get(j).is_some_and(|b| edge_blend_of(own, color(b), p[3])) {
|
||||
match near.iter_mut().find(|n| n.0 == l) {
|
||||
@@ -453,17 +474,7 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nx, ny) in [(cx.wrapping_sub(1), cy), (cx + 1, cy), (cx, cy.wrapping_sub(1)), (cx, cy + 1)] {
|
||||
if nx < x0 || nx > x1 || ny < y0 || ny > y1 || alpha(ny * w + nx) < 128 {
|
||||
continue;
|
||||
}
|
||||
let Some(r) = reached.get_mut(ny - y0).and_then(|row| row.get_mut(nx - x0)) else { continue };
|
||||
if !*r {
|
||||
*r = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if keep || near.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -480,6 +491,114 @@ pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls `visit` with the index of each fully opaque pixel of `img` within two pixels of the pixel
|
||||
/// at `at` that it reaches through pixels of an alpha of 128 or more, going up, down, left or
|
||||
/// right. `stack` is scratch space.
|
||||
fn reached_opaque(img: &Raster, at: (usize, usize), stack: &mut Vec<(usize, usize)>, mut visit: impl FnMut(usize)) {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]);
|
||||
let (x, y) = at;
|
||||
if x >= w || y >= h {
|
||||
return;
|
||||
}
|
||||
let (x0, y0, x1, y1) = (x.saturating_sub(2), y.saturating_sub(2), (x + 2).min(w - 1), (y + 2).min(h - 1));
|
||||
let mut reached = [[false; 5]; 5];
|
||||
stack.clear();
|
||||
stack.push((x, y));
|
||||
if let Some(r) = reached.get_mut(y - y0).and_then(|row| row.get_mut(x - x0)) {
|
||||
*r = true;
|
||||
}
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
let j = cy * w + cx;
|
||||
if alpha(j) == 255 {
|
||||
visit(j);
|
||||
}
|
||||
for (nx, ny) in [(cx.wrapping_sub(1), cy), (cx + 1, cy), (cx, cy.wrapping_sub(1)), (cx, cy + 1)] {
|
||||
if nx < x0 || nx > x1 || ny < y0 || ny > y1 || alpha(ny * w + nx) < 128 {
|
||||
continue;
|
||||
}
|
||||
let Some(r) = reached.get_mut(ny - y0).and_then(|row| row.get_mut(nx - x0)) else { continue };
|
||||
if !*r {
|
||||
*r = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pixels of `img` are on its cut edge: an alpha of 128 to 254, and a pixel above, below,
|
||||
/// left or right of it with an alpha under 128. Pixels outside the image do not count as
|
||||
/// transparent.
|
||||
fn cut_edge(img: &Raster) -> Vec<bool> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let alpha = |j: usize| px.get(j).map_or(0, |p| p[3]);
|
||||
(0..px.len())
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
(128..=254).contains(&alpha(i))
|
||||
&& ((x > 0 && alpha(i - 1) < 128)
|
||||
|| (x + 1 < w && alpha(i + 1) < 128)
|
||||
|| (y > 0 && alpha(i - w) < 128)
|
||||
|| (y + 1 < h && alpha(i + w) < 128))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many steps in from the cut edge (see [`cut_edge`]) the semi-transparent pixels that choose
|
||||
/// no color reach.
|
||||
const CUT_EDGE_BAND: usize = 2;
|
||||
|
||||
/// Which pixels of `img` choose the palette's colors: the opaque ones (an alpha of 128 or more),
|
||||
/// except the pixels that [`settle_cut_edges`] gives the color beside them, and the pixels with an
|
||||
/// alpha of 128 to 254 reached from those in at most [`CUT_EDGE_BAND`] steps up, down, left or
|
||||
/// right through such pixels whose color is a blend of the same kind. The pixels it gives the color
|
||||
/// beside them are the ones on the cut edge (see [`cut_edge`]) whose color is the color of a fully
|
||||
/// opaque pixel that they reach blended with the transparent background (see [`edge_blend_of`]).
|
||||
/// Counted, these pixels could add a second shade of a color to the palette. A thin
|
||||
/// semi-transparent outline of another color still chooses. When no pixel is left to choose, every
|
||||
/// opaque pixel chooses. With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]), as in
|
||||
/// [`settle_cut_edges`].
|
||||
fn choosing_pixels(img: &Raster, gray: bool) -> Vec<bool> {
|
||||
let (w, n) = (img.width as usize, img.rgba.len() / 4);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let semi = |j: usize| px.get(j).is_some_and(|p| (128..=254).contains(&p[3]));
|
||||
let color = |p: &[u8; 4]| if gray { [luma(*p); 3] } else { [p[0], p[1], p[2]] };
|
||||
let edge = cut_edge(img);
|
||||
let mut stack: Vec<(usize, usize)> = Vec::new();
|
||||
// Whether the color of pixel `i` is the color of a fully opaque pixel it reaches blended with
|
||||
// the transparent background.
|
||||
let mut blend_of_art = |i: usize| {
|
||||
let Some(p) = px.get(i) else { return false };
|
||||
let own = oklab(color(p).map(f64::from));
|
||||
let mut blend = false;
|
||||
reached_opaque(img, (i % w.max(1), i / w.max(1)), &mut stack, |j| {
|
||||
blend |= px.get(j).is_some_and(|b| edge_blend_of(own, color(b), p[3]));
|
||||
});
|
||||
blend
|
||||
};
|
||||
let mut left_out: Vec<bool> = (0..n).map(|i| edge.get(i).copied().unwrap_or(false) && blend_of_art(i)).collect();
|
||||
let mut front: Vec<usize> = (0..left_out.len()).filter(|&i| left_out.get(i).copied().unwrap_or(false)).collect();
|
||||
for _ in 0..CUT_EDGE_BAND {
|
||||
let mut next = Vec::new();
|
||||
for i in front {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (i + w < n).then(|| i + w)].into_iter().flatten() {
|
||||
if left_out.get(j) == Some(&false) && semi(j) && blend_of_art(j) {
|
||||
if let Some(out) = left_out.get_mut(j) {
|
||||
*out = true;
|
||||
}
|
||||
next.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
front = next;
|
||||
}
|
||||
let choose: Vec<bool> = px.iter().zip(&left_out).map(|(p, &out)| p[3] >= 128 && !out).collect();
|
||||
if choose.iter().any(|&c| c) { choose } else { px.iter().map(|p| p[3] >= 128).collect() }
|
||||
}
|
||||
|
||||
/// How far (Oklab × 100) the color of a pixel on a cut edge may lie from the color beside it, or
|
||||
/// from a darker shade of that color, and still count as a blend of it with the transparent
|
||||
/// background.
|
||||
@@ -640,6 +759,10 @@ 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);
|
||||
|
||||
/// A color that is no palette color, the index of the rare palette color it traces as, and the
|
||||
/// index of the palette color on the other side of it (see [`rare_colours`]).
|
||||
type TracedAs = ([f64; 3], usize, usize);
|
||||
|
||||
/// 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;
|
||||
@@ -859,6 +982,14 @@ pub(crate) const RARE_ISLAND: usize = 4;
|
||||
/// How many regions of [`RARE_ISLAND`] pixels make a rare colour coherent.
|
||||
const RARE_ISLANDS: usize = 3;
|
||||
|
||||
/// Beside the cut edge, a color is the anti-aliasing between two others only when at least one in
|
||||
/// this many of its pixels has pixels of both around it (see [`rare_colours`]).
|
||||
const CUT_EDGE_MIX_TOUCH: usize = 32;
|
||||
|
||||
/// Beside the cut edge, a color is the anti-aliasing of a rare color only when it lies at least
|
||||
/// this far from the other color toward the rare one, 0 to 1 (see [`rare_colours`]).
|
||||
const CUT_EDGE_MIX_FROM: f64 = 0.45;
|
||||
|
||||
/// Automatic on flat art: the colours too rare to count among its colours (the count keeps colours
|
||||
/// on at least about 1% of the picture), or too close to a large one to get a box of their own,
|
||||
/// that are still colours of the picture: a highlight, a stem, a thin outline. A rare colour is
|
||||
@@ -867,14 +998,24 @@ const RARE_ISLANDS: usize = 3;
|
||||
/// bin; and drawn, some 4-connected region of its own (not blended) pixels of at least
|
||||
/// [`RARE_REGION`] pixels, or [`RARE_ISLANDS`] regions of at least [`RARE_ISLAND`]. At most [`RARE_BUDGET`] are kept, those with the largest regions first.
|
||||
///
|
||||
/// `own` marks the pixels that count, and `choose` the pixels that choose the palette (see
|
||||
/// [`choosing_pixels`]). A color with pixels beside a pixel that `choose` leaves out, and without an
|
||||
/// inside, is not kept when it lies between two colors, one a palette color or another kept rare
|
||||
/// color and the other a kept rare color, at least [`CUT_EDGE_MIX_FROM`] of the way to the rare
|
||||
/// one, and at least one in [`CUT_EDGE_MIX_TOUCH`] of its pixels has pixels of both around it. The
|
||||
/// pixels around it that `choose` leaves out count when their color is within [`RARE_APART`] of
|
||||
/// one of the two. It is returned in the second list instead, with the indexes in `centers`
|
||||
/// followed by the first list of the rare color it traces as and of the color on the other side
|
||||
/// of it (the rare color again when it traces as the rare color alone).
|
||||
///
|
||||
/// The palette is otherwise median cut (Heckbert 1982) refined by k-means (Lloyd 1982); keeping
|
||||
/// salient colours that are rare by area follows the idea of salient-colour preservation in
|
||||
/// palette reduction (Fukuda et al. 2019, doi:10.1117/12.2521607). The rule and its numbers are
|
||||
/// VectorCraft's own.
|
||||
fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> Vec<[f64; 3]> {
|
||||
fn rare_colours(img: &Raster, own: &[bool], choose: &[bool], hk: &HistKey, centers: &[[f64; 3]]) -> (Vec<[f64; 3]>, Vec<TracedAs>) {
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
if centers.is_empty() || own.len() != px.len() {
|
||||
return vec![];
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
// The picture's own (not blended) pixels by histogram bin.
|
||||
let mut bins = vec![Bin::default(); 1 << 15];
|
||||
@@ -919,7 +1060,7 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]])
|
||||
group_of[bin] = g as u16;
|
||||
}
|
||||
if groups.is_empty() {
|
||||
return vec![];
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
// The largest 4-connected region of each group's own pixels.
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
@@ -932,6 +1073,10 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]])
|
||||
// Pixels of a group whose four neighbours are of it too: an edge's blend is a line one pixel
|
||||
// wide and has next to none.
|
||||
let mut inner = vec![0usize; groups.len()];
|
||||
// Groups with a pixel beside a pixel that `choose` leaves out (see `choosing_pixels`): the blend
|
||||
// test of `own` saw no neighbor on that side.
|
||||
let mut beside_left_out = vec![false; groups.len()];
|
||||
let left_out = |j: usize| px.get(j).is_some_and(|p| p[3] >= 128) && !choose.get(j).copied().unwrap_or(true);
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![];
|
||||
for start in 0..w * h {
|
||||
@@ -957,6 +1102,10 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]])
|
||||
{
|
||||
*n += 1;
|
||||
}
|
||||
if let Some(b) = beside_left_out.get_mut(g as usize).filter(|b| !**b) {
|
||||
let (x0, y0, x1, y1) = (x.saturating_sub(1), y.saturating_sub(1), (x + 1).min(w - 1), (y + 1).min(h - 1));
|
||||
*b = (y0..=y1).any(|ny| (x0..=x1).any(|nx| left_out(ny * w + nx)));
|
||||
}
|
||||
}
|
||||
if let Some(l) = largest.get_mut(g as usize) {
|
||||
*l = (*l).max(size);
|
||||
@@ -966,7 +1115,7 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]])
|
||||
}
|
||||
}
|
||||
// Coherent and flat groups, largest region first.
|
||||
let mut chosen: Vec<(usize, f64, [f64; 3])> = vec![];
|
||||
let mut chosen: Vec<(usize, f64, [f64; 3], usize)> = vec![];
|
||||
for (g, members) in groups.iter().enumerate() {
|
||||
let weight: f64 = members.iter().filter_map(|&u| used.get(u)).map(|u| u.2).sum();
|
||||
let heaviest = members.first().and_then(|&u| used.get(u)).map_or(0.0, |u| u.2);
|
||||
@@ -987,20 +1136,177 @@ fn rare_colours(img: &Raster, own: &[bool], hk: &HistKey, centers: &[[f64; 3]])
|
||||
if is_mix(mean, centers) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND) {
|
||||
continue;
|
||||
}
|
||||
chosen.push((region, weight, mean));
|
||||
chosen.push((region, weight, mean, g));
|
||||
}
|
||||
chosen.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.total_cmp(&a.1)).then(a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)));
|
||||
// Beside the pixels that choose no color, the blend test of `own` does not see the
|
||||
// anti-aliasing between a palette color and a rare color drawn along the cut edge. There, a
|
||||
// color without an inside that lies between two colors, one of them another kept rare color,
|
||||
// and at least CUT_EDGE_MIX_FROM of the way from the other to it, is that anti-aliasing when at
|
||||
// least one in CUT_EDGE_MIX_TOUCH of its pixels has pixels of both colors around it: it is not
|
||||
// kept, and it traces as that rare color (the nearer one when there are several). Art that
|
||||
// reaches a rim drawn along the edge touches it in a few places only, and the rim keeps its
|
||||
// color. The smallest regions are tested first, so that of two such colors, one stays, and a
|
||||
// color found to trace as another counts as that color.
|
||||
let tested = |g: usize| beside_left_out.get(g).copied().unwrap_or(false) && inner.get(g).is_none_or(|&n| n < RARE_ISLAND);
|
||||
// The pixels of each color tested, gathered in one pass over the image.
|
||||
let mut pixels_of: Vec<Vec<usize>> = vec![vec![]; groups.len()];
|
||||
let wanted: Vec<bool> = (0..groups.len()).map(|g| tested(g) && chosen.iter().any(|c| c.3 == g)).collect();
|
||||
if wanted.contains(&true) {
|
||||
for i in 0..w * h {
|
||||
if let Some(g) = group_at(i).map(usize::from).filter(|&g| wanted.get(g).copied().unwrap_or(false))
|
||||
&& let Some(p) = pixels_of.get_mut(g)
|
||||
{
|
||||
p.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut onto: Vec<Option<[f64; 3]>> = vec![None; chosen.len()];
|
||||
// For each color in `onto`, where the color on the other side of it comes from (see `from`
|
||||
// below), or `None` when the color traces as the rare color alone.
|
||||
let mut other_of: Vec<Option<usize>> = vec![None; chosen.len()];
|
||||
for k in (0..chosen.len()).rev() {
|
||||
let Some(&(_, _, c, g)) = chosen.get(k) else { continue };
|
||||
if !tested(g) {
|
||||
continue;
|
||||
}
|
||||
// The palette colors, then the other rare colors still kept, and where each comes from: a
|
||||
// palette color's index, or the number of palette colors plus the index in `chosen`.
|
||||
let from: Vec<usize> = (0..centers.len())
|
||||
.chain(chosen.iter().zip(&onto).enumerate().filter(|(o, (_, t))| *o != k && t.is_none()).map(|(o, _)| centers.len() + o))
|
||||
.collect();
|
||||
let with: Vec<[f64; 3]> = centers
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(from.iter().skip(centers.len()).filter_map(|&f| chosen.get(f.checked_sub(centers.len())?)).map(|x| x.2))
|
||||
.collect();
|
||||
let pairs: Vec<(usize, usize)> = (0..with.len())
|
||||
.flat_map(|a| (a + 1..with.len()).map(move |b| (a, b)))
|
||||
.filter(|&(a, b)| matches!((with.get(a), with.get(b)), (Some(&x), Some(&y)) if is_mix(c, &[x, y])))
|
||||
.collect();
|
||||
if pairs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Which of `with` the pixels in the 3 × 3 around the color's own pixels are: the nearest
|
||||
// among `with`, the color itself and the colors that trace as one of `with`. A pixel that
|
||||
// chooses no color counts only within RARE_APART of the one it is nearest to (beyond the
|
||||
// cut edge, an outline over the edge shows its own color).
|
||||
let mut near = with.clone();
|
||||
near.push(c);
|
||||
let mut counts_as: Vec<Option<usize>> = (0..with.len()).map(Some).chain([None]).collect();
|
||||
for (x, t) in chosen.iter().zip(&onto) {
|
||||
if let Some(t) = t {
|
||||
near.push(x.2);
|
||||
counts_as.push(with.iter().position(|w| w == t));
|
||||
}
|
||||
}
|
||||
let near_labs = labs(&near);
|
||||
// For each pair, how many of the color's own pixels have pixels of both around them.
|
||||
let mut both = vec![0usize; pairs.len()];
|
||||
let mut pixels = 0usize;
|
||||
let mut around = vec![false; with.len()];
|
||||
for &i in pixels_of.get(g).map_or(&[][..], Vec::as_slice) {
|
||||
let (x, y) = (i % w, i / w);
|
||||
pixels += 1;
|
||||
around.fill(false);
|
||||
for ny in y.saturating_sub(1)..=(y + 1).min(h - 1) {
|
||||
for nx in x.saturating_sub(1)..=(x + 1).min(w - 1) {
|
||||
let j = ny * w + nx;
|
||||
let Some(p) = px.get(j).filter(|p| p[3] >= 128) else { continue };
|
||||
let o = oklab([p[0], p[1], p[2]].map(f64::from));
|
||||
let n = nearest_lab(&near_labs, o);
|
||||
if !choose.get(j).copied().unwrap_or(false) && near_labs.get(n).is_none_or(|l| dist(*l, o) >= RARE_APART) {
|
||||
continue;
|
||||
}
|
||||
if let Some(&Some(a)) = counts_as.get(n)
|
||||
&& let Some(t) = around.get_mut(a)
|
||||
{
|
||||
*t = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (n, &(a, b)) in pairs.iter().enumerate() {
|
||||
if around.get(a).copied().unwrap_or(false)
|
||||
&& around.get(b).copied().unwrap_or(false)
|
||||
&& let Some(v) = both.get_mut(n)
|
||||
{
|
||||
*v += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lab = oklab(c);
|
||||
// The nearest rare color it traces as, with where the other color of its pair comes from
|
||||
// when its pixels split between the two (see below).
|
||||
let mut best: Option<(f64, [f64; 3], Option<usize>)> = None;
|
||||
for (n, &(a, b)) in pairs.iter().enumerate() {
|
||||
if both.get(n).is_none_or(|&v| v == 0 || v * CUT_EDGE_MIX_TOUCH < pixels) {
|
||||
continue;
|
||||
}
|
||||
for (e, f) in [(a, b), (b, a)].into_iter().filter(|&(e, _)| e >= centers.len()) {
|
||||
let (Some(&x), Some(&y)) = (with.get(e), with.get(f)) else { continue };
|
||||
// How far `c` lies from the other color toward this one.
|
||||
let d2: f64 = (0..3).map(|q| (x[q] - y[q]).powi(2)).sum();
|
||||
let t = if d2 > 0.0 { (0..3).map(|q| (c[q] - y[q]) * (x[q] - y[q])).sum::<f64>() / d2 } else { 0.0 };
|
||||
if t < CUT_EDGE_MIX_FROM {
|
||||
continue;
|
||||
}
|
||||
let d = dist(oklab(x), lab);
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
// A rare color along the cut edge with a region of RARE_REGION pixels is a line
|
||||
// of its own there: the anti-aliasing beside it splits between it and the other
|
||||
// color. Otherwise the anti-aliasing is most of a thin line, or the part of a
|
||||
// line over the edge that lies inside it, and traces as the rare color alone.
|
||||
let body = from
|
||||
.get(e)
|
||||
.and_then(|&m| chosen.get(m.checked_sub(centers.len())?))
|
||||
.is_some_and(|r| r.0 >= RARE_REGION && beside_left_out.get(r.3).copied().unwrap_or(false));
|
||||
best = Some((d, x, from.get(f).copied().filter(|_| body)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(o) = onto.get_mut(k) {
|
||||
*o = best.map(|b| b.1);
|
||||
}
|
||||
if let Some(o) = other_of.get_mut(k) {
|
||||
*o = best.and_then(|b| b.2);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<[f64; 3]> = vec![];
|
||||
for (_, _, c) in chosen {
|
||||
let mut traced_as = vec![];
|
||||
for (((_, _, c, _), o), &from) in chosen.iter().zip(&onto).zip(&other_of) {
|
||||
if let Some(o) = o {
|
||||
traced_as.push((*c, *o, from));
|
||||
continue;
|
||||
}
|
||||
if out.len() >= RARE_BUDGET {
|
||||
break;
|
||||
}
|
||||
let lab = oklab(c);
|
||||
let lab = oklab(*c);
|
||||
if out.iter().all(|o| dist(oklab(*o), lab) >= RARE_APART) {
|
||||
out.push(c);
|
||||
out.push(*c);
|
||||
}
|
||||
}
|
||||
out
|
||||
// The index in `centers` followed by `out` of a palette color or a rare color, or of the kept
|
||||
// rare color one that is not kept traces as.
|
||||
let index = |from: usize| -> Option<usize> {
|
||||
let Some(m) = from.checked_sub(centers.len()) else { return Some(from) };
|
||||
let (_, _, x, _) = chosen.get(m)?;
|
||||
let x = onto.get(m).copied().flatten().unwrap_or(*x);
|
||||
Some(centers.len() + out.iter().position(|o| *o == x)?)
|
||||
};
|
||||
// A color whose rare color is not kept (past the budget) is left out.
|
||||
let traced_as = traced_as
|
||||
.into_iter()
|
||||
.filter_map(|(c, o, from)| {
|
||||
let rare = centers.len() + out.iter().position(|x| *x == o)?;
|
||||
let other = match from {
|
||||
Some(f) => index(f)?,
|
||||
None => rare,
|
||||
};
|
||||
Some((c, rare, other))
|
||||
})
|
||||
.collect();
|
||||
(out, traced_as)
|
||||
}
|
||||
|
||||
/// Whether `c` (sRGB) lies between two of `centers`, as anti-aliasing mixes them: within 12 levels
|
||||
|
||||
@@ -408,6 +408,64 @@ fn a_thin_semi_transparent_outline_keeps_its_own_color() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_out_edge_leaves_the_color_of_the_art_inside_it_alone() {
|
||||
// A white pixel inside a ring of half-opaque dark gray edge pixels on a transparent
|
||||
// background: the edge chooses no color, and the white still takes the white swatch.
|
||||
let img = Raster::from_fn(5, 5, |x, y| match (x, y) {
|
||||
(2, 2) => [255, 255, 255, 255],
|
||||
(1..=3, 1..=3) => [64, 64, 64, 128],
|
||||
_ => [0, 0, 0, 0],
|
||||
});
|
||||
let params = TraceParams {
|
||||
mode: Mode::Color,
|
||||
palette: Palette::DocumentLibrary,
|
||||
swatches: vec![[0, 0, 0], [255, 255, 255]],
|
||||
colors: 2,
|
||||
..TraceParams::default()
|
||||
};
|
||||
let q = quantize(&img, ¶ms);
|
||||
assert_eq!(q.palette.get(q.labels[12] as usize), Some(&[255, 255, 255]), "{q:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_semi_transparent_stripe_beside_a_cut_out_edge_keeps_its_own_color() {
|
||||
// Columns: transparent, a half-opaque gray edge (white blended with the background), a
|
||||
// semi-transparent yellow stripe, then opaque white. The gray chooses no color; the yellow,
|
||||
// no blend of the white, still does.
|
||||
let img = Raster::from_fn(8, 10, |x, _| match x {
|
||||
0 => [0, 0, 0, 0],
|
||||
1 => [128, 128, 128, 128],
|
||||
2 => [250, 200, 40, 192],
|
||||
_ => [255, 255, 255, 255],
|
||||
});
|
||||
let q = quantize(&img, &TraceParams { mode: Mode::Color, palette: Palette::Limited, colors: 3, ..TraceParams::default() });
|
||||
let yellow = q.palette.get(q.labels[5 * 8 + 2] as usize).copied();
|
||||
assert!(yellow.is_some_and(|c| c[2] < 100), "{q:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_takes_no_gray_from_a_cut_out_edge() {
|
||||
// A white square cut out of a transparent border, with a black and a mid-gray block in it,
|
||||
// and a two-pixel cut edge of white darkened by the transparent background. The edge
|
||||
// chooses no gray, so three grays keep the black, the mid-gray and the white apart.
|
||||
let img = Raster::from_fn(40, 40, |x, y| {
|
||||
let ring = x.min(y).min(39 - x).min(39 - y);
|
||||
match (x, y) {
|
||||
_ if ring < 1 => [0, 0, 0, 0],
|
||||
_ if ring < 3 => [170, 170, 170, 200],
|
||||
(6..=17, 6..=33) => [20, 20, 20, 255],
|
||||
(22..=33, 6..=33) => [110, 110, 110, 255],
|
||||
_ => [255, 255, 255, 255],
|
||||
}
|
||||
});
|
||||
let q = quantize(&img, &TraceParams { mode: Mode::Grayscale, colors: 3, ..TraceParams::default() });
|
||||
let gray = |x: usize, y: usize| q.palette.get(q.labels[y * 40 + x] as usize).map(|c| c[0]);
|
||||
for ((x, y), want) in [((10, 20), 20u8), ((28, 20), 110), ((20, 3), 255)] {
|
||||
assert!(gray(x, y).is_some_and(|g| g.abs_diff(want) <= 4), "({x}, {y}) traces as {:?}, not {want}: {q:?}", gray(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_controls_black_and_white() {
|
||||
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE });
|
||||
@@ -2192,6 +2250,302 @@ fn scattered_noise_adds_no_colour() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A light disc (radius 190) cut out of a transparent background, with a blue and a black block
|
||||
/// in it and a tan outline `width` px wide and `alpha` (0–1) opaque drawn over the disc's edge,
|
||||
/// anti-aliased with 4 × 4 samples a pixel. Inside the edge the outline is an opaque blend of the
|
||||
/// tan and the light color; outside it, the tan alone, semi-transparent.
|
||||
fn cut_out_with_outline(width: f64, alpha: f64) -> Raster {
|
||||
let (light, tan, blue, black) = ([253.0, 247.0, 227.0], [154.0, 132.0, 82.0], [34.0, 125.0, 250.0], [5.0, 5.0, 3.0]);
|
||||
Raster::from_fn(400, 400, |x, y| {
|
||||
// Premultiplied color and alpha, summed over the samples.
|
||||
let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64);
|
||||
for s in 0..16 {
|
||||
let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0);
|
||||
let r = (fx - 200.0).hypot(fy - 200.0);
|
||||
let (mut c, mut a) = if r >= 190.0 {
|
||||
([0.0; 3], 0.0)
|
||||
} else if (125.0..275.0).contains(&fx) && (100.0..200.0).contains(&fy) {
|
||||
(blue, 1.0)
|
||||
} else if (125.0..275.0).contains(&fx) && (225.0..300.0).contains(&fy) {
|
||||
(black, 1.0)
|
||||
} else {
|
||||
(light, 1.0)
|
||||
};
|
||||
c = c.map(|v| v * a);
|
||||
if (r - 190.0).abs() < width / 2.0 {
|
||||
c = [0, 1, 2].map(|k| tan[k] * alpha + c[k] * (1.0 - alpha));
|
||||
a = alpha + a * (1.0 - alpha);
|
||||
}
|
||||
for k in 0..3 {
|
||||
sum[k] += c[k];
|
||||
}
|
||||
cover += a;
|
||||
}
|
||||
if cover <= 0.0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_takes_no_color_from_a_cut_out_edge() {
|
||||
// The semi-transparent pixels along the disc's cut edge are the tan alone, or a mix of it and
|
||||
// the outline's opaque blend inside the edge: counted, they make a second tan, or leave the
|
||||
// outline no color of its own. They trace, but choose no color: one tan. Inside the edge, the
|
||||
// anti-aliasing between the disc and the outline is no second tan either.
|
||||
let params = preset("Auto Color").unwrap();
|
||||
let mut failed = vec![];
|
||||
for width in [1.0, 1.5, 2.0, 2.5, 3.0, 4.0] {
|
||||
for alpha in [0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 1.0] {
|
||||
// A 1 px outline under alpha 0.7 traces as the disc's color: the opaque pixels it
|
||||
// covers vary too much to be a color of their own, and the ones outside the edge are
|
||||
// darker shades of them.
|
||||
if width < 1.5 && alpha < 0.7 {
|
||||
continue;
|
||||
}
|
||||
let res = trace(&cut_out_with_outline(width, alpha), ¶ms);
|
||||
if distinct_colours(&res) != 4 {
|
||||
failed.push(format!("outline {width} px, alpha {alpha}: {:?}", res.palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_a_thin_cut_edge_outline_in_few_pieces() {
|
||||
// A fully opaque outline 1.1 to 1.3 px wide over the cut edge: along the circle its opaque
|
||||
// pixels are the tan or a mix of the tan and the disc. The mix is the anti-aliasing between
|
||||
// them and traces as the tan, so the outline stays a ring of few pieces.
|
||||
let main = [[253, 247, 227], [34, 125, 250], [5, 5, 3]].map(quantize::tests_oklab);
|
||||
let is_main = |c: [u8; 3]| {
|
||||
let o = quantize::tests_oklab(c);
|
||||
main.iter().any(|m| (0..3).map(|k| (o[k] - m[k]).powi(2)).sum::<f64>().sqrt() <= 6.0)
|
||||
};
|
||||
let params = preset("Auto Color").unwrap();
|
||||
let mut failed = vec![];
|
||||
for width in [1.1, 1.25, 1.3] {
|
||||
let res = trace(&cut_out_with_outline(width, 1.0), ¶ms);
|
||||
let pieces = res.paths.iter().filter(|p| !is_main(p.color)).count();
|
||||
if distinct_colours(&res) != 4 || pieces >= 24 {
|
||||
failed.push(format!("outline {width} px: {} colors, {pieces} outline paths", distinct_colours(&res)));
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// A light disc (radius 140) cut out of a transparent background, with a blue block and four dark
|
||||
/// bars 2 px tall in it, and an opaque rim `width` px wide along the inside of its cut edge whose
|
||||
/// color is `t` (0–1) of the way from the light color to the bars', anti-aliased with 4 × 4 samples
|
||||
/// a pixel. Outside the edge there is nothing: the edge pixels are the rim's color, semi-transparent.
|
||||
fn cut_out_with_rim(width: f64, t: f64) -> Raster {
|
||||
let (light, blue, dark) = ([253.0, 247.0, 227.0], [34.0, 125.0, 250.0], [110.0, 80.0, 40.0]);
|
||||
let rim: [f64; 3] = [0, 1, 2].map(|k| (light[k] * (1.0 - t) + dark[k] * t).round());
|
||||
let bars = [(80.0, 190.0, 120.0, 192.0), (130.0, 190.0, 170.0, 192.0), (180.0, 190.0, 220.0, 192.0), (80.0, 215.0, 220.0, 217.0)];
|
||||
Raster::from_fn(300, 300, |x, y| {
|
||||
let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64);
|
||||
for s in 0..16 {
|
||||
let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0);
|
||||
let r = (fx - 150.0).hypot(fy - 150.0);
|
||||
if r >= 140.0 {
|
||||
continue;
|
||||
}
|
||||
let c = if r >= 140.0 - width {
|
||||
rim
|
||||
} else if bars.iter().any(|&(x0, y0, x1, y1)| (x0..x1).contains(&fx) && (y0..y1).contains(&fy)) {
|
||||
dark
|
||||
} else if (90.0..210.0).contains(&fx) && (70.0..150.0).contains(&fy) {
|
||||
blue
|
||||
} else {
|
||||
light
|
||||
};
|
||||
for k in 0..3 {
|
||||
sum[k] += c[k];
|
||||
}
|
||||
cover += 1.0;
|
||||
}
|
||||
if cover <= 0.0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_an_opaque_rim_along_a_cut_edge() {
|
||||
// An opaque rim drawn along the inside of the cut edge, its color between the disc's and the
|
||||
// bars': beside the cut edge, but touching no bar, it is drawn and not anti-aliasing between
|
||||
// the two, and keeps its color.
|
||||
let mut failed = vec![];
|
||||
for (width, detail) in [(1.5, 85.0), (2.0, 50.0), (2.5, 50.0)] {
|
||||
for t in [0.35, 0.5, 0.65] {
|
||||
let rim = [0, 1, 2].map(|k| ([253.0f64, 247.0, 227.0][k] * (1.0 - t) + [110.0, 80.0, 40.0][k] * t).round() as u8);
|
||||
let params = TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() };
|
||||
let res = trace(&cut_out_with_rim(width, t), ¶ms);
|
||||
if !has_colour(&res, rim, 3.0) {
|
||||
failed.push(format!("rim {width} px, {t}, color detail {detail}: {:?}", res.palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_splits_the_anti_aliasing_beside_a_cut_edge_outline() {
|
||||
// A 2.5 px outline at alpha 0.7 and 0.75 over the cut edge has an opaque band of its own
|
||||
// inside the edge. The anti-aliasing between that band and the disc is no second shade of
|
||||
// the outline, and each pixel of it traces as the color it is nearer to. Traced so, the
|
||||
// outline's paths average 2.18 and 2.27 px wide; traced as the outline, the anti-aliasing
|
||||
// adds 0.075 px.
|
||||
use kurbo::Shape;
|
||||
let main = [[253, 247, 227], [34, 125, 250], [5, 5, 3]].map(quantize::tests_oklab);
|
||||
let is_main = |c: [u8; 3]| {
|
||||
let o = quantize::tests_oklab(c);
|
||||
main.iter().any(|m| (0..3).map(|k| (o[k] - m[k]).powi(2)).sum::<f64>().sqrt() <= 6.0)
|
||||
};
|
||||
let mut failed = vec![];
|
||||
for (alpha, widest) in [(0.7, 2.22), (0.75, 2.31)] {
|
||||
let res = trace(&cut_out_with_outline(2.5, alpha), &preset("Auto Color").unwrap());
|
||||
let area: f64 = res.paths.iter().filter(|p| !is_main(p.color)).map(|p| p.path.to_bezpath().area().abs()).sum();
|
||||
let width = area / (2.0 * std::f64::consts::PI * 190.0);
|
||||
if distinct_colours(&res) != 4 || width > widest {
|
||||
failed.push(format!("alpha {alpha}: {} colors, outline {width:.2} px wide", distinct_colours(&res)));
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// `layers` (where, color, alpha 0–1) drawn in order over a transparent 300 × 300 image,
|
||||
/// anti-aliased with 4 × 4 samples a pixel; each pixel's color is unpremultiplied.
|
||||
fn cut_out_layers(layers: &[(Inside, [f64; 3], f64)]) -> Raster {
|
||||
Raster::from_fn(300, 300, |x, y| {
|
||||
// Premultiplied color and alpha, summed over the samples.
|
||||
let (mut sum, mut cover) = ([0.0f64; 3], 0.0f64);
|
||||
for s in 0..16 {
|
||||
let (fx, fy) = (f64::from(x) + (f64::from(s % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(s / 4) + 0.5) / 4.0);
|
||||
let (mut c, mut a) = ([0.0f64; 3], 0.0f64);
|
||||
for (inside, color, alpha) in layers {
|
||||
if inside(fx, fy) {
|
||||
c = [0, 1, 2].map(|k| color[k] * alpha + c[k] * (1.0 - alpha));
|
||||
a = alpha + a * (1.0 - alpha);
|
||||
}
|
||||
}
|
||||
for k in 0..3 {
|
||||
sum[k] += c[k];
|
||||
}
|
||||
cover += a;
|
||||
}
|
||||
if cover <= 0.0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let c = sum.map(|v| (v / cover).round().clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], (cover / 16.0 * 255.0).round() as u8]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_traces_an_outline_over_a_cut_edge_in_the_color_of_other_art() {
|
||||
// A light disc cut out of a transparent background, with a blue block and dark bars, and a
|
||||
// dark outline at alpha 0.8 over the cut edge in the bars' color. Inside the edge the outline
|
||||
// is an opaque blend of the dark and the light color; outside it, the dark alone. The blend is
|
||||
// the outline, not a second shade of it: three colors, and the outline in few pieces.
|
||||
let (light, blue, dark) = ([253.0, 247.0, 227.0], [34.0, 125.0, 250.0], [110.0, 80.0, 40.0]);
|
||||
let r = |x: f64, y: f64| (x - 150.0).hypot(y - 150.0);
|
||||
let disc = |x: f64, y: f64| r(x, y) < 140.0;
|
||||
let block = |x: f64, y: f64| (90.0..210.0).contains(&x) && (70.0..150.0).contains(&y);
|
||||
let bars = |x: f64, y: f64| {
|
||||
[(80.0, 190.0, 120.0, 192.0), (130.0, 190.0, 170.0, 192.0), (180.0, 190.0, 220.0, 192.0), (80.0, 215.0, 220.0, 217.0)]
|
||||
.iter()
|
||||
.any(|&(x0, y0, x1, y1)| (x0..x1).contains(&x) && (y0..y1).contains(&y))
|
||||
};
|
||||
let mut failed = vec![];
|
||||
for width in [1.5, 2.0, 2.5] {
|
||||
let outline = move |x: f64, y: f64| (r(x, y) - 140.0).abs() < width / 2.0;
|
||||
let img = cut_out_layers(&[(&disc, light, 1.0), (&block, blue, 1.0), (&bars, dark, 1.0), (&outline, dark, 0.8)]);
|
||||
for detail in [85.0, 50.0] {
|
||||
let res = trace(&img, &TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() });
|
||||
if distinct_colours(&res) != 3 || !has_colour(&res, [110, 80, 40], 3.0) || res.paths.len() > 24 {
|
||||
failed.push(format!("outline {width} px, color detail {detail}: {} paths, {:?}", res.paths.len(), res.palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A 1 px outline's blend inside the edge is less than halfway from the light color to the
|
||||
// dark: it keeps a color of its own, and the outline stays a ring of few pieces.
|
||||
let outline = move |x: f64, y: f64| (r(x, y) - 140.0).abs() < 0.5;
|
||||
let img = cut_out_layers(&[(&disc, light, 1.0), (&block, blue, 1.0), (&bars, dark, 1.0), (&outline, dark, 0.8)]);
|
||||
let res = trace(&img, &preset("Auto Color").unwrap());
|
||||
if distinct_colours(&res) != 4 || res.paths.len() > 20 {
|
||||
failed.push(format!("outline 1 px: {} paths, {:?}", res.paths.len(), res.palette));
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_an_opaque_rim_that_other_art_reaches() {
|
||||
// An opaque 2 px rim along the inside of the cut edge, its color between the disc's and that of
|
||||
// dark bars, two of which run out to the rim and touch it: drawn, not anti-aliasing between the
|
||||
// disc and the bars, so it keeps its color. A light rim with brown bars, and a gray rim with
|
||||
// black bars on a near-white disc.
|
||||
let r = |x: f64, y: f64| (x - 150.0).hypot(y - 150.0);
|
||||
let disc = |x: f64, y: f64| r(x, y) < 140.0;
|
||||
let rim = |x: f64, y: f64| (138.0..140.0).contains(&r(x, y));
|
||||
let block = |x: f64, y: f64| (90.0..210.0).contains(&x) && (70.0..150.0).contains(&y);
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| move |x: f64, y: f64| (x0..x1).contains(&x) && (y0..y1).contains(&y);
|
||||
let (top, bottom, left, wide) =
|
||||
(rect(146.0, 12.0, 154.0, 60.0), rect(146.0, 240.0, 154.0, 288.0), rect(12.0, 196.0, 60.0, 199.0), rect(80.0, 215.0, 220.0, 217.0));
|
||||
let light: [f64; 3] = [253.0, 247.0, 227.0];
|
||||
let tan: [f64; 3] = [0, 1, 2].map(|k| (light[k] * 0.65 + [110.0, 80.0, 40.0][k] * 0.35).round());
|
||||
let (blue, dark) = ([34.0, 125.0, 250.0], [110.0, 80.0, 40.0]);
|
||||
let brown = cut_out_layers(&[
|
||||
(&disc, light, 1.0),
|
||||
(&block, blue, 1.0),
|
||||
(&top, dark, 1.0),
|
||||
(&bottom, dark, 1.0),
|
||||
(&left, dark, 1.0),
|
||||
(&wide, dark, 1.0),
|
||||
(&rim, tan, 1.0),
|
||||
]);
|
||||
let tall = rect(80.0, 215.0, 220.0, 218.0);
|
||||
let (white, red, black, gray) = ([250.0; 3], [200.0, 40.0, 40.0], [5.0, 5.0, 3.0], [128.0; 3]);
|
||||
let black_bars = cut_out_layers(&[(&disc, white, 1.0), (&block, red, 1.0), (&top, black, 1.0), (&tall, black, 1.0), (&rim, gray, 1.0)]);
|
||||
let mut failed = vec![];
|
||||
for (name, img, color) in [("tan rim", &brown, tan.map(|v| v as u8)), ("gray rim", &black_bars, [128, 128, 128])] {
|
||||
for detail in [85.0, 50.0] {
|
||||
let res = trace(img, &TraceParams { color_detail: detail, ..preset("Auto Color").unwrap() });
|
||||
if !has_colour(&res, color, 3.0) {
|
||||
failed.push(format!("{name}, color detail {detail}: {:?}", res.palette));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_keeps_an_opaque_line_between_two_kept_colors() {
|
||||
// Two thin opaque lines on a light ground with a blue and a black block: a tan, and a lighter
|
||||
// tan halfway between it and the ground. Away from a cut edge, the lighter one is drawn, not a
|
||||
// blend: both stay.
|
||||
let img = Raster::from_fn(400, 400, |x, y| {
|
||||
if (100..300).contains(&x) && (40..120).contains(&y) {
|
||||
[34, 125, 250, 255]
|
||||
} else if (100..300).contains(&x) && (280..360).contains(&y) {
|
||||
[5, 5, 3, 255]
|
||||
} else if (60..340).contains(&x) && (180..182).contains(&y) {
|
||||
[154, 132, 82, 255]
|
||||
} else if (60..340).contains(&x) && (220..222).contains(&y) {
|
||||
[204, 190, 155, 255]
|
||||
} else {
|
||||
[253, 247, 227, 255]
|
||||
}
|
||||
});
|
||||
let res = trace(&img, &preset("Auto Color").unwrap());
|
||||
assert!(has_colour(&res, [154, 132, 82], 3.0), "{:?}", res.palette);
|
||||
assert!(has_colour(&res, [204, 190, 155], 3.0), "{:?}", res.palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_keeps_a_kept_rare_colours_regions_but_not_its_specks() {
|
||||
// Labels: 0 background, 1 a rare colour kept on purpose (four islands of 4, 6, 9 and 2
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user