Speed up Iris Blur without changing output (#2578)

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:00:53 -04:00
1 parent 7e46557283
commit fa33712dd3
6 files changed
+776 -24

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
//! End-to-end Iris Blur timings; fixture construction and output drop are untimed.
//! `cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3`
//! An optional fourth argument models the UI's proxy factor (e.g. 1500 1000 5 4).
use photocraft_algo::{FilterParams, IrisPin, apply_in};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn main() {
let args: Vec<String> = std::env::args().collect();
let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(fallback);
let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 3).clamp(1, 20));
let proxy_factor = number(4, 1).clamp(1, 8) as f32;
let bounds = Rect::new(0, 0, w, h);
for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
// Write rows to avoid a second full-image fixture allocation.
for y in 0..h {
let row: Vec<f32> = (0..w)
.flat_map(|x| {
let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 };
let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 };
[hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a]
})
.collect();
source.write_region(Rect::new(0, y, w, y + 1), &row);
}
let mut cases = vec![("medium", vec![IrisPin { blur: 80.0, angle: 27.0, ..IrisPin::default() }])];
if sample == SampleType::U8 {
cases.extend([
("small", vec![IrisPin::default()]),
("large", vec![IrisPin { blur: 300.0, ..IrisPin::default() }]),
(
"multiple",
vec![
IrisPin { x: 0.3, blur: 80.0, roundness: 65.0, ..IrisPin::default() },
IrisPin { x: 0.8, angle: 73.0, blur: 40.0, ..IrisPin::default() },
],
),
("focused", vec![IrisPin { radius_x: 5.0, radius_y: 5.0, blur: 80.0, ..IrisPin::default() }]),
("blurred", vec![IrisPin { x: -5.0, blur: 80.0, ..IrisPin::default() }]),
]);
}
for (label, mut pins) in cases {
// Top-level one-pin blur scales in preview_document_with. Its nested
// JSON pin list retains supplied amounts; model that existing path.
if pins.len() == 1 {
for pin in &mut pins {
pin.blur /= proxy_factor;
}
}
let p = FilterParams::IrisBlur { pins };
let warm = apply_in(&source, &p, bounds, bounds, None, bounds);
std::hint::black_box(&warm);
drop(warm);
let mut times = Vec::new();
for _ in 0..reps {
let start = std::time::Instant::now();
let output = apply_in(&source, &p, bounds, bounds, None, bounds);
times.push(start.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
drop(output);
}
println!("{w}x{h} {name} {label}: {times:?} ms");
}
}
}
+123 -24
View File
@@ -8,10 +8,14 @@
use photocraft_geom::Rect;
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, premul_window, smoothstep, unpremul_px, xy};
use crate::image::Image;
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};
#[cfg(test)]
#[path = "tests_iris.rs"]
mod tests;
/// Gaussian σ for a gallery blur amount in px.
fn sigma_of(blur: f32) -> f32 {
blur.clamp(0.0, 500.0) * 0.6
@@ -25,17 +29,27 @@ pub(crate) fn reach(blur: f32) -> f32 {
const LEVELS: usize = 6;
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
let n = src.ch;
if n == 0 || out.is_empty() {
return Vec::new();
}
if smax < 0.3 {
return src.crop(out);
}
let win = src.rect;
let (ww, wh) = (win.width() as usize, win.height() as usize);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let Some(row_len) = ww.checked_mul(n).filter(|&len| len > 0) else { return Vec::new() };
let Some(source_len) = row_len.checked_mul(wh) else { return Vec::new() };
let Some(pixels) = ow.checked_mul(oh) else { return Vec::new() };
let Some(samples) = pixels.checked_mul(n) else { return Vec::new() };
if trim_levels && src.data.len() != source_len {
return Vec::new();
}
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
let sig: Vec<f32> = (0..ow * oh)
let sig: Vec<f32> = (0..pixels)
.map(|i| {
let (x, y) = xy(out, i);
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
@@ -54,20 +68,49 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
0.0
}
};
let p = premul_window(src, win, ctx.alpha);
let mut acc = vec![0.0f32; ow * oh * n];
let p = if trim_levels {
// The window is exactly src.rect: read interleaved channels together,
// retaining the same multiplication and alpha order as premul_window.
let mut p = src.data.clone();
premultiply(&mut p, n, ctx.alpha);
p
} else {
premul_window(src, win, ctx.alpha)
};
let mut acc = vec![0.0f32; samples];
let mut buf = Vec::new();
for (k, &lvk) in lv.iter().enumerate() {
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
continue;
}
let level: &[f32] = if k == 0 {
&p
let (level, lw): (&[f32], usize) = if k == 0 {
(&p, ww)
} else {
// Only Iris opts in. Each level needs its own box-cascade reach,
// not the maximum blur's trailing halo. Keep the original top/left:
// moving the running sums' start changes floating-point cancellation.
let trim = trim_levels && win.intersect(&out) == out;
let reach = if !trim || lvk < 0.3 { 0 } else { gauss_box_radii(lvk).iter().sum::<usize>() };
let reach = i32::try_from(reach).unwrap_or(i32::MAX);
let lw = if trim { (i64::from(out.x1.saturating_add(reach).min(win.x1)) - i64::from(win.x0)) as usize } else { ww };
let lh = if trim { (i64::from(out.y1.saturating_add(reach).min(win.y1)) - i64::from(win.y0)) as usize } else { wh };
buf.clear();
if buf.capacity() == 0 {
// Match the old single full-window allocation. Row appends must
// not geometrically grow the capacity beyond that window.
buf.reserve_exact(p.len());
}
if trim {
for row in p.chunks_exact(row_len).take(lh) {
if let Some(row) = row.get(..lw * n) {
buf.extend_from_slice(row);
}
}
} else {
buf.extend_from_slice(&p);
gauss_blur_n(&mut buf, ww, wh, n, lvk);
&buf
}
gauss_blur_n(&mut buf, lw, lh, n, lvk);
(&buf, lw)
};
for (i, &s) in sig.iter().enumerate() {
let wk = weight(k, s);
@@ -75,9 +118,11 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
continue;
}
let (x, y) = xy(out, i);
let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n;
for c in 0..n {
acc[i * n + c] += level[o + c] * wk;
let o = (((y - win.y0) as usize) * lw + (x - win.x0) as usize) * n;
if let (Some(dst), Some(px)) = (acc.get_mut(i * n..(i + 1) * n), level.get(o..o + n)) {
for (dst, px) in dst.iter_mut().zip(px) {
*dst += px * wk;
}
}
}
}
@@ -103,7 +148,7 @@ pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (
let (nx, ny) = (s, c);
let smax = sigma_of(blur);
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
variable_blur(src, out, ctx, smax, |x, y| {
variable_blur(src, out, ctx, smax, false, |x, y| {
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
smax * smoothstep(f0, f1, d)
})
@@ -128,20 +173,74 @@ fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, ang
pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec<f32> {
let b = ctx.bounds;
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
if pins.is_empty() {
if pins.is_empty() || smax < 0.3 {
return src.crop(out);
}
variable_blur(src, out, ctx, smax, |x, y| {
pins.iter()
.map(|p| {
let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0;
let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m);
sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e)
})
.fold(f32::MAX, f32::min)
// Small immutable per-tile preparation; allocation failure retains the
// same arithmetic without the table. No additional image-sized cache.
let mut prepared = Vec::new();
let prepared = if prepared.try_reserve_exact(pins.len()).is_ok() {
prepared.extend(pins.iter().map(|p| PreparedIris::new(p, b)));
Some(prepared)
} else {
None
};
variable_blur(src, out, ctx, smax, true, |x, y| {
if let Some(prepared) = &prepared {
prepared.iter().map(|p| p.sigma(x, y)).fold(f32::MAX, f32::min)
} else {
pins.iter().map(|p| PreparedIris::new(p, b).sigma(x, y)).fold(f32::MAX, f32::min)
}
})
}
struct PreparedIris {
cx: f32,
cy: f32,
rx: f32,
ry: f32,
sin: f32,
cos: f32,
exponent: f32,
feather: f32,
blur: f32,
}
impl PreparedIris {
fn new(p: &IrisPin, b: Rect) -> Self {
let ss = short_side(b);
let angle = if p.angle.is_finite() { p.angle } else { 0.0 };
let (sin, cos) = angle.to_radians().sin_cos();
Self {
cx: b.x0 as f32 + p.x * b.width() as f32,
cy: b.y0 as f32 + p.y * b.height() as f32,
rx: p.radius_x.max(1e-3) * ss,
ry: p.radius_y.max(1e-3) * ss,
sin,
cos,
exponent: 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0,
feather: p.feather.clamp(0.0, 0.999),
blur: sigma_of(p.blur),
}
}
fn sigma(&self, x: f32, y: f32) -> f32 {
let (dx, dy) = (x - self.cx, y - self.cy);
let (u, v) = ((dx * self.cos + dy * self.sin) / self.rx, (-dx * self.sin + dy * self.cos) / self.ry);
// Outside the normalized bounding square, the superellipse distance
// is at least one. The falloff is saturated: no sqrt/powf is needed.
let e = if u.abs() >= 1.0 || v.abs() >= 1.0 {
f32::INFINITY
} else if (self.exponent - 2.0).abs() < 1e-3 {
(u * u + v * v).sqrt()
} else {
(u.abs().powf(self.exponent) + v.abs().powf(self.exponent)).powf(1.0 / self.exponent)
};
let e = if e.is_finite() { e } else { f32::INFINITY };
self.blur * smoothstep(self.feather, 1.0, e)
}
}
/// Field Blur: blur amounts at pins, interpolated by inverse squared distance.
pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec<f32> {
let b = ctx.bounds;
@@ -151,7 +250,7 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
let pts: Vec<(f32, f32, f32)> =
pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
variable_blur(src, out, ctx, smax, |x, y| {
variable_blur(src, out, ctx, smax, false, |x, y| {
let (mut ws, mut s) = (0.0f32, 0.0f32);
for &(px, py, sg) in &pts {
let w = 1.0 / ((x - px).powi(2) + (y - py).powi(2) + 1.0);
+222
View File
@@ -0,0 +1,222 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
#[path = "tests_iris_reference.rs"]
mod reference;
fn image(rect: Rect, ch: usize) -> Image {
let data = (rect.y0..rect.y1)
.flat_map(|y| {
(rect.x0..rect.x1).flat_map(move |x| {
(0..ch).map(move |c| match c {
0 => ((x * 17 + y * 31).rem_euclid(113)) as f32 / 37.0 - 0.5,
1 => {
if (x + y).rem_euclid(3) == 0 {
1.0
} else {
0.0
}
}
_ => ((x ^ y).unsigned_abs() as usize + c) as f32 % 23.0 / 22.0,
})
})
})
.collect();
Image { rect, ch, data }
}
fn check(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) {
let want = reference::iris(src, out, ctx, pins);
let got = iris(src, out, ctx, pins);
assert_eq!(got.len(), want.len());
for (i, (&got, &want)) in got.iter().zip(&want).enumerate() {
assert!(got.to_bits() == want.to_bits() || (got.is_nan() && want.is_nan()), "sample {i}: {got} != {want}, delta {}", (got - want).abs());
}
}
#[test]
fn iris_matches_frozen_kernel_at_transitions_and_edges() {
let bounds = Rect::new(-19, 7, 54, 66);
let mut cases = vec![vec![], vec![IrisPin::default()]];
for blur in [0.0, 0.49, 0.5, 1.0, 15.0, 80.0, 500.0] {
for roundness in [0.0, 0.01, 37.0, 100.0] {
cases.push(vec![IrisPin { x: 0.18, y: 0.91, radius_x: 0.45, radius_y: 0.13, angle: 71.0, feather: 0.998, roundness, blur }]);
}
}
cases.push(vec![
IrisPin { x: 0.2, blur: 30.0, angle: -43.0, ..IrisPin::default() },
IrisPin { x: 0.7, y: 0.1, blur: 11.0, roundness: 80.0, ..IrisPin::default() },
]);
cases.push(vec![IrisPin { radius_x: 8.0, radius_y: 9.0, ..IrisPin::default() }]);
cases.push(vec![IrisPin { x: -5.0, ..IrisPin::default() }]);
for ch in 1..=6 {
let src = image(bounds.inflate(12), ch);
for alpha in [false, true] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
for pins in &cases {
check(&src, bounds, &ctx, pins);
check(&src, Rect::new(-7, 17, 15, 39), &ctx, pins);
}
}
}
}
#[test]
fn iris_preserves_degenerate_and_nonfinite_pin_behavior() {
for bounds in [Rect::EMPTY, Rect::new(1, -1, 2, 0), Rect::new(-5, 3, 14, 4)] {
let src = image(bounds, 4);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for value in [0.0, -1.0, f32::MIN, f32::MAX, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
for field in 0..9 {
let mut p = IrisPin::default();
match field {
0 => p.x = value,
1 => p.y = value,
2 => p.radius_x = value,
3 => p.radius_y = value,
4 => p.angle = value,
5 => p.feather = value,
6 => p.blur = value,
7 => p.roundness = value,
_ => {
p.radius_x = value;
p.radius_y = value;
}
}
check(&src, bounds, &ctx, &[p]);
}
}
}
}
#[test]
fn iris_keeps_running_sum_order_for_extreme_hdr_and_signed_zero() {
let bounds = Rect::new(-13, -5, 24, 28);
let mut src = image(bounds.inflate(15), 4);
let values = [0.0, -0.0, 1e30, -1e30, 1e-30, -1e-30, 0.8];
for (i, px) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
px[0] = values[i % values.len()];
px[1] = values[(i + 3) % values.len()];
px[3] = [0.0, 1e-8, 0.5, 1.0][i % 4];
}
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for blur in [0.5, 15.0, 80.0, 300.0] {
let pins = [IrisPin { angle: 37.0, roundness: 55.0, blur, ..IrisPin::default() }];
check(&src, bounds, &ctx, &pins);
check(&src, Rect::new(0, 0, 10, 17), &ctx, &pins);
}
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid_samples = src.clone();
invalid_samples.data[19] = value;
invalid_samples.data[47] = value;
check(&invalid_samples, bounds, &ctx, &[IrisPin::default()]);
}
let pins = vec![IrisPin::default(); 4096];
check(&src, Rect::new(0, 0, 1, 1), &ctx, &pins);
let malformed = Image { rect: bounds, ch: 4, data: vec![] };
assert!(iris(&malformed, bounds, &ctx, &[IrisPin::default()]).is_empty());
let no_channels = Image { rect: bounds, ch: 0, data: vec![] };
assert!(iris(&no_channels, bounds, &ctx, &[IrisPin::default()]).is_empty());
}
#[test]
fn iris_cancellation_after_progress_preserves_source() {
use std::sync::atomic::{AtomicBool, Ordering};
let bounds = Rect::new(0, 0, 512, 256);
let mut source = Surface::new(PixelFormat::RGBA8);
source.write_region(bounds, &image(bounds, 4).data);
let original = source.clone();
let cancelled = AtomicBool::new(false);
let cancel = || cancelled.load(Ordering::Relaxed);
let progress = |p| {
if p > 0.0 {
cancelled.store(true, Ordering::Relaxed);
}
};
let ctl = Interrupt::new(&cancel, &progress);
let params = crate::FilterParams::IrisBlur { pins: vec![IrisPin::default()] };
assert!(crate::apply_in_with(&source, &params, bounds, bounds, None, bounds, &ctl).is_none());
assert!(cancelled.load(Ordering::Relaxed));
assert_eq!(source, original);
}
#[test]
fn iris_depths_models_selection_tiles_and_cancellation() {
let bounds = Rect::new(-17, 11, 96, 84);
let area = Rect::new(-9, 17, 79, 77);
let pins = vec![IrisPin { angle: 29.0, roundness: 35.0, blur: 17.0, ..IrisPin::default() }];
let params = crate::FilterParams::IrisBlur { pins: pins.clone() };
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(bounds, &[0.4]);
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, sample, alpha);
let mut surface = Surface::new(fmt);
surface.write_region(bounds, &image(bounds, fmt.channels()).data);
let ctl = Interrupt::cancel_only(&|| true);
assert!(crate::apply_in_with(&surface, &params, area, bounds, Some(&selection), bounds, &ctl).is_none());
let original = surface.clone();
let ctx = Ctx { bounds, mode, alpha };
let radius = reach(17.0).ceil() as i32 + 1;
assert_eq!(params.halo_for(bounds), crate::Halo::Radius(radius));
for tile in [16, 47, 128] {
let got = crate::apply_tiled(&surface, &params, area, bounds, Some(&selection), tile, Some(bounds));
let mut want = surface.clone();
for y in (area.y0..area.y1).step_by(tile as usize) {
for x in (area.x0..area.x1).step_by(tile as usize) {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src);
want.write_region(t, &data);
}
}
assert_eq!(got.read_region(bounds), want.read_region(bounds), "{fmt:?}, tile {tile}");
}
assert_eq!(surface, original);
}
}
}
}
#[test]
#[ignore = "release profiling; run alone with --nocapture"]
fn iris_profile_baseline_stages() {
for blur in [15.0, 80.0, 300.0] {
let out = Rect::new(1500, 1000, 2012, 1512);
let bounds = Rect::new(0, 0, 6000, 4000);
let src = image(out.inflate(reach(blur).ceil() as i32), 4);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let pin = IrisPin { blur, angle: 27.0, roundness: 60.0, ..IrisPin::default() };
let start = std::time::Instant::now();
let sig: Vec<f32> = (0..out.width() as usize * out.height() as usize)
.map(|i| {
let (x, y) = xy(out, i);
let (e, _, _) = pin_distance(x as f32 + 0.5, y as f32 + 0.5, bounds, pin.x, pin.y, pin.radius_x, pin.radius_y, pin.angle, 5.6);
sigma_of(blur) * smoothstep(pin.feather, 1.0, e)
})
.collect();
println!("blur {blur}: geometry {:?}", start.elapsed());
std::hint::black_box(sig);
let start = std::time::Instant::now();
let p = premul_window(&src, src.rect, ctx.alpha);
println!("blur {blur}: premul {:?}", start.elapsed());
for k in 0..LEVELS {
let mut buf = p.clone();
let sigma = sigma_of(blur) / 2f32.powi((LEVELS - 1 - k) as i32);
let start = std::time::Instant::now();
gauss_blur_n(&mut buf, src.rect.width() as usize, src.rect.height() as usize, src.ch, sigma);
println!("blur {blur}: level {sigma} {:?}", start.elapsed());
std::hint::black_box(buf);
}
let start = std::time::Instant::now();
std::hint::black_box(reference::iris(&src, out, &ctx, std::slice::from_ref(&pin)));
println!("blur {blur}: reference whole kernel {:?}", start.elapsed());
let start = std::time::Instant::now();
std::hint::black_box(iris(&src, out, &ctx, &[pin]));
println!("blur {blur}: candidate whole kernel {:?}", start.elapsed());
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Frozen Iris Blur kernel from base 7722172585a01cbdb93c06f0f5ff2634fcb17999.
use super::*;
fn sigma_of(blur: f32) -> f32 {
blur.clamp(0.0, 500.0) * 0.6
}
const LEVELS: usize = 6;
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
let n = src.ch;
if smax < 0.3 {
return src.crop(out);
}
let win = src.rect;
let (ww, wh) = (win.width() as usize, win.height() as usize);
let (ow, oh) = (out.width() as usize, out.height() as usize);
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
let sig: Vec<f32> = (0..ow * oh)
.map(|i| {
let (x, y) = xy(out, i);
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
})
.collect();
// Hat-function weight of level k for σ s.
let weight = |k: usize, s: f32| -> f32 {
let c = lv[k];
if k > 0 && s >= lv[k - 1] && s <= c {
(s - lv[k - 1]) / (c - lv[k - 1]).max(1e-6)
} else if k + 1 < lv.len() && s >= c && s <= lv[k + 1] {
(lv[k + 1] - s) / (lv[k + 1] - c).max(1e-6)
} else if k + 1 == lv.len() && s >= c {
1.0
} else {
0.0
}
};
let p = premul_window(src, win, ctx.alpha);
let mut acc = vec![0.0f32; ow * oh * n];
let mut buf = Vec::new();
for (k, &lvk) in lv.iter().enumerate() {
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
continue;
}
let level: &[f32] = if k == 0 {
&p
} else {
buf.clear();
buf.extend_from_slice(&p);
gauss_blur_n(&mut buf, ww, wh, n, lvk);
&buf
};
for (i, &s) in sig.iter().enumerate() {
let wk = weight(k, s);
if wk <= 0.0 {
continue;
}
let (x, y) = xy(out, i);
let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n;
for c in 0..n {
acc[i * n + c] += level[o + c] * wk;
}
}
}
for px in acc.chunks_exact_mut(n) {
unpremul_px(px, ctx.alpha);
}
acc
}
fn short_side(b: Rect) -> f32 {
(b.width().min(b.height()) as f32).max(1.0)
}
/// Normalized (super)elliptical distance of a point from an iris/spin pin.
#[allow(clippy::too_many_arguments)]
fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, angle: f32, m: f32) -> (f32, f32, f32) {
let ss = short_side(b);
let (cx, cy) = (b.x0 as f32 + px * b.width() as f32, b.y0 as f32 + py * b.height() as f32);
let angle_deg = if angle.is_finite() { angle } else { 0.0 };
let (s, c) = angle_deg.to_radians().sin_cos();
let (dx, dy) = (x - cx, y - cy);
let (u, v) = (dx * c + dy * s, -dx * s + dy * c);
let (u, v) = (u / (rx.max(1e-3) * ss), v / (ry.max(1e-3) * ss));
let e = if (m - 2.0).abs() < 1e-3 { (u * u + v * v).sqrt() } else { (u.abs().powf(m) + v.abs().powf(m)).powf(1.0 / m) };
if !e.is_finite() { (f32::INFINITY, 0.0, 0.0) } else { (e, u, v) }
}
/// Iris Blur: everything outside each pin's ellipse is blurred; the sharp
/// core is `feather` of the ellipse. Sharpness from any pin wins.
pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec<f32> {
let b = ctx.bounds;
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
if pins.is_empty() {
return src.crop(out);
}
variable_blur(src, out, ctx, smax, |x, y| {
pins.iter()
.map(|p| {
let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0;
let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m);
sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e)
})
.fold(f32::MAX, f32::min)
})
}
+247
View File
@@ -0,0 +1,247 @@
# Iris Blur computation (2026-10-10)
Base: `7722172585a01cbdb93c06f0f5ff2634fcb17999` (freshly fetched
`origin/main` and `upstream/main`). Branch: `codex/iris-blur-invariants-20261010`.
## Upstream investigation
Open, closed and merged issues/PRs were searched for Iris, Blur Gallery, blur,
sampling, portable and SIMD. No overlapping Iris Blur change was found.
Gallery history includes [PR #1010](https://github.com/storytold/photocraft/pull/1010),
which normalizes nonfinite pin angles and maps nonfinite distance to infinity
(part of #916). Its behaviour is retained in prepared Iris geometry and tested.
[PR #1524](https://github.com/storytold/photocraft/pull/1524), merged as
`d651be1393b709d638dd9d0e4824a420f795d3ae`, is the merged SIMD-related work:
the [review](https://github.com/storytold/photocraft/pull/1524#issuecomment-6072959394)
requested removal of the AVX2/pulp portion because it did not improve complete
filter timings. The author removed that portion before merge. Main has portable
contiguous Mosaic row reads, with exact-output tests, rather than a SIMD subsystem.
[PR #1602](https://github.com/storytold/photocraft/pull/1602) carries fixed-channel
Gaussian/resize accumulators from #1511, allowing compiler vectorization without
unsafe code or a new dependency. Those box accumulators use f32; Gallery uses f64
running sums for tile independence, so they are not an exact drop-in replacement.
No nightly `std::simd`, architecture-specific instructions, new dependency or
`target-cpu=native` is introduced here. Transitive codec SIMD dependencies do not
provide a Gallery kernel.
[PR #1899](https://github.com/storytold/photocraft/pull/1899) already runs filter
previews on a cancellable worker, with a 150 ms settle delay for stale requests.
[PR #2207](https://github.com/storytold/photocraft/pull/2207) addresses preview scale.
This change leaves the worker, proxy size, engine command and UI interactions intact.
## Algorithm and numerical contract
Iris Blur interpolates seven Gaussian levels (zero plus six geometrically spaced
sigmas). This is not a ray/disk sampling kernel. Every level, box radius, weight,
premultiplication, unpremultiplication and pin combination remains the same.
- Prepare each pin's centre, rotation, normalized radii, exponent, feather and
blur once per tile. Allocation failure uses on-demand preparation instead.
- A point outside a pin's normalized bounding square is beyond its superellipse.
Its falloff is saturated; skip the square root/powers there. Inside the square,
use the original expression, divisions and floating-point order.
- Copy interleaved source samples together and premultiply in place, following
the portable row-read principle in #1524.
- For each nonzero level, retain the source's top/left and trim the right/bottom
to the output plus that level's actual three-box reach. Removing leading rows
or columns would change running-sum cancellation, especially for HDR. Keeping
the start preserves exact results; the trailing clamp cannot reach output.
Tilt-Shift and Field retain their full windows.
There is no additional image-sized cache. The premultiplied window, sigma map,
output accumulator and reused level buffer already existed. The level buffer
reserves the original full-window capacity once when needed, preventing row
appends from doubling capacity; only its populated length and blur scratch shrink.
Prepared geometry uses 36 bytes per pin per active tile. Source reads, tile sizes,
Rayon dispatch, selection mixing and cancellation services remain unchanged.
`tests_iris_reference.rs` freezes the base kernel. Differential tests require
exact finite f32 bits (including signed zero), with matching NaN classifications.
They exercise 1–6 channels, RGB/Gray/CMYK/Lab, alpha/no alpha, U8/U16/F32, negative
and HDR samples, extreme HDR cancellation, rotated/translated/anisotropic pins,
roundness and blur thresholds, feather 0.998, image edges, cropped output, full
focus/full blur, empty/multiple/4096 pins, degenerate images, nonfinite/extreme pin
fields, selections and multiple tile sizes. Cancellation before work and after
progress preserves the source. Invalid optimized source lengths/zero channels
return an empty internal result. Public API/command behaviour is unchanged.
## Measurement method
Windows x86_64, AMD Ryzen 7 9700X (8 cores / 16 logical), 64 GiB RAM, stable Rust
1.99.0 / LLVM 23.1.1. Normal workspace release profile (opt-level 3, thin LTO, one
codegen unit), eight Rayon workers. No concurrent Cargo builds or other task
benchmarks were scheduled during the measurements; ordinary system activity remains.
`bench_iris` constructs deterministic textured RGB layers with varied alpha and
HDR F32 values. Fixture construction and output destruction are excluded; source
reads, pin preparation, filtering, selection-free surface writeback and pruning
are included. Each case has one untimed warmup, then repeated complete calls.
The unchanged base executable is retained locally for repeat comparisons. Use
the same harness on the base revision to reproduce a comparison.
```powershell
$env:CARGO_TARGET_DIR = 'target/agent-iris-blur'
$env:RAYON_NUM_THREADS = '8'
cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3
cargo run --release -p photocraft-algo --example bench_iris -- 6000 6000 3
cargo run --release -p photocraft-algo --example bench_iris -- 1500 1000 5 4
cargo run --release -p photocraft-algo --example bench_iris -- 2000 1334 5
cargo run --release -p photocraft-algo --example bench_iris -- 128 96 5
cargo test --release -p photocraft-algo iris_profile_baseline_stages -- --ignored --nocapture
```
The 2000×1334 run is a preview-sized stress workload at fixed blur parameters.
PhotoCraft actually reduces 24 MP by factor four to 1500×1000 (its factor is the
ceiling of sqrt(pixels/2,500,000)). The separate 1500×1000 run models that existing
filtered-proxy workload directly: top-level document blur 15/80/300 becomes
3.75/20/75; the nested multiple-pin JSON amounts retain their existing values.
Neither run measures proxy construction, engine history, preview-worker queueing,
compositing, GPU upload, frame presentation or native slider response. Baseline and
candidate always use identical resolution, blur values and sample counts.
Before-change stage profiling of a 512×512 output tile, one rotated rounded pin:
geometry 4.3–5.8 ms; premultiplication 1.5/3.5/11.7 ms at blur 15/80/300;
each active Gaussian level about 3–9/18–19/87–100 ms. Whole kernels were
30.8/52.1/208.8 ms (unused levels are skipped). These diagnostic single-tile
figures isolate the computation; end-to-end results below use repeated runs.
## Repeated release results
Values are median milliseconds (minimum–maximum), not frame times. Run order:
base 24 MP / stress / small, candidate 24 MP / stress / small, base 24 MP again;
then base 36 MP, candidate 36 MP, base 36 MP again, base default proxy and candidate
default proxy. Each full-size base column pools six samples from two three-repeat
rounds; each candidate has three. Proxy, stress and small-image columns each have
five samples.
Both base rounds have slower medians than the candidate in every 24 MP case.
Variation is visible, particularly in the larger proxy runs; these are local
measurements, not cross-machine performance guarantees.
Small = blur 15; medium = blur 80, rotation 27°; large = blur 300.
Multiple = two pins at blur 80/40, one with roundness 65 and one rotated 73°.
Focused = radii 5×5, blur 80; blurred = pin centre x = −5, blur 80.
### 6000×4000 (24 MP)
| Depth / case | Base ms (range) | Candidate ms (range) | Speedup |
|---|---:|---:|---:|
| U8 medium | 1395.7 (1191.4–1550.9) | 1092.7 (1070.0–1113.7) | 1.28× |
| U8 small | 626.8 (589.8–655.9) | 426.9 (425.8–437.3) | 1.47× |
| U8 large | 4760.6 (4369.7–5154.0) | 3148.4 (3112.9–3175.3) | 1.51× |
| U8 multiple | 1789.9 (1695.9–1885.7) | 1016.2 (1015.6–1018.6) | 1.76× |
| U8 focused | 489.8 (472.8–527.3) | 405.3 (400.6–423.1) | 1.21× |
| U8 blurred | 1161.4 (1102.5–1195.4) | 981.3 (924.7–1007.1) | 1.18× |
| U16 medium | 1522.5 (1410.5–1577.9) | 1115.9 (1108.9–1119.3) | 1.36× |
| F32 medium | 1570.8 (1453.0–1656.7) | 1131.1 (1117.1–1134.3) | 1.39× |
### 6000×6000 (36 MP)
| Depth / case | Base ms (range) | Candidate ms (range) | Median ratio |
|---|---:|---:|---:|
| U8 medium | 1976.4 (1842.1–2360.1) | 1923.9 (1745.4–1993.3) | 1.03× |
| U8 small | 854.1 (773.1–947.7) | 806.0 (784.6–818.5) | 1.06× |
| U8 large | 6162.4 (5767.0–6578.0) | 5423.8 (5245.8–5995.2) | 1.14× |
| U8 multiple | 2595.8 (2544.7–2731.7) | 1822.8 (1781.5–1826.7) | 1.42× |
| U8 focused | 708.7 (684.9–740.7) | 628.6 (618.0–630.7) | 1.13× |
| U8 blurred | 1705.5 (1585.5–1821.2) | 1609.4 (1561.4–1613.6) | 1.06× |
| U16 medium | 2217.2 (2034.6–2438.8) | 2065.5 (1961.2–2127.9) | 1.07× |
| F32 medium | 2346.8 (2318.4–2411.9) | 1702.1 (1647.2–1795.3) | 1.38× |
The second base round was faster than the candidate for U8 small and medium,
despite better pooled medians: neither demonstrates a consistent improvement at
36 MP. U16 medium and fully blurred ranges also overlap; their gains are
inconclusive. Large blur improves both round medians but ranges overlap.
Multiple pins, full focus and F32 medium have separated observed ranges.
### 1500×1000 default filtered proxy for a 24 MP document
Single-pin blur values below are the document values divided by four; multiple
pins retain the supplied JSON blur values, matching the existing preview path.
| Depth / case | Base ms (range) | Candidate ms (range) | Speedup |
|---|---:|---:|---:|
| U8 medium | 51.8 (47.9–54.9) | 42.6 (39.3–45.5) | 1.22× |
| U8 small | 28.7 (27.4–31.0) | 24.9 (23.5–25.9) | 1.15× |
| U8 large | 144.0 (132.5–153.0) | 110.6 (104.0–111.9) | 1.30× |
| U8 multiple | 178.6 (174.8–205.9) | 111.6 (109.3–114.7) | 1.60× |
| U8 focused | 18.0 (17.1–19.0) | 15.0 (14.8–15.9) | 1.20× |
| U8 blurred | 33.1 (31.9–36.9) | 28.2 (27.8–29.9) | 1.17× |
| U16 medium | 52.1 (48.1–55.3) | 42.8 (40.7–49.4) | 1.22× |
| F32 medium | 60.4 (56.6–63.5) | 45.0 (43.9–50.0) | 1.34× |
Every default-proxy median improves. U16 medium ranges overlap, so that case's
gain is less certain than the other seven separated-range cases.
### 2000×1334 preview-sized stress workload
| Depth / case | Base ms (range) | Candidate ms (range) | Speedup |
|---|---:|---:|---:|
| U8 medium | 306.0 (280.6–465.1) | 163.8 (150.1–203.0) | 1.87× |
| U8 small | 82.5 (75.0–87.4) | 59.0 (58.2–63.4) | 1.40× |
| U8 large | 2051.0 (1724.3–2087.2) | 1324.2 (1106.4–1533.7) | 1.55× |
| U8 multiple | 303.6 (289.2–330.7) | 209.1 (200.5–231.5) | 1.45× |
| U8 focused | 61.5 (60.6–73.2) | 55.4 (51.6–78.7) | 1.11× |
| U8 blurred | 151.0 (146.5–155.1) | 143.3 (136.3–145.8) | 1.05× |
| U16 medium | 269.7 (262.7–289.8) | 216.1 (207.1–249.0) | 1.25× |
| F32 medium | 252.5 (237.9–295.5) | 228.0 (207.6–254.8) | 1.11× |
The already fully blurred stress case has little distance/level work to eliminate;
its improvement is modest. Focused and F32 stress ranges overlap, so their small
median gains are inconclusive. These cases do not justify additional machinery.
### 128×96 overhead
| Depth / case | Base ms (range) | Candidate ms (range) | Speedup |
|---|---:|---:|---:|
| U8 medium | 30.8 (30.3–31.3) | 17.7 (17.7–17.8) | 1.74× |
| U8 small | 4.1 (4.0–4.1) | 3.2 (3.1–3.3) | 1.26× |
| U8 large | 280.4 (276.1–294.6) | 149.1 (145.2–151.2) | 1.88× |
| U8 multiple | 26.0 (25.7–26.6) | 13.0 (13.0–13.1) | 1.99× |
| U8 focused | 1.7 (1.6–1.7) | 1.2 (1.2–1.3) | 1.36× |
| U8 blurred | 6.9 (6.9–7.0) | 6.3 (6.3–6.4) | 1.09× |
| U16 medium | 30.4 (30.2–31.0) | 18.3 (18.3–18.3) | 1.67× |
| F32 medium | 30.1 (30.0–30.5) | 18.3 (18.2–18.7) | 1.64× |
Windows process peak working set for one entire 24 MP suite (including fixture
creation and all depths/cases): base repeat 2,068,135,936 bytes, candidate
1,990,852,608 bytes, about 3.7% lower. This is an allocator-dependent process
high-water measurement sampled via `PeakWorkingSet64`, not per-filter live memory
or a new allocation bound. Existing full-window buffer capacities remain the same.
Maximum observed finite pixel error in the differential tests is **zero**: exact
bits, rather than an average-error bound. Matching every finite pixel covers
focus-transition structure, sampling aliasing, opacity and tile boundaries without
permitting a localized artifact to hide in a mean error. NaN classifications match.
## Validation and limits
Algorithm tests: 399 passed, 15 existing/diagnostic tests ignored. The five Iris
differential/cancellation tests also pass in release; their profiling test is
opt-in. All-target
algorithm Clippy passes with warnings denied. Layering passes (29 crates). Wasm
checks pass for all required crates and HEIF; unrelated existing dead-code warnings
in CMS/engine remain. Scorecard regeneration produces identical content; no budget,
corpus floor, menu parity or checklist status changed. `test-corpus --changed`
correctly skips this algorithm-only change. Commands and UI code are unchanged,
so command panic-hunt and UI screenshot checks are not triggered.
`cargo xtask perf --quick` completed successfully, running its configured quick
native CPU/GPU examples. Its regression comparison was skipped because the
checked-in baseline is for an Apple M4 Pro, while this machine is Windows/Ryzen
with a Radeon RX 9060 XT. Quick mode does not enforce the full-size budgets.
Results are retained locally in `target/agent-iris-blur/perf/results.json`;
the Iris base/candidate measurements above provide this change's comparison.
Final complete wasm and algorithm formatting checks also pass.
Final diagnostic single-tile reference/candidate calls (same profile workload,
one pair each, not a statistical benchmark): blur 15, 31.6 → 25.3 ms; blur 80,
52.9 → 43.1 ms; blur 300, 224.1 → 174.7 ms. The repeated complete-filter results
remain the performance evidence used to retain the change.
Native Iris GUI frame/slider latency, ARM/macOS/Linux performance and wasm runtime
speed were not measured. Compilation confirms wasm compatibility only. Photoshop
equivalence beyond the base implementation's semantics is not asserted. The private
shared craftrules checkout was unavailable at the supplied locations; repository
AGENTS.md and architecture/development/contributing/control/performance rules were
followed. Existing source allocation and per-tile cancellation limits are retained.
+10
View File
@@ -77,6 +77,16 @@ i7-9750H, 12 workers, 24 MP RGBA8, Good quality, amount 1: Spin 66.10 → 16.43
(4.02×), Zoom 19.19 → 5.35 s (3.59×), one paired run each, exact output equality.
These are local measurements at amount 1; see [method and limits](radial-blur-performance.md).
2026-10-10: Iris Blur pin preparation, saturated-distance evaluation and trailing
Gaussian-level halo trimming measure 1.18–1.76× faster across eight local 24 MP
RGB U8/U16/F32 cases (Ryzen 7 9700X, Windows, eight workers). Default blur 15:
626.8 → 426.9 ms; two pins: 1789.9 → 1016.2 ms. The default 1500×1000 filtered
proxy for 24 MP, document blur 15/80: 28.7/51.8 → 24.9/42.6 ms. At 36 MP,
two pins: 2595.8 → 1822.8 ms; small/medium U8 gains are inconclusive.
Differential tests preserve exact finite pixel bits.
These are computation timings, not GUI frame latency or new Photoshop parity;
see [measurements, upstream SIMD findings and limits](iris-blur-performance.md).
2026-10-10: native selection distance transforms measured 7.59–10.57x faster on six
24–36 MP synthetic masks on an AWS c7i.4xlarge (16 workers, three paired release runs).
The transform also corrects f32 envelope errors past coordinate 4096, including nonzero