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:
BFlat
2026-10-26 09:18:07 +07:00
committed by GitHub
3 changed files with 713 additions and 52 deletions
+358 -52
View File
@@ -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, &centers);
let (found, blends) = rare_colours(img, &own_choosing, &choose, &hk, &centers);
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(&centers);
let center_labs = labs(&centers.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(&center_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(&center_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
+354
View File
@@ -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, &params);
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), &params);
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), &params);
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), &params);
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