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