diff --git a/crates/algo/examples/bench_algorithms.rs b/crates/algo/examples/bench_algorithms.rs new file mode 100644 index 000000000..45464ebe3 --- /dev/null +++ b/crates/algo/examples/bench_algorithms.rs @@ -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> { + 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> { + 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, ¶ms, 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(()) +} diff --git a/crates/algo/src/gallery.rs b/crates/algo/src/gallery.rs index 7cd78dbac..414faddb1 100644 --- a/crates/algo/src/gallery.rs +++ b/crates/algo/src/gallery.rs @@ -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 { - 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(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin], prepare: bool) -> Vec { + 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= 1.0 { continue; @@ -288,15 +302,20 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec(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; diff --git a/crates/algo/src/gallery/prepared_spin_tests.rs b/crates/algo/src/gallery/prepared_spin_tests.rs new file mode 100644 index 000000000..f24f0d268 --- /dev/null +++ b/crates/algo/src/gallery/prepared_spin_tests.rs @@ -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}"); + } + } + } +} diff --git a/crates/algo/src/gallery/spin_plan.rs b/crates/algo/src/gallery/spin_plan.rs new file mode 100644 index 000000000..3880bd867 --- /dev/null +++ b/crates/algo/src/gallery/spin_plan.rs @@ -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> { + // 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(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::()) 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; + } + } + } +} diff --git a/crates/algo/src/lens.rs b/crates/algo/src/lens.rs index 13933b0f7..39f8086b6 100644 --- a/crates/algo/src/lens.rs +++ b/crates/algo/src/lens.rs @@ -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 + 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 + 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(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::()?; + 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; diff --git a/crates/algo/src/lens/shared_sample_tests.rs b/crates/algo/src/lens/shared_sample_tests.rs new file mode 100644 index 000000000..8a09ae75e --- /dev/null +++ b/crates/algo/src/lens/shared_sample_tests.rs @@ -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 = (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()); +} diff --git a/crates/algo/src/stylize2.rs b/crates/algo/src/stylize2.rs index f955d51ff..602b6c271 100644 --- a/crates/algo/src/stylize2.rs +++ b/crates/algo/src/stylize2.rs @@ -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 { + 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 { 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 { + 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 { 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; diff --git a/crates/algo/src/stylize2/extrude.rs b/crates/algo/src/stylize2/extrude.rs new file mode 100644 index 000000000..78a3df273 --- /dev/null +++ b/crates/algo/src/stylize2/extrude.rs @@ -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> { + 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)) +} diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs new file mode 100644 index 000000000..cc44558ea --- /dev/null +++ b/crates/algo/src/stylize2/tests.rs @@ -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) -> Vec { + 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 = (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()); + } + } + } + } +} diff --git a/crates/algo/src/stylize2/wind.rs b/crates/algo/src/stylize2/wind.rs new file mode 100644 index 000000000..fc96249e2 --- /dev/null +++ b/crates/algo/src/stylize2/wind.rs @@ -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> { + 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) +} diff --git a/crates/algo/src/trap.rs b/crates/algo/src/trap.rs index bf4d0d596..bb6035a17 100644 --- a/crates/algo/src/trap.rs +++ b/crates/algo/src/trap.rs @@ -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 { + 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 { 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> = (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 = (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 = (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::>(), want.iter().map(|v| v.to_bits()).collect::>()); + } + } + } + #[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); + } +} diff --git a/docs/five-algorithm-performance.json b/docs/five-algorithm-performance.json new file mode 100644 index 000000000..725dd56cc --- /dev/null +++ b/docs/five-algorithm-performance.json @@ -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" +} diff --git a/docs/five-algorithm-performance.md b/docs/five-algorithm-performance.md new file mode 100644 index 000000000..e82a1b04b --- /dev/null +++ b/docs/five-algorithm-performance.md @@ -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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7a4f238cc..0f9460165 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.