Skip to content

feat: zone placement — pin tones to zones, solve density and grade - #719

Merged
marcinz606 merged 4 commits into
mainfrom
feat/zone-placement
Aug 1, 2026
Merged

feat: zone placement — pin tones to zones, solve density and grade#719
marcinz606 merged 4 commits into
mainfrom
feat/zone-placement

Conversation

@marcinz606

@marcinz606 marcinz606 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

The spot densitometer made actionable — what a darkroom enlarging analyser (RH Designs Analyser Pro) does. A new crosshair tool on the canvas toolbar:

  • Click pins a spot (max 2; a third click replaces the nearer pin). Each pin shows on the canvas as a numbered ring with its measured zone.
  • The Analysis panel grows a row per pin: what it reads now, and a target zone stepped in thirds (Roman numerals).
  • One pinPlace zones solves Print Density so that tone prints on its target. Two pins (shadow + highlight) → solves Print Density + ISO-R Grade so both land.
  • Stepping a target live-previews the solve without committing (config-override render). Apply commits one undoable edit and turns off Auto Density (and Auto Grade for two pins) — a meter left on would re-move the placed tones.
  • Asking for a zone the paper can't reach shows an amber → lands … with the closest achievable zone; the solve pegs at the slider bound like an analyser pegging at grade 5.
  • Pins are proofs, not edits: Esc clears them (second Esc puts the tool down), any other edit or frame switch drops them.

How

  • Pure solver negpy/features/exposure/placement.py: bisection over the chart's own print_curve (green/base channel) via curve_params_from_metricsprint_curve_outputzone_of_encoded, so measured/target/achieved sit on one ruler with the chart and step wedge. Two-pin is a nested bisection (outer grade, inner density pinning the dark tone). Bisection depth matches slider resolution (0.01 density / 1 R) — 2-pin solve ~33 ms.
  • encoded_of_zone added beside zone_of_encoded (exact closed-form inverse, same file, so the ruler can't fork).
  • Pins ride the test-strip lifecycle: dropped by any real render / load_file, kept through the one render Apply fires. Patch sampling is a 5×5 mean of normalized_log through a _sample_normalized_log helper factored out of the hover probe (hover keeps its 1×1 read).
  • Split grade folds into every candidate curve, so the two-pin solve stays enabled with trims active. B&W/E-6 need no branch.
  • No render-path change: solver writes existing ExposureConfig fields; no CPU/GPU/WGSL work, no parity surface.
  • Shortcut registry entry ships unbound (every Tools-row Shift+letter is taken); bind in the shortcut editor.

Docs

USER_GUIDE gains a Zone placement subsection in the Analysis panel section (feeds the in-app ⓘ guide). PIPELINE.md untouched — no pixel math changed.

Verification

  • 25 new tests (tests/test_zone_placement.py): solver landing/clamping/degenerate/split-grade/rounding, controller pin lifecycle, sidebar widget. TDD, each watched fail first.
  • make all green; full suite 2947 passed.
  • Headless E2E on the real app (samples/DSC00448.ARW): one-pin landed 8.326 vs target 8.333; two-pin landed 9.00/3.35 vs targets 9.00/3.33 with grade solving to 159 R; autos, single undo step, Esc grammar and pin ephemerality all asserted.

Update — pins are draggable, and read live

  • Press a pin to grab it (16 px hit radius, hand cursor on hover, crosshair for the tool itself); only a press on bare frame drops a new pin.
  • The tone under a dragged pin is re-read as it moves, so its zone tracks the cursor. Caption reads 1 · IV⅓ → VI — where the pin reads now, and where it is asked to print; the arrow drops once the two agree.
  • A pin still on its own reading follows the tone it lands on; one whose target has been stepped keeps that target through a drag.
  • The solve waits for the drag to end (two-pin nested bisection is ~15 ms, too slow per mouse-move); the last solution stands in for the sidebar meanwhile.
  • 13 more tests (tests/test_zone_pin_drag.py + controller lifecycle cases); headless E2E drives real Qt press/move/release on the overlay: pin placed, dragged, zone re-read (IX⅓ → VI⅔), target retention verified, applied tone landed 8.326 vs target 8.333.

Update 2 — the zone strip is the control

Entry point redesigned: the toolbar crosshair button and its (unbound) shortcut are gone.

  • Click a zone on the Analysis zone strip, then click the photo — that spot is asked to print on that zone. Clicking the armed cell again cancels; the armed cell is outlined and a HUD hint says what the next click will do.
  • Placing previews the solve at once. Place zones (now the panel's primary/accent action) or Enter over the canvas commits and closes the tool; Esc discards the armed zone, then the pins and the preview with them.
  • One pin is enough to accept. Each row has its own ; removing the last pin rolls back to the committed print.
  • A canvas click with nothing armed still just meters (pin at its measured zone, print untouched).
  • Zone strip height matched to the step wedge; ToolMode.ZONE_PLACE stays internally (crosshair cursor, pin hit-testing, drop-pins-on-exit).
  • Tests: strip click→zone mapping, arming/disarming lifecycle, armed vs unarmed clicks, per-pin removal, Enter-accepts (1 and 2 pins), Esc ladder. Full suite 2974 passed. Headless E2E drives real presses on the sidebar strip and canvas: arm → place → remove → re-place → Enter → committed solve matches the sidebar's promise (clamped ask reported as amber "lands"), then the Esc ladder.

Spot densitometer made actionable, the way a darkroom enlarging
analyser works: pin 1-2 spots on the canvas, step each pin's target
zone, and Print Density (one pin) or Density + ISO-R Grade (two pins)
solve so the pinned tones print there. Solver bisects the chart's own
print_curve so measured/target/achieved share one ruler with the
chart and step wedge; pins are ephemeral proofs on the test-strip
grammar. Apply commits one undo step and turns the matching autos off
so a meter can't re-move the placed tones. No render-path change.
A placed pin is a handle: press it to drag, and the tone under it is
re-read as it travels, so finding the spot to meter no longer means
clicking, reading, clicking again. A pin still sitting on its own
reading follows the tone it lands on; one whose target has been stepped
keeps it, and the caption reads "1 · IV⅓ → VI" until the two agree.
The solve waits for the drag to end — the two-pin nested bisection is
~15 ms, too slow per mouse-move. Tool gains a crosshair cursor, and a
hand over a grabbable pin.
The toolbar toggle and its shortcut go; the strip in the Analysis panel
becomes the control. Click zone V, click the photo, and that spot is
asked to print there — the zone comes first, which is the order a
darkroom analyser works in, and the strip is where you can already see
whether the zone is occupied. Clicking the armed cell again cancels; a
click on the photo with nothing armed still just meters.

Place zones (now the panel's primary action) or Enter over the canvas
commits and closes the tool; Esc discards the armed zone, then the pins
and their preview. Each row carries its own remove button, and dropping
the last pin rolls back to the committed print. Strip height matched to
the step wedge above it.
@marcinz606
marcinz606 marked this pull request as ready for review August 1, 2026 10:01
@marcinz606
marcinz606 merged commit 5a095f3 into main Aug 1, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/zone-placement branch August 1, 2026 10:08
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