Browse / github.com/storytold/photocraft / patch
perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426)
perf(algo): row-parallel local_sharpen with fast-path protect_detail (#5426) Refs #5426. Follow-up to #5499. ## Problem #5499 parallelized `local_blur` and the per-dab mix in `DabTool::Focus`, but `local_sharpen` (`crates/algo/src/retouch.rs`) stayed single-threaded. `protect_detail` also recomputed boundary clamping and strided lookups for the 8-neighbour window inside the per-channel loop, for every pixel in the dab. With large brushes (500–2500 px) interactive strokes lagged badly and stalled the application. ## Change - `local_sharpen` processes window rows in parallel (`out.par_chunks_mut(stride)`), with a `wasm32` serial fallback like `local_blur`. - `protect
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- wanikhawar (imported from pull request #9651, open)
- Base
59cb4c49bd236df04d8e37d4764253f29ca6600a- Size
- +201 −10 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
26ffaef5087c4f7b5dc4bcbf
How it went
No other operator has reported using it yet.
Diff
raw · crates/algo/src/retouch.rs crates/algo/tests/it/main.rs crates/algo/tests/it/retouch_sharpen_exact.rs crates/engine/examples/bench_sharpen_tool.rs
@@ -210,34 +210,80 @@ pub fn local_sharpen( protect_detail: bool, ) -> Vec<f32> { let (x0, y0, x1, y1) = win;- let ow = x1 - x0;+ let (ow, oh) = (x1 - x0, y1 - y0);+ if ow == 0 || oh == 0 || ch == 0 {+ return Vec::new();+ } let blur = local_blur(data, w, h, ch, alpha, win, 1.0); let mut out = vec![0.0f32; blur.len()];- for y in y0..y1 {- for x in x0..x1 {- let o = ((y - y0) * ow + (x - x0)) * ch;+ let stride = ow * ch;++ // Rows are independent, so process them in parallel with rayon (#5426).+ let process_row = |(oy, dst): (usize, &mut [f32])| {+ let y = y0 + oy;+ let blur_row = &blur[oy * stride..(oy + 1) * stride];+ for ox in 0..ow {+ let x = x0 + ox;+ let o = ox * ch; let i = (y * w + x) * ch;++ // Precompute 3×3 neighbourhood offsets once per pixel if protecting detail.+ let offsets: [usize; 8] = if protect_detail {+ if x > 0 && x + 1 < w && y > 0 && y + 1 < h {+ let w_ch = w * ch;+ [i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch]+ } else {+ let get_n_idx = |dx: i32, dy: i32| -> usize {+ let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;+ let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;+ (ny * w + nx) * ch+ };+ [+ get_n_idx(-1, -1),+ get_n_idx(0, -1),+ get_n_idx(1, -1),+ get_n_idx(-1, 0),+ get_n_idx(1, 0),+ get_n_idx(-1, 1),+ get_n_idx(0, 1),+ get_n_idx(1, 1),+ ]+ }+ } else {+ [0; 8]+ };+ for c in 0..ch { let v = data[i + c]; if Some(c) == alpha {- out[o + c] = v;+ dst[o + c] = v; continue; }- let mut s = v + amount * (v - blur[o + c]);+ let mut s = v + amount * (v - blur_row[o + c]); if protect_detail { let (mut lo, mut hi) = (v, v);- for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {- let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);- let nv = data[(ny * w + nx) * ch + c];+ for &n_idx in &offsets {+ let nv = data[n_idx + c]; lo = lo.min(nv); hi = hi.max(nv); } s = s.clamp(lo, hi); }- out[o + c] = s;+ dst[o + c] = s; } }+ };++ #[cfg(not(target_arch = "wasm32"))]+ {+ use rayon::prelude::*;+ out.par_chunks_mut(stride).enumerate().for_each(process_row); }+ #[cfg(target_arch = "wasm32")]+ {+ out.chunks_mut(stride).enumerate().for_each(process_row);+ }+ out } @@ -3,4 +3,5 @@ mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact;+mod retouch_sharpen_exact; mod warp_clipped;new file mode 100644@@ -0,0 +1,82 @@+//! Frozen scalar sharpen from main, used as an exact-output oracle.+use photocraft_algo::retouch::{local_blur, local_sharpen};++#[test]+fn matches_scalar_sharpen_bit_for_bit_across_layouts_edges_and_hdr() {+ for (w, h) in [(1, 1), (3, 7), (37, 29)] {+ for ch in 1..=5 {+ for alpha in std::iter::once(None).chain((0..ch).map(Some)) {+ let data: Vec<f32> = (0..w * h * ch)+ .map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })+ .collect();+ for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {+ for amount in [0.0, 0.5, 1.0, 2.5] {+ for protect_detail in [false, true] {+ let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);+ let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);+ assert_eq!(actual.len(), expected.len());+ for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {+ assert_eq!(+ a.to_bits(),+ b.to_bits(),+ "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} amount={amount} protect={protect_detail} sample={i}"+ );+ }+ }+ }+ }+ }+ }+ }+}++#[test]+fn empty_channel_layout_remains_empty() {+ assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty());+}++#[allow(clippy::too_many_arguments)]+fn scalar_local_sharpen(+ data: &[f32],+ w: usize,+ h: usize,+ ch: usize,+ alpha: Option<usize>,+ win: (usize, usize, usize, usize),+ amount: f32,+ protect_detail: bool,+) -> Vec<f32> {+ let (x0, y0, x1, y1) = win;+ let (ow, oh) = (x1 - x0, y1 - y0);+ if ow == 0 || oh == 0 || ch == 0 {+ return Vec::new();+ }+ let blur = local_blur(data, w, h, ch, alpha, win, 1.0);+ let mut out = vec![0.0f32; blur.len()];+ for y in y0..y1 {+ for x in x0..x1 {+ let o = ((y - y0) * ow + (x - x0)) * ch;+ let i = (y * w + x) * ch;+ for c in 0..ch {+ let v = data[i + c];+ if Some(c) == alpha {+ out[o + c] = v;+ continue;+ }+ let mut s = v + amount * (v - blur[o + c]);+ if protect_detail {+ let (mut lo, mut hi) = (v, v);+ for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {+ let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);+ let nv = data[(ny * w + nx) * ch + c];+ lo = lo.min(nv);+ hi = hi.max(nv);+ }+ s = s.clamp(lo, hi);+ }+ out[o + c] = s;+ }+ }+ }+ out+}new file mode 100644@@ -0,0 +1,62 @@+//! Reproducible soft Sharpen strokes on a 24 MP striped document. Run in release mode.+//! Setup, Undo and the output fingerprint are excluded from the timing.+use std::time::Instant;++use photocraft_engine::{EngineError, Session};+use photocraft_geom::Rect;+use serde_json::json;++fn run() -> Result<(), Box<dyn std::error::Error>> {+ for depth in [8, 16] {+ let mut session = Session::new();+ session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?;+ session.execute("layer.new.layer", json!({}))?;+ session.edit("stripes", |doc, active| {+ let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;+ let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;+ for x in (0..6000).step_by(64) {+ let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };+ surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color);+ }+ Ok(())+ })?;+ for size in [100, 500, 1000] {+ let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25,"protectDetail":true});+ let mut times = Vec::new();+ let mut fingerprint = 0u64;+ for sample in 0..6 {+ let start = Instant::now();+ session.execute("paint.sharpen", params.clone())?;+ let ms = start.elapsed().as_secs_f64() * 1000.0;+ if sample > 0 {+ times.push(ms);+ }+ if sample == 5 {+ let st = session.active().ok_or("no document")?;+ let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?;+ for y in (1500..2500).step_by(17) {+ for x in (2000..4000).step_by(13) {+ for channel in surface.rgba(x, y) {+ fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits());+ }+ }+ }+ }+ session.execute("edit.undo", json!({}))?;+ }+ times.sort_by(f64::total_cmp);+ println!(+ "depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}",+ times[2], times[0], times[4]+ );+ }+ }+ Ok(())+}++fn main() {+ if let Err(error) = run() {+ eprintln!("{error}");+ std::process::exit(1);+ }+}