Skip to content

Grain focuser loupe on the canvas preview - #671

Merged
marcinz606 merged 1 commit into
mainfrom
feat/loupe
Jul 29, 2026
Merged

Grain focuser loupe on the canvas preview#671
marcinz606 merged 1 commit into
mainfrom
feat/loupe

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

What

The darkroom grain magnifier: a circular window following the cursor showing the frame's own pixels at 2×, with an acutance figure so sharpness is a number rather than a vibe. Shift+L, or Grain Focuser in the toolbar's overflow menu.

Directly useful next to Unsharp Mask and Richardson-Lucy Deconvolution, where the question is whether a setting actually resolved more detail or just added halo.

Honest about resolution, rather than pretending

This is the part worth reviewing. A truthful 1:1-of-scan loupe isn't available for the money, and the design says so on screen rather than quietly lying — which for an instrument that exists to report sharpness would be the worst possible failure.

Unless HQ Preview is on, the full-resolution pixels are not merely out of reach — they are never allocated: preview_manager.py:127 decodes the raw half_size=True, then :178 scales to preview_render_size (1600 px). And RenderTask carries a single preview_size — whole-frame renders only; active_roi is the crop ROI, not a viewport window. A viewport-ROI render means a new task type, a new worker slot and a full-res re-decode. That's a feature, not an overlay, and it's out of scope here.

So instead:

  • Nearest-neighbour, never smoothed. Running out of data must look like running out of data; smoothing would invent grain that isn't in the scan.
  • The badge names the statepreview res or full res, off state.hq_preview.
  • A ponytail: comment records the ceiling and what a true 1:1 loupe would need.
  • 2× of buffer pixels, not 1:1. At fit-zoom on a 1600 px frame the canvas already shows ~0.75 device px per buffer px, so a literal 1:1 glass would magnify ~1.3× and show nothing new.

Acutance: RMS Laplacian, not σ

A plain standard deviation reads high on a smooth gradient — the opposite of sharp — so a σ-based figure calls a soft ramp crisp. It's the RMS Laplacian of the patch's luma, and a test pins the distinction so nobody "simplifies" it back:

assert loupe_acutance(ramp) < loupe_acutance(hard) / 10.0   # same σ, very different sharpness

The tooltip says the figure is for comparing regions of one frame, not one frame against another — it's resolution-dependent by nature, so that's the honest claim.

Colour management and suppression

The glass samples the QImage the canvas already blitted, so it is display-transformed exactly once. Re-running ImageConverter here would double-proof it: under a soft proof the worker has already baked the transform and _display_cs is sRGB (the PR #661 trap).

It is suppressed wherever something else has replaced the frame with a different image than _qimage holds — the test strip's mosaic and the raw IR layer — because magnifying the plain render there would show pixels that aren't on screen. (I initially had it drawing over the strip on the theory that magnifying a patch is legitimate; it isn't, for exactly that reason.)

Two shared fixes riding along

  • _zone_source_display_buffer. It stopped being zones-only the moment a second instrument measured pixels.
  • leaveEvent never reset _mouse_pos, so every cursor-following overlay — crosshair and brush ring included, not just the loupe — stayed drawn at the last position whenever the pointer exited over the floating toolbar, which sits inside the image rect. Fixed at the root rather than special-cased for the loupe.

Verification

make all green — 2693 passed, lint and ty clean. 11 new unit tests: the acutance ordering (hard edge ≫ blurred ≫ flat), the σ trap, degenerate inputs, the sample window centring/shifting/clamping at all four corners, paint suppression per guard, and the leaveEvent regression.

Headless E2E on samples/DSC00448.ARW:

  • the glass's region mean tracks the source window to 0.1/255 (133.4 vs 133.3) and its centre pixel matches exactly
  • nothing is painted outside the circular clip
  • the same spot reads acutance 12.1 before Unsharp Mask and 18.3 after — the figure responds to real sharpening
  • test strip and IR overlay both suppress it; leaving the canvas parks the cursor

Two probe bugs found and fixed on the way, both mine: comparing single pixels is fragile under nearest-neighbour (each source pixel becomes a 2×2 block, so int() truncation lands on a neighbour — delta 0 once compared across the 3×3 neighbourhood), and my region-mean check sampled the glass's central 62% against 100% of the source window. A dest offset maps to src at offset ÷ magnification.

One legibility fix from looking at the render: the badge was drawn in the accent colour per the existing badge idiom, but that accent is a dark maroon on a 170-alpha plate and the readout was barely legible over a light frame. It's white now — same fix as the burn map's card in #670.

The darkroom grain magnifier: a circular window following the cursor showing the
frame's own pixels at 2x, with an acutance figure so sharpness is a number rather than
a vibe (Shift+L, or Grain Focuser in the toolbar's overflow menu). Useful next to
Unsharp Mask and Richardson-Lucy, where the question is whether a setting actually
resolved more detail.

Honest about resolution rather than pretending. Unless HQ Preview is on the
full-resolution pixels are never allocated — the raw is decoded half-size and scaled
to 1600 px — and RenderTask renders whole frames only, so there is no viewport-ROI
path to ask for more. Rather than fake it:

- pixels are magnified nearest-neighbour, so running out of data looks like running
  out of data instead of being smoothed into invented grain
- the badge names the state, "preview res" or "full res"
- a ponytail: comment records the ceiling and what a true 1:1-of-scan loupe would need

Magnification is 2x of buffer pixels, not 1:1: at fit-zoom on a 1600 px frame the
canvas already shows ~0.75 device px per buffer px, so a literal 1:1 glass would
magnify ~1.3x and show nothing new.

Acutance is the RMS Laplacian of the patch's luma, not a standard deviation — σ reads
high on a smooth gradient, which is the opposite of sharp, so it would call a soft
ramp crisp. A test pins that so it can't be "simplified" back.

The glass samples the QImage the canvas already blitted, so it is display-transformed
exactly once; re-running the converter would double-proof it, since under a soft proof
the worker has already baked the transform. It is suppressed wherever something else
replaced the frame with a different image — the test strip's mosaic and the raw IR
layer — because magnifying the plain render there would show pixels that are not on
screen.

Two shared fixes ride along. _zone_source becomes _display_buffer, since it stopped
being zones-only the moment a second instrument measured pixels. And leaveEvent never
reset _mouse_pos, so every cursor-following overlay — crosshair and brush ring
included — stayed drawn at the last position whenever the pointer exited over the
floating toolbar, which sits inside the image rect.

Verified end to end on samples/DSC00448.ARW: the glass's region mean tracks the source
window it sampled to 0.1/255 and its centre pixel matches exactly, and the same spot
reads acutance 12.1 before Unsharp Mask and 18.3 after.
@marcinz606
marcinz606 merged commit 64b6e79 into main Jul 29, 2026
1 check passed
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