Make Crystallize 4x and Pointillize 3x faster on 24 MP layers (#6200)

* Speed up Crystallize and Pointillize with cell-space rasterization

* Measure pixelate commands on unlocked layers and Background separately

* Record pixel-exact 24 MP filter and command benchmarks
This commit is contained in:
Ayan
2026-10-24 11:13:03 -04:00
committed by GitHub
parent 1c3db2e5c3
commit 47d9266fa1
8 changed files with 2155 additions and 11 deletions
+6 -1
View File
@@ -1,5 +1,6 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin
//! Also: crystallize, crystallize-large, pointillize, pointillize-large.
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
@@ -29,11 +30,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
_ => return Err("depth must be u8, u16 or f32".into()),
};
let params = match case {
"crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
"crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 },
"pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] },
"pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] },
"facet" => FilterParams::Facet,
"halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] },
_ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()),
_ => return Err("unknown pixelate benchmark case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
+44 -8
View File
@@ -1142,16 +1142,43 @@ fn apply_tiled_with_impl(
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let run = |t: &Rect| -> (Rect, Vec<f32>) {
enum TileData {
Samples(Vec<f32>),
Encoded(Vec<u8>),
}
// Cell filters become writeback-bound once their geometry is reused. Encode
// on the tile workers; committing the group then only copies native bytes.
let finish = |data: Vec<f32>| {
if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. })
&& let Some(bytes) = pixelate::cells::encode(fmt, &data)
{
TileData::Encoded(bytes)
} else {
TileData::Samples(data)
}
};
let run = |t: &Rect| -> (Rect, TileData) {
// Cancelled: skip the remaining tiles of the group (the result is discarded).
if ctl.cancelled() {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
}
let tick = || {
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes after each group.
ctl.progress(0.98 * n as f32 / total as f32);
};
let sparse = match *params {
FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent),
FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent),
_ => None,
};
if let Some((src, mut data)) = sparse {
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
tick();
return (*t, finish(data));
}
let owned;
let src = match (&shared, halo) {
(Some(s), _) => s,
@@ -1180,13 +1207,13 @@ fn apply_tiled_with_impl(
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
(*t, finish(data))
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
// group is written before the next starts, so a huge layer never holds all its results as
@@ -1202,24 +1229,33 @@ fn apply_tiled_with_impl(
return None;
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
let results: Vec<(Rect, TileData)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect();
if ctl.cancelled() {
return None;
}
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
&& results.iter().any(|(t, data)| {
let pixels = t.width() as usize * t.height() as usize;
match data {
TileData::Samples(v) => v.len() != pixels * fmt.channels(),
TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(),
}
})
{
return None;
}
for (t, data) in results {
out.write_region(t, &data);
match data {
TileData::Samples(data) => out.write_region(t, &data),
TileData::Encoded(data) => out.write_interleaved(t, &data),
}
}
}
out.prune();
+11 -2
View File
@@ -5,6 +5,7 @@
use photocraft_geom::Rect;
pub(crate) mod cells;
mod facet;
#[cfg(test)]
mod reference;
@@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType,
/// Pointillize: randomly sized dots, coloured from the image at their centre,
/// on a canvas of the background colour.
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
}
res
}
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed))
}
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background))
}
+571
View File
@@ -0,0 +1,571 @@
//! Reuse the jittered lattice in cell space. Raster work stays in the original
//! row/candidate order, including f32 ties and boundary-cell average additions.
use super::*;
#[derive(Clone, Copy)]
struct Site {
point: (f32, f32),
color: [f32; MAXC],
radius: f32,
sampled: bool,
}
struct Grid {
x0: i32,
y0: i32,
width: usize,
sites: Vec<Site>,
}
fn key(p: i32, origin: f32, cell: f32) -> i32 {
((p as f32 + 0.5 - origin) / cell).floor() as i32
}
impl Grid {
fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option<Self> {
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? {
return None;
}
Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| {
for (c, value) in color.iter_mut().enumerate() {
*value = src.get(sx, sy, c);
}
src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy)
})
}
#[allow(clippy::too_many_arguments)]
fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option<Self> {
if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC {
return None;
}
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let x0 = key(area.x0, origin.0, cell).checked_sub(1)?;
let y0 = key(area.y0, origin.1, cell).checked_sub(1)?;
let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?;
let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?;
let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?;
let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?;
let len = width.checked_mul(height)?;
// Optional tile-local scratch, at most 16 MiB even for a direct kernel call.
if len.checked_mul(std::mem::size_of::<Site>())? > 16 * 1024 * 1024 {
return None;
}
let mut sites = Vec::new();
sites.try_reserve_exact(len).ok()?;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
for gy in y0..=y1 {
for gx in x0..=x1 {
let point = cell_point(gx, gy, cell, seed, origin);
let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32);
let mut color = [0.0; MAXC];
let sampled = sample(sx, sy, color.get_mut(..n)?);
let radius = if dots {
let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08;
for value in color.iter_mut().take(cc) {
*value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0);
}
cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed))
} else {
0.0
};
sites.push(Site { point, color, radius, sampled });
}
}
Some(Self { x0, y0, width, sites })
}
fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> {
let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?;
let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?;
if x.checked_add(2)? >= self.width {
return None;
}
let start = y.checked_mul(self.width)?.checked_add(x)?;
let last = start.checked_add(2 * self.width)?.checked_add(2)?;
self.sites.get(last)?;
Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3))
}
}
/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once
/// per column, rather than use an inverse mapping that could round differently.
fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> {
let mut runs: Vec<(i32, i32, i32)> = Vec::new();
for x in area.x0..area.x1 {
let cx = key(x, origin, cell);
if let Some(last) = runs.last_mut()
&& last.0 == cx
{
last.2 = x + 1;
} else {
runs.push((cx, x, x + 1));
}
}
runs
}
fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option<usize> {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let mut best = (f32::MAX, *neighbours.get(4)?);
for &i in neighbours {
let p = grid.sites.get(i)?.point;
let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2);
if d < best.0 {
best = (d, i);
}
}
Some(best.1)
}
/// Bounds on the *same f32 score* used by the scalar comparison. Along a
/// scanline, squared x distance has its maximum at an endpoint and its lower
/// bound at the clamped site centre. IEEE subtraction, square, addition and
/// sqrt are monotone on these nonnegative inputs, so these bounds also cover
/// rounding. No estimated bisectors or epsilon decide ownership.
fn score_range<const DOTS: bool>(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) {
let left = x0 as f32 + 0.5;
let right = (x1 - 1) as f32 + 0.5;
let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2);
let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2;
let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2;
if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) }
}
fn score<const DOTS: bool>(site: &Site, x: i32, y: i32) -> f32 {
let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2);
if DOTS { site.radius - d.sqrt() } else { -d }
}
/// Certify a whole run when one candidate's worst score beats every other's
/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the
/// output tile width (at most 11 splits for a 2048-pixel application tile).
fn certified_span<const DOTS: bool>(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let len = owners.len();
if len == 0 {
return Some(());
}
let x1 = x0.checked_add(i32::try_from(len).ok()?)?;
let middle = x0 + (len / 2) as i32;
let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None);
for &i in ids {
let value = score::<DOTS>(grid.sites.get(i)?, middle, y);
if value > best.0 {
best = (value, Some(i));
}
}
if let Some(i) = best.1 {
let site = grid.sites.get(i)?;
let lower = score_range::<DOTS>(site, x0, x1, y).0;
let certified = (!DOTS || lower > -0.5)
&& ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::<DOTS>(other, x0, x1, y).1));
if len == 1 || certified {
owners.fill(i as u32);
if DOTS {
if lower >= 0.5 {
depths.fill(0.5);
} else {
for (offset, depth) in depths.iter_mut().enumerate() {
*depth = score::<true>(site, x0 + offset as i32, y);
}
}
}
return Some(());
}
} else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::<DOTS>(site, x0, x1, y).1 <= -0.5)) {
owners.fill(u32::MAX);
return Some(());
}
let half = len / 2;
let (left, right) = owners.split_at_mut(half);
let (ld, rd) = depths.split_at_mut(half);
certified_span::<DOTS>(grid, ids, x0, y, left, ld)?;
certified_span::<DOTS>(grid, ids, x0 + half as i32, y, right, rd)
}
pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
let n = src.ch;
let b = ctx.bounds;
// Match the scalar averaging window, including floor/ceil at negative origins.
// Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it.
let c0 = (
(((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?,
(((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?,
);
let c1 = (
(((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?,
(((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?,
);
let win = Rect::new(
b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?,
b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?,
b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?,
b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?,
)
.intersect(&src.rect)
.intersect(&b);
let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?;
let origin = (b.x0 as f32, b.y0 as f32);
if grid.sites.iter().any(|s| !s.sampled) {
let mut acc = Vec::new();
let count = grid.sites.len().checked_mul(n.checked_add(1)?)?;
acc.try_reserve_exact(count).ok()?;
acc.resize(count, 0.0f32);
let columns = runs(win, origin.0, cell);
for y in win.y0..win.y1 {
let cy = key(y, origin.1, cell);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
// An interior cell's average is never used. Skip the entire run if
// all nine possible winners have an on-image colour sample.
if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) {
continue;
}
for x in x0..x1 {
let i = nearest(&grid, &neighbours, x, y)?;
if grid.sites.get(i)?.sampled {
continue;
}
let gx = grid.x0.checked_add((i % grid.width) as i32)?;
let gy = grid.y0.checked_add((i / grid.width) as i32)?;
if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 {
continue;
}
let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?;
let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 };
for (c, value) in sum.iter_mut().enumerate().take(n) {
let s = src.get(x, y, c);
*value += if ctx.alpha && c + 1 < n { s * alpha } else { s };
}
*sum.get_mut(n)? += 1.0;
}
}
}
for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) {
let count = *sum.get(n)?;
if !site.sampled && count > 0.0 {
for (dst, value) in site.color.iter_mut().zip(sum).take(n) {
*dst = *value / count;
}
crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha);
site.sampled = true;
}
}
}
crystallize_grid(src, out, ctx, cell, &grid)
}
fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option<Vec<f32>> {
let n = src.ch;
let b = ctx.bounds;
let origin = (b.x0 as f32, b.y0 as f32);
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let columns = runs(out, origin.0, cell);
let mut distances = vec![f32::MAX; width];
let mut owners = vec![0u32; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
distances.fill(f32::MAX);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
if cell >= 32.0 && out.width() <= 2048 {
certified_span::<false>(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?;
continue;
}
owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32);
for &i in &neighbours {
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (x0 + offset as i32) as f32 + 0.5;
let d = (site.point.0 - fx).powi(2) + dy2;
if d < *distance {
*distance = d;
*owner = i as u32;
}
}
}
}
for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() {
if !b.contains(out.x0 + x as i32, y) {
continue;
}
let site = grid.sites.get(owner as usize)?;
if site.sampled {
pixel.copy_from_slice(site.color.get(..n)?);
}
}
}
Some(result)
}
/// A site may only compete in its own base column and its two neighbours.
/// Derive the inclusive pixel runs from the exact forward f32 cell mapping.
fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option<Vec<(i32, i32)>> {
let columns = runs(out, origin, cell);
(0..grid.width)
.map(|column| {
let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?;
let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1);
let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1);
if lo == hi {
return Some((out.x0, out.x0));
}
Some((columns.get(lo)?.1, columns.get(hi - 1)?.2))
})
.collect()
}
pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
// This path uses integer pixel centres and conservative one-pixel support
// margins. Beyond exact half-integer f32 coordinates use the scalar kernel.
if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) {
return None;
}
let grid = Grid::new(src, out, ctx, cell, seed, true)?;
pointillize_grid(src, out, ctx, cell, background, &grid)
}
fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option<Vec<f32>> {
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let bg = native(ctx, background);
let n = src.ch;
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let columns = influence_columns(out, origin.0, cell, grid)?;
let base_runs = runs(out, origin.0, cell);
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let mut depths = vec![-0.5f32; width];
let mut owners = vec![u32::MAX; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
depths.fill(-0.5);
owners.fill(u32::MAX);
if cell >= 32.0 && out.width() <= 2048 {
for &(cx, x0, x1) in &base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
} else {
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &(x0, x1)) in columns.iter().enumerate() {
if x0 >= x1 {
continue;
}
let i = row.checked_add(column)?;
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
continue;
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
continue;
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
}
}
}
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
}
}
Some(result)
}
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
/// tiles read those sparse samples directly; keep only the original output
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
/// use the dense path, whose row-order f32 averages are part of its result.
#[allow(clippy::too_many_arguments)]
pub(crate) fn tile(
surface: &photocraft_raster::Surface,
out: Rect,
ctx: &Ctx,
cell: f32,
seed: u32,
background: Option<[f32; 4]>,
extent: Option<Rect>,
) -> Option<(Image, Vec<f32>)> {
if !cell.is_finite()
|| !(2.0..=300.0).contains(&cell)
|| out.is_empty()
|| out.width() > 2048
|| out.height() > 2048
|| [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000)
|| extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out)
{
return None;
}
let dots = background.is_some();
let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| {
let (x, y) = match extent {
Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)),
None => (sx, sy),
};
surface.read_pixel(x, y, color);
ctx.bounds.contains(sx, sy)
})?;
if !dots && grid.sites.iter().any(|s| !s.sampled) {
return None;
}
let original = Image::read(surface, out);
let data = match background {
Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?,
None => crystallize_grid(&original, out, ctx, cell, &grid)?,
};
Some((original, data))
}
/// Same rounding and native byte order as Surface::write_region, before the
/// worker hands its result back. Bounds the allocation and retains float data
/// if an optional encode buffer cannot be reserved.
pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option<Vec<u8>> {
use photocraft_color::SampleType;
let mut bytes = Vec::new();
let len = samples.len().checked_mul(format.sample.bytes())?;
bytes.try_reserve_exact(len).ok()?;
bytes.resize(len, 0);
match format.sample {
SampleType::U8 => {
for (dst, value) in bytes.iter_mut().zip(samples) {
*dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes());
}
}
SampleType::F32 => {
for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&value.to_ne_bytes());
}
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
#[test]
fn span_certificates_keep_discrete_winners_and_antialias_coverage() {
for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] {
let out = Rect::new(origin, -3, origin + 601, 4);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let src = Image::new(out, 4);
for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] {
for seed in 0..32 {
let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap();
for y in out.y0..out.y1 {
let cy = key(y, ctx.bounds.y0 as f32, cell);
for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) {
let ids = grid.neighbours(cx, cy).unwrap();
for dots in [false, true] {
let mut owners = vec![u32::MAX; (x1 - x0) as usize];
let mut depths = vec![-0.5; owners.len()];
if dots {
certified_span::<true>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
} else {
certified_span::<false>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
}
for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() {
let x = x0 + offset as i32;
let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX);
for &i in &ids {
let value = if dots { score::<true>(&grid.sites[i], x, y) } else { score::<false>(&grid.sites[i], x, y) };
if value > best.0 {
best = (value, i as u32);
}
}
assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}");
if dots && owner != u32::MAX {
assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits());
}
}
}
}
}
}
}
}
}
#[test]
fn worker_encoding_is_surface_write_region_at_every_depth() {
let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY];
let area = Rect::new(-17, -9, 281, 2);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let format = PixelFormat::new(mode, sample, alpha);
let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect();
let mut surface = Surface::new(format);
surface.write_region(area, &data);
assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area));
}
}
}
}
#[test]
fn optional_grid_rejects_unbounded_or_incomplete_scratch() {
let area = Rect::new(0, 0, 100_000, 100_000);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let image = Image { rect: area, ch: 4, data: Vec::new() };
assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none());
assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none());
}
}
+76
View File
@@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() {
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
assert!(facet::run(&src, out, &ctx).is_none());
}
#[test]
fn cell_filters_match_scalar_models_edges_and_fractional_cells() {
let bounds = Rect::new(-19, -13, 32, 29);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
for pattern in 0..5 {
for halo in [0, 7] {
let src = fixture(bounds.inflate(halo), &ctx, pattern);
for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] {
for cell in [2.0, 3.75, 10.0, 100.0, 300.0] {
for seed in [0, 37, u32::MAX] {
let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap();
assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed));
for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] {
let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap();
assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg));
}
}
}
}
}
}
}
}
}
#[test]
fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() {
use crate::{FilterParams, apply_tiled};
let bounds = Rect::new(-17, -9, 86, 78);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
let src = fixture(bounds, &ctx, 2);
let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha));
surface.write_region(bounds, &src.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect();
selection.write_region(bounds, &coverage);
for params in [
FilterParams::Crystallize { cell_size: 3.75, seed: 37 },
FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] },
FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] },
] {
for extent in [None, Some(bounds)] {
assert!(matches!(params.halo(), crate::Halo::Radius(_)));
let halo = match params.halo() {
crate::Halo::Radius(r) => r,
_ => 0,
};
let image = match extent {
Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e),
None => Image::read(&surface, bounds.inflate(halo)),
};
let mut expected_data = match params {
FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed),
FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&surface, &params, bounds, bounds, Some(&selection), tile, extent);
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
}
}
}
}
}
}
}
@@ -0,0 +1,99 @@
//! Time the actual menu command on a 24 MP layer, including history commit.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8
//! Arguments: crystallize|pointillize, cell size, repeats (1..9), depth (8|16|32), layer|background.
//! Run the same example at the base and changed revisions; compare the output hashes.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
use std::time::Instant;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let case = args.first().map(String::as_str).unwrap_or("crystallize");
let cell: f32 = args.get(1).map(|v| v.parse()).transpose()?.unwrap_or(10.0);
let reps: usize = args.get(2).map(|v| v.parse()).transpose()?.unwrap_or(5);
let depth: u32 = args.get(3).map(|v| v.parse()).transpose()?.unwrap_or(8);
if !matches!(case, "crystallize" | "pointillize") || !(3.0..=300.0).contains(&cell) || !(1..=9).contains(&reps) {
return Err("case, cell size or repeat count out of range".into());
}
let kind = args.get(4).map(String::as_str).unwrap_or("layer");
if !matches!(kind, "layer" | "background") {
return Err("kind must be layer or background".into());
}
let (w, h) = (6000, 4000);
let bounds = Rect::new(0, 0, w, h);
let sample = match depth {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => return Err("invalid depth".into()),
};
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
for tile in bounds.tiles() {
let r = tile.rect().intersect(&bounds);
let mut data = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
for c in 0..4 {
let value = seed.rotate_left(c * 7).wrapping_add(c * 71) % 65536;
data.push(if c == 3 && kind == "background" {
1.0
} else if c == 3 && seed.is_multiple_of(5) {
0.0
} else {
value as f32 / 65535.0
});
}
}
}
source.write_region(r, &data);
}
let id = format!("filter.pixelate.{case}");
let mut times = Vec::new();
let hash = |surface: &Surface| {
let mut hash = blake3::Hasher::new();
for tile in bounds.tiles() {
hash.update(&surface.to_interleaved(tile.rect().intersect(&bounds)));
}
hash.finalize()
};
let source_hash = hash(&source);
let mut output_hash = None;
for run in 0..reps {
let mut s = Session::new();
s.execute("file.new", json!({"width":w,"height":h,"depth":depth,"background":if kind == "background" { "white" } else { "transparent" }}))?;
s.edit("fixture", |doc, _| {
let layer = doc.layers.first_mut().ok_or(photocraft_engine::EngineError::Other("no layer".into()))?;
let surface = layer.surface_mut().ok_or(photocraft_engine::EngineError::Other("no surface".into()))?;
*surface = source.clone();
Ok(())
})?;
let start = Instant::now();
s.execute(&id, json!({"cellSize":cell,"seed":37,"background":[1.0,1.0,1.0,1.0]}))?;
let seconds = start.elapsed().as_secs_f64();
println!("COMMAND {case} cell={cell} depth={depth} kind={kind} run={} seconds={seconds:.6}", run + 1);
times.push(seconds);
if run + 1 == reps {
let doc = s.active().ok_or("no document")?;
let layer = doc.doc.layers.first().ok_or("no layer")?;
let surface = layer.surface().ok_or("no surface")?;
let filtered_hash = hash(surface);
s.execute("edit.undo", json!({}))?;
let restored = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no undo surface")?;
if hash(restored) != source_hash {
return Err("undo changed source pixels".into());
}
s.execute("edit.redo", json!({}))?;
let redone = s.active().and_then(|d| d.doc.layers.first()).and_then(|l| l.surface()).ok_or("no redo surface")?;
if hash(redone) != filtered_hash {
return Err("redo changed filtered pixels".into());
}
output_hash = Some(filtered_hash);
}
}
times.sort_by(f64::total_cmp);
println!("MEDIAN {} HASH {}", times.get(times.len() / 2).ok_or("no runs")?, output_hash.ok_or("no output hash")?);
Ok(())
}
+139
View File
@@ -0,0 +1,139 @@
# Crystallize and Pointillize performance
Measured on 2026-10-15, against `4e7a17cd5d5723313f459753663d0d155f047e19`.
The code revision is `3a527eb64e0d0108e44c70dfd200afc0ffd5fe67`.
## Whole command
The command bench calls `Session::execute` on a populated 6000 x 4000 RGB layer.
The table below uses 16 workers and 8-bit storage.
The clock includes filtering and the history commit. It excludes document setup,
GUI repaint and presentation. The last run also checks that Undo restores the
source and Redo restores the filtered result. Both checks happen outside the clock.
| Layer kind | Filter | Cell size | Before | After | Speedup |
|---|---|---:|---:|---:|---:|
| Layer | Crystallize | 10 | 560.8 ms | 137.5 ms | 4.08x |
| Layer | Pointillize | 5 | 430.5 ms | 146.4 ms | 2.94x |
| Layer | Crystallize | 100 | 1387.5 ms | 202.1 ms | 6.87x |
| Layer | Pointillize | 100 | 543.4 ms | 131.9 ms | 4.12x |
| Background | Crystallize | 10 | 891.5 ms | 441.1 ms | 2.02x |
| Background | Pointillize | 5 | 738.5 ms | 608.2 ms | 1.21x |
| Background | Crystallize | 100 | 1708.4 ms | 500.1 ms | 3.42x |
| Background | Pointillize | 100 | 851.4 ms | 439.0 ms | 1.94x |
Ordinary layers are unlocked and have varying alpha, including zero alpha.
Background layers are opaque and transparency-locked. The existing transparency
restoration pass limits their speedup, especially Pointillize at small cell sizes.
These are command timings, not input-to-display timings.
## Whole filter
The filter bench times `apply_in`: reading the input, filtering, converting the
result back to the document depth, writing the tiles and pruning. The table below uses 16 workers.
There is no selection in these timing cases. Correctness tests also cover soft selections.
The fixture has varying alpha, fully transparent pixels and a negative origin.
Every run filters the original surface again; no result cache survives a run.
| Format | Filter | Cell size | Before | After | Speedup |
|---|---|---:|---:|---:|---:|
| RGB u8 | Crystallize | 10 | 601.6 ms | 137.8 ms | 4.36x |
| RGB u8 | Pointillize | 5 | 449.9 ms | 147.3 ms | 3.05x |
| RGB u8 | Crystallize | 100 | 1340.5 ms | 175.1 ms | 7.65x |
| RGB u8 | Pointillize | 100 | 526.2 ms | 128.1 ms | 4.11x |
| RGB u16 | Crystallize | 10 | 647.3 ms | 214.0 ms | 3.02x |
| RGB u16 | Pointillize | 5 | 502.7 ms | 219.8 ms | 2.29x |
| RGB u16 | Crystallize | 100 | 1413.8 ms | 262.2 ms | 5.39x |
| RGB u16 | Pointillize | 100 | 580.5 ms | 209.3 ms | 2.77x |
| RGB f32 | Crystallize | 10 | 688.4 ms | 362.7 ms | 1.90x |
| RGB f32 | Pointillize | 5 | 551.5 ms | 360.9 ms | 1.53x |
| RGB f32 | Crystallize | 100 | 1495.2 ms | 412.1 ms | 3.63x |
| RGB f32 | Pointillize | 100 | 643.7 ms | 361.6 ms | 1.78x |
| CMYK u16 | Crystallize | 10 | 729.5 ms | 260.2 ms | 2.80x |
| CMYK u16 | Pointillize | 5 | 574.6 ms | 262.2 ms | 2.19x |
| CMYK u16 | Crystallize | 100 | 1560.3 ms | 329.1 ms | 4.74x |
| CMYK u16 | Pointillize | 100 | 675.0 ms | 255.6 ms | 2.64x |
| LAB f32 | Crystallize | 10 | 706.2 ms | 367.6 ms | 1.92x |
| LAB f32 | Pointillize | 5 | 550.4 ms | 361.9 ms | 1.52x |
| LAB f32 | Crystallize | 100 | 1469.7 ms | 404.9 ms | 3.63x |
| LAB f32 | Pointillize | 100 | 647.8 ms | 362.2 ms | 1.79x |
| GRAY u8 | Crystallize | 10 | 396.0 ms | 93.4 ms | 4.24x |
| GRAY u8 | Pointillize | 5 | 332.9 ms | 109.2 ms | 3.05x |
| GRAY u8 | Crystallize | 100 | 1020.5 ms | 105.1 ms | 9.71x |
| GRAY u8 | Pointillize | 100 | 352.9 ms | 76.7 ms | 4.60x |
All trials and output hashes are in [pixelate-cell-timings.json](pixelate-cell-timings.json).
Filter hashes are SHA-256 of the complete tile-ordered normalized f32 output.
Command hashes are BLAKE3 of the complete tile-ordered native storage bytes.
Fixture generation, output dumps and hashing are outside the measured interval.
## Where the time went
Both filters were rebuilding the seeded cell geometry for every pixel and every
neighbour. Pointillize also repeated the dot radius, source lookup and colour
jitter. Those values belong to the cell. The new grid prepares them once per cell
in a tile, reducing that setup work from roughly nine times the pixel count to
the number of cells.
Crystallize accumulated a premultiplied average for every cell, then usually threw
it away and used the pixel at the cell centre. Only cells whose centres are outside
the image need that average. The new path skips interior averages. Boundary cells
still add their samples in the original row order, with the original f32 rounding.
Larger cells also allow whole horizontal spans to share a winner. For each site,
the absolute horizontal distance has its maximum at an endpoint. Clamping the site
centre to the span gives a lower bound. The rounded coordinate map is monotone;
squaring nonnegative distances, adding the fixed vertical term and taking a square
root preserve these bounds through the same f32 operations. Subtracting distance
from a fixed dot radius reverses the endpoints for Pointillize. If one site's
worst score beats every other site's best score, it owns the span. Otherwise the span splits. Single pixels retain the original strict
comparison and tie order. A fully covered Pointillize span also has exactly unit
coverage, so it needs no per-pixel square roots.
The tile path reads sparse cell-centre samples directly from the surface.
Crystallize's boundary tiles retain the dense input for their averages. Extent
clamping and selection mixing follow the original path.
After these changes, profiling showed a serial tail: converting samples and
writing them back took about 160-180 ms of a roughly 260 ms operation. The two
filters now convert on their existing tile workers. Committing a group copies
encoded bytes. Integer rounding and native byte order are the same as
`Surface::write_region`; float bits are kept. The optional site grid is capped at
16 MiB. Failed grid/average reservations use the scalar kernel; a failed encode
reservation keeps the existing floating-point writeback.
## Reproduce
Machine: EC2 c7i.4xlarge, 16 vCPUs (8 cores with SMT), 32 GiB RAM, Intel Xeon
Platinum 8488C, Ubuntu 24.04, rustc 1.99.0. Release profile, thin LTO, one codegen
unit. No `target-cpu` override. Five trials per row; tables use the median.
One-worker rows are in the raw results too. Before and after use separate target
directories so Cargo cannot reuse a library from the other worktree. No builds
ran during the timed trials.
```sh
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- crystallize 6000 4000 u8 5 /tmp/crystallize.bin rgb
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- pointillize-large 6000 4000 u16 5 /tmp/pointillize.bin cmyk
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- crystallize 10 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 100 5 8 layer
RAYON_NUM_THREADS=16 cargo run --release -p photocraft-engine --example bench_cell_filters -- pointillize 5 5 8 background
```
For the before run, create a worktree at the base above and copy the two benchmark
examples into it. Build with a separate `CARGO_TARGET_DIR` and run there with the same arguments
and thread count.
The `-large` filter cases use cell size 100; the other cases use 10 for Crystallize
and 5 for Pointillize. The engine example takes the size explicitly.
## Validation
- 2,447 tests passed across algo and engine; 43 ignored.
- Clippy passed for both crates with all targets and warnings denied.
- Layer checks, wasm checks, scorecard freshness and the quick performance suite passed.
- Scalar comparisons cover RGB, Gray, CMYK, Lab and Multichannel, alpha on/off,
negative origins, fractional cells, boundary averages, HDR and nonfinite samples.
- Storage comparisons cover U8/U16/F32, soft selections, clamped extents and
several tile sizes. The span tests compare discrete winners and coverage,
including coordinates near f32 precision boundaries.
- All 44 measured before/after configurations have identical output hashes.
The 16 command configurations also pass Undo/Redo checks.
File diff suppressed because it is too large Load Diff