Skip to content

HDR merge of bracketed slide captures - #804

Merged
marcinz606 merged 2 commits into
marcinz606:mainfrom
seanharding:hdr-bracket-merge
Aug 11, 2026
Merged

HDR merge of bracketed slide captures#804
marcinz606 merged 2 commits into
marcinz606:mainfrom
seanharding:hdr-bracket-merge

Conversation

@seanharding

@seanharding seanharding commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

A single camera-scan exposure cannot record a slide's full density range: the densest parts land in sensor noise, and the longer exposure that reaches them blows the thin areas. Select several captures of one frame in the film strip and right-click -> Merge exposures (HDR) to combine them into one frame carrying the lot; Unmerge exposures puts the originals back, their own edits untouched. Measured on a five-frame bracket a stop apart, deep-shadow signal-to-noise went from 3.0 to 12.3 with the midtones unchanged.

Source assembly, not a pipeline stage. The merge runs at decode beside the RGB-scan triplet merge, so no stage changes and there is no WGSL mirror to keep in numerical parity.

Exposure ratios are solved from the images rather than read from shutter tags, since several supported loaders are scanner formats that carry none. Frames are ordered by clipped fraction before level -- level is a high percentile and pins at 1.0 for anything clipping more than 1%, so it cannot order the long end of a bracket, and mis-ordering fits ratios between frames stops apart instead of neighbours, which prints as a hard seam across a gradient. Samples are combined by inverse variance, weight going as the square of the ratio, which is what makes the merge no worse than the best single frame. Output is float32: the recovered detail sits below the reference exposure's quantization step.

Two separate choices, deliberately kept apart. The reference -- the longest exposure that does not clip -- defines white, which is what keeps the result inside [0, 1] where the pipeline clips at entry. Which exposure the merge renders at is intent, and no measurement recovers it: a slide's own brightest point is denser than clear film, so the longest unclipped capture is brighter than the shot the photographer metered. Right-click -> Render exposure nominates it. The menu can only offer frames the bracket contains, so it lists the reference and every shorter exposure; a longer one clamps back to the reference and would be an entry that provably cannot change the picture.

A fresh merge opens with Shadows Density seeded from the range the bracket recovered. Merging frames that did not clip buys precision rather than range, and precision is invisible at the same tone, so a merge left neutral renders indistinguishable from the frame it was metered on. Seeded rather than baked in: the slider shows the value and zeroing it returns the faithful render.

Also here, because the feature could not work without them and they apply to stitch and RGB-scan composites just as much:

  • A composite no longer adopts, or steals, its primary source's edit. Edits resolve by content hash then by path, and a composite's path is its reference frame's, so it inherited that frame's whole edit and re-homed the row onto its own hash. Half-frames were already guarded; composites were not.
  • A composite inherits its sources' film process by majority vote instead of taking the stale sticky mode.
  • Reset Settings keeps what an asset is -- the bracket, the stitch registration, the triplet, the inherited process -- and clears only the edit.
  • Source identity is derived from the whole config (domain/tokens.py, rendering/source_identity.py) rather than a hand-listed set of fields, and AppController.apply_config decides re-decode versus re-render from it. A field left out of a token is invisible until a stale buffer is served for the setting just changed, which is how the render exposure first shipped writing a value that never reached the canvas.

Fixes #801

A single camera-scan exposure cannot record a slide's full density range:
the densest parts land in sensor noise, and the longer exposure that
reaches them blows the thin areas. Select several captures of one frame
in the film strip and right-click -> Merge exposures (HDR) to combine
them into one frame carrying the lot; Unmerge exposures puts the
originals back, their own edits untouched. Measured on a five-frame
bracket a stop apart, deep-shadow signal-to-noise went from 3.0 to 12.3
with the midtones unchanged.

Source assembly, not a pipeline stage. The merge runs at decode beside
the RGB-scan triplet merge, so no stage changes and there is no WGSL
mirror to keep in numerical parity.

Exposure ratios are solved from the images rather than read from shutter
tags, since several supported loaders are scanner formats that carry
none. Frames are ordered by clipped fraction before level -- level is a
high percentile and pins at 1.0 for anything clipping more than 1%, so it
cannot order the long end of a bracket, and mis-ordering fits ratios
between frames stops apart instead of neighbours, which prints as a hard
seam across a gradient. Samples are combined by inverse variance, weight
going as the square of the ratio, which is what makes the merge no worse
than the best single frame. Output is float32: the recovered detail sits
below the reference exposure's quantization step.

Two separate choices, deliberately kept apart. The *reference* -- the
longest exposure that does not clip -- defines white, which is what keeps
the result inside [0, 1] where the pipeline clips at entry. Which
exposure the merge *renders* at is intent, and no measurement recovers
it: a slide's own brightest point is denser than clear film, so the
longest unclipped capture is brighter than the shot the photographer
metered. Right-click -> Render exposure nominates it. The menu can only
offer frames the bracket contains, so it lists the reference and every
shorter exposure; a longer one clamps back to the reference and would be
an entry that provably cannot change the picture.

A fresh merge opens with Shadows Density seeded from the range the
bracket recovered. Merging frames that did not clip buys precision rather
than range, and precision is invisible at the same tone, so a merge left
neutral renders indistinguishable from the frame it was metered on.
Seeded rather than baked in: the slider shows the value and zeroing it
returns the faithful render.

Also here, because the feature could not work without them and they apply
to stitch and RGB-scan composites just as much:

- A composite no longer adopts, or steals, its primary source's edit.
  Edits resolve by content hash then by *path*, and a composite's path is
  its reference frame's, so it inherited that frame's whole edit and
  re-homed the row onto its own hash. Half-frames were already guarded;
  composites were not.
- A composite inherits its sources' film process by majority vote instead
  of taking the stale sticky mode.
- Reset Settings keeps what an asset *is* -- the bracket, the stitch
  registration, the triplet, the inherited process -- and clears only the
  edit.
- Source identity is derived from the whole config (domain/tokens.py,
  rendering/source_identity.py) rather than a hand-listed set of fields,
  and AppController.apply_config decides re-decode versus re-render from
  it. A field left out of a token is invisible until a stale buffer is
  served for the setting just changed, which is how the render exposure
  first shipped writing a value that never reached the canvas.
@seanharding
seanharding marked this pull request as ready for review August 11, 2026 20:34
The merge accumulate ran as four full-size numpy temporaries per frame and
cost more than the RAW decode it follows. Fused into one parallel kernel.

The solve measured the level percentile and the ratio median by partitioning
the whole frame; both only read a distribution, so they now run on a strided
subsample. The clipped fraction stays on every pixel — it picks the exposure
reference against a hard threshold — and moved to its own kernel, which is
bit-exact and faster than the numpy form anyway.

Measured on an 8-frame 24MP bracket:

  merge, align off      6.22 s -> 1.91 s   (accumulate alone 4.81 -> 0.19)
  merge, align on       9.05 s -> 4.71 s
  solve                16.92 s -> 2.98 s
  transient RAM        +0.87 GB -> none

Ratios move by <0.1%, which is 1.15e-4 max in the merged result — below one
16-bit step. Reference frame and clipped fractions are unchanged.
@marcinz606
marcinz606 merged commit 85afd44 into marcinz606:main Aug 11, 2026
2 checks passed
@seanharding
seanharding deleted the hdr-bracket-merge branch August 11, 2026 21:00
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.

[Feature] HDR merge of bracketed slide captures

2 participants