Skip to content

Colour ring-around: the RA4 filtration proof - #672

Merged
marcinz606 merged 2 commits into
mainfrom
feat/ring-around
Jul 29, 2026
Merged

Colour ring-around: the RA4 filtration proof#672
marcinz606 merged 2 commits into
mainfrom
feat/ring-around

Conversation

@marcinz606

@marcinz606 marcinz606 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

What

The classic Kodak/Fuji ring-around, as a canvas proof: the frame printed as a 5×5 mosaic stepping 1cc at a time out to ±2cc on the magenta and yellow axes. Shift+F, the target button beside the Filtration sliders, or the toolbar's overflow menu. Click a patch to keep its filtration.

It answers the one question a colour printer can't answer by eye: not whether there's a cast, but which way it goes. Sibling to #669's test strip, which grids density × grade.

Axis labels read the rungs' absolute filtration in cc, M -2 … +2 down the left and Y -2 … +2 across the top, with the rung nearest the current filtration accented.

This PR also retunes the test strip to 5×5 (it shares the mosaic machinery), with both ladders centred exactly on their defaults so the settings already in force are the middle patch: density 0.4-1.6 in 0.3 steps, grade R75-R155 in 20-point steps.

The finding that made it cheap

The test strip is affordable because "density and grade are absent from the analysis cache key, so the per-frame metering is reused". I checked whether that holds for filtration, since otherwise the ring would re-meter 25 times:

  • GPU _analysis_cache_key (gpu_engine.py:134) keys on source hash, settings.process, settings.geometry, three ExposureConfig booleans, and TARGETS_REVISION. No CMY field.
  • CPU base_key (engine.py:129) carries only process.*, geometry, distortion_k1, TARGETS_REVISION. Zero exposure fields.

Measured in the E2E below, not just argued: 0.05s per patch for both proofs.

Both ladders absolute, which is what makes the cache work

The ring's rungs are fixed filtration values centred on neutral, exactly like the strip's density and grade ladders. That has three consequences, all of them simplifications:

  • The mosaic is invariant to the filtration in force, so _strip_memo_key pins M/Y the way it pins density and grade. Printing, picking a patch, and printing again is a cache hit: the patches never depended on what was dialled in.
  • No rail clamping. Every rung sits well inside the sliders' travel, so the duplicate-patch case a relative ladder had at the extremes cannot arise.
  • A ring printed off one frame compares to the next, the same argument Test strip: density x grade grid on the preview #661 made for its ladders.

The accented rung follows the current filtration through ring_nearest_cell, mirroring how the strip accents the nearest density and grade.

One consequence worth knowing: with an absolute ladder the pick sets filtration rather than nudging it, so the ring only reaches as far as its own span. RING_CC_STEP is a single constant if that wants widening.

One proof slot, not two

The tempting shape is a second set of AppState fields and a parallel lifecycle. Don't — and the reason isn't code volume. _clear_test_strip() has six call sites: request_render, the toggle, the pick, and the compare / flat-peek / load_file navigate-back paths. A parallel _clear_ring_around() means six more places to keep in sync forever, and #661's own write-up shows the navigate-back path is the one that gets silently missed.

So the strip's slot gains a kind. Every existing drop path covers the ring for free, the _draw_ui guard stays one branch rather than two racing for the content rect, and asking for one proof while the other is up swaps it, which falls out of comparing kind.

The worker is generalized, not duplicated: TestStripTask carries per-patch ExposureConfig override dicts plus a grid shape, build_strip applies them, and the mosaic slicer takes the grid. The tone strip becomes one caller, and its tests passing untouched is the refactor's regression check.

Step: RING_CC_STEP = 0.05. Grounded, not guessed: filtration_offsets documents 1.0 slider = 20cc, so the step is 1cc and the outer rung 2cc.

5×5, not a honeycomb: a hex layout needs a new slicer, new hit-testing and a new assembler to show nothing the M/Y corners already do.

Verification

make all green: 2706 passed, lint and ty clean. New unit tests across the ladder (absolute rungs symmetric about neutral, cc scale, accent tracking, every rung inside the slider travel), the worker's override sequence, lifecycle, swap, cache behaviour, and 5×5 hit-testing. The existing strip tests are parameterized over both grids so one test pins the shared slicer for both.

Headless E2E on samples/DSC00448.ARW:

  • the neutral centre patch is bit-identical to a plain neutral render, max abs diff 0.00e+00. If the centre isn't the print those settings produce, the whole instrument compares against something that isn't there.
  • all 25/25 patch means distinct
  • the outer magenta rungs move green in opposite directions from the centre, and the outer yellow rungs move blue: the axes are wired the right way round, not merely varying
  • picking commits only filtration: cyan, density, grade, Cast Removal and both Auto toggles untouched
  • 0.05s per patch for both the ring and the tone strip, which is the metering reuse measured rather than assumed

One honest caveat, which is in the tooltip: with Cast Removal on the patches separate less, because the neutral-axis solve is correcting toward neutral underneath. They still show the print you'd actually get.


Fourth of the darkroom-instrument set: #669 step wedge, #670 burn map, #671 grain focuser, this one. Each is independent; this is the only one that touches already-shipped code.

The classic Kodak/Fuji ring-around as a canvas proof: the frame printed as a 3x3
mosaic with the centre patch at the current filtration and the ring 5cc either way on
the magenta and yellow axes, so the direction of a colour cast is visible rather than
guessed. Click a patch to keep its filtration. Shift+F, the target button beside the
Filtration sliders, or the toolbar's overflow menu.

The step is grounded rather than picked: filtration_offsets documents 1.0 slider =
20cc, so RING_CC_STEP = 0.25 is the classic RA4 5cc increment, and it keeps the centre
unclamped anywhere in [-0.75, +0.75].

One proof slot, not two. _clear_test_strip() has six call sites — request_render, the
toggle, the pick, and the compare / flat-peek / load_file navigate-back paths — so a
parallel ring lifecycle would mean six more places to keep in sync forever, and the
navigate-back path is exactly the one that gets missed. Instead the strip's slot gains
a `kind`, which means every existing drop path covers the ring for free and asking for
one proof while the other is up swaps rather than dismisses.

The worker is generalized rather than duplicated: TestStripTask carries per-patch
ExposureConfig overrides plus a grid shape, and build_strip applies them. The mosaic
slicer takes the grid too. The tone strip becomes one caller, and its tests passing
untouched is the regression check.

Relative, not absolute — the opposite choice from the strip's ladders, for a reason: a
ring-around asks "is this print neutral, and if not, which way", which an absolute
filtration grid cannot ask once a correction is already dialled in. Three consequences
fall out. The centre patch is the current filtration by construction, so the accent
needs no nearest-rung search. The memo must NOT pin filtration — picking correctly
invalidates, because the ring is meant to be iterated toward neutral and each round is
genuinely new pixels. And at a slider rail the ring prints duplicate patches, which is
the head running out of travel, not a bug; a test documents it rather than engineering
it away.

Verified end to end on samples/DSC00448.ARW from a non-neutral M=0.2 Y=-0.1 start: the
centre patch is bit-identical to a plain render at those settings (max abs diff 0.0),
all nine patch means are distinct, the two magenta rungs move green in opposite
directions and the two yellow rungs move blue, and picking leaves cyan, density, grade,
Cast Removal and both Auto toggles untouched. Per-patch cost measures 0.06s against the
36-patch strip's 0.05s, confirming the ring gets the same metering reuse.
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Preview load metrics (github-linux-x64)

format cold load throughput
CR2 0.791 s 28.34 MPx/s
NEF 0.629 s 39.08 MPx/s
ARW 0.217 s 92.86 MPx/s
RAF 0.635 s 25.62 MPx/s
DNG 0.634 s 37.76 MPx/s

fixtures: rawsamples.ch · sha: 8fb0d26 · full JSON

Ring-around:

- 5x5 instead of 3x3, stepping 1cc at a time out to 2cc either way on magenta and
  yellow. More resolution near neutral rather than a wider swing.
- The ladder is now absolute and centred on neutral, like the strip's. That makes the
  mosaic invariant to the filtration in force, so _strip_memo_key pins M/Y the way it
  pins density and grade for the strip, and picking a patch no longer forces a reprint.
  test_strip_origin goes away with it, and so does the rail-clamping the relative
  ladder needed: every rung now sits well inside the sliders' travel.
- The accented rung follows the filtration in force via ring_nearest_cell, the same way
  the strip accents the nearest density and grade.

Test strip: 5x5 instead of 6x6, with both ladders centred exactly on their defaults
(density 1.0, grade R115), so the settings already in force are the middle patch.
Density spans 0.4-1.6 in 0.3 steps and grade R75-R155 in 20-point steps.

Consequence worth knowing: with an absolute ladder the pick sets filtration outright
rather than nudging it, so the ring only reaches as far as its own span.

Verified on samples/DSC00448.ARW: the neutral centre patch is bit-identical to a plain
neutral render (max abs diff 0.0), all 25 patch means distinct, the outer magenta rungs
move green in opposite directions and the outer yellow rungs move blue, and per-patch
cost matches the tone strip's at 0.05s.
@marcinz606
marcinz606 merged commit b646e23 into main Jul 29, 2026
2 checks passed
@marcinz606
marcinz606 deleted the feat/ring-around branch July 29, 2026 09:51
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