Repository navigation
fix(fade): restore a camera RAW slide in its own channels, about its own white - #1596
Merged
marcinz606 merged 27 commits intoNov 5, 2026
Merged
Conversation
…old around the density stage A camera RAW applied its whole matrix before the log, so the crosstalk unmix, Fade Restoration and Remove Stain acted on mixed working-space channels and not on the film's own dye channels. The camera matrix now splits into two parts (camera_matrix_parts). The white-balance gains apply before the log. The primaries apply after the density matrix, in linear light, on the Positive and Transfer paths. The primaries rows sum to 1, so a border that Remove Stain makes neutral in white-balanced camera channels stays neutral. With the whole matrix after the stain offset, a synthetic border (WB 2:1:1.5, white 0.8) came out at log (+0.201, -0.100, +0.076). It now comes out at -0.1 in each channel. A frame with no density matrix keeps its bits. The print path does not change. Fade Estimate now reads a camera RAW in white-balanced camera channels, which are the channels the fade acts on. The slide characterization goldens move by at most 0.0074 because they use a crosstalk matrix. Bumps THUMBNAIL_RENDER_VERSION to 32. BOUNDS_VERSION does not change: only the transfer meters move, and the app does not save them as bounds.
…ity matrix offset The camera white-balance gains are a per-channel log offset. They now go into the last column of the density matrix (density_stage), so neither engine multiplies the full image by a diagonal matrix before the log. The GPU cam rows are identity in this case. The GPU meter grids reuse the log grid they already have. Identity primaries (an Input ICC, wb_only_cam_xyz) apply nothing after the density matrix. The Fade Estimate grid (transfer_grid) is made only when PipelineContext.wants_fade_grid is set. fade_estimate_grid sets it. Before, every CPU readback render of a camera RAW without a density matrix did a second full-image pass for that grid. density_stage returns one DensityStage (pre, unmix, post). The white-balance fold is decided in one place, and both engines build their meter grid with density_stage_grid. The Remove Stain test now has a colored patch. It fails if the camera matrix applies before the log.
Fade Restoration on camera RAWs gave casts that changed from frame to frame. Two faults caused them. The restoration scaled density from the decoder's white. A JPEG or scanner TIFF has its white there, but a camera captures clear film about 2 stops lower, so the restoration scaled that offset into a cast. The cast moved with exposure and white balance: on a faded NEF, +-1 stop moved the white's B/G from 0.35 to 0.81 and the Estimate's Blue Survival from 0.75 to 0.63. A camera RAW now renders M (L - w) + w, so density above the white is restored and the white stays. Remove Stain only maps the white to neutral. Estimate reads the grays above the white and stores it with Remove Stain off too. The Estimate and the Positive render now do not change with exposure. The stored white could come from another color space than the render. Two Positive NEFs held a white read without the camera matrix, 0.28 D off in red, which rendered magenta under Remove Stain. The white now carries fade_white_space, a hash of the camera matrix and as-shot white balance it was read through. A render in another space ignores it, and the desktop measures it again on the open frame, with no roll lock. JPEG and TIFF sources keep the empty tag and render as before. Bumps THUMBNAIL_RENDER_VERSION to 32.
On top of the camera-matrix split, a camera RAW's fade white is read in the white-balanced camera channels, not in working space. The fade_white_space tag hashed only the camera matrix and white balance, so a white read in working space on an earlier build matched the tag and applied in the wrong channels. The hash now also holds a channel marker. A white with an older tag reads as stale, and the desktop measures it again on the open frame.
…white A slide's rebate is black, so a frame with no clear film in the picture has no true white. The measured white is then the brightest picture color in each channel, for example a blue sky. With Remove Stain off, the render added that white back with its color, while Estimate had made the grays neutral relative to it. Every gray took the white's color: on a synthetic faded slide the cast was +0.33 D in R-G and +0.50 D in B-G. With Remove Stain off, a camera RAW now scales density about the neutral level of the white's cleanest channel (density_anchor), and Estimate reads the grays against the same level. The grays stay within 0.03 D of neutral at any exposure, with or without clear film, on Positive and Transfer. Remove Stain on does not change. The automatic re-measure of a stale white records no undo step. Before, an undo back to the stale white measured it again and dropped the redo branch.
Highlight Reconstruction decodes a blown highlight as neutral at full scale. On a camera RAW the fade scales density about the frame's white, which sits below full scale, so the blown highlight sat above the anchor and the fade scaled it per channel. It turned pink with Remove Stain off and yellow with Remove Stain on. On a synthetic faded slide the highlight came out (1.00, 0.83, 1.00) and (1.00, 0.97, 0.73). Dyed film is not brighter than its own white, so a pixel above the anchor in every channel is light past the film. The fade now acts on each channel capped at the anchor (fade_anchor), and the amount the pixel is above the anchor in every channel is added after it, equal in each channel. The CPU uses the new negpy_core.unmix_log_anchored kernel, and the GPU uses an anchor row in the normalization shader. At Cast Removal 0, a reconstructed highlight now renders the same neutral white with and without the fade. Pixels below the anchor do not change. Estimate clears the stored white before it renders its grid, so the anchor does not cap the grid that a new white is read from.
test_slide_separation requires that features/exposure does not name the fade. The unmix_log_image docstring and the shader comment now call the anchor the density anchor.
A scanner TIFF or JPEG with Remove Stain on capped each pixel above its white and passed the excess unrestored. The white of such a file is a percentile of its clear film, so the light above it is film. Now only a camera RAW, whose white sits below the decoder's full scale, has a cap.
The fade card pushed one frame's white to every frame of the roll. A camera RAW's white moves with each frame's exposure, so other frames scaled their density about the wrong level and took a cast. A stale roll white also came back on each open and was measured again. The roll now drops a white tagged with a decode space when it is set, and ignores one already stored. A scanner white still follows the roll.
The tag hashed the whole camera matrix, primaries included. The white is read in the white-balanced camera channels, before the primaries, so an Input ICC changed the tag and made a good white stale. The preview then measured it again, and export and thumbnails ignored it.
The fade white is a bounds input. A white measured again on the open frame kept the cached bounds, which were read with the old white. Every other edit of the white clears them.
…lock A failed Estimate on a camera RAW stored the white through the roll's fade card, which locked the card away from the roll. The roll holds no camera RAW white, so the white now goes on the frame alone.
Only the open frame in the preview measured a stale or missing white, on the GUI thread. Export, thumbnails and batch renders skipped it and rendered without Remove Stain or the anchor until the frame was opened. Each render now measures the white on the Estimate grid before the density stage (resolve_fade_white, fade_white_grid). This applies to the CPU and GPU engines and to the tiled export's reference pass. The CPU exposure stage reads it too, for the Remove Stain target. The desktop stores the white from the render's metrics with no render, no undo step and no roll lock. With a crosstalk unmix U, the white is read after U, so the restoration now scales about U^-1 w. It scaled about w, in the wrong channels. Crosstalk alone takes no white: it keeps a neutral neutral, so a crosstalk-only frame renders as before. fade_density returns the matrix and the anchor from one pass, and the white's tag is computed once per render.
The roll holds no camera RAW white, but the Fade card's lock check needed every card field in the roll. Any fade edit on a RAW frame then locked the card, even when its survivals matched the roll's. A roll's scanner white (space "") also replaced a RAW frame's measured white on each open. The render measured it again and the card locked as diverged. frame_roll_defaults now drops the roll's white for a frame that holds its own. The overlay, the lock check, the diverged-card scan and Reset to Roll use it, and the lock check does not need a white the roll does not hold. A scanner roll keeps its shared white.
…talk The desktop stored a white the render measured without moving the render memo or the render identity, so the first navigate-back rendered again. A half frame could also get its own settings row. The white now joins the measured-bounds write, which handles all three. The white is read after the crosstalk unmix, so its tag now names the unmix (render_white_space). A crosstalk change makes the white stale, and the render measures it again. The GPU engine keeps the last white it measured for a frame, so drag ticks before the white is stored do not measure it again. fade_anchor had no caller left and is removed.
relog_matrix took 10^v in float64 one pixel at a time, which kept the loop scalar. It now works in runs of 256 pixels, one channel at a time, with the same arithmetic in the same order, so the pins do not move. A 24 MP frame on 4 cores: 0.45 s to 0.12 s. Every camera RAW slide with a density correction, and every Positive source profile, runs it.
…he image grid A camera RAW's density matrix carries its white-balance gains as a log offset, so every faded RAW frame took a full-image NumPy add after the unmix. unmix_log_anchored now adds the offset in the same pass, with the same float32 add, so the render keeps its bits. A render that measures a stale white prefiltered the full image a second time. The white and the meters now share one prefiltered grid. The slide base stage of a 24 MP frame on 4 cores, against the base branch: fade 0.44 s to 0.52 s, fade with Remove Stain 0.65 s to 0.57 s, a stale white 0.59 s, an unfaded frame unchanged at 0.42 s.
…order-96c3ad # Conflicts: # negpy/features/process/fade.py
…esets The roll keeps a camera RAW's fade white on its frame, but Paste, the Film Strip's Apply, presets and sticky settings copied the whole Fade Restoration row. Two frames from one camera with the same white balance have the same white tag, so the target took the source's white as its own anchor and rendered with a cast. apply_selected_fields now leaves a white with a camera tag on the target, as rolls._without_decode_white does for a roll. The survivals and Remove Stain still copy, and a scanner white still copies.
Highlight Reconstruction rebuilds the clipped pixels that a camera RAW's white is read from. The tag named only the white-balance gains and the crosstalk unmix, so a stored white stayed in use after the level changed. The tag now hashes the effective highlight mode, and a white stored with the old tag reads as stale. A negative now takes the empty tag.
The lock check ignored the white fields that the roll did not hold on every frame. A scanner frame's own Remove Stain white then matched a roll with no white, so the card stayed unlocked and its Roll button had nothing to push. The check now ignores those fields only on a camera RAW frame, whose white stays its own.
…Fade card A push that held a camera RAW white deleted the roll's white fields. The scanner frames on a mixed roll then lost the scanner white they share. The push now leaves out only the RAW white. A RAW frame still ignores the roll's scanner white, through its tag and frame_roll_defaults. Updates test_a_camera_raw_fade_white_stays_on_its_frame in tests/test_rolls.py, which asserted the deletion: the roll now keeps its scanner white and takes only the pushed survivals.
The Estimate grid is fade_white_grid, which never applies the white. With the survivals at 1.0 and Remove Stain off, no white acts in that render. The zeroed white fields and their comment are removed. test_estimate_reads_the_grid_without_the_stored_white in tests/test_fade_white_space.py asserted the zeroed fields. It now renders the grid with two different stored whites and expects equal grids.
Contributor
Author
|
Sorry for all the commits, the plumbing on the camera raw side turned out to be a bit complicated to get right. |
thetalkingdrum
marked this pull request as draft
November 3, 2026 18:00
thetalkingdrum
marked this pull request as ready for review
November 3, 2026 18:08
With Highlight Recovery off, green clips and the white-balance gains lift red and blue past full scale in the same cells. The white read that clip color as the film's, so Remove Stain turned the frame green (white +0.104, 0, +0.102 on a clipped sky). Each channel now stops at full scale, as the decode with Recovery does, and the white is the same at both levels.
…order-96c3ad # Conflicts: # negpy/desktop/controller.py # tests/test_rolls.py # tests/test_settings_catalog_presets.py
…e-conflicts-81fec0 # Conflicts: # negpy/desktop/controller.py
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.
The problem
Fade Restoration and Remove Stain gave camera RAW slides casts that changed from frame to frame. Positive was worse. There were four causes:
There were also problems in how the white was kept:
The stage order
A dye layer's density is linear in the log of what each capture channel records. The crosstalk unmix, the fade survivals and Remove Stain all depend on that, so they must act on the camera's own channels. The camera matrix mixes the channels in linear light. Applied before the log, it puts every density correction on a blend of dyes.
This PR splits the matrix:
This matches the order #1593 uses for Positive TIFFs and JPEGs. Negatives already keep the camera's own channels. Future crosstalk work for slides, for example profiles from datasheets or sensor calibration, now acts on the channels those profiles describe.
What else this changes
unmix_log_anchored) does this on the CPU, and a matching row in the normalization shader does it on the GPU. Only camera RAWs get this cap.relog_matrixvectorizes and keeps its output pins. The density offset is added inside the anchored kernel.What does not change
THUMBNAIL_RENDER_VERSIONgoes to 32.BOUNDS_VERSIONdoes not change, because no release has shipped Fade yet.Trade-offs
Note for #1595
The fade white is a bounds input, so it is part of
meter_inputs_key. The first render of a frame whose white was stale stores a new one. The background average check may then measure that frame again once.Testing
make rust,make typeandruffare clean.