collective

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

diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs--- a/crates/algo/src/retouch.rs+++ b/crates/algo/src/retouch.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 } diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs--- a/crates/algo/tests/it/main.rs+++ b/crates/algo/tests/it/main.rs@@ -3,4 +3,5 @@ mod liquify_freeze_formats; mod liquify_source_footprint; mod retouch_blur_exact;+mod retouch_sharpen_exact; mod warp_clipped;diff --git a/crates/algo/tests/it/retouch_sharpen_exact.rs b/crates/algo/tests/it/retouch_sharpen_exact.rsnew file mode 100644--- /dev/null+++ b/crates/algo/tests/it/retouch_sharpen_exact.rs@@ -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+}diff --git a/crates/engine/examples/bench_sharpen_tool.rs b/crates/engine/examples/bench_sharpen_tool.rsnew file mode 100644--- /dev/null+++ b/crates/engine/examples/bench_sharpen_tool.rs@@ -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);+    }+}