Add density-space Print Saturation, composed into the paper's dye crosstalk matrix - #664
Merged
marcinz606 merged 1 commit intoJul 29, 2026
Conversation
…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>
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
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.
Contributor
Author
|
Thanks! Yeah it’s something I have been thinking about for some time.
I actually made a new dye mute that works slightly differently to go with
the density saturation. But I wasn’t completely done yet so I just pushed
this one first to get it out the door.
ons. 29. juli 2026 kl. 07:25 skrev marcinz606 ***@***.***>:
… Merged #664 <#664> into main.
—
Reply to this email directly, view it on GitHub
<#664?email_source=notifications&email_token=B4YPVFLRB52VA5PLFLT6QZT5HGDGBA5CNFSNUABQM5UWIORPF5TWS5BNNB2WEL2JONZXKZKFOZSW45CON52GSZTJMNQXI2LPNYXTEOBWGM4TENZRGUZTBJTSMVQXG33OUZQXK5DIN5ZKKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#event-28639271530>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B4YPVFPARHGJLX457TKUEIT5HGDGBAVCNFSNUABGKJSXA33TNF2G64TZHMYTCMRTG43DMNRQHE5US43TOVSTWNJQGA2DGNRWGIYTTILWAI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/B4YPVFNAMUOV7WSCNOR4UXL5HGDGBA5CNFSNUABQM5UWIORPF5TWS5BNNB2WEL2JONZXKZKFOZSW45CON52GSZTJMNQXI2LPNYXTEOBWGM4TENZRGUZTBJTSMVQXG33OUZQXK5DIN5ZKKZLWMVXHJKTGN5XXIZLSL5UW64Y>
and Android
<https://github.com/notifications/mobile/android/B4YPVFKV7VSJPSODT4CXDYD5HGDGBA5CNFSNUABQM5UWIORPF5TWS5BNNB2WEL2JONZXKZKFOZSW45CON52GSZTJMNQXI2LPNYXTEOBWGM4TENZRGUZTBJTSMVQXG33OUZQXK5DIN5ZKKZLWMVXHJLTGN5XXIZLSL5QW4ZDSN5UWI>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
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>
Owner
|
i already did the changing the dye mute to work with it here: #667 but maybe your idea was slightly different? |
Contributor
Author
|
yeah I've been working on a density-based vibrance/anti-vibrance concept,
and my dye mute work borrows from that anti-vibrance side of it. None of
it's fully done yet, but I think it's giving some nice real-world results.
I can put together a draft soon if you want to try it yourself? Then you
can see if it something you are interesed in going further with. The
density-based vibrance looks pretty similar to normal Lab vibrance, hard to
say which is actually better there. But the "anti-vibrance" pairs really
well with density saturation for taming already-saturated colors. Main
difference from your slope-based damping is that it's per-pixel rather than
frame-wide, so it only tames pixels that are actually already saturated
instead of damping everything in the frame the same amount.
…On Wed, Jul 29, 2026 at 12:42 PM marcinz606 ***@***.***> wrote:
*marcinz606* left a comment (marcinz606/NegPy#664)
<#664 (comment)>
i already did the changing the dye mute to work with it here: #667
<#667> but maybe your idea was
slightly different?
—
Reply to this email directly, view it on GitHub
<#664?email_source=notifications&email_token=B4YPVFO54DCAAVJJPXQ2DT35HHIJJA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMJRGY3DKNZUGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-5116657429>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B4YPVFKUSE2IDUNI4ZOV4TT5HHIJJAVCNFSNUABGKJSXA33TNF2G64TZHMYTCMRTG43DMNRQHE5US43TOVSTWNJQGA2DGNRWGIYTTILWAI>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/B4YPVFP7STF6LVBUFEZGKID5HHIJJA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMJRGY3DKNZUGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG>
and Android
<https://github.com/notifications/mobile/android/B4YPVFM2KMQNB5QQE7BW4VD5HHIJJA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMJRGY3DKNZUGI42M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA>.
Download it today!
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
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.
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 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.