HDR merge of bracketed slide captures - #804
Merged
Merged
Conversation
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
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.
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.
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:
Fixes #801