NC Picto · Docs

Content-Aware Scale performance

2026-10-22. Two things are recorded here: what this change saves, and a correction to the P28 row's recorded number, which predates the seam-search proxies and reads as hundreds of times worse than the code does today.

The change

Content-Aware Scale shrinks an image by removing one seam at a time. On large images the seams are searched on a vertically averaged copy (carve_width_proxy_height), and a panel column found there is not an original column: each seam already removed has shifted the panel left. That was tracked with an idx map — one usize per proxy pixel, built up front and shifted by every seam.

The original column is recoverable without the map: for the i-th column already taken from a row, o - i is the panel column it occupied, so the taken columns at or left of panel column x are those with o - i <= x, and the original column is x plus that count. o - i never decreases along the row's list, so that count is a binary search and the original column inserts where the search stopped; the rows are independent and update in parallel, like the pixel shift.

Counting it by scanning the row's list instead is a comparison per already-removed column, so its cost grows with the square of the seam count and a deep shrink pays it on every row and every seam. The binary search removes that term; what is left per removed column is one insert into a list of at most seam_count entries, i.e. an element move rather than a comparison.

Output is unchanged: the same original columns reach the assembly pass, in the same order, so every pixel written is the pixel the map produced. The 4-channel cases below hash identically (FNV-1a over the output's raw f32 bits); the 3-channel case is unchanged within timer noise.

Timings

Windows 11, Intel i5-10300H (4 cores / 8 threads), rustc 1.98.1, --release, default rayon pool. The timer covers seam::carve_width only: document read, tile writeback, history and the undo that follows the command are outside it. Each cell is the same binary run before and after the change, in the same session, nothing else running.

Case Before (ms) After (ms) Change Output
4000 × 3000 → 3600, 4 ch 1424.7 1132.9 −20.5 % identical
6000 × 4000 → 5400, 4 ch 3009.3 2559.4 −15.0 % identical
7200 × 5000 → 6480, 4 ch 4352.8 3766.9 −13.5 % identical

Deep shrinks, where the counting scan was quadratic, on the same machine and the same binary with only the recording swapped (three alternating runs per side, min/median):

Case Scan (ms) Binary search (ms) Change Output
6000 × 4000 → 600, 4 ch (5 400 seams) 16 640 / 16 757 13 915 / 14 066 −16 % identical, fnv 0f7eaa6af3cf1557
6000 × 4000 → 3000, 4 ch (3 000 seams) 10 617 / 10 814 9 936 / 10 012 −6 % identical, fnv 1780ee9bc9077ee9

The element move that remains is still linear in the row's list length, so a shrink this deep is not down to the index map's cost yet on four cores; a rank bitvector over the row would remove it, and is not in this change.

The map was 24 MB next to a 36 MB pixel buffer at 24 MP, so the saving tracks how many bytes of row the seams have to shift. A three-channel case was measured too and moved inside the timer noise, so it is not reported as a win here. Further seam-search savings have to come from the dynamic program itself, not from bookkeeping.

P28's recorded number is stale

perf/baseline.json records P28 (Content-Aware Scale on 24 MP to 90 % width) as 954 192 ms from the 2026-10-05 baseline, which is why perf/budgets.toml carries enforce = false for that row. The seam-search proxies and the per-seam energy update landed after that capture:

Tree 6000 × 4000 → 5400, 4 ch
f5d2bcb (the 2026-10-05 baseline commit) 266 039 ms
1104b6f (main, plus this change) 2 559 ms

f5d2bcb has no PROXY_SHRINK_MIN_HEIGHT: it carves at full height with a full energy recompute per seam. The 954 s in the baseline was that code on a machine whose recorded load average was 226–290 on 14 cores. The two example runs above are 104× apart (266 039 ms against 2 559 ms); that is the seam-search core alone, and it is not the same measurement as the row's 954 s, which also includes a document read and writeback the timer above excludes.

The row itself is not flipped to enforce = true here: the budget is set for the macOS/aarch64 machine class and only a run on that class can vouch for it. The carving core above is 2.56 s at 24 MP on a 4-core laptop CPU; the budgeted machine is faster, but the row also includes the document read and writeback this measurement excludes. Flip it in the PR whose nightly run shows the row under 3 s, and delete this paragraph with it.

Reproduce

cargo build --release -p picto-algo --example bench_seam
./target/release/examples/bench_seam 6000 4000 5400 4   # width height new_width channels (1..=4)

Build the base and the changed tree with separate CARGO_TARGET_DIRs and run the same line in both; the printed FNV hash of the output must match, and the millisecond figure is the row above. The example builds deterministic gradient-plus-detail content, so two runs of the same binary agree.

Correctness and repository checks

cargo test --release -p picto-algo seam passes, including carver_matches_reference, output_bits_are_pinned, tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels and the cancellation test. cargo fmt --all --check and cargo clippy -p picto-algo --all-targets -- -D warnings are clean. No command, UI or format contract changed; Carver's track mode is still used by the enlargement path, which needs the original columns it was written for.