Files
photocraft/docs/seam-carving-performance.md
T
ebosido444-aiandebosido444-ai b07b7fab55 perf(seam): the shrink proxy drops its original-column map, same pixels (−15 % at 24 MP) (#8526)
* perf(seam): drop the original-column map from the shrink proxy, same pixels (-15 % at 24 MP)

Content-Aware Scale's proxy seam search tracked each panel column's original
column in an idx map, one usize per proxy pixel, shifted by every seam. The
original column is x plus the number of columns already taken at or left of it,
so a per-row list of the taken columns answers the same question without a
buffer the size of the energy map.

The recorded P28 budget number predates the proxies: the 2026-10-05 baseline
commit carves at full height and recomputes energy per seam, and measures
266 s for the 24 MP case on the machine this was timed on, against 2.6 s now.
Documented in docs/seam-carving-performance.md; enforce stays off until a
budgeted-class run can vouch for the whole row.

* review: generate exactly ch samples per pixel in bench_seam, reject ch > 4

The fixture flattened four samples per pixel and then truncated to w*h*ch, so a
three-channel run grouped samples across pixel boundaries and any ch above four
built a buffer shorter than the rows carve_width indexes.

Generate ch samples inside the per-pixel closure and refuse ch outside 1..=4.
The three-channel row is dropped from the perf note rather than re-measured
here: it moved inside the timer noise either way, and the other agents' builds
on this machine make a fresh number worthless. The four-channel hashes are
unchanged, which is the check that this edit touched only the fixture.

* perf(seam): find the removed column by binary search, not a linear scan

Recording a seam recovered each removed original column by scanning the row's
list of already-taken columns, so a deep shrink paid a comparison per removed
column on every row and every seam: quadratic in the seam count, and worse than
the index map this PR replaced once a shrink got deep enough.

`o - i` never decreases along the row's sorted list, so that count is a binary
search, and the original column belongs where the search stopped; the rows are
independent and update in parallel, like the pixel shift. What is left per
removed column is one insert.

Output is unchanged: same columns, same order, same hashes (10 % and 50 % width
cases below), and `counted_removed_columns_match_the_tracked_map` now runs a
tracking and a counting carver side by side to one column and compares every
row's list after every seam.

docs/seam-carving-performance.md gains those cases, states that the ~104x
figure is the two example runs rather than the baseline row, and names
perf/baseline.json as the source of the 954 s.

---------

Co-authored-by: ebosido444-ai <ebosido444-ai@users.noreply.github.com>
2026-10-24 11:14:00 -04:00

5.8 KiB
Raw Blame History

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 photocraft-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 photocraft-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 photocraft-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.