8.1 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.
Cropped selection morphology (2026-10-26)
Expand, Contract and Border now keep distance work inside the nonzero mask bounds,
expanded by ceil(radius) + 1 and clipped to the supplied canvas. Nonzero bounds include
fractional coverage below the binary 0.5 threshold. Real pixels in the halo provide
outside seeds; cropping does not introduce an artificial selection edge. The engine's
existing canvas-bound padding remains responsible for Apply effect at canvas bounds.
For radii up to 5 px, two vertical sweeps store capped distances in one byte per pixel.
A short horizontal stencil computes exact Euclidean distances only within radius + 1;
farther samples cannot contribute to the existing coverage formulas. Larger radii use
the general EDT above. Contract classifies complement seeds directly and writes the
complement coverage formula without allocating inverted float masks. Border runs its
two cropped transforms sequentially, reusing one seed mask and retaining one distance
plane at a time.
A 50×50 selection at radius 5 needs a 62×62 distance window (3,844 samples), even on a 36 MP canvas. Seed, vertical and output-distance buffers total about 23 KB on the bounded path, excluding allocator overhead. The flat API still scans its input for bounds and returns a full-sized output (144 MB at 36 MP). Engine mask conversion, tiled writeback, undo and compositing remain outside this optimization; this does not establish a GUI latency budget or solve sparse document storage.
Regression tests compare against the previous full-canvas formulas for empty/full masks, one-row/column canvases, holes, edge-touching shapes, fractional radii, and fractional coverage including complement rounding at 0.5. Every 3×3 binary mask is also checked inside a larger canvas with a faint sample outside its binary bounds. The small-radius distances are compared with the general EDT. Invalid dimensions and nonfinite/negative radii return empty buffers.
Reproduce on the fork through the CI workflow's optional selection_morphology input.
It runs cargo run --release -p photocraft-algo --example bench_selection_morphology
and cargo xtask perf --quick remotely. The comparison uses a 6000×6000 mask with either
a centred 50×50 square or a centred 4000×4000 square, radii 3 and 50, and four Rayon
workers. It checks every output sample against the previous formulas before timing;
reported medians include output allocation and exclude output destruction, with three
runs per implementation. These measurements are separate from the historical EDT-only
measurements above and do not change the scorecard baseline.
Measurements from scratch CI job 121583341033,
2026-10-26, commit 5afd231138368f9be856e8c21c2946f2e386797c, GitHub-hosted Ubuntu 26.04,
stable Rust, normal release profile. The benchmark step completed successfully; the
superseded run was cancelled during the later quick performance build. The full validation
run is recorded in the PR. Times below are milliseconds; this table reports Expand and Border (eight cases).
All twelve output comparisons, including Contract, matched exactly. The current benchmark
also times Contract using its original owning iterator so allocation reuse is preserved.
| Selection | Operation | Radius | Before p50 | After p50 | Speedup |
|---|---|---|---|---|---|
| 50×50 | Expand | 3 | 359.55 | 94.51 | 3.80× |
| 50×50 | Border | 3 | 738.30 | 19.52 | 37.82× |
| 50×50 | Expand | 50 | 357.84 | 99.76 | 3.59× |
| 50×50 | Border | 50 | 738.50 | 20.08 | 36.79× |
| 4000×4000 | Expand | 3 | 416.83 | 171.26 | 2.43× |
| 4000×4000 | Border | 3 | 802.48 | 377.78 | 2.12× |
| 4000×4000 | Expand | 50 | 414.50 | 268.96 | 1.54× |
| 4000×4000 | Border | 50 | 784.89 | 467.06 | 1.68× |