Skip to content

Test strip: density x grade grid on the preview - #661

Merged
marcinz606 merged 4 commits into
mainfrom
feat/test-strips
Jul 28, 2026
Merged

Test strip: density x grade grid on the preview#661
marcinz606 merged 4 commits into
mainfrom
feat/test-strips

Conversation

@marcinz606

@marcinz606 marcinz606 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

What

A darkroom test strip for Print Density and ISO-R Grade. The Tone panel gets a Test Strip button that prints the frame as a 6×6 grid of real renders — density increasing left to right, grade softening top to bottom — so the diagonals read light-to-dark and soft-to-hard exactly like a split-filter test strip. Click the patch you like and its settings commit.

Choosing density and grade previously meant dragging two sliders and re-judging the whole frame after every move. This shows all thirty-six candidates at once.

The ladders are absolute rather than offsets around the current values, so a strip printed off one frame is comparable to the next, and they cover most of both sliders' travel:

  • density 0.4 … 1.9 in steps of 0.3
  • grade R55 … R180 in steps of 25

Both straddle the defaults and stay inside the ranges their controls accept — outside them the values clamp, and two rungs would render the same patch twice, so a test pins that.

No gridlines are drawn over the mosaic: it reads as one print, the way a real test strip does. Only the patch under the cursor is outlined, so it stays obvious what a click would take, and the rung matching the current settings is accented on its axis label.

How

Follows the zone overlay's split: pure NumPy in features/exposure/analysis.py, painting in overlay.py, session-only AppState flags. No WorkspaceConfig field, no migration, no pipeline stage, no WGSL. Grid size is derived from the two ladders throughout, so 4×4 → 6×6 was a two-constant change.

The one thing the zone grid didn't need is real pixels at other configs. Those come from a new RenderWorker.build_strip slot on the render thread's warm ImageProcessor: density and grade are absent from the analysis cache key, so the per-frame metering is reused and only the exposure stage onward re-dispatches. The strip always renders at preview resolution, never HQ — 36 full-res renders would take minutes, and each patch is shown at a sixth of the frame's width.

Three decisions worth a reviewer's eye:

  • Picking leaves Auto Density / Auto Grade alone. The patches were rendered under them, so flipping them would render something other than the patch that was clicked.
  • The assembled mosaic is soft-proofed once, not per tile. The transform is per-pixel, so one pass is identical and 36× cheaper. Without it, a proofed canvas would blow out — the canvas buffer is already sRGB-baked when a proof is active.
  • load_file clears the strip, not only request_render: the navigate-back memo path repaints without going through request_render, which would leave a strip hanging over a different frame. Compare and flat peek clear it too — both want the canvas.

Verification

make lint, make type clean; 2638 tests pass. 31 new unit tests across ladder math, worker, controller lifecycle and overlay paint/click.

Headless E2E on samples/DSC00448.ARW, both engines (gpu_enabled True and False, no fallback):

  • all 36 patches are distinct renders — no clamped duplicates at the ladder ends
  • every patch is bit-identical (max abs diff 0.00e+00) to a full render at its own rung, so patch assignment and mosaic slicing are exact
  • over the whole frame, more density prints darker in every grade row, and the softest rung is flatter than the hardest in every density column
mean luma per rung                    p90-p10 spread per rung
[[0.5769 0.4645 0.3568 0.2583 …]      [[0.9392 0.9269 0.8722 0.7417 …]
 [0.5550 0.4581 0.3647 0.2798 …]       [0.8364 0.8274 0.7645 0.6460 …]
 [0.5375 0.4542 0.3736 0.2995 …]       [0.7227 0.7003 0.6462 0.5714 …]
 …                             ]       …                             ]

Two measurement notes, both about the probe rather than the feature:

  • Patch means are deliberately not compared to each other. Each patch is a different part of the picture, so its mean reports subject matter, not exposure; monotonicity is measured against full-frame renders over one fixed region.
  • Contrast is strictly monotonic in grade for every density column except the densest. At density 1.9 a harder grade drives more of the frame off the paper's bottom end, so measured contrast peaks mid-ladder — real print behaviour, and the sort of thing a test strip exists to show. The E2E asserts the end-to-end trend for every column and strict monotonicity for the rest, and prints the exception rather than asserting it away.

Print the frame as a 4x4 grid of real renders: Print Density increasing
left to right, ISO-R Grade softening top to bottom, so the diagonals read
light-to-dark and soft-to-hard like a split-filter test strip. Click a
patch to commit its settings.

The ladder is absolute (density 0.6/0.9/1.2/1.5, grade 90/105/120/135)
rather than an offset around the current values, so a strip printed off
one frame is comparable to the next.

Session-only display state, like the zone overlay: no WorkspaceConfig
field, no migration, no pipeline stage. Patches come from a new
RenderWorker slot instead of the display buffer, since they are pixels
the canvas does not have; density and grade are absent from the analysis
cache key, so the per-frame metering is reused and only the exposure
stage onward re-dispatches.

- Picking leaves Auto Density / Auto Grade untouched: the patches were
  rendered under them, so changing them would render something other
  than the patch that was clicked.
- The assembled mosaic is soft-proofed once rather than per tile; the
  transform is per-pixel, so it is identical and 16x cheaper. Without it
  a proofed canvas would blow out.
- Cleared by any real render, by compare / flat peek taking the canvas,
  and by load_file, whose navigate-back memo path repaints without
  going through request_render.
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

Preview load metrics (github-linux-x64)

format cold load throughput
CR2 0.771 s 29.11 MPx/s
NEF 0.595 s 41.30 MPx/s
ARW 0.215 s 94.02 MPx/s
RAF 0.643 s 25.30 MPx/s
DNG 0.644 s 37.15 MPx/s

fixtures: rawsamples.ch · sha: cc4aadf · full JSON

- 6x6 instead of 4x4, over most of both sliders' travel: density
  0.4-1.9 in steps of 0.3, grade R55-R180 in steps of 25. Both ladders
  straddle the defaults and stay inside the ranges their controls accept
  — outside them the values clamp and two rungs would render the same
  patch twice, so a test pins that.
- Always render at preview resolution, never HQ. 36 full-res renders
  would take minutes, and each patch is shown at a sixth of the frame's
  width; the mosaic scales to the content rect regardless.
- No gridlines over the mosaic: it reads as one print, the way a real
  test strip does. Only the patch under the cursor is outlined, so it
  stays obvious what a click would take.
- Axis labels move to the zone numerals' size (bold, 1.25x), sharing one
  font helper with them, and the rung matching the current settings is
  accented in place of the box that used to mark that patch.

Grid size is derived from the ladders throughout, so this is a
two-constant change plus the label and gridline work.
- The button becomes an icon-only toggle sitting next to Set Targets in
  the Auto Density / Auto Grade row, fixed to the same 36px width so the
  row doesn't stair-step. It no longer spans the panel.
- Shift+T toggles it (Shift+T was free; only Alt+Shift+T was taken), and
  the tooltip advertises the binding via tooltip_with_shortcut.
- Printing shows a toast and ticks the HUD progress bar once per patch,
  since 36 renders take a few seconds and an icon-only button has no
  label left to say so. Cancelling mid-print hides the bar, and late
  progress from a cancelled job cannot resurrect it.

CLAUDE.md gains the shortcut registry: REGISTRY is the single source of
truth feeding the editor, the ? overlay and tooltips, so any new toggle,
tool or action belongs there — with a note to check for collisions, since
a duplicate key makes Qt fire activatedAmbiguously and both go dead.
Reprinting an unchanged strip was 36 renders for pixels we already had.

The key is the render memo key with density and grade pinned: the strip
supplies those two itself, one per patch, so the mosaic is invariant to
whatever they currently are. That makes the print/pick/print-again loop a
cache hit, since picking a patch changes nothing else — measured at
1749 ms to print vs 0 ms from cache on a real scan. Any other edit (crop,
filtration, paper, toning) lands on a different key and re-prints.

Reuses RenderMemo, so entries are per frame with the same LRU budget and
by-reference contract as the navigate-back memo, and are dropped when the
session empties.

Keyed at store time rather than dispatch: measured bounds are persisted
after a render with render=False, so the config drifts mid-print without
invalidating anything (the drift RenderMemo.rekey exists for), and keying
at dispatch made every reprint a miss. Safe because a change that did
matter would have gone through request_render, which cancels the strip.

_render_memo_key takes an optional config to build the pinned variant.
@marcinz606
marcinz606 merged commit e4bc450 into main Jul 28, 2026
2 checks passed
@marcinz606
marcinz606 deleted the feat/test-strips branch July 28, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant