Skip to content

Hue Trim, and one home for the capture-side colour corrections - #763

Merged
marcinz606 merged 5 commits into
mainfrom
feat/hue-trim
Aug 5, 2026
Merged

Hue Trim, and one home for the capture-side colour corrections#763
marcinz606 merged 5 commits into
mainfrom
feat/hue-trim

Conversation

@marcinz606

@marcinz606 marcinz606 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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+.

target chroma median |ΔH|
4–8 16.7°
8–15 14.9°
25–40 18.1°
60+ 20.6°

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.

  • Where: the scene-linear print, after the H&D curve and before the working OETF (the domain the reference measurement was taken in).
  • Neutrals are fixed points, so it cannot fight the per-channel colour clip in Process. The two are orthogonal: that one places the grey axis, this one turns everything around it.
  • Rides the exposure stage rather than owning one, so a slider drag does not re-run normalization behind it.
  • Stays inside 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.
  • Sticky, like Linear RAW and Narrowband, because a light source is a property of the rig rather than the frame.

GPU parity

The mirror inlines rgb_to_lab/lab_to_rgb verbatim 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 of the pipeline. Theta rides a hue vec4 appended to ExposureUniforms; 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 sensor section 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 type key, 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

  • The built-in profile is Generic C41, not 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.
  • Hue Trim was missing from the copy-settings catalog; added, so it can be pasted between frames. Correctly absent from _BOUNDS_INPUT_FIELDS, since it acts after inversion and never feeds the meters.
  • Corrected a stale CROSSTALK.md claim that the bundled gallery is copied into the user folder on first run. It is read live (ensure_user_dir explicitly 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 DefaultGeneric 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 MainWindow headlessly: every SLIDER_ATTRS path and tutorial target/section resolves, both sidebars sync, shortcut tooltips apply, and the grouped dropdown renders its headings with no profile lost.

make all green: 3329 passed, 2 skipped.

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 marcinz606 changed the title Hue Trim: correct an odd scanning light's hue rotation Hue Trim, and one home for the capture-side colour corrections Aug 5, 2026
@marcinz606
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.
@marcinz606
marcinz606 merged commit 7a07f5c into main Aug 5, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/hue-trim branch August 5, 2026 17:28
@thetalkingdrum

Copy link
Copy Markdown
Contributor

Nice!

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.

2 participants