mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Image Trace: a JPEG's 8 x 8 blocks no longer trace as staircase steps
A JPEG rounds each 8 x 8 block's tone on its own, which leaves a small step at every block edge. Where a dark ground changes slowly, those steps decided which of two palette colors a whole block got, and the trace followed the block edges as a staircase. The new deblock pass finds the block grid in the image: the mean step between neighboring opaque pixels must peak at the same one of eight positions at both block edges of a 16 px span, across and down. Cropped JPEGs and JPEGs saved in another format are found the same way; pictures of flat 8 x 8 cells are not. At each block edge, a stretch of eight lines whose step is under the limit and whose pixels hold no edge has the step spread over the eight pixels around the edge, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Larger steps are kept. The limit, the edge guard and how much of each line's own step is smoothed depend on the mode: Black and White smooths only the smallest steps, and traces of more than 16 colors move every line of a stretch by the same amount. The pass runs before the photo pre-filter and works in strips of rows, so only its output is as large as the image.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
//! The 8 x 8 block edges of a block-coded image (a JPEG, or a copy of one saved in another format)
|
||||
//! smoothed away before it is traced.
|
||||
//!
|
||||
//! A JPEG codes each 8 x 8 block on its own and rounds each block's brightness in coarse steps, which
|
||||
//! leaves a small step at every block edge. Where an area's tone changes slowly, as a dark ground does,
|
||||
//! those steps decide which of two palette colors a whole block gets, and the trace follows the block
|
||||
//! edges as a staircase. The block grid is found in the image itself: the mean step between
|
||||
//! neighboring pixels peaks at one position in every eight, across and down, at every block edge and
|
||||
//! not only at every other one (a pattern of 16 px squares has that too). Where it does, the step
|
||||
//! at each block edge is spread evenly over the eight pixels around it when it is small and those
|
||||
//! pixels hold no edge. Larger steps are left as they are. How large a step is smoothed, and whether
|
||||
//! each row keeps part of its own step, depends on the trace's mode and number of colors (see
|
||||
//! [`Strength`]). The deblocking filters of ITU-T H.263
|
||||
//! Annex J and ISO/IEC 14496-2 (MPEG-4 Part 2) Annex F.3.1 treat block edges the same way: small steps
|
||||
//! between blocks become ramps, and large ones are kept.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::{Raster, parallel_map, quantize};
|
||||
|
||||
/// The block size of a JPEG.
|
||||
const BLOCK: usize = 8;
|
||||
/// Steps between neighboring pixels of this many levels or more are edges, left out when the grid is
|
||||
/// measured. No block-edge step this large is smoothed.
|
||||
const EDGE: f64 = 16.0;
|
||||
/// The mean step at the block edges must be this many times the mean step at the other positions,
|
||||
/// across and down, for the image to count as block-coded.
|
||||
const PEAK: f64 = 1.5;
|
||||
/// The smallest limit on the block-edge steps that are smoothed (levels, after the image's own slope
|
||||
/// is taken out); the limit is under `EDGE` too.
|
||||
const LEAST: f64 = 2.5;
|
||||
/// Fixed-point scale of the working copy: sixteenths of a level.
|
||||
const SCALE: f64 = 16.0;
|
||||
|
||||
/// How the block edges are smoothed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Strength {
|
||||
/// The largest block-edge step that is smoothed: this many times the extra step the block edges
|
||||
/// show on average, at least `LEAST` and under `EDGE`.
|
||||
reach: f64,
|
||||
/// A block edge with a step of this many levels or more between neighbors in the pixels around
|
||||
/// it is left as it is.
|
||||
guard: f64,
|
||||
/// How far one row's step may differ from its stretch's mean step and still be smoothed, as a
|
||||
/// share of the limit. The rest of a row's own step is kept.
|
||||
row_share: f64,
|
||||
}
|
||||
|
||||
/// For a trace in black and white, which thresholds the image without smoothing it first: small
|
||||
/// steps only, each row with its own.
|
||||
pub(crate) const BLACK_AND_WHITE: Strength = Strength { reach: 2.0, guard: 8.0, row_share: 0.5 };
|
||||
/// For a trace of up to 16 colors: each row with its own step.
|
||||
pub(crate) const FEW_COLORS: Strength = Strength { reach: 8.0, guard: 13.0, row_share: 0.5 };
|
||||
/// For a trace of more than 16 colors: larger steps, but only each stretch's mean step, so that
|
||||
/// every row of a stretch moves by the same amount.
|
||||
pub(crate) const MANY_COLORS: Strength = Strength { reach: 4.0, guard: EDGE, row_share: 0.0 };
|
||||
|
||||
/// Where the block edges of a block-coded image are, and how large their steps are.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Grid {
|
||||
/// A block edge lies between columns `x - 1` and `x` when `x % 8 == across`.
|
||||
across: usize,
|
||||
/// A block edge lies between rows `y - 1` and `y` when `y % 8 == down`.
|
||||
down: usize,
|
||||
/// The extra step the block edges show on average over the other positions, across or down,
|
||||
/// whichever is smaller.
|
||||
extra: f64,
|
||||
}
|
||||
|
||||
/// Positions counted when the grid is measured: two blocks, so that a pattern of 16 px or more
|
||||
/// (a checkerboard, cells of a picture enlarged 16 times), whose edges fall on every other block
|
||||
/// edge only, is not taken for blocks.
|
||||
const SPAN: usize = 2 * BLOCK;
|
||||
|
||||
/// For one direction, from the mean step between neighboring opaque pixels at each of 16 positions:
|
||||
/// the position (of eight) where it peaks at both block edges of the span, the extra step those
|
||||
/// show over the other positions, and whether there is no step at all at the other positions; `None`
|
||||
/// when no position stands out.
|
||||
fn peak(sum: &[f64; SPAN], count: &[u64; SPAN]) -> Option<(usize, f64, bool)> {
|
||||
if count.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let mean: Vec<f64> = sum.iter().zip(count).map(|(&s, &n)| s / n as f64).collect();
|
||||
let pair = |at: usize| (mean.get(at).copied().unwrap_or(0.0), mean.get(at + BLOCK).copied().unwrap_or(0.0));
|
||||
let at = (0..BLOCK).max_by(|&a, &b| {
|
||||
let (pa, pb) = (pair(a), pair(b));
|
||||
pa.0.min(pa.1).total_cmp(&pb.0.min(pb.1))
|
||||
})?;
|
||||
let (first, second) = pair(at);
|
||||
let others = || mean.iter().enumerate().filter(|&(i, _)| i != at && i != at + BLOCK).map(|(_, &m)| m);
|
||||
let rest = others().sum::<f64>() / (SPAN - 2) as f64;
|
||||
(first.min(second) > PEAK * rest).then_some((at, (first + second) / 2.0 - rest, others().all(|m| m == 0.0)))
|
||||
}
|
||||
|
||||
/// The block grid of `img`, if it has one.
|
||||
fn grid(img: &Raster) -> Option<Grid> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w < 2 * SPAN || h < 2 * SPAN || img.rgba.len() != w * h * 4 {
|
||||
return None;
|
||||
}
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let luma = |p: &[u8; 4]| (p[3] == 255).then(|| f64::from(quantize::luma(*p)));
|
||||
let (mut across, mut down) = (([0.0; SPAN], [0u64; SPAN]), ([0.0; SPAN], [0u64; SPAN]));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let Some(here) = px.get(y * w + x).and_then(luma) else { continue };
|
||||
let add = |bins: &mut ([f64; SPAN], [u64; SPAN]), at: usize, other: Option<f64>| {
|
||||
if let Some(step) = other.map(|o| (here - o).abs()).filter(|&s| s < EDGE) {
|
||||
bins.0[at % SPAN] += step;
|
||||
bins.1[at % SPAN] += 1;
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
add(&mut across, x, px.get(y * w + x - 1).and_then(luma));
|
||||
}
|
||||
if y > 0 {
|
||||
add(&mut down, y, px.get((y - 1) * w + x).and_then(luma));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (across, extra_across, flat_across) = peak(&across.0, &across.1)?;
|
||||
let (down, extra_down, flat_down) = peak(&down.0, &down.1)?;
|
||||
// Blocks that are each one flat color, with no step at all inside them, are cells of a picture
|
||||
// enlarged eight times, which stay as they are.
|
||||
if flat_across && flat_down {
|
||||
return None;
|
||||
}
|
||||
Some(Grid { across, down, extra: extra_across.min(extra_down) })
|
||||
}
|
||||
|
||||
/// Smooths one stretch of a block edge in `band` (values in sixteenths of a level, 3 channels per
|
||||
/// pixel): `lines` (1 to 8) lines that cross the edge, the pixel at position `i` (0 to 7; the edge lies between
|
||||
/// 3 and 4) of line `l` starting at `band[index(l, i)]`, where `opaque(l, i)` says whether it is fully
|
||||
/// opaque. The stretch's step is the mean over its lines of the step across the edge less the slope
|
||||
/// on either side. A stretch of opaque pixels whose step is under `limit` on every channel, with no
|
||||
/// step of `strength.guard` or more between neighbors in its pixels (across, the edge itself aside, or
|
||||
/// along), has that step spread over the eight pixels of each line: with the line's own difference
|
||||
/// from it, to at most `strength.row_share` of `limit` either way, each pixel moves by the step times
|
||||
/// 3.5/8, 2.5/8, 1.5/8 or 0.5/8 by its distance from the edge, so the pixels in the middle of the
|
||||
/// blocks hardly move.
|
||||
fn smooth_edge(
|
||||
band: &mut [i16],
|
||||
lines: usize,
|
||||
index: impl Fn(usize, usize) -> usize,
|
||||
opaque: impl Fn(usize, usize) -> bool,
|
||||
limit: f64,
|
||||
strength: Strength,
|
||||
) {
|
||||
if lines == 0 || lines > BLOCK || !(0..lines).all(|l| (0..BLOCK).all(|i| opaque(l, i))) {
|
||||
return;
|
||||
}
|
||||
let at = |l: usize, i: usize, k: usize| f64::from(band.get(index(l, i) + k).copied().unwrap_or(0)) / SCALE;
|
||||
// Each line's step on each channel, and the largest step within the pixels around the edge.
|
||||
let mut steps = [[0.0; 3]; BLOCK];
|
||||
let steps = steps.get_mut(..lines).unwrap_or_default();
|
||||
let mut inner = 0.0f64;
|
||||
for (l, step) in steps.iter_mut().enumerate() {
|
||||
for (k, s) in step.iter_mut().enumerate() {
|
||||
let p: [f64; BLOCK] = std::array::from_fn(|i| at(l, i, k));
|
||||
*s = (p[4] - p[3]) - ((p[3] - p[2]) + (p[5] - p[4])) / 2.0;
|
||||
for i in (0..BLOCK - 1).filter(|&i| i != 3) {
|
||||
inner = inner.max((p[i + 1] - p[i]).abs());
|
||||
}
|
||||
// And from this line to the next: a line along the edge is an edge too.
|
||||
if l + 1 < lines {
|
||||
for (i, &here) in p.iter().enumerate() {
|
||||
inner = inner.max((at(l + 1, i, k) - here).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mean: [f64; 3] = std::array::from_fn(|k| steps.iter().map(|s| s[k]).sum::<f64>() / lines as f64);
|
||||
if inner >= strength.guard || mean.iter().any(|m| m.abs() >= limit) {
|
||||
return;
|
||||
}
|
||||
let own = limit * strength.row_share;
|
||||
for (l, step) in steps.iter().enumerate() {
|
||||
for k in 0..3 {
|
||||
let step = mean[k] + (step[k] - mean[k]).clamp(-own, own);
|
||||
for i in 0..BLOCK / 2 {
|
||||
let shift = (step * (3.5 - i as f64) / BLOCK as f64 * SCALE).round() as i16;
|
||||
if let Some(p) = band.get_mut(index(l, 3 - i) + k) {
|
||||
*p = p.saturating_add(shift);
|
||||
}
|
||||
if let Some(p) = band.get_mut(index(l, 4 + i) + k) {
|
||||
*p = p.saturating_sub(shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the stretches of eight lines start along a block edge `len` lines long, when they start at
|
||||
/// the lines with `l % 8 == at`: the first, from line 0, can be shorter. Each as (first line, count).
|
||||
fn stretches(len: usize, at: usize) -> Vec<(usize, usize)> {
|
||||
let mut out: Vec<(usize, usize)> = if at > 0 { vec![(0, at.min(len))] } else { vec![] };
|
||||
out.extend((at..len).step_by(BLOCK).map(|s| (s, BLOCK.min(len - s))));
|
||||
out
|
||||
}
|
||||
|
||||
/// `f` of each of `bands` (with what it needs to know about the band), which it may change, spread
|
||||
/// over the available cores.
|
||||
fn each_band<K: Copy + Send, T: Send>(bands: Vec<(K, &mut [T])>, f: impl Fn(K, &mut [T]) + Sync) {
|
||||
let bands: Vec<Mutex<(K, &mut [T])>> = bands.into_iter().map(Mutex::new).collect();
|
||||
parallel_map(&bands, |band| {
|
||||
// Each band is locked by one worker only, so the lock is never held elsewhere.
|
||||
if let Ok(mut band) = band.lock() {
|
||||
let (first, values) = &mut *band;
|
||||
f(*first, values);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The first block edge (of positions `at`, `at + 8`, ...) with four pixels before it.
|
||||
fn first_edge(at: usize) -> usize {
|
||||
if at >= BLOCK / 2 { at } else { at + BLOCK }
|
||||
}
|
||||
|
||||
/// Smooths the block edges of `v`, rows `top` on of the image (`w` pixels of 3 channels, in
|
||||
/// sixteenths of a level, from the first row of a stretch of rows to the last row of one), where
|
||||
/// `opaque(y, x)` is set: the edges between columns `x - 1` and `x` with `x % 8 == across` in stretches
|
||||
/// of eight rows that start at the rows with `y % 8 == down`, then the edges between rows `e - 1` and
|
||||
/// `e` for each `e` of `edges` in stretches of eight columns that start at the columns with
|
||||
/// `x % 8 == across` (see `smooth_edge`).
|
||||
fn smooth_strip(
|
||||
v: &mut [i16],
|
||||
top: usize,
|
||||
w: usize,
|
||||
grid: &Grid,
|
||||
edges: &[usize],
|
||||
opaque: impl Fn(usize, usize) -> bool,
|
||||
(limit, strength): (f64, Strength),
|
||||
) {
|
||||
let row = w * 3;
|
||||
if w < BLOCK || row == 0 {
|
||||
return;
|
||||
}
|
||||
for (at, rows) in stretches(v.len() / row, (grid.down + BLOCK - top % BLOCK) % BLOCK) {
|
||||
let Some(band) = v.get_mut(at * row..(at + rows) * row) else { continue };
|
||||
for e in (first_edge(grid.across)..=w - BLOCK / 2).step_by(BLOCK) {
|
||||
smooth_edge(band, rows, |l, i| (l * w + e - BLOCK / 2 + i) * 3, |l, i| opaque(top + at + l, e - BLOCK / 2 + i), limit, strength);
|
||||
}
|
||||
}
|
||||
for &e in edges {
|
||||
let Some(band) = e.checked_sub(top + BLOCK / 2).and_then(|at| v.get_mut(at * row..(at + BLOCK) * row)) else { continue };
|
||||
for (left, columns) in stretches(w, grid.across) {
|
||||
smooth_edge(band, columns, |l, i| (i * w + left + l) * 3, |l, i| opaque(e - BLOCK / 2 + i, left + l), limit, strength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges between rows smoothed in one piece of work, with the stretches of rows around them.
|
||||
const STRIP: usize = 8;
|
||||
|
||||
/// `img` with its block edges smoothed away as `strength` says, or `None` when it shows no block grid.
|
||||
/// The image is worked on in strips of rows, each with the stretches of rows its `STRIP` edges between
|
||||
/// rows need, so only the copy it returns is as large as the image.
|
||||
pub(crate) fn deblocked(img: &Raster, strength: Strength) -> Option<Raster> {
|
||||
let grid = grid(img)?;
|
||||
let limit = (strength.reach * grid.extra).clamp(LEAST, EDGE);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let opaque = |y: usize, x: usize| px.get(y * w + x).is_some_and(|p| p[3] == 255);
|
||||
let edges: Vec<usize> = (first_edge(grid.down)..=h.saturating_sub(BLOCK / 2)).step_by(BLOCK).collect();
|
||||
let groups: Vec<&[usize]> = edges.chunks(STRIP).collect();
|
||||
// The rows each strip writes: from four rows above its first edge (the top of the image for the
|
||||
// first strip) to four rows below its last (the bottom of the image for the last strip).
|
||||
let mut rgba = img.rgba.clone();
|
||||
let mut bands = vec![];
|
||||
let mut rest: &mut [u8] = &mut rgba;
|
||||
let mut above = 0;
|
||||
for (n, group) in groups.iter().enumerate() {
|
||||
let below = if n + 1 == groups.len() { h } else { group.last().map_or(h, |e| e + BLOCK / 2) };
|
||||
let (band, after) = std::mem::take(&mut rest).split_at_mut_checked(below.checked_sub(above)? * w * 4)?;
|
||||
bands.push(((above, *group), band));
|
||||
rest = after;
|
||||
above = below;
|
||||
}
|
||||
// The first row of the stretch of rows that holds row `y`, and the row after the stretch.
|
||||
let stretch = |y: usize| {
|
||||
let first = if y < grid.down { 0 } else { y - (y - grid.down) % BLOCK };
|
||||
(first, if y < grid.down { grid.down } else { first + BLOCK }.min(h))
|
||||
};
|
||||
each_band(bands, |(above, group), band| {
|
||||
let below = above + band.len() / (w * 4);
|
||||
let (first, last) = (stretch(above).0, stretch(below.saturating_sub(1)).1);
|
||||
let Some(source) = px.get(first * w..last * w) else { return };
|
||||
let mut v: Vec<i16> = source.iter().flat_map(|p| [p[0], p[1], p[2]].map(|c| i16::from(c) * SCALE as i16)).collect();
|
||||
smooth_strip(&mut v, first, w, &grid, group, opaque, (limit, strength));
|
||||
let skip = (above - first) * w;
|
||||
for (p, c) in band.as_chunks_mut::<4>().0.iter_mut().zip(v.as_chunks::<3>().0.iter().skip(skip)) {
|
||||
for k in 0..3 {
|
||||
p[k] = ((i32::from(c[k]) + 8).div_euclid(16)).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(Raster { width: img.width, height: img.height, rgba })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A number in -1..1 that looks random, from `x`, `y` and `k`.
|
||||
fn jitter(x: u32, y: u32, k: u32) -> f64 {
|
||||
let mut n = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ k.wrapping_mul(0xC2B2_AE3D);
|
||||
n ^= n >> 15;
|
||||
n = n.wrapping_mul(0x2C1B_3C6D);
|
||||
n ^= n >> 12;
|
||||
f64::from(n % 2001) / 1000.0 - 1.0
|
||||
}
|
||||
|
||||
/// A dark ground that brightens slowly to the right, with a level of noise and, when `blocks`,
|
||||
/// every 8 x 8 block (counted from `(dx, dy)`) moved by up to 2 levels, as a JPEG leaves it.
|
||||
fn ground(w: u32, h: u32, blocks: bool, dx: u32, dy: u32) -> Raster {
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let offset = if blocks { 2.0 * jitter((x + dx) / 8, (y + dy) / 8, 9) } else { 0.0 };
|
||||
let v = 20.0 + 30.0 * f64::from(x) / f64::from(w) + offset + jitter(x, y, 0);
|
||||
let v = v.round().clamp(0.0, 255.0) as u8;
|
||||
[v, v, v, 255]
|
||||
})
|
||||
}
|
||||
|
||||
/// The mean step between neighboring columns at the block edges (`x % 8 == across`) and elsewhere.
|
||||
fn steps(img: &Raster, across: u32) -> (f64, f64) {
|
||||
let (mut on, mut off) = ((0.0, 0), (0.0, 0));
|
||||
for y in 0..img.height {
|
||||
for x in 1..img.width {
|
||||
let d = (f64::from(img.pixel(x, y)[0]) - f64::from(img.pixel(x - 1, y)[0])).abs();
|
||||
let bin = if x % 8 == across { &mut on } else { &mut off };
|
||||
bin.0 += d;
|
||||
bin.1 += 1;
|
||||
}
|
||||
}
|
||||
(on.0 / f64::from(on.1), off.0 / f64::from(off.1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_grid_is_found_where_the_blocks_are() {
|
||||
let g = grid(&ground(160, 120, true, 0, 0)).unwrap();
|
||||
assert_eq!((g.across, g.down), (0, 0));
|
||||
// The same image with 3 columns and 5 rows cut off: its block edges are at x % 8 == 5, y % 8 == 3.
|
||||
let g = grid(&ground(160, 120, true, 3, 5)).unwrap();
|
||||
assert_eq!((g.across, g.down), (5, 3));
|
||||
assert!(g.extra > 0.5, "{}", g.extra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_without_blocks_is_left_as_it_is() {
|
||||
assert_eq!(grid(&ground(160, 120, false, 0, 0)), None);
|
||||
assert!(deblocked(&ground(160, 120, false, 0, 0), FEW_COLORS).is_none());
|
||||
// A picture of four flat colors enlarged eight times has steps only at its cells' edges, and
|
||||
// none within them; two of its colors are 6 levels apart.
|
||||
let colors = [[30, 30, 36, 255], [36, 30, 30, 255], [200, 60, 40, 255], [240, 220, 120, 255]];
|
||||
let cells = Raster::from_fn(160, 120, |x, y| colors[((jitter(x / 8, y / 8, 1) + 1.0) * 2.0).min(3.0) as usize]);
|
||||
assert_eq!(grid(&cells), None);
|
||||
// So does a picture of many shades enlarged eight times, as a photo's would.
|
||||
let shades = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = (40.0 + 0.6 * f64::from(x / 8 * 8) + 0.5 * f64::from(y / 8 * 8) + 3.0 * jitter(x / 8, y / 8, 4)).round() as u8;
|
||||
[v, v.saturating_sub(10), v.saturating_add(10), 255]
|
||||
});
|
||||
assert_eq!(grid(&shades), None);
|
||||
// A noisy checkerboard of 16 px squares 10 levels apart has its steps on every other block edge.
|
||||
let squares = Raster::from_fn(160, 120, |x, y| {
|
||||
let v = (20.0 + 10.0 * f64::from((x / 16 + y / 16) % 3) + 4.0 * jitter(x, y, 2)).round() as u8;
|
||||
[v, v, v, 255]
|
||||
});
|
||||
assert_eq!(grid(&squares), None);
|
||||
// Too small to hold two spans of 16 px each way.
|
||||
assert_eq!(grid(&ground(31, 120, true, 0, 0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steps_at_block_edges_become_ramps() {
|
||||
for ((dx, dy), strength) in [((0, 0), FEW_COLORS), ((3, 5), FEW_COLORS), ((0, 0), MANY_COLORS), ((0, 0), BLACK_AND_WHITE)] {
|
||||
let img = ground(160, 120, true, dx, dy);
|
||||
let across = (8 - dx % 8) % 8;
|
||||
let (on, off) = steps(&img, across);
|
||||
assert!(on > 2.0 * off, "before: {on} at the block edges, {off} elsewhere");
|
||||
let out = deblocked(&img, strength).unwrap();
|
||||
let (on, off) = steps(&out, across);
|
||||
assert!(on < 1.25 * off, "after: {on} at the block edges, {off} elsewhere");
|
||||
// The ground's own slope stays: 30 levels across the image.
|
||||
let mean = |r: &Raster, x: u32| (0..r.height).map(|y| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(r.height);
|
||||
assert!((mean(&out, 156) - mean(&out, 4) - (mean(&img, 156) - mean(&img, 4))).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretches_cover_every_line_once() {
|
||||
assert_eq!(stretches(20, 0), [(0, 8), (8, 8), (16, 4)]);
|
||||
assert_eq!(stretches(20, 3), [(0, 3), (3, 8), (11, 8), (19, 1)]);
|
||||
assert_eq!(stretches(2, 3), [(0, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_many_colors_every_row_of_a_stretch_moves_alike() {
|
||||
let img = ground(160, 120, true, 0, 0);
|
||||
let v: Vec<i16> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]].map(|c| i16::from(c) * SCALE as i16)).collect();
|
||||
// How far each pixel moves when the edges between columns are smoothed.
|
||||
let shifts = |strength: Strength| -> Vec<i16> {
|
||||
let mut out = v.clone();
|
||||
for top in (0..120).step_by(8) {
|
||||
let band = &mut out[top * 160 * 3..(top + 8) * 160 * 3];
|
||||
for e in (8..=156).step_by(8) {
|
||||
smooth_edge(band, 8, |l, i| (l * 160 + e - 4 + i) * 3, |_, _| true, 4.0, strength);
|
||||
}
|
||||
}
|
||||
out.iter().zip(&v).map(|(o, i)| o - i).step_by(3).collect()
|
||||
};
|
||||
let (many, few) = (shifts(MANY_COLORS), shifts(FEW_COLORS));
|
||||
let differ = |s: &[i16]| {
|
||||
(0..160)
|
||||
.flat_map(|x| (0..120).step_by(8).map(move |top| (x, top)))
|
||||
.filter(|&(x, top)| (top..top + 8).any(|y| s[y * 160 + x] != s[top * 160 + x]))
|
||||
.count()
|
||||
};
|
||||
assert!(many.iter().filter(|&&s| s != 0).count() > 160 * 120 / 2);
|
||||
assert_eq!(differ(&many), 0);
|
||||
// With few colors, each row keeps part of its own step.
|
||||
assert!(differ(&few) > 160 * 15 / 2, "{}", differ(&few));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_fine_lines_stay_sharp() {
|
||||
// Black lines 1 to 4 px wide and white gaps between them on a block-coded mid-gray ground, one
|
||||
// line on a block edge.
|
||||
let base = ground(160, 120, true, 0, 0);
|
||||
let lines = [(16, 1), (31, 2), (40, 3), (61, 4)];
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
if lines.iter().any(|&(at, wide)| (at..at + wide).contains(&x)) {
|
||||
[0, 0, 0, 255]
|
||||
} else {
|
||||
let v = base.pixel(x, y)[0] + 80;
|
||||
[v, v, v, 255]
|
||||
}
|
||||
});
|
||||
let out = deblocked(&img, FEW_COLORS).unwrap();
|
||||
for y in 0..120 {
|
||||
for &(at, wide) in &lines {
|
||||
for x in at..at + wide {
|
||||
assert_eq!(out.pixel(x, y), [0, 0, 0, 255], "line pixel ({x}, {y})");
|
||||
}
|
||||
// The ground beside a line moves by no more than a block's offset (the edges between
|
||||
// rows in its column are smoothed; the one across the line is not).
|
||||
for x in [at - 1, at + wide] {
|
||||
let moved = (i32::from(out.pixel(x, y)[0]) - i32::from(img.pixel(x, y)[0])).abs();
|
||||
assert!(moved <= 3, "beside the line, ({x}, {y}) moved {moved}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_keep_their_colors() {
|
||||
let base = ground(160, 120, true, 0, 0);
|
||||
let img = Raster::from_fn(160, 120, |x, y| {
|
||||
let mut p = base.pixel(x, y);
|
||||
if x >= 100 {
|
||||
p[3] = if x < 104 { 128 } else { 0 };
|
||||
}
|
||||
p
|
||||
});
|
||||
let out = deblocked(&img, FEW_COLORS).unwrap();
|
||||
for y in 0..120 {
|
||||
for x in 100..160 {
|
||||
assert_eq!(out.pixel(x, y), img.pixel(x, y), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert_eq!(out.rgba.chunks(4).map(|p| p[3]).collect::<Vec<_>>(), img.rgba.chunks(4).map(|p| p[3]).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
mod centerline;
|
||||
mod contour;
|
||||
mod deblock;
|
||||
mod fit;
|
||||
mod gradient;
|
||||
mod group;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use super::layers::{Layers, parallel_map_at_most};
|
||||
use super::params::{Method, Mode, TraceParams};
|
||||
use super::raster::{Raster, TraceError};
|
||||
use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, gradient, group, logo, quantize, smooth};
|
||||
use super::{AtomicUsize, PathData, Quantized, TRANSPARENT, TracedGradient, TracedShape, deblock, gradient, group, logo, quantize, smooth};
|
||||
|
||||
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
|
||||
/// lines stroked with it.
|
||||
@@ -209,9 +209,21 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize, steps: &(
|
||||
Some(name) if params.ignore_white => Some(logo::parse_hex(name).ok_or_else(|| TraceError::BadPalette(name.clone()))?),
|
||||
_ => None,
|
||||
};
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`).
|
||||
let smoothed = (params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img)).then(|| smooth::smoothed(img));
|
||||
let img = smoothed.as_ref().unwrap_or(img);
|
||||
// A JPEG's 8 x 8 block edges are smoothed away first (see `deblock`).
|
||||
let strength = match params.mode {
|
||||
Mode::BlackAndWhite => deblock::BLACK_AND_WHITE,
|
||||
_ if params.colors > 16 => deblock::MANY_COLORS,
|
||||
_ => deblock::FEW_COLORS,
|
||||
};
|
||||
// A photo is traced from a copy with its noise smoothed away (see `smooth`); the deblocked copy is
|
||||
// dropped once it is smoothed.
|
||||
let photo = |img: &Raster| params.mode != Mode::BlackAndWhite && quantize::continuous_tone(img);
|
||||
let prepared = match deblock::deblocked(img, strength) {
|
||||
Some(deblocked) if photo(&deblocked) => Some(smooth::smoothed(&deblocked)),
|
||||
Some(deblocked) => Some(deblocked),
|
||||
None => photo(img).then(|| smooth::smoothed(img)),
|
||||
};
|
||||
let img = prepared.as_ref().unwrap_or(img);
|
||||
go_on()?;
|
||||
// Gradients are found first and kept out of the palette: no colour is spent on bands of them.
|
||||
// Grayscale finds them in the image's grays (as its flat colours are), so their stops are gray.
|
||||
|
||||
@@ -2086,6 +2086,74 @@ fn small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` saved as a JPEG of `quality` and read back.
|
||||
fn through_jpeg(img: &Raster, quality: u8) -> Raster {
|
||||
let rgb: Vec<u8> = img.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let mut bytes = vec![];
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality)
|
||||
.encode(&rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
|
||||
.unwrap();
|
||||
Raster::decode(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// The straight segments of `res`' paths that run across or down, at least 2 px long: for each, whether
|
||||
/// it runs down, its column (down) or row (across), its length, and whether it is a step's riser, the
|
||||
/// segments before and after it both running the other way and turning the path the same way.
|
||||
fn axis_segments(res: &TraceResult) -> Vec<(bool, f64, f64, bool)> {
|
||||
let mut out = vec![];
|
||||
for sp in res.paths.iter().flat_map(|p| &p.path.subpaths) {
|
||||
let n = sp.anchors.len();
|
||||
let seg = |i: usize| {
|
||||
let (a, b) = (&sp.anchors[i % n], &sp.anchors[(i + 1) % n]);
|
||||
let straight = a.h_out.distance(a.p) < 1e-6 && b.h_in.distance(b.p) < 1e-6;
|
||||
let (dx, dy) = (b.p.x - a.p.x, b.p.y - a.p.y);
|
||||
// 1 across, 2 down, 0 neither; and the direction along it.
|
||||
let axis = if !straight || dx.hypot(dy) < 2.0 {
|
||||
0
|
||||
} else if dy.abs() < 0.05 {
|
||||
1
|
||||
} else if dx.abs() < 0.05 {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(axis, dx.signum(), dy.signum(), dx.hypot(dy), a.p)
|
||||
};
|
||||
let last = if sp.closed { n } else { n.saturating_sub(1) };
|
||||
for i in 0..last {
|
||||
let (axis, _, _, len, p) = seg(i);
|
||||
if axis == 0 {
|
||||
continue;
|
||||
}
|
||||
let (before, after) = (seg(i + n - 1), seg(i + 1));
|
||||
let riser = sp.closed && before.0 != 0 && before.0 != axis && after.0 == before.0 && before.1 == after.1 && before.2 == after.2;
|
||||
out.push((axis == 2, if axis == 2 { p.x } else { p.y }, len, riser));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpegs_blocks_are_not_traced_as_steps() {
|
||||
// A dark noisy ground whose tone changes slowly across and down, saved as a JPEG of quality 40.
|
||||
let img = through_jpeg(
|
||||
&Raster::from_fn(320, 240, |x, y| {
|
||||
let v = 14.0 + 22.0 * f64::from(x) / 320.0 + 14.0 * f64::from(y) / 240.0;
|
||||
let c = [0.9 * v, v, 0.8 * v].map(|c| (c + 8.0 * jitter(x, y, 3)).round().clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], 255]
|
||||
}),
|
||||
40,
|
||||
);
|
||||
let res = trace(&img, &preset("16 Colors").unwrap());
|
||||
// The image's border is left out.
|
||||
let segments: Vec<_> = axis_segments(&res).into_iter().filter(|s| s.1 > 0.6 && s.1 < if s.0 { 319.4 } else { 239.4 }).collect();
|
||||
let on_grid: Vec<_> = segments.iter().filter(|s| (s.1 / 8.0 - (s.1 / 8.0).round()).abs() * 8.0 < 0.6).collect();
|
||||
// By chance, 15 in 100 segments are within 0.6 px of a block edge.
|
||||
assert!(on_grid.len() * 4 < segments.len().max(1), "{} of {} straight segments on the block edges: {segments:?}", on_grid.len(), segments.len());
|
||||
let risers: Vec<_> = on_grid.iter().filter(|s| s.3 && s.2 >= 4.0).collect();
|
||||
assert!(risers.is_empty(), "risers of 4 px or more on the block edges: {risers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tinted_flat_art_stays_flat_art() {
|
||||
let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)];
|
||||
|
||||
@@ -1367,6 +1367,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged.
|
||||
- **Image Trace, gray photos:** Color and Grayscale smooth a gray photo within its tones before choosing its colors, as they do a color photo. A picture with at least 95% of its opaque pixels within 8 levels across their channels counts as continuous tone when 8 colors leave an error of more than 6 levels and more than 0.065 of the error of one color, instead of 0.1, unless 16 colors leave at most a fifth of the error of 8 (VectorCraft's own values). That exception keeps gray flat art of up to 16 clean levels on its old route; anti-aliased or noisy gray flat art can now count as continuous tone too. Most gray photos the change reaches, and very noisy gray scans of flat art, trace with fewer specks and fewer staircase edges. Some 16 Colors and High Fidelity Photo palettes of gray photos grow toward the requested count, with more anchors and regions. Some gray illustrations and sketches get more anchors and staircase edges at 16 Colors and High Fidelity Photo, and some 6 Colors traces get more staircase edges. Tests: `tests::a_gray_photo_is_continuous_tone_as_its_color_original_is`, `tests::a_gray_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged.
|
||||
- **Image Trace, tinted photos:** Color and Grayscale smooth a sepia, single-hue or duotone photo within its tones before choosing its colors, as they do a gray photo. A picture with at least 95% of its opaque pixels within 6 levels of its tone curve counts as continuous tone under the same 0.065 rule as a neutral picture. The tone curve runs through the median color of each 8-level stretch along the direction in which the picture's colors spread the most, and a pixel on it must also lie within 6 levels of the plane the colors spread along most, so art in several unrelated colors is not counted (VectorCraft's own rule and values). The neutral rule's exception for 16 colors leaving at most a fifth of the error of 8 applies to it too. Clean tinted bands of 2, 3 and 9 levels and tinted scans of flat art with up to 8 levels of noise stay on the flat-art route. On the synthetic tinted photos the 16 Colors trace keeps 12 to 15 colors instead of 6 to 13, with 525 to 708 anchors instead of up to 1,402. A duotone with strong noise on each channel can still miss the curve and stay unsmoothed. Tests: `tests::a_tinted_photo_is_continuous_tone_as_a_gray_photo_is`, `tests::small_accents_of_other_colors_leave_a_tinted_photo_continuous_tone`, `tests::tinted_flat_art_stays_flat_art`, `tests::pictures_without_tones_are_not_continuous_tone`, `tests::a_tinted_photo_is_smoothed_before_it_is_traced`. Scores and hours unchanged.
|
||||
- **Image Trace, JPEG blocks:** Color, Grayscale and Black and White traces smooth the edges of a JPEG's 8 × 8 blocks before tracing, so a dark ground whose tone changes slowly is no longer traced as staircases along the block edges. The block grid is found in the image itself: the mean step between neighboring pixels must peak at one position in eight, across and down. A cropped JPEG and a JPEG saved in another format are smoothed too. A picture of flat 8 × 8 cells is left as it is, and art made of 8 × 8 cells with slight texture inside each cell is smoothed as a JPEG copy would be. A small step at a block edge, with no edge in the eight pixels around it, is spread over those eight pixels, and larger steps are kept, as the deblocking filters of ITU-T H.263 Annex J and MPEG-4 Part 2 Annex F.3.1 do. Black and White smooths only the smallest steps, and traces of more than 16 colors move every row of a stretch of eight by the same amount (VectorCraft's own rule and values). On a 4000 × 3000 photo saved as JPEG, the straight outline segments that lie on the block grid fall from 52% and 51% (across and down) to 24% and 29% at 16 Colors, and from 54% and 51% to 43% and 42% at High Fidelity Photo; staircase runs fall from 83 to 67 (16 Colors), 11 to 1 (6 Colors), 30 to 13 (Black and White Logo) and 428 to 403 (High Fidelity Photo). A 600 px photo saved as a quality-40 JPEG has 4 staircase runs instead of 2 at 16 Colors. Images without a block grid trace as before. Tests: `tests::a_jpegs_blocks_are_not_traced_as_steps`, `deblock::tests`. Scores and hours unchanged.
|
||||
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.
|
||||
- **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`.
|
||||
- **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`.
|
||||
|
||||
Reference in New Issue
Block a user