Five exact-output speedups: dense Extrude 160s to 0.39s (#2685)

* Rasterize Extrude blocks and scatter Wind streaks once

* Prepare Spin Blur rotations and fixed-channel sampling

* Share Lens Correction interpolation across color and alpha

* Make wide prepress traps independent of radius

* Add full-operation timing for five imaging algorithms

* Record 24 full-operation benchmarks and exact-output checks

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Ayan
2026-10-11 10:50:45 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 0aaf5b60fe
commit acce8e0e99
14 changed files with 1795 additions and 9 deletions
+143
View File
@@ -0,0 +1,143 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_algorithms -- extrude 6000 4000 u8 3 output.bin
//! 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};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::io::Write;
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("extrude");
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
Ok(match args.get(i) {
Some(s) => s.parse()?,
None => fallback,
})
};
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
return Err("dimensions must be 1..10000, repeats 1..9".into());
}
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
"u8" => SampleType::U8,
"u16" => SampleType::U16,
"f32" => SampleType::F32,
_ => return Err("depth must be u8, u16 or f32".into()),
};
let extrude = |size, depth| FilterParams::Extrude {
kind: photocraft_algo::ExtrudeType::Blocks,
size,
depth,
level_based: false,
solid_front: false,
mask_incomplete: false,
seed: 17,
};
let params = match case {
"extrude" => extrude(30.0, 30.0),
"extrude-dense" => extrude(8.0, 128.0),
"extrude-small" => extrude(8.0, 64.0),
"wind" => FilterParams::Wind { method: photocraft_algo::WindMethod::Wind, from_right: false, seed: 17 },
"blast" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: false, seed: 17 },
"blast-right" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: true, seed: 17 },
"spin" => FilterParams::SpinBlur {
pins: vec![photocraft_algo::SpinPin { radius_x: 1.0, radius_y: 1.0, blur_angle: 30.0, angle: 17.0, ..Default::default() }],
},
"spin-default" => FilterParams::SpinBlur { pins: vec![Default::default()] },
"lens" | "lens-ca" | "trap" | "trap-wide" | "trap-default" => FilterParams::Invert,
_ => return Err("unknown case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
"gray" => ColorMode::Grayscale,
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
};
if case.starts_with("trap") && mode != ColorMode::Cmyk {
return Err("Trap needs CMYK samples".into());
}
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let bounds = Rect::new(-17, -29, w - 17, h - 29);
let mut source = Surface::new(format);
for tile in bounds.tiles() {
let r = tile.rect().intersect(&bounds);
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
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..n {
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
}
}
}
source.write_region(r, &pixels);
}
let run_filter = |area: Rect| match case {
"lens" | "lens-ca" => photocraft_algo::lens::correct(
&source,
area,
&photocraft_algo::lens::LensCorrection {
distortion: 12.0,
angle: 2.5,
vignette_amount: 20.0,
red_cyan: if case == "lens-ca" { 12.0 } else { 0.0 },
blue_yellow: if case == "lens-ca" { -8.0 } else { 0.0 },
..Default::default()
},
),
"trap" | "trap-wide" | "trap-default" => {
let mut data = source.read_region(area);
photocraft_algo::trap::trap(
&mut data,
area.width() as usize,
area.height() as usize,
n,
if case == "trap-wide" {
50
} else if case == "trap-default" {
1
} else {
10
},
);
let mut result = source.clone();
result.write_region(area, &data);
result.prune();
result
}
_ => apply_in(&source, &params, area, bounds, None, bounds),
};
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
drop(run_filter(warm));
let mut times = Vec::new();
for run in 0..repeats {
let start = Instant::now();
let result = run_filter(bounds);
let seconds = start.elapsed().as_secs_f64();
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
times.push(seconds);
if run + 1 == repeats
&& let Some(path) = args.get(5)
{
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
for tile in bounds.tiles() {
for sample in result.read_region(tile.rect().intersect(&bounds)) {
file.write_all(&sample.to_bits().to_le_bytes())?;
}
}
file.flush()?;
}
}
times.sort_by(f64::total_cmp);
if let Some(median) = times.get(times.len() / 2) {
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
}
Ok(())
}
+26 -4
View File
@@ -8,6 +8,8 @@
use photocraft_geom::Rect;
mod spin_plan;
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy};
use crate::image::{Image, premultiply};
use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
@@ -264,7 +266,18 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
/// Spin Blur: content inside each ellipse is blurred along arcs about its
/// centre (`blur_angle` degrees of rotation), feathered at the rim.
pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f32> {
let n = src.ch;
match src.ch {
1 => spin_impl::<1>(src, out, ctx, pins, true),
2 => spin_impl::<2>(src, out, ctx, pins, true),
3 => spin_impl::<3>(src, out, ctx, pins, true),
4 => spin_impl::<4>(src, out, ctx, pins, true),
5 => spin_impl::<5>(src, out, ctx, pins, true),
_ => spin_impl::<0>(src, out, ctx, pins, true),
}
}
fn spin_impl<const N: usize>(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin], prepare: bool) -> Vec<f32> {
let n = if N == 0 { src.ch } else { N };
let b = ctx.bounds;
let ss = short_side(b);
let mut res = src.crop(out);
@@ -272,11 +285,12 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
if clip.is_empty() {
return res;
}
let plans = prepare.then(|| spin_plan::prepare(pins)).flatten();
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
for p in pins {
for (pi, p) in pins.iter().enumerate() {
let (e, u, v) = pin_distance(fx, fy, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, 2.0);
if !e.is_finite() || e >= 1.0 {
continue;
@@ -288,15 +302,20 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
let (cx, cy) = (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32);
let angle_deg = if p.angle.is_finite() { p.angle } else { 0.0 };
let (sa, ca) = angle_deg.to_radians().sin_cos();
let rotations = plans.as_ref().and_then(|plans| plans.get(pi)).and_then(|plan| plan.rotations(m));
acc[..n].fill(0.0);
for j in 0..m {
let phi = theta * (j as f32 / (m - 1) as f32 - 0.5);
let (sp, cp) = phi.sin_cos();
let (sp, cp) = rotations.and_then(|table| table.get(j)).copied().unwrap_or_else(|| phi.sin_cos());
// Rotate in the ellipse's normalized space, then map back.
let (ru, rv) = (u * cp - v * sp, u * sp + v * cp);
let (lu, lv) = (ru * p.radius_x * ss, rv * p.radius_y * ss);
let (sx, sy) = (cx + lu * ca - lv * sa, cy + lu * sa + lv * ca);
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
if N == 0 {
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
} else {
spin_plan::sample::<N>(src, sx, sy, clip, ctx.alpha, &mut acc);
}
}
for v in acc.iter_mut().take(n) {
*v /= m as f32;
@@ -376,3 +395,6 @@ pub(crate) fn path(src: &Image, out: Rect, ctx: &Ctx, paths: &[BlurPath]) -> Vec
}
res
}
#[cfg(test)]
mod prepared_spin_tests;
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn all_rotation_tables_keep_the_direct_bits() {
for angle in [0.0, 0.1, 15.0, 73.7, 180.0, 360.0, f32::NAN, f32::INFINITY] {
let p = SpinPin { blur_angle: angle, ..Default::default() };
let plans = spin_plan::prepare(&[p]).unwrap();
let theta = angle.clamp(0.0, 360.0).to_radians();
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
let (a, b) = phi.sin_cos();
let (c, d) = plans[0].rotation(count, j).unwrap();
assert_eq!((a.to_bits(), b.to_bits()), (c.to_bits(), d.to_bits()));
}
}
}
assert!(spin_plan::prepare(&vec![SpinPin::default(); 121]).is_none());
}
#[test]
fn prepared_spin_matches_the_original_kernel() {
let bounds = Rect::new(-17, -11, 58, 45);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
let mut src = Image::new(bounds, n);
for (i, v) in src.data.iter_mut().enumerate() {
*v = ((i * 71 + i / n * 107) % 65536) as f32 / 65535.0;
}
for (i, px) in src.data.chunks_exact_mut(n).enumerate() {
if i % 11 == 0 {
px[n - 1] = 0.0;
}
}
for angle in [0.0, 0.1, 15.0, 91.0, 360.0, f32::NAN] {
let pins = [
SpinPin { x: 0.2, y: 0.3, radius_x: 0.25, radius_y: 0.5, angle: 31.0, blur_angle: angle },
SpinPin { x: 0.7, y: 0.6, radius_x: 0.8, radius_y: 0.6, angle: -15.0, blur_angle: angle },
];
let out = Rect::new(-19, -8, 55, 49);
let a = spin(&src, out, &ctx, &pins);
let b = spin_impl::<0>(&src, out, &ctx, &pins, false);
let first = a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits());
assert_eq!(first, None, "{mode:?} alpha {alpha} angle {angle}");
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//! All possible sample counts share a bounded rotation table per pin.
use crate::SpinPin;
pub(super) struct Plan {
rotations: Vec<(f32, f32)>,
}
impl Plan {
#[cfg(test)]
pub(super) fn rotation(&self, count: usize, sample: usize) -> Option<(f32, f32)> {
self.rotations(count)?.get(sample).copied()
}
pub(super) fn rotations(&self, count: usize) -> Option<&[(f32, f32)]> {
if !(2..=64).contains(&count) {
return None;
}
let start = count.checked_mul(count - 1)?.checked_div(2)?.checked_sub(1)?;
self.rotations.get(start..start.checked_add(count)?)
}
}
pub(super) fn prepare(pins: &[SpinPin]) -> Option<Vec<Plan>> {
// Under 2 MiB, even for 120 overlapping pins. Larger lists keep direct sampling.
if pins.len() > 120 {
return None;
}
let mut plans = Vec::new();
plans.try_reserve_exact(pins.len()).ok()?;
for p in pins {
let theta = p.blur_angle.clamp(0.0, 360.0).to_radians();
let mut rotations = Vec::new();
rotations.try_reserve_exact(2079).ok()?;
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
rotations.push(phi.sin_cos());
}
}
plans.push(Plan { rotations });
}
Some(plans)
}
/// Fixed channels keep the bilinear accumulators in registers. Tap and multiply order
/// stay identical to add_sample_premul; alpha is still multiplied by the tap weight first.
pub(super) fn sample<const N: usize>(src: &crate::image::Image, x: f32, y: f32, clip: photocraft_geom::Rect, alpha: bool, acc: &mut [f32; 8]) {
if !x.is_finite() || !y.is_finite() || clip.is_empty() {
return;
}
let fx = (x - 0.5).clamp(clip.x0 as f32, (clip.x1 - 1) as f32);
let fy = (y - 0.5).clamp(clip.y0 as f32, (clip.y1 - 1) as f32);
let (x0, y0) = (fx.floor() as i32, fy.floor() as i32);
let (x1, y1) = ((x0 + 1).min(clip.x1 - 1), (y0 + 1).min(clip.y1 - 1));
let (ax, ay) = (fx - x0 as f32, fy - y0 as f32);
let w = src.rect.width() as usize;
let idx = |xx: i32, yy: i32| ((yy - src.rect.y0) as usize * w + (xx - src.rect.x0) as usize) * N;
for (i, k) in [(idx(x0, y0), (1.0 - ax) * (1.0 - ay)), (idx(x1, y0), ax * (1.0 - ay)), (idx(x0, y1), (1.0 - ax) * ay), (idx(x1, y1), ax * ay)] {
if k <= 0.0 {
continue;
}
let Some(px) = src.data.get(i..i + N).and_then(|s| s.first_chunk::<N>()) else {
return;
};
let a = if alpha { px.get(N - 1).copied().unwrap_or(0.0) * k } else { k };
if alpha {
for (sum, value) in acc.iter_mut().zip(px).take(N - 1) {
*sum += value * a;
}
if let Some(sum) = acc.get_mut(N - 1) {
*sum += a;
}
} else {
for (sum, value) in acc.iter_mut().zip(px).take(N) {
*sum += value * a;
}
}
}
}
+51 -2
View File
@@ -269,6 +269,10 @@ const TILE: i32 = 128;
/// to: samples clamp to it, and pixels mapping outside it take the edge mode. `map` returns where
/// each output pixel centre samples (`None` = outside).
pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync)) -> Surface {
remap_impl(src, frame, out_area, edge, map, true)
}
fn remap_impl(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync), shared: bool) -> Surface {
let sfmt = src.format();
let needs_alpha = matches!(edge, EdgeMode::Transparency) && !sfmt.alpha;
let ofmt = if needs_alpha { PixelFormat::new(sfmt.mode, sfmt.sample, true) } else { sfmt };
@@ -363,7 +367,14 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
let fy = ((s.p[1] - fy0).min(fy1 - s.p[1]) + 0.5).clamp(0.0, 1.0);
(fx * fy) as f32
};
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
// Color channels and coverage usually sample the same location. Prepare the 16
// tap addresses and weights once; each channel keeps its original summation order.
let center = if shared && (!rgb || (s.pr == s.p && s.pb == s.p)) { sample_channels(&buf, win, frame, n_in, s.p) } else { None };
let sample = |c: usize, p: [f64; 2]| {
let cached = if p == s.p { center.as_ref() } else { None };
cached.and_then(|v| v.get(c)).copied().unwrap_or_else(|| bicubic(c, p))
};
let alpha = if sfmt.alpha { sample(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
let mut col = [0.0f32; 8];
for (c, v) in col.iter_mut().enumerate().take(cc) {
let p = if rgb && c == 0 {
@@ -373,7 +384,7 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
} else {
s.p
};
let pm = bicubic(c, p);
let pm = sample(c, p);
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
let g = s.gain;
*v = if subtractive {
@@ -418,6 +429,41 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
out
}
fn sample_channels(buf: &[f32], win: Rect, frame: Rect, n: usize, p: [f64; 2]) -> Option<[f32; 8]> {
match n {
1 => sample_channels_impl::<1>(buf, win, frame, p),
2 => sample_channels_impl::<2>(buf, win, frame, p),
3 => sample_channels_impl::<3>(buf, win, frame, p),
4 => sample_channels_impl::<4>(buf, win, frame, p),
5 => sample_channels_impl::<5>(buf, win, frame, p),
8 => sample_channels_impl::<8>(buf, win, frame, p),
_ => None,
}
}
fn sample_channels_impl<const N: usize>(buf: &[f32], win: Rect, frame: Rect, p: [f64; 2]) -> Option<[f32; 8]> {
if N == 0 || N > 8 || win.is_empty() || frame.is_empty() || !p.iter().all(|v| v.is_finite()) {
return None;
}
let x = (p[0] - 0.5).clamp(frame.x0 as f64, frame.x1 as f64 - 1.0);
let y = (p[1] - 0.5).clamp(frame.y0 as f64, frame.y1 as f64 - 1.0);
let (xi, yi) = (x.floor(), y.floor());
let (wx, wy) = (catmull_rom((x - xi) as f32), catmull_rom((y - yi) as f32));
let mut acc = [0.0f32; 8];
for (j, wyv) in wy.iter().enumerate() {
let yy = ((yi as i32 + j as i32 - 1).clamp(win.y0, win.y1 - 1) - win.y0) as usize;
for (i, wxv) in wx.iter().enumerate() {
let xx = ((xi as i32 + i as i32 - 1).clamp(win.x0, win.x1 - 1) - win.x0) as usize;
let start = yy.checked_mul(win.width() as usize)?.checked_add(xx)?.checked_mul(N)?;
let px = buf.get(start..start.checked_add(N)?)?.first_chunk::<N>()?;
let weight = wxv * wyv;
for (a, v) in acc.iter_mut().zip(px) {
*a += weight * *v;
}
}
}
Some(acc)
}
/// Applies a lens correction to `src` over `frame` (normally the canvas).
pub fn correct(src: &Surface, frame: Rect, lc: &LensCorrection) -> Surface {
let map = LensMap::new(lc, frame);
@@ -522,3 +568,6 @@ mod tests {
assert!(out.pixel(0, 0)[0] < 0.4 && (out.pixel(20, 20)[0] - 0.5).abs() < 0.02, "{:?}", out.pixel(0, 0));
}
}
#[cfg(test)]
mod shared_sample_tests;
@@ -0,0 +1,54 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn shared_interpolation_matches_original_at_all_depths_and_edges() {
let bounds = Rect::new(-13, -9, 39, 26);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, sample, alpha);
let n = fmt.channels();
let mut s = Surface::new(fmt);
let data: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize * n)
.map(|i| if i % 17 == 0 { 0.0 } else { ((i * 103 + i / n * 71) % 65536) as f32 / 65535.0 })
.collect();
s.write_region(bounds, &data);
for edge in [EdgeMode::Transparency, EdgeMode::Extension, EdgeMode::Color([0.2, 0.5, 0.7, 0.4])] {
for ca in [false, true] {
let map = |x: f64, y: f64| {
if (x as i32 + y as i32) % 13 == 0 {
return None;
}
let p = [x * 0.83 - 3.7, y * 1.14 + 1.3];
Some(Sample { p, pr: if ca { [p[0] - 0.9, p[1] + 0.3] } else { p }, pb: if ca { [p[0] + 0.7, p[1] - 0.4] } else { p }, gain: 1.17 })
};
let out = bounds.inflate(3);
let a = remap_impl(&s, bounds, out, edge, &map, true).read_region(out);
let b = remap_impl(&s, bounds, out, edge, &map, false).read_region(out);
assert_eq!(
a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits()),
None,
"{mode:?} {sample:?} alpha {alpha} edge {edge:?} ca {ca}"
);
}
}
}
}
}
}
#[test]
fn shared_sampler_rejects_bad_windows_and_nonfinite_points() {
let win = Rect::new(0, 0, 3, 3);
assert!(sample_channels(&[], win, win, 4, [1.0, 1.0]).is_none());
assert!(sample_channels(&[0.0; 36], win, win, 4, [f64::NAN, 1.0]).is_none());
assert!(sample_channels(&[], Rect::EMPTY, win, 4, [1.0, 1.0]).is_none());
}
+14
View File
@@ -2,6 +2,9 @@
use photocraft_geom::Rect;
mod extrude;
mod wind;
use crate::fxutil::{MAXC, luma, native, ncol, subtractive, xy};
use crate::image::Image;
use crate::noise::hash01;
@@ -86,6 +89,10 @@ pub(crate) fn extrude_reach(depth: f32) -> f32 {
/// height, with shaded sides); Pyramids raise four shaded triangular faces.
/// Heights are random or follow the cell's brightness.
pub(crate) fn extrude(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
extrude::blocks(src, out, ctx, spec).unwrap_or_else(|| extrude_direct(src, out, ctx, spec))
}
fn extrude_direct(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
let n = src.ch;
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let b = ctx.bounds;
@@ -358,6 +365,10 @@ pub(crate) fn wind_reach(method: WindMethod) -> f32 {
/// Wind: streaks trail downwind from edges (Blast: longer and denser);
/// Stagger shifts random row segments along the wind.
pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
wind::streaks(src, out, ctx, method, from_right, seed).unwrap_or_else(|| wind_direct(src, out, ctx, method, from_right, seed))
}
fn wind_direct(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
let n = src.ch;
let lmax = wind_reach(method) as i32;
// Streaks move downwind: from the left they extend to the right (+x).
@@ -406,3 +417,6 @@ pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_r
}
res
}
#[cfg(test)]
mod tests;
+159
View File
@@ -0,0 +1,159 @@
//! Rasterize each swept block only where it can hit, in the original cell order.
use super::{ExtrudeSpec, extrude_reach, read_px};
use crate::fxutil::{MAXC, luma, ncol, subtractive, unpremul_px};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, ExtrudeType};
use photocraft_geom::Rect;
const MAX_PIXELS: usize = 4_194_304;
const MAX_CELLS: i64 = 262_144;
// Beyond this, f32 pixel centres can lose enough precision to widen the sweep bounds.
const MAX_COORD: i32 = 1 << 22;
pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Option<Vec<f32>> {
if spec.kind != ExtrudeType::Blocks || src.ch == 0 || src.ch > MAXC || !spec.size.is_finite() || !spec.depth.is_finite() {
return None;
}
let b = ctx.bounds;
if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) {
return None;
}
let n = src.ch;
let w = out.width() as usize;
let count = w.checked_mul(out.height() as usize)?;
if count > MAX_PIXELS {
return None;
}
let mut heights = Vec::new();
heights.try_reserve_exact(count).ok()?;
heights.resize(count, f32::NEG_INFINITY);
let mut res = src.crop(out);
let area = out.intersect(&b);
if area.is_empty() {
return Some(res);
}
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let reach = extrude_reach(spec.depth);
let k = (reach / cs as f32).ceil() as i32 + 1;
let (gx0, gy0) = ((area.x0 - b.x0).div_euclid(cs), (area.y0 - b.y0).div_euclid(cs));
let (gx1, gy1) = ((area.x1 - 1 - b.x0).div_euclid(cs), (area.y1 - 1 - b.y0).div_euclid(cs));
let (cx0, cy0) = ((gx0 - k).max(0), (gy0 - k).max(0));
let (cx1, cy1) = ((gx1 + k).min((b.width() as i32 - 1) / cs), (gy1 + k).min((b.height() as i32 - 1) / cs));
if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS {
return None;
}
let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0);
let half = cs as f32 / 2.0;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
// Ascending gy, then gx, is the tie order of every pixel's old candidate search.
for cy in cy0..=cy1 {
for cx in cx0..=cx1 {
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
let clipped = r.intersect(&b);
if spec.mask_incomplete && clipped != r {
continue;
}
let mut col = [0.0f32; MAXC];
let mut cnt = 0.0;
let step = (cs / 8).max(1) as usize;
for yy in (clipped.y0..clipped.y1).step_by(step) {
for xx in (clipped.x0..clipped.x1).step_by(step) {
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
for (c, v) in col.iter_mut().enumerate().take(n) {
let s = src.get(xx, yy, c);
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
}
cnt += 1.0;
}
}
if cnt > 0.0 {
for v in col.iter_mut().take(n) {
*v /= cnt;
}
}
unpremul_px(&mut col[..n], ctx.alpha);
let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) };
if !h.is_finite() {
return None;
}
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
let off = reach * h / half_diag;
let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off);
// Two extra pixels conservatively cover rounding in the unchanged interval test.
let sweep = Rect::new(
(mx.min(mx + ox) - half).floor() as i32 - 2,
(my.min(my + oy) - half).floor() as i32 - 2,
(mx.max(mx + ox) + half).ceil() as i32 + 2,
(my.max(my + oy) + half).ceil() as i32 + 2,
)
.intersect(&area);
for y in sweep.y0..sweep.y1 {
let gy = (y - b.y0).div_euclid(cs);
if (cy - gy).abs() > k {
continue;
}
let fy = y as f32 + 0.5;
let Some(iy) = interval(fy, my, oy, half) else { continue };
for x in sweep.x0..sweep.x1 {
if (cx - (x - b.x0).div_euclid(cs)).abs() > k {
continue;
}
let i = (y - out.y0) as usize * w + (x - out.x0) as usize;
let height = heights.get_mut(i)?;
if *height >= h {
continue;
}
let fx = x as f32 + 0.5;
let Some(ix) = interval(fx, mx, ox, half) else { continue };
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
if lo > hi {
continue;
}
*height = h;
let face = if hi >= 1.0 {
0
} else if ix.1 < iy.1 {
if ox > 0.0 { 1 } else { 2 }
} else if oy > 0.0 {
3
} else {
4
};
let px = res.get_mut(i * n..(i + 1) * n)?;
if face == 0 && !spec.solid_front {
let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32);
read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px);
} else {
let shade = match face {
0 => 1.0,
1 => 0.85,
2 => 0.55,
3 => 0.95,
_ => 0.45,
};
for (v, c) in px.iter_mut().zip(&col).take(cc) {
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
}
if ctx.alpha {
*px.last_mut()? = col.get(n - 1).copied()?;
}
}
}
}
}
}
Some(res)
}
fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> {
if o.abs() < 1e-6 {
return ((p - m).abs() <= half).then_some((0.0, 1.0));
}
let (a, b) = ((p - m - half) / o, (p - m + half) / o);
let (lo, hi) = (a.min(b).max(0.0), a.max(b).min(1.0));
(lo <= hi).then_some((lo, hi))
}
+190
View File
@@ -0,0 +1,190 @@
use super::*;
use crate::{FilterParams, apply_tiled};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn image(rect: Rect, n: usize, kind: usize) -> Image {
let mut img = Image::new(rect, n);
let mut s = 0x2545_f491u32;
for (i, v) in img.data.iter_mut().enumerate() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = match kind {
0 => (s % 256) as f32 / 255.0,
1 => ((i / n) % 5) as f32 / 4.0,
2 => 0.0,
3 => (s % 65536) as f32 / 65535.0,
_ => [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, -2.0, 3.5, 0.5][s as usize % 7],
};
}
img
}
fn bits(v: Vec<f32>) -> Vec<u32> {
v.into_iter().map(f32::to_bits).collect()
}
const MODELS: [ColorMode; 8] =
[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel];
#[test]
fn swept_blocks_match_the_pixel_search() {
let bounds = Rect::new(-13, -9, 31, 26);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds.inflate(20), n, kind);
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
for flags in 0..8 {
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
depth,
level_based: flags & 1 != 0,
solid_front: flags & 2 != 0,
mask_incomplete: flags & 4 != 0,
seed: 17,
};
let out = Rect::new(-17, -2, 24, 29);
assert_eq!(
bits(extrude(&src, out, &ctx, &spec)),
bits(extrude_direct(&src, out, &ctx, &spec)),
"{mode:?} alpha {alpha} kind {kind} size {size} depth {depth} flags {flags}"
);
}
}
}
}
}
}
#[test]
fn streak_scatter_matches_the_pixel_search() {
let bounds = Rect::new(-17, -9, 39, 14);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds, n, kind);
for method in [WindMethod::Wind, WindMethod::Blast, WindMethod::Stagger] {
for right in [false, true] {
for seed in [0, 1, 777, u32::MAX] {
let out = bounds.inflate(3);
assert_eq!(
bits(wind(&src, out, &ctx, method, right, seed)),
bits(wind_direct(&src, out, &ctx, method, right, seed)),
"{mode:?} alpha {alpha} kind {kind} {method:?} right {right} seed {seed}"
);
}
}
}
}
}
}
}
#[test]
fn thin_and_partial_windows_match() {
for bounds in [Rect::new(0, 0, 1, 1), Rect::new(-5, -6, -4, 31), Rect::new(3, 8, 43, 9)] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for src in [image(bounds, 4, 0), image(bounds.inflate(2), 4, 1)] {
let out = bounds.inflate(2);
for right in [false, true] {
assert_eq!(bits(wind(&src, out, &ctx, WindMethod::Blast, right, 17)), bits(wind_direct(&src, out, &ctx, WindMethod::Blast, right, 17)));
}
for kind in [ExtrudeType::Blocks, ExtrudeType::Pyramids] {
let spec = ExtrudeSpec { kind, size: 8.0, depth: 255.0, level_based: true, solid_front: false, mask_incomplete: false, seed: 17 };
assert_eq!(bits(extrude(&src, out, &ctx, &spec)), bits(extrude_direct(&src, out, &ctx, &spec)));
}
}
}
}
#[test]
fn block_bounds_cover_float_rounding_and_ties() {
for offset in [0, -(1 << 22), (1 << 22) - 80] {
let bounds = Rect::new(offset, offset, offset + 64, offset + 49);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 1);
for size in [2.0, 7.0, 19.0] {
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, depth: 255.0, level_based: true, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, bounds, &ctx, &spec).is_some());
assert_eq!(bits(extrude(&src, bounds, &ctx, &spec)), bits(extrude_direct(&src, bounds, &ctx, &spec)));
}
}
}
#[test]
fn optional_scratch_rejects_large_and_unsupported_geometry() {
let bounds = Rect::new(0, 0, 1, 1);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 0);
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size: 2.0, depth: 255.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, Rect::new(0, 0, 4096, 4096), &ctx, &spec).is_none());
assert!(wind::streaks(&src, Rect::new(0, 0, 1_048_577, 1), &ctx, WindMethod::Blast, false, 0).is_none());
let far = Ctx { bounds: Rect::new(10_000_000, 0, 10_000_010, 10), ..ctx };
assert!(extrude::blocks(&src, bounds, &far, &spec).is_none());
}
#[test]
fn storage_selection_and_tile_sizes_match_original_kernels() {
let bounds = Rect::new(-11, -7, 32, 24);
let area = Rect::new(-9, -5, 29, 21);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(mode, sample, true);
let ctx = Ctx { bounds, mode, alpha: true };
let img = image(bounds, fmt.channels(), 3);
let mut s = Surface::new(fmt);
s.write_region(bounds, &img.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let mask: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize).map(|i| (i % 5) as f32 / 4.0).collect();
selection.write_region(bounds, &mask);
let cases = [
FilterParams::Extrude {
kind: ExtrudeType::Blocks,
size: 7.0,
depth: 170.0,
level_based: true,
solid_front: false,
mask_incomplete: false,
seed: 17,
},
FilterParams::Wind { method: WindMethod::Blast, from_right: true, seed: 17 },
];
for p in cases {
let halo = match p {
FilterParams::Extrude { .. } => 70,
_ => 40,
};
let src = Image::read_clamped(&s, area.inflate(halo), bounds);
let mut raw = match p {
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
extrude_direct(&src, area, &ctx, &ExtrudeSpec { kind, size, depth, level_based, solid_front, mask_incomplete, seed })
}
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&s, &p, area, bounds, Some(&selection), tile, Some(bounds));
assert_eq!(bits(got.read_region(bounds)), bits(want.read_region(bounds)), "{mode:?} {sample:?} {} tile {tile}", p.label());
}
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Scatter each source streak once, retaining the old nearest-source tie rule.
use super::{read_px, wind_reach};
use crate::fxutil::{MAXC, luma};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, WindMethod};
use photocraft_geom::Rect;
const MAX_ROW: usize = 1_048_576;
pub(super) fn streaks(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Option<Vec<f32>> {
if method == WindMethod::Stagger || src.ch == 0 || src.ch > MAXC {
return None;
}
let n = src.ch;
let w = out.width() as usize;
if w == 0 || w > MAX_ROW || out.is_empty() {
return None;
}
let lmax = wind_reach(method) as i32;
let d = if from_right { -1 } else { 1 };
// Include the upstream halo and one more source for the edge difference.
let start = if from_right { out.x1.checked_add(lmax - 1)? } else { out.x0.checked_sub(lmax)? };
start.checked_sub(d)?;
let sources = w.checked_add(lmax as usize)?;
let mut best = Vec::new();
best.try_reserve_exact(w).ok()?;
best.resize(w, (0.0f32, lmax + 1));
let mut res = src.crop(out);
let mut tmp = [0.0f32; MAXC];
let strength = if method == WindMethod::Blast { 8.0 } else { 4.0 };
for y in out.y0..out.y1 {
best.fill((0.0, lmax + 1));
read_px(src, start - d, y, &mut tmp[..n]);
let mut prev = luma(ctx, &tmp[..n]);
for step in 0..sources {
let s = i32::try_from(i64::from(start) + i64::from(d) * i64::try_from(step).ok()?).ok()?;
read_px(src, s, y, &mut tmp[..n]);
let lum = luma(ctx, &tmp[..n]);
let edge = ((lum - prev).abs() * strength).min(1.0);
prev = lum;
if edge <= 0.0 {
continue;
}
let len = (hash01(s, y, 44, seed).powi(2) * lmax as f32).max(1.0);
for k in 1..=(len.floor() as i32).min(lmax) {
let x = s.checked_add(d * k)?;
if x < out.x0 || x >= out.x1 {
continue;
}
let weight = edge * (1.0 - k as f32 / (len + 1.0));
let hit = best.get_mut((x - out.x0) as usize)?;
// Sources are visited downwind; equal weights prefer the smallest original k.
if weight > hit.0 || (weight == hit.0 && k < hit.1) {
*hit = (weight, k);
}
}
}
let row = (y - out.y0) as usize * w * n;
for (x, &(weight, k)) in best.iter().enumerate() {
if weight > 0.0 {
let sx = i32::try_from(i64::from(out.x0) + x as i64 - i64::from(d * k)).ok()?;
read_px(src, sx, y, &mut tmp[..n]);
let px = res.get_mut(row + x * n..row + (x + 1) * n)?;
for (v, s) in px.iter_mut().zip(&tmp) {
*v += (*s - *v) * weight;
}
}
}
}
Some(res)
}
+140 -3
View File
@@ -8,8 +8,82 @@
use crate::photo_util::par_rows;
/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px.
/// Prefix/suffix block maxima, with clipped edge windows and the original zero floor.
/// One comparison per sample in each direction, independent of the trap width.
fn max_line(out: &mut [f32], radius: usize, get: impl Fn(usize) -> f32) {
let len = out.len();
if len == 0 {
return;
}
let r = radius.min(len - 1);
let mut suffix = Vec::new();
if r <= 2 || suffix.try_reserve_exact(len).is_err() {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
suffix.resize(len, 0.0f32);
let block = r.saturating_mul(2).saturating_add(1);
let mut acc = 0.0f32;
let mut negative_zero = false;
for (i, v) in out.iter_mut().enumerate() {
if i % block == 0 {
acc = 0.0;
}
let value = get(i);
negative_zero |= value.to_bits() == (-0.0f32).to_bits();
acc = acc.max(value);
*v = acc;
}
// f32::max may choose either sign of equal zero depending on operand order.
// Preserve the original left-to-right fold for these uncommon float lines.
if negative_zero {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
acc = 0.0;
for (i, v) in suffix.iter_mut().enumerate().rev() {
if i == len - 1 || (i + 1) % block == 0 {
acc = 0.0;
}
acc = acc.max(get(i));
*v = acc;
}
for x in 0..len {
let (lo, hi) = (x.saturating_sub(r), x.saturating_add(r).min(len - 1));
let value = if lo == 0 {
out.get(hi).copied().unwrap_or(0.0)
} else if hi == len - 1 && lo / block == hi / block {
suffix.get(lo).copied().unwrap_or(0.0)
} else {
suffix.get(lo).copied().unwrap_or(0.0).max(out.get(hi).copied().unwrap_or(0.0))
};
if let Some(v) = out.get_mut(x) {
*v = value;
}
}
}
/// A channel's square dilation, transposed so each vertical scan writes a contiguous row.
fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
if r <= 2 {
return dilate_channel_direct(src, w, h, ch, c, r);
}
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
max_line(row, r, |x| src.get((y * w + x) * ch + c).copied().unwrap_or(0.0));
});
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, h, 1, |x, column| {
max_line(column, r, |y| tmp.get(y * w + x).copied().unwrap_or(0.0));
});
out
}
fn dilate_channel_direct(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c];
// Horizontal pass.
let mut tmp = vec![0.0f32; w * h];
@@ -44,14 +118,14 @@ fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize
/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has
/// no ink at all (paper), so only internal colour edges gain a trap band.
pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) {
if width == 0 || w == 0 || h == 0 || ch < 4 {
if width == 0 || w == 0 || h == 0 || ch < 4 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)) != Some(px.len()) {
return;
}
let inks = 4.min(ch);
let dil: Vec<Vec<f32>> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect();
par_rows(px, w, ch, |y, row| {
for (x, pixel) in row.chunks_exact_mut(ch).enumerate() {
let i = y * w + x;
let i = if width <= 2 { y * w + x } else { x * h + y };
// Printed where any ink is present.
let inked = (0..inks).any(|c| pixel[c] > 1e-4);
if !inked {
@@ -123,3 +197,66 @@ mod tests {
assert_eq!(px, orig);
}
}
#[cfg(test)]
mod linear_tests {
use super::*;
#[test]
fn all_clipped_windows_match_the_direct_maximum() {
let mut seed = 1234567u32;
for len in 1..90 {
let values: Vec<f32> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, -2.0, 0.25, 1.0, 2.0][seed as usize % 9]
})
.collect();
for radius in [0, 1, 2, 3, 5, 16, 50, 128, usize::MAX] {
let mut out = vec![0.0; len];
max_line(&mut out, radius, |i| values[i]);
for (x, v) in out.iter().enumerate() {
let want = (x.saturating_sub(radius)..=x.saturating_add(radius).min(len - 1)).fold(0.0f32, |m, i| m.max(values[i]));
assert_eq!(v.to_bits(), want.to_bits(), "len {len} radius {radius} x {x}");
}
}
}
}
#[test]
fn trap_preserves_paper_alpha_and_spare_channels_exactly() {
for (w, h, ch) in [(1, 31, 5), (47, 1, 4), (37, 29, 4), (37, 29, 5), (23, 19, 8)] {
for radius in [1, 3, 10, 128] {
let src: Vec<f32> = (0..w * h * ch).map(|i| if (i / ch) % 5 == 0 { 0.0 } else { ((i * 71 + i / ch * 103) % 65536) as f32 / 65535.0 }).collect();
let mut want = src.clone();
for y in 0..h {
for x in 0..w {
let index = (y * w + x) * ch;
if !(0..4).any(|c| src[index + c] > 1e-4) {
continue;
}
for c in 0..4 {
let mut m = 0.0f32;
for yy in y.saturating_sub(radius)..=y.saturating_add(radius).min(h - 1) {
for xx in x.saturating_sub(radius)..=x.saturating_add(radius).min(w - 1) {
m = m.max(src[(yy * w + xx) * ch + c]);
}
}
want[index + c] = src[index + c].max(m);
}
}
}
let mut got = src;
trap(&mut got, w, h, ch, radius);
assert_eq!(got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), want.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
}
}
}
#[test]
fn malformed_buffer_is_left_alone() {
let mut pixels = vec![0.5; 20];
let original = pixels.clone();
trap(&mut pixels, usize::MAX, 2, 5, 10);
assert_eq!(pixels, original);
}
}
+646
View File
@@ -0,0 +1,646 @@
{
"base_commit": "7722172585a01cbdb93c06f0f5ff2634fcb17999",
"binary_sha256": {
"before": "bb14e16ad044ecee5a6355e2393030e1a1a7c9f42eab774932790417291eac5b",
"after": "b30d377aba15113382d2f7d9d17415caaa59eb00558fdbef584f25da9063e050"
},
"workers": 16,
"runs_per_case": 3,
"cases": [
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
2.064125,
2.0476,
2.079877
],
"after_seconds": [
0.329602,
0.331145,
0.326371
],
"before_median_seconds": 2.064125,
"after_median_seconds": 0.329602,
"speedup": 6.262477169434652,
"output_sha256": [
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7",
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7",
"fd2d0948da70b40e73140752bb9a6c031a56b90b5cb45674c193e120fb3847e7"
]
},
{
"case": "extrude-dense",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
161.319687,
159.928418,
160.300969
],
"after_seconds": [
0.389538,
0.368512,
0.386554
],
"before_median_seconds": 160.300969,
"after_median_seconds": 0.386554,
"speedup": 414.69230430935914,
"output_sha256": [
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab",
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab",
"21cb6ba46bdcb57483d35215bd2c9d4037c869d1f707485744c47a96cb5e71ab"
]
},
{
"case": "extrude",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
3.318431,
3.118615,
3.153926
],
"after_seconds": [
0.495448,
0.511174,
0.486668
],
"before_median_seconds": 3.153926,
"after_median_seconds": 0.495448,
"speedup": 6.3658063005603,
"output_sha256": [
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb",
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb",
"187fa5c5ce673080385953f2b19f0c210b3a21c620f10611904f2f9ac43835fb"
]
},
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
2.145204,
2.153517,
2.151485
],
"after_seconds": [
0.400436,
0.394881,
0.405546
],
"before_median_seconds": 2.151485,
"after_median_seconds": 0.400436,
"speedup": 5.372856086865317,
"output_sha256": [
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d",
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d",
"c991f02f91ae4a1110cebac56c10799e5c40f52c0dbc386dc34a687c575fd47d"
]
},
{
"case": "extrude",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
2.231126,
2.254061,
2.296168
],
"after_seconds": [
0.476943,
0.481711,
0.477403
],
"before_median_seconds": 2.254061,
"after_median_seconds": 0.477403,
"speedup": 4.721505729959803,
"output_sha256": [
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e",
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e",
"7495bee1ce54d961e780139010d4bb5604a379ab09d0bfc269eeade232dca27e"
]
},
{
"case": "blast",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
1.427601,
1.484093,
1.441316
],
"after_seconds": [
0.408616,
0.427326,
0.411401
],
"before_median_seconds": 1.441316,
"after_median_seconds": 0.411401,
"speedup": 3.503433389807025,
"output_sha256": [
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512",
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512",
"902cbdc3491e45d7a795371c5d9f2649f31d0466371ab407b921815c6b1c9512"
]
},
{
"case": "blast",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
2.251381,
2.134731,
2.164608
],
"after_seconds": [
0.65199,
0.616526,
0.632713
],
"before_median_seconds": 2.164608,
"after_median_seconds": 0.632713,
"speedup": 3.421153034630235,
"output_sha256": [
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff",
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff",
"2bbc9893dfdb35bc6cf88050c898c6fcb9280de4bd0d48a4c949489bb8c4d9ff"
]
},
{
"case": "blast",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
6.125603,
6.230961,
6.299123
],
"after_seconds": [
0.708941,
0.709191,
0.742445
],
"before_median_seconds": 6.230961,
"after_median_seconds": 0.709191,
"speedup": 8.786012512849148,
"output_sha256": [
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0",
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0",
"57fbc0141bbc01343ea160d77fe00bc177ca1821276c1eb03d466f6451f7f1c0"
]
},
{
"case": "blast-right",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
1.502531,
1.515667,
1.504332
],
"after_seconds": [
0.483179,
0.48207,
0.480633
],
"before_median_seconds": 1.504332,
"after_median_seconds": 0.48207,
"speedup": 3.120567552430145,
"output_sha256": [
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065",
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065",
"b0a197677513fae949ad37bfa1fdeb868b2bd8b49b6ebcaefe545d6138cc1065"
]
},
{
"case": "wind",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
0.766914,
0.76776,
0.773241
],
"after_seconds": [
0.482883,
0.486564,
0.506624
],
"before_median_seconds": 0.76776,
"after_median_seconds": 0.486564,
"speedup": 1.5779219177744346,
"output_sha256": [
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2",
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2",
"c08e7ab142a84d8e2bdeecdcb22bb58d0bad159c89075a6ba65679aa9d0164a2"
]
},
{
"case": "wind",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
2.627367,
2.582852,
2.524798
],
"after_seconds": [
0.693205,
0.656818,
0.627828
],
"before_median_seconds": 2.582852,
"after_median_seconds": 0.656818,
"speedup": 3.932370915535201,
"output_sha256": [
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1",
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1",
"a46653d72bbf346a7595a66486a79882f899b160cdb57ea797e2bdb0fe9ae6e1"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
6.250012,
6.330716,
6.585078
],
"after_seconds": [
3.967455,
3.909264,
3.891597
],
"before_median_seconds": 6.330716,
"after_median_seconds": 3.909264,
"speedup": 1.619413782236247,
"output_sha256": [
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e",
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e",
"5d372066a16f19e0c2caeffe80fb020f372c0da8a2bea9075ff2b4b51b2c037e"
]
},
{
"case": "spin-default",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
1.596144,
1.590946,
1.63901
],
"after_seconds": [
1.132313,
1.112719,
1.120303
],
"before_median_seconds": 1.596144,
"after_median_seconds": 1.120303,
"speedup": 1.4247431275288918,
"output_sha256": [
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb",
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb",
"858c74f4adbf59f7cbcd23341f4728bada0bbfb682a387e976c4df5f99b7cbfb"
]
},
{
"case": "spin",
"width": 7200,
"height": 5000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
9.433136,
9.43879,
9.453744
],
"after_seconds": [
5.895634,
5.867902,
5.86936
],
"before_median_seconds": 9.43879,
"after_median_seconds": 5.86936,
"speedup": 1.608146373710251,
"output_sha256": [
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f",
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f",
"551be3b93c55a209c08e0b044a1c49ce05ebeed7c622dea1a375970db3c92c5f"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "rgb",
"before_seconds": [
6.27807,
6.306617,
6.322252
],
"after_seconds": [
3.954162,
3.961668,
3.989054
],
"before_median_seconds": 6.306617,
"after_median_seconds": 3.961668,
"speedup": 1.5919095189198085,
"output_sha256": [
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092",
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092",
"b58cd212eab11c55667d38725deef4e12195ff2186e6f40afdb9b0120d19e092"
]
},
{
"case": "spin",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
6.445198,
6.374566,
6.3749
],
"after_seconds": [
4.061227,
4.061895,
4.086393
],
"before_median_seconds": 6.3749,
"after_median_seconds": 4.061895,
"speedup": 1.569439879662079,
"output_sha256": [
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9",
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9",
"c05682c67fade43cd7f05a9d7cb624cad6ea17df100c766df9b65707296459d9"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
0.617702,
0.6242,
0.620756
],
"after_seconds": [
0.446379,
0.444779,
0.444774
],
"before_median_seconds": 0.620756,
"after_median_seconds": 0.444779,
"speedup": 1.3956504241432262,
"output_sha256": [
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c",
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c",
"dde9489970cce66f360d9ad48af37ad4e27070cf84f28b5d3e9a9880ad753a6c"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "u16",
"mode": "cmyk",
"before_seconds": [
0.806734,
0.812071,
0.807998
],
"after_seconds": [
0.564873,
0.567843,
0.562198
],
"before_median_seconds": 0.807998,
"after_median_seconds": 0.564873,
"speedup": 1.4304064807487702,
"output_sha256": [
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01",
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01",
"8d7236617b986dd3b6aa8873b6ce2d79694ea11d330e952a7990a4886ef72c01"
]
},
{
"case": "lens",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "rgb",
"before_seconds": [
0.681554,
0.695372,
0.684666
],
"after_seconds": [
0.511379,
0.510318,
0.508948
],
"before_median_seconds": 0.684666,
"after_median_seconds": 0.510318,
"speedup": 1.341645797326373,
"output_sha256": [
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb",
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb",
"d78838902900d89a95811fd22e2bbcc7487d0d7d8442bde36e896579d62bbebb"
]
},
{
"case": "lens-ca",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "rgb",
"before_seconds": [
0.618384,
0.621923,
0.621404
],
"after_seconds": [
0.62899,
0.646063,
0.631199
],
"before_median_seconds": 0.621404,
"after_median_seconds": 0.631199,
"speedup": 0.9844819145784451,
"output_sha256": [
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926",
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926",
"537a709a3f841fe8938f977fc47b896d342771df27dc386d78f151b6c24e0926"
]
},
{
"case": "trap",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
0.970982,
0.968794,
0.97437
],
"after_seconds": [
0.808246,
0.80402,
0.834011
],
"before_median_seconds": 0.970982,
"after_median_seconds": 0.808246,
"speedup": 1.201344640121943,
"output_sha256": [
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea",
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea",
"20e01beda3d74c49b6000376cdf246ab4546731d03acdf2d3352386abf05c8ea"
]
},
{
"case": "trap-wide",
"width": 7200,
"height": 5000,
"depth": "u16",
"mode": "cmyk",
"before_seconds": [
4.641605,
4.626547,
4.62479
],
"after_seconds": [
1.333131,
1.323794,
1.337324
],
"before_median_seconds": 4.626547,
"after_median_seconds": 1.333131,
"speedup": 3.470436888797875,
"output_sha256": [
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074",
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074",
"836270556943a6a9a30e1fc0e2d862d7cf39e31e1899526b73269e480460f074"
]
},
{
"case": "trap",
"width": 6000,
"height": 4000,
"depth": "f32",
"mode": "cmyk",
"before_seconds": [
1.192386,
1.177692,
1.203718
],
"after_seconds": [
1.027262,
1.023985,
1.043099
],
"before_median_seconds": 1.192386,
"after_median_seconds": 1.027262,
"speedup": 1.1607418555344207,
"output_sha256": [
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361",
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361",
"33a7e49036067ef4d38ee9a24c4e6c1c9b22490d587d18e08ff6721e45732361"
]
},
{
"case": "trap-default",
"width": 6000,
"height": 4000,
"depth": "u8",
"mode": "cmyk",
"before_seconds": [
0.637037,
0.636293,
0.632893
],
"after_seconds": [
0.650605,
0.64445,
0.652568
],
"before_median_seconds": 0.636293,
"after_median_seconds": 0.650605,
"speedup": 0.9780020135105018,
"output_sha256": [
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8",
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8",
"eb3c498a1511f6a57de1ea50a7b7e2b64af537a42fd82c670ad7920d5c6b8dd8"
]
}
],
"measured_at": "2026-10-10",
"after_commit": "849b8ff4eec0fa212b67f9b3c93a18dc792cd53c",
"instance": "AWS m7i.4xlarge",
"cpu": "Intel Xeon Platinum 8488C",
"ram_gib": 61,
"os": "Ubuntu 24.04.5",
"rustc": "1.99.0 (b940084d7 2026-09-28)",
"pair_orders": [
[
"before",
"after"
],
[
"after",
"before"
],
[
"before",
"after"
]
],
"exact_output_pairs": 72,
"equivalent_after_commit": "8782e1c0997fcc340e0463c6f9279855ad1819f5",
"rebased_onto": "3e000bc1d5f946fdba8c325532cb7e976f8f0538",
"unchanged_build_inputs_after_rebase": [
"crates/algo",
"crates/raster",
"crates/color",
"crates/geom",
"crates/cms",
"Cargo.toml",
"Cargo.lock",
".cargo"
],
"algo_tree": "65e87dd4d19d5aa8f715ca4ad734ddfe3cb82047"
}
+152
View File
@@ -0,0 +1,152 @@
# Five imaging algorithms: measured CPU performance
Measured on 2026-10-10. These changes remove repeated work from Extrude Blocks,
Wind/Blast, Spin Blur, Lens Correction and prepress Trap. They preserve the existing
output and parameter behavior. Measurements compare the full CPU operation against
`7722172585a01cbdb93c06f0f5ff2634fcb17999`, not just the inner kernel.
## What changed
- **Extrude Blocks:** visit each cell once, calculate its color and height once, then
rasterize its swept bounds. Keep the original candidate-range checks, intersection
tests and cell tie order. The default 30 px blocks benefit too, not only dense blocks.
- **Wind/Blast:** scatter each actual source streak once. Reuse source luminance and
hash work, retain the nearest-source tie rule, then blend once per output pixel.
- **Spin Blur:** prepare all 2..64 sample-count rotation tables once per pin. Fixed
channel samplers keep bilinear accumulators in registers without changing tap or
premultiplied-alpha arithmetic order.
- **Lens Correction:** share Catmull-Rom coordinates and weights across channels
sampling the same point, using fixed channel accumulators. RGB chromatic-aberration
offsets still use independent samples.
- **Trap:** replace wide-window ink scans with separable prefix/suffix block maxima,
independent of radius. Transpose the intermediate output for contiguous column
writes. Widths 1 and 2 retain the original path.
## Method
AWS m7i.4xlarge, 16 vCPUs, Intel Xeon Platinum 8488C, 61 GiB RAM, Ubuntu 24.04.5.
Rust `1.99.0 (b940084d7 2026-09-28)`, release profile, `RAYON_NUM_THREADS=16`.
Builds used independent target directories. No build or test ran during timing.
The final rebase adds upstream canvas and automation changes; the timed algorithm
source, fixture, dependencies and Cargo configuration are unchanged. The raw results
record both the measured revision and its equivalent rebased revision.
The identical committed `bench_algorithms` example runs at both revisions. It creates
native RGB or CMYK tiles at 8-bit, 16-bit or 32-bit float depth, with deterministic
65536-level busy texture, variable alpha and roughly 20% zero-alpha pixels. Bounds begin at
(-17, -29). A 128 x 128 operation warms each process before timing.
Each case has three independent before/after pairs, alternating process order:
before/after, after/before, before/after. The table reports the median of each set
of three, with speedup computed from unrounded medians. Fixture construction,
warmup, output serialization and result destruction are outside the timer.
For filters, the timer includes `apply_in`, tile reads, processing, writeback and
pruning. Lens times the full `lens::correct` call. Trap includes surface reads,
ink dilation, clone, writeback and pruning. Document flattening, undo bookkeeping
and UI rendering are outside these CPU measurements.
Every pair serializes every output sample as its normalized f32 bits in tile order.
All 72 before/after SHA-256 comparisons match exactly, including alpha. The
[raw results](five-algorithm-performance.json) contain all times, medians and hashes.
Extrude uses random-depth Blocks, seed 17, size/depth 30/30 px, or 8/128 px for
`extrude-dense`; solid-front, level-based and incomplete-cell masking are off.
Wind/Blast use seed 17; `blast-right` changes direction. `spin` uses one full-frame
elliptical pin, 30 degree blur and 17 degree orientation; `spin-default` uses the
unchanged default pin. Lens uses distortion 12, rotation 2.5 degrees and vignette 20;
`lens-ca` also enables red/cyan 12 and blue/yellow -8. Trap widths are 10, 50
(`trap-wide`) and 1 (`trap-default`) pixels.
## Full-operation results
| Case | Dimensions | Mode/depth | Before (s) | After (s) | Speedup |
|---|---|---|---:|---:|---:|
| `extrude` | 6000 x 4000 | RGB/u8 | 2.0641 | 0.3296 | 6.26x |
| `extrude-dense` | 6000 x 4000 | RGB/u8 | 160.3010 | 0.3866 | 414.69x |
| `extrude` | 7200 x 5000 | RGB/u8 | 3.1539 | 0.4954 | 6.37x |
| `extrude` | 6000 x 4000 | RGB/u16 | 2.1515 | 0.4004 | 5.37x |
| `extrude` | 6000 x 4000 | RGB/f32 | 2.2541 | 0.4774 | 4.72x |
| `blast` | 6000 x 4000 | RGB/u8 | 1.4413 | 0.4114 | 3.50x |
| `blast` | 7200 x 5000 | RGB/u8 | 2.1646 | 0.6327 | 3.42x |
| `blast` | 6000 x 4000 | CMYK/u8 | 6.2310 | 0.7092 | 8.79x |
| `blast-right` | 6000 x 4000 | RGB/u16 | 1.5043 | 0.4821 | 3.12x |
| `wind` | 6000 x 4000 | RGB/f32 | 0.7678 | 0.4866 | 1.58x |
| `wind` | 6000 x 4000 | CMYK/u8 | 2.5829 | 0.6568 | 3.93x |
| `spin` | 6000 x 4000 | RGB/u8 | 6.3307 | 3.9093 | 1.62x |
| `spin-default` | 6000 x 4000 | RGB/u8 | 1.5961 | 1.1203 | 1.42x |
| `spin` | 7200 x 5000 | RGB/u8 | 9.4388 | 5.8694 | 1.61x |
| `spin` | 6000 x 4000 | RGB/u16 | 6.3066 | 3.9617 | 1.59x |
| `spin` | 6000 x 4000 | RGB/f32 | 6.3749 | 4.0619 | 1.57x |
| `lens` | 6000 x 4000 | RGB/u8 | 0.6208 | 0.4448 | 1.40x |
| `lens` | 6000 x 4000 | CMYK/u16 | 0.8080 | 0.5649 | 1.43x |
| `lens` | 6000 x 4000 | RGB/f32 | 0.6847 | 0.5103 | 1.34x |
| `lens-ca` | 6000 x 4000 | RGB/u8 | 0.6214 | 0.6312 | 0.98x |
| `trap` | 6000 x 4000 | CMYK/u8 | 0.9710 | 0.8082 | 1.20x |
| `trap-wide` | 7200 x 5000 | CMYK/u16 | 4.6265 | 1.3331 | 3.47x |
| `trap` | 6000 x 4000 | CMYK/f32 | 1.1924 | 1.0273 | 1.16x |
| `trap-default` | 6000 x 4000 | CMYK/u8 | 0.6363 | 0.6506 | 0.98x |
## Correctness and bounded work
407 algorithm tests passed, with 14 existing ignored tests. New direct-reference
comparisons cover all eight color models, alpha on/off, all three depths, edge
modes, unusual tile sizes and soft selections where applicable. They also exercise
ties, partial cells, extreme coordinates, nonfinite samples and fallback paths.
Spin rotation tables match the original trigonometric results bit for bit.
Trap tests include clipped windows, paper preservation, extra channels and signed zero.
Extrude caps its additional winner plane at 4,194,304 pixels (16 MiB), cell visits
at 262,144 and coordinates at +/-2^22; normal tiled execution fits these bounds.
Wind caps winner scratch at 1,048,576 entries per row (8 MiB). Spin tables are
under 2 MiB for up to 120 pins. These scratch allocations are fallible and use the
original direct implementation if preparation fails or bounds are exceeded.
Extrude Pyramids and Wind Stagger keep their original implementations. Trap's new
per-line suffix buffer is fallible, with a direct fallback; it adds no full-image
plane beyond the existing dilation workspace. Lines containing negative zero use
the original scan to retain float bit patterns.
`cargo fmt --all -- --check`, algorithm clippy with all targets and denied warnings,
`cargo xtask layers`, full `cargo xtask wasm`, `cargo xtask perf --quick`, and
scorecard generation/check passed. The quick performance run used llvmpipe and
still reports existing over-budget UI scenarios. These operation measurements do
not change those budget claims or establish Photoshop pixel parity.
## Reproduce
From the PR checkout, create a base worktree and copy only the benchmark fixture:
```sh
base=7722172585a01cbdb93c06f0f5ff2634fcb17999
git worktree add --detach ../photocraft-five-base "$base"
cp crates/algo/examples/bench_algorithms.rs ../photocraft-five-base/crates/algo/examples/
CARGO_TARGET_DIR="$PWD/target/five-after" cargo build --release -p photocraft-algo --example bench_algorithms
(cd ../photocraft-five-base && CARGO_TARGET_DIR="$PWD/target/five-before" cargo build --release -p photocraft-algo --example bench_algorithms)
before="$PWD/../photocraft-five-base/target/five-before/release/examples/bench_algorithms"
after="$PWD/target/five-after/release/examples/bench_algorithms"
export RAYON_NUM_THREADS=16
# Each invocation prints its full-operation time. Keep the machine otherwise idle.
# Use the case names, dimensions, depth and mode from the table.
case=extrude-dense
w=6000
h=4000
depth=u8
mode=rgb
for pair in 1 2 3; do
if [ "$pair" = 2 ]; then
"$after" "$case" "$w" "$h" "$depth" 1 after.bin "$mode"
"$before" "$case" "$w" "$h" "$depth" 1 before.bin "$mode"
else
"$before" "$case" "$w" "$h" "$depth" 1 before.bin "$mode"
"$after" "$case" "$w" "$h" "$depth" 1 after.bin "$mode"
fi
cmp before.bin after.bin || exit 1
sha256sum before.bin after.bin
done
```
Take the median of the three times for each binary. Output files can reach 720 MB
per side for 36 MP CMYK+alpha; reuse the two filenames between cases. Hardware,
worker count and image content affect the times, so preserve the fixture when
comparing revisions.
+12
View File
@@ -514,6 +514,18 @@ Bounded halo tiles reduce the large-mask workspace allocation bound from 192/288
to about 115/163 MB, including output and 16 workers' scratch, excluding the source.
See [method and results](octagonal-mask-performance.md).
2026-10-10: five CPU imaging algorithms now reuse source work and interpolation.
On an m7i.4xlarge (16 vCPUs), three alternating paired release runs measured
dense Extrude Blocks (8/128 px, 24 MP RGB8) 160.301 s -> 0.387 s (414.69x);
Blast (24 MP CMYK8) 6.231 s -> 0.709 s (8.79x);
Spin Blur (36 MP RGB8) 9.439 s -> 5.869 s (1.61x);
Lens Correction (24 MP CMYK16) 0.808 s -> 0.565 s (1.43x);
Trap (50 px, 36 MP CMYK16) 4.627 s -> 1.333 s (3.47x).
Default 30/30 px Extrude is 6.26x faster at 24 MP. All 72 full-operation output
comparisons match exactly, including alpha, across 24 cases and 8/16/32-bit depths.
Timers include surface assembly but exclude UI, undo and Trap document flattening.
See [method, raw measurements and reproduction](five-algorithm-performance.md).
### Where we're going (priority order)
1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them.