mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge pull request #7193 from dotCooCoo/fix/trace-blur-rings
Image Trace: blurred edges no longer trace as rings of in-between colors
This commit is contained in:
@@ -420,13 +420,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_dense_trace_is_grouped_with_a_few_box_tests_per_path() {
|
||||
let img = noise(120, 120);
|
||||
let img = noise(150, 150);
|
||||
for (method, stacked) in [(crate::Method::Abutting, false), (crate::Method::Overlapping, true)] {
|
||||
let paths = traced(&img, method);
|
||||
let n = paths.len();
|
||||
let counted = |by_pairs: bool| {
|
||||
BOX_TESTS.with(|n| n.set(0));
|
||||
let found = if by_pairs { parents_by_pairs(&paths, 120, 120, stacked) } else { parents(&paths, 120, 120, stacked) };
|
||||
let found = if by_pairs { parents_by_pairs(&paths, 150, 150, stacked) } else { parents(&paths, 150, 150, stacked) };
|
||||
(found, BOX_TESTS.with(std::cell::Cell::get))
|
||||
};
|
||||
let (found, tests) = counted(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ mod denoise;
|
||||
mod palette;
|
||||
mod rare;
|
||||
|
||||
use cut_edge::{choosing_pixels, compact, settled};
|
||||
use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide};
|
||||
pub(crate) use cut_edge::{continuous_tone, settle_cut_edges};
|
||||
pub use denoise::denoise;
|
||||
pub(crate) use denoise::denoise_keeping;
|
||||
@@ -91,7 +91,8 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
let g = l.round().clamp(0.0, 255.0) as u8;
|
||||
[g, g, g]
|
||||
});
|
||||
(settled(merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]), img, &own), vec![])
|
||||
let q = merge_mixed(compact(labels, palette.collect()), img.width as usize, img.height as usize, &[]);
|
||||
(if settles_wide(params) { settled_wide(q, img, &own, true) } else { settled(q, img, &own) }, vec![])
|
||||
}
|
||||
// Logo mode traces without quantising; asked directly, it quantises like Color.
|
||||
Mode::Color | Mode::Logo => {
|
||||
@@ -170,7 +171,7 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized
|
||||
if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) }
|
||||
};
|
||||
let q = reduce(params.colors);
|
||||
let q = settled(q, img, &own);
|
||||
let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) };
|
||||
rare.retain(|c| q.palette.contains(c));
|
||||
(q, rare)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab};
|
||||
use super::{Quantized, Raster, TRANSPARENT, luma, oklab};
|
||||
use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab};
|
||||
|
||||
/// `q` with its blend pixels settled, and the colours that leaves unused dropped.
|
||||
///
|
||||
@@ -65,6 +65,214 @@ pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized
|
||||
if changed { compact(q.labels, q.palette) } else { q }
|
||||
}
|
||||
|
||||
/// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels
|
||||
/// settled by [`settled_wide`] rather than [`settled`]. The limited-color presets ask for 3 to 16
|
||||
/// colors.
|
||||
const LIMITED_COLORS: u32 = 16;
|
||||
|
||||
/// Whether a trace with `params` settles its blend pixels with [`settled_wide`]: Grayscale, and
|
||||
/// Color with a Limited palette, of at most [`LIMITED_COLORS`] colors.
|
||||
pub(super) fn settles_wide(params: &TraceParams) -> bool {
|
||||
match params.mode {
|
||||
Mode::Grayscale => params.colors <= LIMITED_COLORS,
|
||||
Mode::Color | Mode::Logo => params.palette == Palette::Limited && params.colors <= LIMITED_COLORS,
|
||||
Mode::BlackAndWhite => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (px, across, along or diagonally) [`settled_wide`] looks for the picture's own pixels
|
||||
/// around a blend pixel: blur spreads an edge over several pixels, and its two sides can lie this
|
||||
/// far apart.
|
||||
const BLEND_REACH: usize = 8;
|
||||
|
||||
/// How far (px, across, along or diagonally) an own pixel of its color keeps a blend pixel's color
|
||||
/// in [`settled_wide`]: where three colors meet, the blend is of its color too.
|
||||
const OWN_NEAR: usize = 4;
|
||||
|
||||
/// How many of the colors within reach of a blend pixel, nearest to its color first,
|
||||
/// [`settled_wide`] pairs up.
|
||||
const NEAR_COLORS: usize = 8;
|
||||
|
||||
/// How many columns at a time [`settled_wide`] keeps the colors within reach for, so that its
|
||||
/// memory does not grow with the width of the picture.
|
||||
const BLEND_STRIP: usize = 7;
|
||||
|
||||
/// How far apart (squared levels) the distances of a blend pixel off the lines between two pairs
|
||||
/// of colors may be for [`settled_wide`] to count them as equal: rounding leaves a gray that lies
|
||||
/// between two grays about 1e-28 off the line between them.
|
||||
const SAME_FIT: f64 = 1e-6;
|
||||
|
||||
/// How much nearer (squared) to the line between a pair of colors than to its own color a blend
|
||||
/// pixel must lie for [`settled_wide`] to settle it: half as far, as [`settled`] asks.
|
||||
const BETTER_FIT: f64 = 0.25;
|
||||
|
||||
/// How far off the line between a pair of colors (as a share of their distance apart) a blend pixel
|
||||
/// that lies between the two may be for [`settled_wide`] to settle it whatever its own color.
|
||||
const ON_THE_LINE: f64 = 0.15;
|
||||
|
||||
/// [`settled`] for a limited palette (see [`settles_wide`]), which also settles the in-between
|
||||
/// tones of an edge blurred over several pixels: without it they trace as a ring of a third color
|
||||
/// around the edge.
|
||||
///
|
||||
/// A blend pixel (see [`own_colours`]) whose color is none of those of the picture's own pixels
|
||||
/// within [`OWN_NEAR`] pixels of it takes the side it is nearer to of the pair of the colors of own
|
||||
/// pixels within [`BLEND_REACH`] pixels that it is best a blend of (of two pairs that explain it
|
||||
/// equally well, the closer one), when that explains it twice as well as its own color does, or
|
||||
/// when it lies between the two and off the line between them by less than [`ON_THE_LINE`] of
|
||||
/// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps
|
||||
/// its own color when that color is one of the pair, or when one color is all there is around it.
|
||||
/// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]).
|
||||
pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if q.labels.len() != w * h || own.len() != w * h {
|
||||
return q;
|
||||
}
|
||||
let before = q.labels.clone();
|
||||
let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect();
|
||||
let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>();
|
||||
// The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette
|
||||
// colors), a strip of columns at a time: along its row for the rows within reach (a window
|
||||
// sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows.
|
||||
let words = q.palette.len().div_ceil(64).max(1);
|
||||
let span = 2 * BLEND_REACH + 1;
|
||||
let strip = BLEND_STRIP.min(w);
|
||||
let mut rows = vec![0u64; span * strip * words];
|
||||
let mut counts = vec![0u32; q.palette.len()];
|
||||
let mut window = vec![0u64; words];
|
||||
let mut bits = vec![0u64; words];
|
||||
// The bits of row `y` for the columns `x0..x1` (at most `strip` of them).
|
||||
let mut row_colors = |y: usize, x0: usize, x1: usize, rows: &mut [u64]| {
|
||||
let Some(out) = rows.get_mut((y % span) * strip * words..(y % span + 1) * strip * words) else { return };
|
||||
counts.fill(0);
|
||||
window.fill(0);
|
||||
// The own pixel at `x` enters the window (`add`) or leaves it.
|
||||
let step = |counts: &mut [u32], window: &mut [u64], x: usize, add: bool| {
|
||||
let i = y * w + x;
|
||||
let l = before[i] as usize;
|
||||
if !own[i] || l >= counts.len() {
|
||||
return;
|
||||
}
|
||||
if add {
|
||||
counts[l] += 1;
|
||||
window[l / 64] |= 1 << (l % 64);
|
||||
} else {
|
||||
counts[l] -= 1;
|
||||
if counts[l] == 0 {
|
||||
window[l / 64] &= !(1 << (l % 64));
|
||||
}
|
||||
}
|
||||
};
|
||||
let first = x0.saturating_sub(BLEND_REACH);
|
||||
for x in first..(x0 + BLEND_REACH).min(w) {
|
||||
step(&mut counts, &mut window, x, true);
|
||||
}
|
||||
for x in x0..x1 {
|
||||
if x + BLEND_REACH < w {
|
||||
step(&mut counts, &mut window, x + BLEND_REACH, true);
|
||||
}
|
||||
if let Some(gone) = x.checked_sub(BLEND_REACH + 1).filter(|&g| g >= first) {
|
||||
step(&mut counts, &mut window, gone, false);
|
||||
}
|
||||
if let Some(o) = out.get_mut((x - x0) * words..(x - x0 + 1) * words) {
|
||||
o.copy_from_slice(&window);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut changed = false;
|
||||
let mut near: Vec<u16> = Vec::new();
|
||||
let mut by_distance: Vec<(f64, u16)> = Vec::new();
|
||||
let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] };
|
||||
for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) {
|
||||
let x1 = (x0 + strip).min(w);
|
||||
if y == 0 {
|
||||
for yy in 0..BLEND_REACH.min(h) {
|
||||
row_colors(yy, x0, x1, &mut rows);
|
||||
}
|
||||
}
|
||||
if y + BLEND_REACH < h {
|
||||
row_colors(y + BLEND_REACH, x0, x1, &mut rows);
|
||||
}
|
||||
'pixels: for x in x0..x1 {
|
||||
let i = y * w + x;
|
||||
let mine_l = before[i];
|
||||
if own[i] || mine_l == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
// Its color is the picture's own here.
|
||||
for yy in y.saturating_sub(OWN_NEAR)..=(y + OWN_NEAR).min(h - 1) {
|
||||
for xx in x.saturating_sub(OWN_NEAR)..=(x + OWN_NEAR).min(w - 1) {
|
||||
if own[yy * w + xx] && before[yy * w + xx] == mine_l {
|
||||
continue 'pixels;
|
||||
}
|
||||
}
|
||||
}
|
||||
bits.fill(0);
|
||||
for yy in y.saturating_sub(BLEND_REACH)..=(y + BLEND_REACH).min(h - 1) {
|
||||
let at = (yy % span) * strip * words + (x - x0) * words;
|
||||
for (b, r) in bits.iter_mut().zip(rows.get(at..at + words).unwrap_or_default()) {
|
||||
*b |= r;
|
||||
}
|
||||
}
|
||||
near.clear();
|
||||
for (k, &b) in bits.iter().enumerate() {
|
||||
let mut b = b;
|
||||
while b != 0 {
|
||||
let l = (k * 64 + b.trailing_zeros() as usize) as u16;
|
||||
b &= b - 1;
|
||||
near.push(l);
|
||||
}
|
||||
}
|
||||
let (Some(p), Some(&mine)) = (img.rgba.get(i * 4..i * 4 + 4).map(pixel), colors.get(mine_l as usize)) else {
|
||||
continue;
|
||||
};
|
||||
if near.iter().filter(|&&l| l != mine_l).count() < 2 {
|
||||
continue;
|
||||
}
|
||||
// In busy art many colors lie within reach: only the ones nearest to its color are paired.
|
||||
// Each distance is worked out once, and only the nearest few are sorted (nearest first,
|
||||
// then by index).
|
||||
if near.len() > NEAR_COLORS {
|
||||
by_distance.clear();
|
||||
by_distance.extend(near.iter().map(|&l| (colors.get(l as usize).map_or(f64::INFINITY, |&c| dist2(p, c)), l)));
|
||||
let order = |a: &(f64, u16), b: &(f64, u16)| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1));
|
||||
by_distance.select_nth_unstable_by(NEAR_COLORS, order);
|
||||
by_distance.truncate(NEAR_COLORS);
|
||||
by_distance.sort_unstable_by(order);
|
||||
near.clear();
|
||||
near.extend(by_distance.iter().map(|&(_, l)| l));
|
||||
}
|
||||
// The pair it is best a blend of, by the square of how far it lies off the line between them,
|
||||
// and of two pairs it lies equally far off, the closer pair (in grays it lies on the line
|
||||
// between every pair of grays darker and lighter than it).
|
||||
let mut best: Option<(f64, [u16; 2], u16, f64, bool)> = None;
|
||||
for (n, &a) in near.iter().enumerate() {
|
||||
let Some(&ca) = colors.get(a as usize) else { continue };
|
||||
let pa = [p[0] - ca[0], p[1] - ca[1], p[2] - ca[2]];
|
||||
for &b in &near[n + 1..] {
|
||||
let Some(&cb) = colors.get(b as usize) else { continue };
|
||||
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
|
||||
let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let along = if len2 > 0.0 { (pa[0] * d[0] + pa[1] * d[1] + pa[2] * d[2]) / len2 } else { 0.0 };
|
||||
let t = along.clamp(0.0, 1.0);
|
||||
let off2 = (pa[0] - t * d[0]).powi(2) + (pa[1] - t * d[1]).powi(2) + (pa[2] - t * d[2]).powi(2);
|
||||
if best.is_none_or(|x| off2 < x.0 - SAME_FIT || (off2 <= x.0 + SAME_FIT && len2 < x.3)) {
|
||||
best = Some((off2, [a, b], if t < 0.5 { a } else { b }, len2, along > 0.0 && along < 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pixel already of one of the two colors it is best a blend of stays as it is.
|
||||
if let Some((off2, pair, to, apart2, between)) = best
|
||||
&& !pair.contains(&mine_l)
|
||||
&& (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2))
|
||||
{
|
||||
q.labels[i] = to;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed { compact(q.labels, q.palette) } else { q }
|
||||
}
|
||||
|
||||
/// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy
|
||||
/// or unevenly lit (a scan): whether eight colours leave much of it unsaid. Flat art is a few
|
||||
/// colours and what noise and lighting make of them, and eight colours come within a few levels
|
||||
@@ -443,3 +651,66 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool {
|
||||
let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum();
|
||||
off2 <= EDGE_BLEND * EDGE_BLEND
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() {
|
||||
// Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black
|
||||
// along the top row, and a palette gray of 120 of its own. It lies between 0 and 200 exactly
|
||||
// and between 50 and 200 to within rounding: it takes 50, the nearer of the closer pair.
|
||||
let gray = |x: u32, y: u32| match (x, y) {
|
||||
(8, 8) => 105,
|
||||
(_, 0) => 0,
|
||||
(0..=8, _) => 50,
|
||||
_ => 200,
|
||||
};
|
||||
let img = Raster::from_fn(17, 17, |x, y| {
|
||||
let v = gray(x, y);
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let palette = vec![[0, 0, 0], [50, 50, 50], [200, 200, 200], [120, 120, 120]];
|
||||
let labels: Vec<u16> = (0..17 * 17)
|
||||
.map(|i| match gray(i % 17, i / 17) {
|
||||
0 => 0,
|
||||
50 => 1,
|
||||
200 => 2,
|
||||
_ => 3,
|
||||
})
|
||||
.collect();
|
||||
let own: Vec<bool> = labels.iter().map(|&l| l != 3).collect();
|
||||
let q = settled_wide(Quantized { labels, palette }, &img, &own, true);
|
||||
let middle = q.labels[8 * 17 + 8];
|
||||
assert_eq!(q.palette[usize::from(middle)], [50, 50, 50]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_pixel_beyond_the_end_of_a_pair_keeps_its_nearest_color() {
|
||||
// A column of [92, 92, 92] between own pixels of [100, 100, 100] to its left and
|
||||
// [200, 200, 200] to its right, with a palette color of [90, 90, 90]: it lies beyond the
|
||||
// darker end of the pair, not between the two, and its own color is the nearer, so it
|
||||
// keeps it.
|
||||
let value = |x: u32| match x {
|
||||
0..=7 => 100,
|
||||
8 => 92,
|
||||
_ => 200,
|
||||
};
|
||||
let img = Raster::from_fn(17, 17, |x, _| {
|
||||
let v = value(x);
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let palette = vec![[100, 100, 100], [200, 200, 200], [90, 90, 90]];
|
||||
let labels: Vec<u16> = (0..17 * 17u32)
|
||||
.map(|i| match value(i % 17) {
|
||||
100 => 0,
|
||||
200 => 1,
|
||||
_ => 2,
|
||||
})
|
||||
.collect();
|
||||
let own: Vec<bool> = labels.iter().map(|&l| l != 2).collect();
|
||||
let q = settled_wide(Quantized { labels, palette }, &img, &own, false);
|
||||
assert_eq!(q.palette[usize::from(q.labels[8 * 17 + 8])], [90, 90, 90]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,6 +1330,115 @@ fn a_blend_of_two_colours_is_no_sliver_of_a_third() {
|
||||
assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}");
|
||||
}
|
||||
|
||||
/// A light disc of radius 32 at (55, 60) on a dark ground, its edge blurred into a ramp 10 px
|
||||
/// wide, and elsewhere a patch of the tone halfway between the two, so that the palette holds
|
||||
/// that tone. Returns the picture and whether a color is that tone.
|
||||
fn blurred_disc_and_halfway_patch() -> (Raster, impl Fn([u8; 3]) -> bool) {
|
||||
const DARK: [f64; 3] = [20.0, 25.0, 35.0];
|
||||
const LIGHT: [f64; 3] = [225.0, 195.0, 160.0];
|
||||
let mid = [0, 1, 2].map(|k| (DARK[k] + LIGHT[k]) / 2.0);
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let c = if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) {
|
||||
mid
|
||||
} else {
|
||||
let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|k| DARK[k] + t * (LIGHT[k] - DARK[k]))
|
||||
};
|
||||
let c = |k: usize| c[k].round() as u8;
|
||||
[c(0), c(1), c(2), 255]
|
||||
});
|
||||
(img, move |c: [u8; 3]| (0..3).all(|k| (f64::from(c[k]) - mid[k]).abs() <= 12.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blurred_edge_is_no_ring_of_a_tone_between_its_sides() {
|
||||
let (img, is_mid) = blurred_disc_and_halfway_patch();
|
||||
let res = trace(&img, &sixteen_colors());
|
||||
let mids: Vec<&TracedPath> = res.paths.iter().filter(|p| is_mid(p.color)).collect();
|
||||
assert_eq!(mids.len(), 1, "the patch, and no ring around the disc: {:?}", res.paths.iter().map(|p| p.color).collect::<Vec<_>>());
|
||||
let b = mids[0].path.bounds().unwrap();
|
||||
assert!(b.x0 > 110.0 && b.y0 > 38.0 && b.y1 < 82.0, "{b:?}");
|
||||
// The disc's edge lies halfway along the ramp.
|
||||
let light = res.paths.iter().find(|p| p.color[0] > 180).unwrap();
|
||||
let off = along(&light.path).iter().map(|p| (p.distance(Point::new(55.0, 60.0)) - 32.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off < 1.0, "{off} px off the middle of the ramp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_settles_a_tinted_blurred_edge_by_its_lightness() {
|
||||
// The same blurred disc and halfway patch in grays, and tinted green at the same lightness
|
||||
// (every pixel moved by the same color, which keeps its luma): Grayscale reads lightness
|
||||
// alone, so both make the same grays in the same places.
|
||||
let lightness = |x: u32, y: u32| {
|
||||
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
if (112.0..152.0).contains(&px) && (40.0..80.0).contains(&py) {
|
||||
130
|
||||
} else {
|
||||
let t = ((32.0 - Point::new(px, py).distance(Point::new(55.0, 60.0))) / 10.0 + 0.5).clamp(0.0, 1.0);
|
||||
(40.0 + 180.0 * t).round() as u8
|
||||
}
|
||||
};
|
||||
let gray = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = lightness(x, y);
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let tinted = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = lightness(x, y);
|
||||
[v - 40, v + 28, v - 40, 255]
|
||||
});
|
||||
let lumas = |img: &Raster| img.rgba.as_chunks::<4>().0.iter().map(|&p| crate::quantize::luma(p)).collect::<Vec<_>>();
|
||||
assert_eq!(lumas(&tinted), lumas(&gray));
|
||||
let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() };
|
||||
assert_eq!(quantize(&tinted, ¶ms), quantize(&gray, ¶ms));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_settles_a_blurred_edge_between_its_own_two_grays() {
|
||||
// A dark gray and a light gray meet in a ramp 12 px wide, and a black band runs across the top.
|
||||
// In grays every blend lies between black and the light gray as well as between the two grays
|
||||
// (to within rounding): the ramp's pixels just below the band take the nearer of the two grays,
|
||||
// the grays on either side of them.
|
||||
for (dark, light) in [(40u8, 200u8), (50, 200), (30, 170), (60, 230), (45, 215)] {
|
||||
let img = Raster::from_fn(160, 60, |x, y| {
|
||||
let v = if y < 8 {
|
||||
0
|
||||
} else {
|
||||
let t = ((f64::from(x) + 0.5 - 74.0) / 12.0).clamp(0.0, 1.0);
|
||||
(f64::from(dark) + f64::from(light - dark) * t).round() as u8
|
||||
};
|
||||
[v, v, v, 255]
|
||||
});
|
||||
let params = TraceParams { mode: Mode::Grayscale, colors: 8, ..TraceParams::default() };
|
||||
let q = quantize(&img, ¶ms);
|
||||
let middle = u16::from(dark) + u16::from(light);
|
||||
for y in 10..16 {
|
||||
for x in 74..86 {
|
||||
let v = img.rgba[(y * 160 + x) * 4];
|
||||
let g = q.palette[usize::from(q.labels[y * 160 + x])][0];
|
||||
assert_eq!(2 * u16::from(g) < middle, 2 * u16::from(v) < middle, "{dark} to {light}, ({x}, {y}): {v} took gray {g}");
|
||||
assert!(g >= dark, "{dark} to {light}, ({x}, {y}): {v} took gray {g}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_photo_presets_keep_the_tones_of_a_blurred_edge() {
|
||||
// The photo presets ask for more colors than the limited-color presets and keep the middle of
|
||||
// the ramp in the halfway tone: only a blend pixel with the colors on both sides within 2 px of
|
||||
// it takes a side.
|
||||
let (img, is_mid) = blurred_disc_and_halfway_patch();
|
||||
for name in ["High Fidelity Photo", "Low Fidelity Photo"] {
|
||||
let q = quantize(&img, &preset(name).unwrap());
|
||||
let middle = (0..160 * 120)
|
||||
.filter(|&i| (Point::new((i % 160) as f64 + 0.5, (i / 160) as f64 + 0.5).distance(Point::new(55.0, 60.0)) - 32.0).abs() < 1.0)
|
||||
.filter(|&i| q.palette.get(usize::from(q.labels[i])).is_some_and(|&c| is_mid(c)))
|
||||
.count();
|
||||
assert!(middle > 100, "{name}: {middle} pixels of the halfway tone in the middle of the ramp");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hairlines_keep_their_colour_in_color_mode() {
|
||||
// Black lines one pixel wide on white: every black pixel is on an edge, none is a blend.
|
||||
|
||||
@@ -156,6 +156,34 @@ under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, a
|
||||
four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a
|
||||
thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both
|
||||
sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side).
|
||||
## Blurred edges
|
||||
`settled` moved a blend pixel to a side of its edge only when the picture's own colors on both sides lay
|
||||
within 2 px of it, so the middle of an edge blurred over more than about 4 px kept the palette's
|
||||
in-between tones and traced as rings of thin paths around the edge. In Color with a Limited palette of
|
||||
at most 16 colors and in Grayscale with at most 16 grays, `cut_edge::settled_wide` now does the work, by
|
||||
VectorCraft's own rule (RGB distances, 0 to 255 a channel; Grayscale reads each pixel as its gray). A
|
||||
blend pixel with no own pixel of its color within 4 px pairs the 8 own colors within 8 px that are nearest
|
||||
to its color. It takes the nearer side of the pair it is best a blend of when that pair explains it twice
|
||||
as well as its own color, or when it lies between the two and off the line between them by less than 0.15
|
||||
of their distance apart. Of two pairs that fit equally well it takes the closer pair. Other palettes,
|
||||
High Fidelity Photo, Low Fidelity Photo and Black and White keep `settled`.
|
||||
Measured with the harness below on `main` at `77970c6924`, before and after the change (256 px variants
|
||||
unless stated): 16 Colors on blurred flat art 9.0 paths to 3.0, 158 anchors to 34, 0.239 px off to 0.069;
|
||||
6 Colors 6.0 paths to 3.0, 118 anchors to 33, 0.226 px off to 0.071; scanned, 16 Colors 8.0 paths to 3.0
|
||||
and 0.259 px off to 0.110, 6 Colors 7.5 paths to 3.0 and 0.235 px off to 0.093. On each preset's own
|
||||
inputs (section 1), 6 Colors went from 4.1 paths to 3.0 and 0.143 px off to 0.101, and 16 Colors from 4.6
|
||||
paths to 3.0 and 0.145 px off to 0.100. Auto Color does not change. Worse by more than 5% (25 of 211 rows
|
||||
that moved, most of them variants of the same inputs): askew shapes, odd px 11 to 22 with 6 Colors (paths
|
||||
4.0 to 4.3, odd colours 0.3 to 0.7), 23 to 26 with 3 Colors, 17 to 22 with 16 Colors and 27 to 30 with
|
||||
Shades of Gray; thin lines, missed max 23.5 to 45.3 px with 16 Colors, and with Shades of Gray paths 5.3 to
|
||||
7.3, anchors 36 to 41 and odd colours 3.0 to 5.0; linear gradients with 3 Colors, paths 3.0 to 3.3, and
|
||||
with 6 Colors with Gradients, paths 1.3 to 1.5 and anchors 13 to 16; two- and three-stop gradients with 6
|
||||
Colors with Gradients, paths 1.2 to 1.3; gradient and photo with 16 Colors, overlap px 0.0 to 0.2 and
|
||||
specks 0.8 to 1.0. The tables below are the previous measurement, not a rerun.
|
||||
Tests on code-drawn images: `tests::a_blurred_edge_is_no_ring_of_a_tone_between_its_sides` (a disc
|
||||
blurred into a 10 px ramp, 16 Colors), `tests::grayscale_settles_a_blurred_edge_between_its_own_two_grays`,
|
||||
`tests::grayscale_settles_a_tinted_blurred_edge_by_its_lightness`,
|
||||
`tests::the_photo_presets_keep_the_tones_of_a_blurred_edge` and `quantize::cut_edge::tests`.
|
||||
## Staircases in smooth areas
|
||||
Where a band border in a gradient or a shaded area runs at a slant, its pixels form a staircase whose
|
||||
steps are two or more pixels long. One piece of outline can run along a sharp edge in the image and then
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user