Skip to content

Peek the negative as loaded - #889

Merged
marcinz606 merged 1 commit into
mainfrom
feat/negative
Aug 18, 2026
Merged

Peek the negative as loaded#889
marcinz606 merged 1 commit into
mainfrom
feat/negative

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Why

Every canvas view is a print. The base stage converts the linear source to normalized log density and the exposure transfer curve (features/exposure/transfer.py) inverts it; Peek Flat Scan inverts too, since RenderIntent.FLAT only drops the creative stages. So there was no way to look at the negative itself.

When a conversion looks wrong, the first question is whether the scan is wrong — thin or dense frame, what colour the mask really is, whether the scanner clipped. Answering it meant exporting a Linear Output TIFF and opening it elsewhere.

What

A Peek Negative toggle: toolbar button, overflow entry, N. It paints AppState.preview_raw — un-inverted, un-metered, un-normalized, none of the tone edits — and returns to the normal render when turned off.

Geometry is the one thing it does apply, through GeometryProcessor and CropProcessor, the same two the base and crop stages use, so the frame keeps its rotation, flip, straighten, keystone and crop. The crop tool is the exception and peeks the uncropped frame, mirroring request_render, or there would be nothing to frame a crop against.

Only working_oetf_encode follows. That is a display encode rather than an edit — a linear buffer would otherwise show as near-black.

Approach

Not a pipeline change and not a config_override render. No config expresses "do not invert", and adding a RenderIntent would mean the CPU engine, the WGSL shaders and a parity test for a diagnostic that needs none of them. This follows the splash path (_on_splash_preview) instead: write the buffer into last_metrics["base_positive"], mark it splash, emit image_updated.

Two consequences worth calling out:

  • Marked splash, so the working-to-display matrix and the soft proof stay off it. The buffer is in camera/scanner primaries, not the working space; expect the raw orange cast rather than an accurate one.
  • content_rect cleared. It marks the picture area inside a border/mat, and no finish stage ran, so leaving the previous render's value would inset the frame wrongly once a border is on.

The state is transient, mutually exclusive with the flat peek and the before/after split, and any render with no config override drops it.

Verification

make all green. Nine controller tests added, plus negative_peek=False on three SimpleNamespace state stubs that model AppState.

Driven headless against samples/20260619SP_EKTAR100_120_1_09_ME_4000PPI.tif, peek vs. print at each geometry setting:

[no geometry] peek (1600,1184) == print,  corr -0.978
[rotate CCW]  peek (1184,1600) == print,  corr -0.978
[rotate 180]  peek (1600,1184) == print,  corr -0.978
[flip H]      peek (1600,1184) == print,  corr -0.978
[crop half]   peek (800,592) — exactly the 0.25-0.75 box
[crop tool]   peek uncropped (1600,1184)

The strong negative correlation is the point: same picture, inverted. Also confirmed the orange mask reads in the raw negative (R=0.416, G=0.243, B=0.164) and that any edit drops the peek. Colour Negative and B&W Negative give identical peek buffers, as they must — the peek sits upstream of the pipeline, so process mode cannot reach it.

One unrelated observation

While checking the crop case I found the CPU and GPU engines disagree on a crop_rect set directly on the config:

gpu=True   with-crop (1600,1184)   <- uncropped
gpu=False  with-crop (800,592)     <- cropped

Still uncropped on the GPU after a fresh load with the crop already in the config, so it is not stale cache in the harness. It may be that poking crop_rect programmatically is not the supported path and the real crop tool sets state the GPU keys on. Pre-existing and untouched by this PR; the peek follows the CPU engine, which is the parity reference. Flagging it rather than chasing it here.

Docs

docs/USER_GUIDE.md gets the panel prose; docs/PIPELINE.md records that the peek is not a stage of its own and what it skips.

Every canvas view is a print: the base stage converts the linear source to
normalized log density and the exposure transfer curve inverts it. Peek Flat
Scan inverts too, so there was no way to look at the negative itself. Judging
whether a scan is thin, dense, colour-cast or clipped meant exporting a Linear
Output TIFF and opening it elsewhere.

Add a Peek Negative toggle (toolbar button, overflow entry, N) that paints
AppState.preview_raw: un-inverted, un-metered, un-normalized, none of the tone
edits. Geometry is the exception and runs through GeometryProcessor and
CropProcessor, the same two the base and crop stages use, so the frame keeps
its rotation, flip, straighten, keystone and crop; the crop tool still peeks
the uncropped frame, or there would be nothing to frame a crop against.

Only working_oetf_encode follows, being a display encode rather than an edit,
since a linear buffer would otherwise show as near-black. The buffer is in
camera primaries, so it is marked splash to keep the working-to-display matrix
and the soft proof off it, and content_rect is cleared because no border stage
ran to inset the picture.

No pipeline or shader change: no config expresses "do not invert", and a new
RenderIntent would mean the CPU engine, the WGSL shaders and a parity test for
a diagnostic that needs none of them. This follows the splash path instead.

The state is transient, mutually exclusive with the flat peek and the
before/after split, and any render with no config override drops it.
@marcinz606
marcinz606 merged commit aebde22 into main Aug 18, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/negative branch August 18, 2026 20:21
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