Skip to content

Add density-space Print Saturation, composed into the paper's dye crosstalk matrix - #664

Merged
marcinz606 merged 1 commit into
marcinz606:mainfrom
thetalkingdrum:experiment/density-saturation
Jul 29, 2026
Merged

Add density-space Print Saturation, composed into the paper's dye crosstalk matrix#664
marcinz606 merged 1 commit into
marcinz606:mainfrom
thetalkingdrum:experiment/density-saturation

Conversation

@thetalkingdrum

Copy link
Copy Markdown
Contributor

Adds a Saturation control that operates in density space — composed into the exact same matrix slot as the paper profile's dye crosstalk (dye_matrix) — as an alternative to scaling Lab a*/b* post-decode. New field ExposureConfig.density_saturation (+ per-channel trims), default 1.0 = identity.

Lab Saturation scales a*/b* uniformly around L* — a purely perceptual-space operation with no relationship to how a physical print produces color; it's a math trick applied after decode. This instead pushes/pulls the print's actual per-channel dye densities apart, in the same coordinate space the paper's own dye crosstalk already operates in, composed via sat_matrix @ dye_matrix into one matmul.

Two consequences follow directly from that, not as separate design choices:

  • It composes correctly with different paper profiles. Because it's literally the same matrix slot as the profile's dye crosstalk, pushing separation on a paper with real (impure) dye response looks different than on a cleaner profile — the way it would on an actual darkroom print — for free, with no per-paper special-casing.
  • Its effect naturally scales with how much density-above-base a channel already has. Saturation is applied to density - paper_base after the full per-channel H&D curve (toe/shoulder/grade/etc.) has already run — so a channel sitting near its shoulder, already compressed by the curve, starts from a smaller density-above-base value than one in the linear region, and gets a proportionally smaller absolute push.

It also supports independent per-channel (R/G/B) trims, matching the existing pattern already used elsewhere in this section (Grade trim, Toe/Shoulder trim, Snap/midtone-gamma trim all support per-layer correction) — none of which the Lab sidebar's controls (Saturation, Vibrance, Dye Mute) currently support at all. That's not incidental to where this control lives: a real paper's dye layers can diverge independently (crossover), and this control can now correct that the same way the other Tone-stage controls already do. Placing it here keeps it consistent with that established convention rather than introducing a first per-channel-capable control in a section that has no precedent for one.

New field, defaults to identity (1.0). compose_density_matrices returns the dye matrix unmodified at default, so output is bit-for-bit unchanged both for the neutral paper (test_identity_is_exact_noop) and for papers with real dye crosstalk like Kodak Endura
(test_identity_is_exact_noop_with_real_dye_matrix). Cannot affect any existing saved edit.

Implemented on both the CPU and GPU render paths — verified by test_cpu_gpu_match_density_saturation (tests/test_gpu_curve_parity.py), which renders through the real GPU compute path with a non-identity paper dye matrix and non-default saturation/trims. No CPU-only fallback or silent no-op under the app's default GPU-first rendering.

  • tests/test_density_saturation.py — matrix math (row-stochastic under per-channel divergence, sat @ dye composition order), per-channel resolve, and end-to-end render behavior (exact no-op at identity for both neutral and non-identity-dye papers, real effect, per-channel trims, B&W inertness)
  • tests/test_gpu_curve_parity.py::test_cpu_gpu_match_density_saturation — CPU/GPU parity against a real paper dye matrix
  • docs/PIPELINE.md updated

…sstalk matrix

Adds a Saturation control that operates in density space — composed into
the exact same matrix slot as the paper profile's dye crosstalk
(dye_matrix) — as an alternative to scaling Lab a*/b* post-decode. New
field ExposureConfig.density_saturation (+ per-channel trims), default
1.0 = identity.

Lab Saturation scales a*/b* uniformly around L* — a purely
perceptual-space operation with no relationship to how a physical print
produces color; it's a math trick applied after decode. This instead
pushes/pulls the print's actual per-channel dye densities apart, in the
same coordinate space the paper's own dye crosstalk already operates in,
composed via sat_matrix @ dye_matrix into one matmul.

Two consequences follow directly from that, not as separate design
choices:
- It composes correctly with different paper profiles. Because it's
  literally the same matrix slot as the profile's dye crosstalk, pushing
  separation on a paper with real (impure) dye response looks different
  than on a cleaner profile — the way it would on an actual darkroom
  print — for free, with no per-paper special-casing.
- Its effect naturally scales with how much density-above-base a channel
  already has. Saturation is applied to density - paper_base after the
  full per-channel H&D curve (toe/shoulder/grade/etc.) has already run —
  so a channel sitting near its shoulder, already compressed by the
  curve, starts from a smaller density-above-base value than one in the
  linear region, and gets a proportionally smaller absolute push.

It also supports independent per-channel (R/G/B) trims, matching the
existing pattern already used elsewhere in this section (Grade trim,
Toe/Shoulder trim, Snap/midtone-gamma trim all support per-layer
correction) — none of which the Lab sidebar's controls (Saturation,
Vibrance, Dye Mute) currently support at all. That's not incidental to
where this control lives: a real paper's dye layers can diverge
independently (crossover), and this control can now correct that the same
way the other Tone-stage controls already do. Placing it here keeps it
consistent with that established convention rather than introducing a
first per-channel-capable control in a section that has no precedent for
one.

New field, defaults to identity (1.0). compose_density_matrices returns
the dye matrix unmodified at default, so output is bit-for-bit unchanged
both for the neutral paper (test_identity_is_exact_noop) and for papers
with real dye crosstalk like Kodak Endura
(test_identity_is_exact_noop_with_real_dye_matrix). Cannot affect any
existing saved edit.

Implemented on both the CPU and GPU render paths — verified by
test_cpu_gpu_match_density_saturation (tests/test_gpu_curve_parity.py),
which renders through the real GPU compute path with a non-identity
paper dye matrix and non-default saturation/trims. No CPU-only fallback
or silent no-op under the app's default GPU-first rendering.

- tests/test_density_saturation.py — matrix math (row-stochastic under
  per-channel divergence, sat @ dye composition order), per-channel
  resolve, and end-to-end render behavior (exact no-op at identity for
  both neutral and non-identity-dye papers, real effect, per-channel
  trims, B&W inertness)
- tests/test_gpu_curve_parity.py::test_cpu_gpu_match_density_saturation —
  CPU/GPU parity against a real paper dye matrix
- docs/PIPELINE.md updated

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@marcinz606

Copy link
Copy Markdown
Owner

really nice idea and results look really good. I'll do one more thing as follow up - move dye mute to this stage as modifier to density saturation

@marcinz606
marcinz606 merged commit 7bc8bdc into marcinz606:main Jul 29, 2026
1 check passed
marcinz606 added a commit that referenced this pull request Jul 29, 2026
* refactor: use _ACHROMATIC_J in resolve_saturation_matrix

The constant was defined by #664 but the function rebuilt the achromatic
projection inline, leaving it dead. No behaviour change — now reads as
the k*I + (1-k)*J formula the docstring and PIPELINE.md describe.

* feat: move Dye Mute into density space, rename Lab Saturation to Chroma

Dye Mute was not an operator — it multiplied a grade-coupled scalar into
Lab Saturation, correcting print-curve chroma inflation in CIELAB while
its physical justification (the green print-curve slope) lived in the
exposure stage. Print Saturation corrects the same inflation in the
domain where it originates, so the two fought each other uncoordinated:
print_slopes never saw density_saturation.

Dye Mute now damps the global density_saturation instead:
k = density_saturation * (slope_min/slope_g)^strength. Per-layer trims
stay outside the damping — a crossover correction is a measured fix for
one paper's diverging dye layers and must not shrink when the grade
hardens. Folded in at the processor on both paths; slopes are already
available before the curve call, so nothing new crosses into the kernel
or a shader.

Default is 0.0 (off, was 0.25 on). The damping factor is exactly 1.0 at
strength 0 for every slope, so the default leaves the saturation matrix
at identity and keeps the dye_mix fast path allocation-free.

lab.chroma_damping is dropped, not renamed. The same strength means a
different effect magnitude in density space (it self-tapers at toe and
shoulder), so carrying the value would apply something the user never
set. Dye Mute resets to off; re-dial it where the number now means
something. The shortcut action ids and slider group id deliberately keep
their chroma_damping names — persisted user bindings and step sizes are
keyed by them and would be silently dropped on rename.

Lab's "Saturation" becomes "Chroma": it scales CIELAB a*/b* evenly after
decode, and two sliders named Saturation in two tabs was the ambiguity
this change exists to remove. Tooltips on both now explain the contrast.

Also closes gaps from the Print Saturation change: it had no shortcut,
settings-catalog, slider-group or tutorial entry, and its slider stayed
live but inert in B&W (the processor forces identity there).

Goldens: default-config chroma rises because Lab no longer carries the
0.921 damping multiplier. Verified as the only delta — lab.saturation
0.9210 reproduces the old values to 2.7e-4, and that is exactly
grade_saturation_damping(2.780, 0.25).

* refactor: rename Dye Mute's shortcut ids to match its config field

chroma_damping_inc/dec -> density_saturation_damping_inc/dec, the slider
group id likewise, and the widget attribute becomes dye_mute_slider.

This drops any custom Dye Mute binding or step size a user had set —
shortcut_bindings and shortcut_slider_steps are keyed by these ids and
both merge functions discard unknown keys. Accepted deliberately: the
actions ship with no default key, and a permanent name mismatch across
five files is the worse long-term cost.

Also documents the renamed controls in USER_GUIDE.md — Print Saturation
and Dye Mute under Tone, Lab's Saturation as Chroma, with each pointing
at the other so the difference between density-space and post-decode
saturation is findable from either side.
@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

thetalkingdrum pushed a commit to thetalkingdrum/NegPy that referenced this pull request Jul 29, 2026
… Dye Mute

Squashed from 6 exploratory commits for a clean rebase base. Still
experimental/pre-PR — built against the pre-marcinz606#664/marcinz606#667 codebase and needs
reconciling against both before this is presentable.

Contains:
- ExposureConfig.density_vibrance (signed, 0=off): per-pixel, inside
  _apply_print_curve_kernel, right after the dye_mix block. Positive boosts
  muted pixels (classic Vibrance), negative compresses vivid pixels (real
  anti-vibrance, no Lab equivalent). Mask *target* flips with sign, not
  just the formula's sign.
- Per-pixel-modulated Dye Mute (variant b): spread = max(e0,e1,e2) -
  min(e0,e1,e2) drives a sigmoid mask so damping concentrates on
  already-separated pixels, leaving near-neutral pixels untouched
  regardless of frame-wide grade.
- CPU + GPU parity for both, verified within this project's established
  tolerance (mean<0.01, max<0.04).
- Divergence-based Dye Mute reference signal prototype: built, tested,
  found redundant with density_saturation itself, removed.

Not yet reconciled with marcinz606#667 (maintainer's own Dye Mute-in-density-space
follow-up, which shipped a global slope_g-based damping term this
per-pixel work needs to either replace or layer with).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@marcinz606

Copy link
Copy Markdown
Owner

i already did the changing the dye mute to work with it here: #667 but maybe your idea was slightly different?

@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

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