diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index f5a2d9fa47..5f3a4e7081 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -10,6 +10,8 @@ mod rare; use cut_edge::{choosing_pixels, compact, settled, settled_wide, settles_wide}; pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; +#[cfg(test)] +use cut_edge::{settled_by, settled_wide_by}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; pub use palette::FULL_TONE_MAX; @@ -191,6 +193,11 @@ fn own_colours(img: &Raster) -> Vec { /// [`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 { + own_colours_among_by(img, among, PIXELS_PER_SHARE) +} + +/// [`own_colours_among`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +fn own_colours_among_by(img: &Raster, among: &[bool], per_share: usize) -> Vec { 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| { @@ -198,28 +205,52 @@ fn own_colours_among(img: &Raster, among: &[bool]) -> Vec { 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 { - for x in 0..w { - let Some(p) = at(x, y) else { - out.push(false); - continue; - }; - let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { - let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; - let d = [0, 1, 2].map(|k| b[k] - a[k]); - let len2: f64 = d.iter().map(|v| v * v).sum(); - if len2 <= 400.0 { - return false; - } - let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::() / len2; - let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); - t > 0.06 && t < 0.94 && off2 < len2 / 16.0 - }); - out.push(!blend); + over_rows(img.width as usize, img.height as usize, per_share, |rows| { + let mut out = Vec::with_capacity(rows.len() * img.width as usize); + for y in rows.start as isize..rows.end as isize { + for x in 0..w { + let Some(p) = at(x, y) else { + out.push(false); + continue; + }; + let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| { + let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false }; + let d = [0, 1, 2].map(|k| b[k] - a[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 400.0 { + return false; + } + let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::() / len2; + let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum(); + t > 0.06 && t < 0.94 && off2 < len2 / 16.0 + }); + out.push(!blend); + } } + out + }) +} + +/// About how many pixels one thread works through at a time in the passes over every pixel that +/// [`over_rows`] spreads over the available cores. A picture of fewer pixels is worked through on +/// one thread. +const PIXELS_PER_SHARE: usize = 1 << 16; + +/// `f` of each band of rows of a picture `w` pixels wide and `h` rows high, each band about +/// `per_share` pixels (at least one row), spread over the available cores (one thread on the web), +/// and the results joined in row order. Each band's result depends only on its rows, so the +/// joined result is the same however many threads there are. A picture with no pixels gives no +/// result. +fn over_rows(w: usize, h: usize, per_share: usize, f: impl Fn(std::ops::Range) -> Vec + Sync) -> Vec { + if w == 0 || h == 0 { + return Vec::new(); } - out + let rows = (per_share / w.max(1)).max(1); + let bands: Vec> = (0..h).step_by(rows).map(|y| y..(y + rows).min(h)).collect(); + if bands.len() <= 1 { + return bands.into_iter().flat_map(&f).collect(); + } + crate::layers::parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect() } /// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque @@ -325,3 +356,78 @@ fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized let labels = q.labels.iter().map(|&l| into.get(l as usize).map_or(l, |&t| t as u16)).collect(); compact(labels, color.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Rings of four colors around a point off the center, blended over three pixels at each edge, + /// with a little noise, and a band of half-transparent pixels along the bottom. + fn rings(w: u32, h: u32) -> Raster { + let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]]; + Raster::from_fn(w, h, |x, y| { + let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0; + let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0)); + let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]); + let noise = f64::from((x * 7 + y * 13) % 5) - 2.0; + let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8); + [c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }] + }) + } + + /// `img` labeled with the palette color nearest to each pixel (none for a transparent one). + fn nearest_labels(img: &Raster, palette: &[[u8; 3]]) -> Quantized { + let labels = img + .rgba + .as_chunks::<4>() + .0 + .iter() + .map(|p| { + let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::(); + (0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16) + }) + .collect(); + Quantized { labels, palette: palette.to_vec() } + } + + #[test] + fn passes_over_every_pixel_give_the_same_result_however_the_picture_is_shared_out() { + // Odd sizes, so that the last band of rows and the last strip of columns are short. + let img = rings(173, 97); + let all = img.width as usize * img.height as usize; + // One row (or one strip) per share, a few rows per share, and the whole picture as one. + let shares = [1, 3 * 173 + 5, all]; + let opaque: Vec = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect(); + let own: Vec> = shares.iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect(); + assert_eq!(own[0].len(), all); + assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels"); + assert!(own.iter().all(|o| *o == own[0])); + // Two colors for each blend, and an olive that the blends lie nearer to than to their sides. + let palette = [[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]]; + let q = nearest_labels(&img, &palette); + let own = &own[0]; + for (name, settle) in [ + ("settled", &(|s| settled_by(q.clone(), &img, own, s)) as &dyn Fn(usize) -> Quantized), + ("settled_wide", &|s| settled_wide_by(q.clone(), &img, own, false, s)), + ("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, own, true, s)), + ] { + let out: Vec = shares.iter().map(|&s| settle(s)).collect(); + assert_ne!(out[0], q, "{name} settles some blend pixels"); + assert!(out.iter().all(|o| *o == out[0]), "{name}"); + } + } + + #[test] + fn a_picture_with_no_pixels_is_not_shared_out() { + // No band for each of the rows of a picture with no columns, however many rows it has. + assert!(over_rows(0, usize::MAX, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + assert!(over_rows(5, 0, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty()); + let img = Raster::new(0, u32::MAX, vec![]).unwrap(); + assert!(own_colours_among(&img, &[]).is_empty()); + // No strip for each of the columns of a picture with no rows. + let img = Raster::new(u32::MAX, 0, vec![]).unwrap(); + let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] }; + assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q); + assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q); + } +} diff --git a/crates/trace/src/quantize/cut_edge.rs b/crates/trace/src/quantize/cut_edge.rs index 27b7727b38..bc0bad05fc 100644 --- a/crates/trace/src/quantize/cut_edge.rs +++ b/crates/trace/src/quantize/cut_edge.rs @@ -1,5 +1,6 @@ use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab}; -use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab}; +use super::{Mode, PIXELS_PER_SHARE, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab, over_rows}; +use crate::layers::parallel_map; /// `q` with its blend pixels settled, and the colours that leaves unused dropped. /// @@ -10,59 +11,76 @@ use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, ok /// 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 { +pub(super) fn settled(q: Quantized, img: &Raster, own: &[bool]) -> Quantized { + settled_by(q, img, own, PIXELS_PER_SHARE) +} + +/// [`settled`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]). +pub(super) fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> 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(); + // Each pixel is settled by the labels as they were before any changed, so the bands of rows + // are settled apart and the changes made afterwards. + let before = &q.labels; 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::().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 = 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 changes: Vec<(usize, u16)> = over_rows(w, h, per_share, |rows| { + let mut changes = vec![]; + for i in rows.start * w..rows.end * w { + 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 = 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::() / 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 })); + 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::() / 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) + { + changes.push((i, to)); + } } - if let Some((off, to)) = best - && off < 0.5 * dist(p, mine) - { - q.labels[i] = to; - changed = true; + changes + }); + if changes.is_empty() { + return q; + } + for (i, to) in changes { + if let Some(l) = q.labels.get_mut(i) { + *l = to; } } - if changed { compact(q.labels, q.palette) } else { q } + compact(q.labels, q.palette) } /// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels @@ -122,12 +140,43 @@ const ON_THE_LINE: f64 = 0.15; /// 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 { +pub(super) fn settled_wide(q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized { + settled_wide_by(q, img, own, gray, PIXELS_PER_SHARE) +} + +/// [`settled_wide`], with its strips of columns worked on in groups of about `per_share` pixels +/// (at least one strip), spread over the available cores (one thread on the web). +pub(super) fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized { let (w, h) = (img.width as usize, img.height as usize); - if q.labels.len() != w * h || own.len() != w * h { + if q.labels.len() != w * h || own.len() != w * h || w == 0 || h == 0 { return q; } - let before = q.labels.clone(); + // Each pixel is settled by the labels as they were before any changed, so the strips are + // settled apart and the changes made afterwards. Strips are grouped so that each group holds + // about `per_share` pixels. + let strip = BLEND_STRIP.min(w).max(1); + let per_group = (per_share / (strip * h).max(1)).max(1); + let starts: Vec = (0..w).step_by(strip).collect(); + let groups: Vec<&[usize]> = starts.chunks(per_group).collect(); + let settle = |starts: &&[usize]| settled_wide_strips(&q, img, own, gray, starts); + let changes: Vec<(usize, u16)> = + if groups.len() <= 1 { groups.iter().flat_map(settle).collect() } else { parallel_map(&groups, settle).into_iter().flatten().collect() }; + if changes.is_empty() { + return q; + } + for (i, to) in changes { + if let Some(l) = q.labels.get_mut(i) { + *l = to; + } + } + compact(q.labels, q.palette) +} + +/// The labels that [`settled_wide`] changes in the strips of [`BLEND_STRIP`] columns that start at +/// the columns `starts`, with the pixel each one is at. +fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, starts: &[usize]) -> Vec<(usize, u16)> { + let (w, h) = (img.width as usize, img.height as usize); + let before = &q.labels; 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::(); // The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette @@ -135,7 +184,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b // 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 strip = BLEND_STRIP.min(w).max(1); + 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])] }; + let mut changes = vec![]; let mut rows = vec![0u64; span * strip * words]; let mut counts = vec![0u32; q.palette.len()]; let mut window = vec![0u64; words]; @@ -178,11 +229,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b } } }; - let mut changed = false; let mut near: Vec = 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))) { + for (x0, y) in starts.iter().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) { @@ -265,12 +314,11 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b && !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; + changes.push((i, to)); } } } - if changed { compact(q.labels, q.palette) } else { q } + changes } /// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy