Grain focuser loupe on the canvas preview - #671
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:127decodes the rawhalf_size=True, then:178scales topreview_render_size(1600 px). AndRenderTaskcarries a singlepreview_size— whole-frame renders only;active_roiis 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:
preview resorfull res, offstate.hq_preview.ponytail:comment records the ceiling and what a true 1:1 loupe would need.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:
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
ImageConverterhere would double-proof it: under a soft proof the worker has already baked the transform and_display_csis sRGB (the PR #661 trap).It is suppressed wherever something else has replaced the frame with a different image than
_qimageholds — 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.leaveEventnever 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 allgreen — 2693 passed, lint andtyclean. 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 theleaveEventregression.Headless E2E on
samples/DSC00448.ARW: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.