Hue Trim, and one home for the capture-side colour corrections - #763
Merged
Conversation
A narrowband or odd-phosphor light source rotates hues instead of casting them, so white balance cannot fix it — there is no grey to correct. Measured on one negative scanned twice (narrowband-ish panel vs broadband white LED, same body, same frame), |dH| held within 16-21 degrees across CIELAB chroma 4 to 60+: a cast would have shrunk with chroma, a general channel mix grown. Flat in chroma means the error is a rotation, so the correction is one. Adds process.hue_trim (degrees, +/-30, 0 = off): a rotation of a*/b* about the neutral axis on the scene-linear print, after the H&D curve and before the working OETF. Neutrals are fixed points, so it cannot disturb the per-channel colour clip upstream. It rides the exposure stage rather than owning one (a drag must not re-run normalization) and stays inside RenderIntent.FLAT, since a light's hue error is a capture defect like the sensor unmix, not a look. Sticky: the light source is a rig property, so it carries to the next file. GPU mirror inlines rgb_to_lab/lab_to_rgb from lab.wgsl (WGSL has no includes) and rotates transmittance before oetf_encode, because the GPU encodes at the end of the exposure pass while the CPU stays linear to the end. Theta rides a hue vec4 on ExposureUniforms; at 288B the block already spanned two aligned slots, so the row costs no extra slot (size 288 -> 304). Tests cover the op's invariants (neutrals still, chroma and L preserved, opposite angles cancel, measured angle recovered) plus CPU/GPU parity. The parity fixture is deliberately chromatic: a grey ramp makes both parity and has-any-effect pass vacuously, since a rotation barely moves low-chroma pixels.
The sensor unmix, the crosstalk matrix and Hue Trim all correct the *capture* rather than the negative-to-positive conversion, and each has a different cause (camera CFA / film dyes / light spectrum). Collect them in one section so the distinction is visible, and leave Process to normalization alone. - Section renamed to "Sensor / Light Calibration"; its first block is now "Trichrome Calibration" (the sensor unmix). Crosstalk and Hue Trim move in from Process. The persisted "sensor" section key is unchanged. - Reframe crosstalk: it is not only film-dye leakage. The light's spectrum and the sensor's CFA mix the channels too, and in log density all three arrive as the same linear operator, so one 3x3 absorbs them jointly. A profile is therefore a property of a whole scanning setup, not of a film stock. Tooltips and docs now say so, and encourage tuning per rig. - Bundled matrices are datasheet-derived, not measured, and describe the dyes alone. All 16 now carry "(approx)" and say plainly that they only tell the whole story for a true RGB scan (Coolscan-style) or a calibrated trichrome rig — under a broadband light plus a Bayer sensor the capture adds its own mixing that a dyes-only matrix cannot describe. - New optional `type` key records provenance (measured / tuned / specsheet-based) and groups the Matrix dropdown under non-selectable headings. Unknown or absent types fall through to "Other" rather than vanishing. The editor gains a Type control; anything it saves defaults to "tuned", since dialled-in-on-a-rig is a different claim from a datasheet estimate. - Hue Trim was missing from the copy-settings catalog; added. It is correctly absent from _BOUNDS_INPUT_FIELDS (it acts after inversion, so it never feeds the meters). Follow-through the move required: the crosstalk shortcut tooltip, SLIDER_ATTRS, the tutorial step's target *and* its section_attr, and the Setup tab tooltip. test_tutorial_overlay's valid-section set was missing sensor_section (it is registered in the tab spec, so reveal_section always resolved it — no step had targeted it before). Corrected a stale CROSSTALK.md claim that the bundled gallery is copied into the user folder on first run; it is read live, so these renames reach existing installs.
marcinz606
marked this pull request as ready for review
August 5, 2026 17:03
- The built-in crosstalk profile is now "Generic C41" rather than "Default", which said nothing about what the matrix is. Saved edits store the display name, so migrate_flat_config rewrites the old value: the render is unaffected (a None matrix still falls back to the built-in) but the dropdown would otherwise match no row and show the wrong profile as selected. - Section renamed again, to plain "Calibration". It holds the crosstalk matrix and Hue Trim as well as the sensor unmix, so the longer name was misleading. - Restore two profile names the previous commit truncated: "Harman Phoenix II (approx, starter)" and "Kodak Aerocolor IV 2460 (approx, unmasked)". Both qualifiers carry information the bare "(approx)" loses, and unmasked matters: Aerocolor has no orange mask. - Cut the comments and docstrings added on this branch back toward the house near-zero density: no rationale narration, no restating the next line. Also fixed a markdown bug in CROSSTALK.md where a parenthetical sat flush against a blockquote and would have been absorbed into it.
Reverts the previous commit's restore of the 'starter' and 'unmasked' qualifiers: those were dropped deliberately, not by accident.
Contributor
|
Nice! |
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.
Three commits: a new Hue Trim control, a regrouping of the capture-side corrections it belongs with, and a rename pass.
1. Hue Trim
Why
A narrowband or odd-phosphor scanning light rotates hues rather than casting them, so yellows read orange and greens go olive. White balance cannot fix it, because a rotation leaves no grey to correct.
Measured on one negative scanned twice (a narrowband-ish panel vs a broadband white LED, same body, same frame, both cropped, default settings):
|ΔH|held within 16–21° across CIELAB chroma 4 to 60+.Flat in chroma is the discriminator: a colour cast would have shrunk with chroma (a fixed a*/b* offset barely turns a saturated colour), and a general channel mix would have grown. So the error is a rotation, and the correction is one.
What
process.hue_trim, degrees, ±30, 0 = off, rotating a*/b* about the neutral axis.RenderIntent.FLAT: a light's hue error is a capture defect like the sensor unmix, not a look, so a digital-intermediate master should already be free of it.GPU parity
The mirror inlines
rgb_to_lab/lab_to_rgbverbatim fromlab.wgsl(WGSL has no includes) and rotatestransmittancebeforeoetf_encode, because the GPU encodes at the end of the exposure pass while the CPU stays linear to the end of the pipeline. Theta rides ahuevec4 appended toExposureUniforms; at 288 B the block already spanned two 256 B-aligned slots, so the row costs no extra slot (288 → 304).Scope
Slider only. The sky-anchored auto-detect is validated in principle (θ +18.6° on the affected scan, +0.61° on a known-good one, so it correctly does nothing when nothing is wrong) but deliberately left out: n=1, it needs an anchor in frame, and it wants a "no anchor, no correction" rule. Better judged once the manual slider is in use.
2. One home for the capture-side corrections
The sensor unmix, the crosstalk matrix and Hue Trim all correct the capture rather than the negative-to-positive conversion, and each has a different cause: camera CFA, film dyes, light spectrum. They now sit together in a Calibration section whose first block is Trichrome Calibration (the sensor unmix). Process keeps normalization alone. The persisted
sensorsection key is unchanged.Crosstalk is not only about film dyes
The matrix operates on log density, and in that domain the film's dyes, the light's spectrum and the sensor's CFA all arrive as the same linear operator, so one 3×3 absorbs them jointly. That makes a profile a property of a whole scanning setup, not of a film stock. Tooltips and docs now say so and encourage tuning per rig.
Relatedly, the bundled matrices are now honest about what they are: datasheet-derived, not measured, and describing the dyes alone. They carry (approx) and state that they only tell the whole story for a true RGB scan (Coolscan-style, one band at a time) or a calibrated trichrome rig. Under a broadband light plus a Bayer sensor the capture adds mixing a dyes-only matrix cannot describe.
Profile provenance (
type)New optional
typekey,measured/tuned/specsheet-based, recording where a matrix's numbers came from, which is what decides how far to trust one. The Matrix dropdown groups under non-selectable headings, and the editor gains a Type control.Two deliberate details: an unknown or absent type falls through to Other rather than vanishing from the dropdown, and an unrecognised value loads as tuned rather than the first combo entry, so a hand-written type is never silently relabelled as a datasheet claim. Anything the editor saves defaults to
tuned.3. Renames and cleanup
migrate_flat_configrewrites the old value. The render is unaffected (aNonematrix still falls back to the built-in) but the dropdown would otherwise match no row._BOUNDS_INPUT_FIELDS, since it acts after inversion and never feeds the meters.CROSSTALK.mdclaim that the bundled gallery is copied into the user folder on first run. It is read live (ensure_user_direxplicitly does not seed), which is why these renames reach existing installs.Tests
tests/test_hue_trim.py: the op's invariants (neutrals still, chroma and L* preserved, opposite angles cancel, the requested angle recovered from real hue measurements) plus CPU/GPU parity at +18.6° and −12°.tests/test_crosstalk_profiles.py: grouping by type, and that grouping can never drop or invent a profile.tests/test_crosstalk_editor_type.py: provenance survives a save and a rename; unknown types fall back to tuned.tests/test_capture_unmix.py: the Default → Generic C41 migration, and that a user profile of another name is untouched.One trap worth flagging: the parity fixture is deliberately chromatic. A grey ramp makes both the parity and the has-any-effect assertions pass vacuously, since a rotation barely moves low-chroma pixels. The first draft passed while the GPU was applying nothing.
Beyond pytest, the reorganisation was checked against the real
MainWindowheadlessly: everySLIDER_ATTRSpath and tutorial target/section resolves, both sidebars sync, shortcut tooltips apply, and the grouped dropdown renders its headings with no profile lost.make allgreen: 3329 passed, 2 skipped.