mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, 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(())
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user