Files
photocraft/docs/selection-distance-performance.md

4.3 KiB

Selection distance transforms

The distance transform used by Select > Modify > Expand, Contract and Border now runs as two native parallel passes. It also serves matting and mask feathering callers in the engine. The compositor has a separate implementation and is outside this change.

The first pass finds the nearest selected pixel above and below each pixel. Since its input is binary, two sweeps replace the general parabola envelope for that axis. Vertical distances are stored as integers in column order. The second pass copies 32-row bands into contiguous rows and evaluates the lower envelope of squared-distance parabolas. Both passes are linear in the number of pixels. WebAssembly uses the same scalar kernels.

The row envelope follows Felzenszwalb and Huttenlocher, 2012. Its intersections use (fq - fp) / (2 * (q - p)) + (q + p) / 2 in f64. This avoids the old subtraction of f32 coordinate squares, which lost half-pixel boundary precision on large canvases. Distances are rounded to f32 only at the output.

Correctness

The previous transform can return a positive distance at a selected pixel past coordinate 4096. A 6000 x 1 fully selected mask reproduces it. The regression tests also compare sparse, competing sites in 65537 x 2 and 2 x 65537 masks against direct Euclidean distances.

Tests enumerate every mask up to 4 x 3 against a brute-force oracle, check rectangular and partial row bands against the previous implementation, preserve fractional Expand/Contract/ Border coverage on smaller inputs, and compare native worker counts 1, 2 and 4. Empty or inconsistent dimensions return an empty buffer. No-seed masks retain the finite 1e10 result. Empty and fully selected masks skip the transform passes.

Large benchmark inputs check every selected pixel has zero distance and check independent nearest-site distances at corners and the centre. The sparse-grid cases match the old output exactly. Ellipse and dense cases intentionally correct old distance errors, so this is not a claim of identical output everywhere or of measured Photoshop parity.

Measurements

Recorded 2026-10-10 on an AWS c7i.4xlarge in ap-southeast-2: 16 vCPUs, 32 GiB RAM, Rust 1.99.0, the workspace's normal release profile, 16 Rayon workers. Synthetic masks are an ellipse, a sparse regular grid, and a deterministic dense pattern. Input generation, reference validation and destruction of the output happen outside the timed interval. Each implementation gets a warmup, then three measured runs with alternating order.

Size Mask Before p50 After p50 Speedup Changed distances Largest correction
24 MP ellipse 630.74 ms 83.11 ms 7.59x 827,500 1 px
24 MP sparse 621.81 ms 71.03 ms 8.75x 0 0 px
24 MP dense 1048.21 ms 99.73 ms 10.51x 2,034,343 1.4142135 px
36 MP ellipse 994.14 ms 124.48 ms 7.99x 2,431,817 1 px
36 MP sparse 984.52 ms 109.94 ms 8.96x 0 0 px
36 MP dense 1588.23 ms 150.28 ms 10.57x 4,771,985 1.4142135 px

These are kernel timings. Mask conversion, selection history, document compositing and canvas upload are excluded. They do not establish an end-to-end interaction budget.

Memory and limits

The old transform used one f32 plane. The new transform needs one u32 plane plus the f32 result, or eight bytes per pixel, excluding the caller's input mask. It also uses up to 32 * width * 4 + width * (size_of::<usize>() + 8) scratch bytes per active row task. At 24 MP the two planes total 192 MB; at 36 MP they total 288 MB. Fully empty and fully selected masks need only the result plane. Small mixed masks below 65536 pixels run serially.

The extra plane buys contiguous writes and independent parallel bands. This remains a dense transform, so it does not solve streaming selections on very large PSB documents. Dimension multiplication, element-size limits and input length are checked before allocation.

Reproduce

cargo test --release -p photocraft-algo distance_transform_release_comparison -- --ignored --nocapture
EDT_WORKERS=1 cargo test --release -p photocraft-algo distance_transform_release_comparison -- --ignored --nocapture

The previous scalar implementation is retained only in the test module as the reference and benchmark baseline.