mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
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:
6 files changed
+777
-25
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-25
@@ -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();
|
||||
buf.extend_from_slice(&p);
|
||||
gauss_blur_n(&mut buf, ww, wh, n, lvk);
|
||||
&buf
|
||||
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, 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);
|
||||
|
||||
@@ -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, ¶ms, 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, ¶ms, 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, ¶ms, 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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user