Files
vectorcraft/crates/trace/src/quantize/cut_edge.rs
T

316 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab};
use super::{Quantized, Raster, TRANSPARENT, luma, oklab};
/// `q` with its blend pixels settled, and the colours that leaves unused dropped.
///
/// A pixel on an edge is a blend of the colours on its two sides, and the palette colour nearest
/// to a blend can be a third colour altogether (halfway from navy to yellow lies an olive that is
/// nearer to a green elsewhere in the picture), which would trace as a sliver of that colour along
/// the edge. A blend pixel (see [`own_colours`]) whose colour is none of those of the picture's
/// own pixels within two pixels of it takes the side it is nearer to of the pair of those colours
/// it is best a blend of, when that explains it twice as well as its own colour does. With one
/// colour around it, it keeps its own.
pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[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 color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from));
let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt();
let mut changed = false;
for i in 0..w * h {
let mine_l = before[i];
if own[i] || mine_l == TRANSPARENT {
continue;
}
let (x, y) = (i % w, i / w);
// The colours of the picture's own pixels within two pixels (seldom more than three).
let mut near: Vec<u16> = Vec::new();
for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) {
for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) {
let j = yy * w + xx;
if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) {
near.push(before[j]);
}
}
}
let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l))
else {
continue;
};
if near.len() < 2 || near.contains(&mine_l) {
continue;
}
let mut best: Option<(f64, u16)> = None;
for (n, &a) in near.iter().enumerate() {
for &b in &near[n + 1..] {
let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue };
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
let len2: f64 = d.iter().map(|v| v * v).sum();
let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 };
let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k]));
if best.is_none_or(|x| off < x.0) {
best = Some((off, if t < 0.5 { a } else { b }));
}
}
}
if let Some((off, to)) = best
&& off < 0.5 * dist(p, mine)
{
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
/// of every pixel; a photo's tones spread all through its range, and eight colours stay far from
/// most of them (more than 6 levels, and more than a tenth of how far a single colour stays).
pub(crate) fn continuous_tone(img: &Raster) -> bool {
let hk = HistKey::new(img);
let mut bins = vec![Bin::default(); 1 << 15];
for p in img.rgba.as_chunks::<4>().0 {
if p[3] >= 128 {
bins[hk.key(p[0], p[1], p[2])].add(p);
}
}
let used: Vec<Used> = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect();
// The root-mean-square distance (levels) from the pixels to the nearest of `k` colours.
let unsaid = |k: usize| {
let colors = centers(&median_cut(&used, k, 0.0));
let (mut sum, mut n) = (0.0, 0.0);
for (_, c, weight) in &used {
let d2 = colors.iter().map(|m| (0..3).map(|ch| (m[ch] - c[ch]).powi(2)).sum::<f64>()).fold(f64::INFINITY, f64::min);
sum += d2 * weight;
n += weight;
}
if n > 0.0 { (sum / n).sqrt() } else { 0.0 }
};
let eight = unsaid(8);
eight > 6.0 && eight > 0.1 * unsaid(1)
}
/// Drop unused palette entries and merge duplicates.
pub(super) fn compact(mut labels: Vec<u16>, palette: Vec<[u8; 3]>) -> Quantized {
let mut used = vec![false; palette.len()];
for &l in &labels {
if l != TRANSPARENT {
used[l as usize] = true;
}
}
let mut remap = vec![0u16; palette.len()];
let mut out: Vec<[u8; 3]> = vec![];
for (i, c) in palette.iter().enumerate() {
if !used[i] {
continue;
}
remap[i] = match out.iter().position(|o| o == c) {
Some(j) => j as u16,
None => {
out.push(*c);
(out.len() - 1) as u16
}
};
}
for l in &mut labels {
if *l != TRANSPARENT {
*l = remap[*l as usize];
}
}
Quantized { labels, palette: out }
}
/// `q` with the semi-transparent pixels along the cut edge of `img` given the color beside them,
/// and the colors that leaves unused dropped.
///
/// A traced pixel with an alpha of 128 to 254 and a pixel above, below, left or right of it with an
/// alpha under 128 is on the cut edge. When its color is the color of a fully opaque pixel within
/// two pixels of it, or a darker shade of that color (see [`edge_blend_of`]), it is that color
/// blended with the transparent background, and its own color can lie nearest to an unrelated
/// palette color, which would trace as a sliver of that color along the edge. Such a pixel takes
/// the color that most of those opaque pixels have, and of colors that tie, the one nearest to its
/// own color. Only the pixels it reaches through pixels of an alpha of 128 or more, going up, down,
/// left or right, count, so that art across a transparent gap is left out. It keeps its own color
/// when one of the fully opaque pixels it reaches has it, or when its color is a blend of none of
/// them, as with a thin semi-transparent outline of another color. Pixels outside the image do not
/// count as transparent. With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]).
pub(crate) fn settle_cut_edges(q: &mut Quantized, img: &Raster, gray: bool) {
let (w, h) = (img.width as usize, img.height as usize);
let px = img.rgba.as_chunks::<4>().0;
if q.labels.len() != w * h || px.len() != w * h {
return;
}
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 || !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 mut near: Vec<(u16, usize)> = Vec::new();
let mut keep = false;
reached_opaque(img, (i % w, i / w), &mut stack, |j| {
let l = q.labels[j];
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) {
Some(n) => n.1 += 1,
None => near.push((l, 1)),
}
}
}
});
if keep || near.is_empty() {
continue;
}
let most = near.iter().map(|n| n.1).max().unwrap_or(0);
let tied: Vec<(u16, [f64; 3])> =
near.iter().filter(|n| n.1 == most).filter_map(|n| Some((n.0, oklab(q.palette.get(n.0 as usize)?.map(f64::from))))).collect();
let tied_labs: Vec<[f64; 3]> = tied.iter().map(|t| t.1).collect();
let Some(&(to, _)) = tied.get(nearest_lab(&tied_labs, own)) else { continue };
q.labels[i] = to;
changed = true;
}
if changed {
*q = compact(std::mem::take(&mut q.labels), std::mem::take(&mut q.palette));
}
}
/// 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`].
pub(super) 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.
const EDGE_BLEND: f64 = 8.0;
/// Whether `own` (Oklab), the color of a pixel with an alpha of `alpha`, is `beside` (sRGB) blended
/// with the transparent background: whether it lies within [`EDGE_BLEND`] of the line in Oklab from
/// `beside` to `beside` with each channel scaled by `alpha` / 510.
fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool {
let to = oklab(beside.map(f64::from));
let k = f64::from(alpha) / 510.0;
let from = oklab(beside.map(|v| f64::from(v) * k));
let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let len2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let t = if len2 > 0.0 { ((0..3).map(|c| (own[c] - from[c]) * d[c]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 };
let off2: f64 = (0..3).map(|c| (own[c] - from[c] - t * d[c]).powi(2)).sum();
off2 <= EDGE_BLEND * EDGE_BLEND
}