Move Dye Mute into density space; rename Lab Saturation to Chroma - #667
Merged
Conversation
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.
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).
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
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>
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>
thetalkingdrum
pushed a commit
to thetalkingdrum/NegPy
that referenced
this pull request
Jul 29, 2026
Same class of gap marcinz606#667 already fixed for Print Saturation: the processor already forces density_vibrance/density_damping_spatial inert in B&W (get_luminance collapses to one channel before either ever runs), but the sliders and the Vibrance A/B row stayed visible and interactive anyway. Lab's own Vibrance slider already hides in B&W (sidebar/lab.py), so the A/B row comparing Density vs Lab Vibrance -- both color-only -- follows the same convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
thetalkingdrum
pushed a commit
to thetalkingdrum/NegPy
that referenced
this pull request
Jul 29, 2026
Light pass appropriate to a draft/exploratory PR, not a full test suite: - tests/test_density_vibrance.py: identity/no-op, boost changes output, compress changes output, boost/compress aren't a simple sign flip (different target populations), B&W inertness -- for both density_vibrance and density_damping_spatial. Also encodes the actual proof that density_damping_spatial=False is bit-exact with manually pre-multiplying density_saturation by grade_saturation_damping (marcinz606#667's own uniform path, untouched by default). - tests/test_gpu_curve_parity.py: CPU/GPU parity for density_vibrance (both directions) and density_damping_spatial -- neither was covered by any existing test; test_cpu_gpu_match_dye_mute only covers marcinz606#667's uniform (non-spatial) damping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
marcinz606
added a commit
that referenced
this pull request
Jul 29, 2026
* Prototype: density-space Vibrance/anti-vibrance + per-pixel-modulated Dye Mute Squashed from 6 exploratory commits for a clean rebase base. Still experimental/pre-PR — built against the pre-#664/#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 #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> * Tone down the Density Vibrance A/B compress button to -0.3 -0.5 was a fairly aggressive anti-vibrance push for a quick-compare demo. Lab has no real equivalent to match against for the compress direction anyway (its sub-1.0 branch desaturates the muted population, not vivid pixels), so 0.33 stays as a fixed reference point rather than a matched value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Hide Density Vibrance / spatial Dye Mute controls in B&W mode Same class of gap #667 already fixed for Print Saturation: the processor already forces density_vibrance/density_damping_spatial inert in B&W (get_luminance collapses to one channel before either ever runs), but the sliders and the Vibrance A/B row stayed visible and interactive anyway. Lab's own Vibrance slider already hides in B&W (sidebar/lab.py), so the A/B row comparing Density vs Lab Vibrance -- both color-only -- follows the same convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Add minimal test coverage for Density Vibrance and spatial Dye Mute Light pass appropriate to a draft/exploratory PR, not a full test suite: - tests/test_density_vibrance.py: identity/no-op, boost changes output, compress changes output, boost/compress aren't a simple sign flip (different target populations), B&W inertness -- for both density_vibrance and density_damping_spatial. Also encodes the actual proof that density_damping_spatial=False is bit-exact with manually pre-multiplying density_saturation by grade_saturation_damping (#667's own uniform path, untouched by default). - tests/test_gpu_curve_parity.py: CPU/GPU parity for density_vibrance (both directions) and density_damping_spatial -- neither was covered by any existing test; test_cpu_gpu_match_dye_mute only covers #667's uniform (non-spatial) damping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Replace Dye Mute and Lab Vibrance with a signed Dye Separation Dye Mute's per-pixel prototype and the Density Vibrance slider turned out to be the same maths: both rescale a pixel's dye-density spread by a spread-masked factor. Collapse them into one signed control. dye_separation (-0.5..0.5) keeps the per-pixel spread mask. The sign selects which pixels the mask hits rather than just the direction, so positive acts on muted pixels and negative on already-separated ones, and neither end touches the other's population. Dropped with it: - density_saturation_damping and its grade-coupled grade_saturation_damping pre-multiply, on both the CPU and GPU paths - density_damping_spatial, which only chose between those two - Lab Vibrance, whose selective-saturation job this now does in density space, including its LabUniforms field across the six lab shaders Removing the uniform field shifts every LabUniforms offset after it, so the struct.pack drops to nine floats and a _pad3 holds the block at 48 bytes. test_pipeline_parity covers the new layout on both paths. Retired keys drop silently via migrations; density_vibrance renames to dye_separation so prototype edits keep their look. Chroma Denoise moves into the Lab panel's COLOUR row where Vibrance was. Also corrects docs that were already stale before this branch: Fine Rotation is +/-45 not +/-5, export offers neither ACES nor XYZ, the Sensor Calibration panel was undocumented, and two override.toml keys were missing. CLAUDE.md said LabUniforms lives in 7 shaders; rl_init binds no uniform, so it is 6. --------- Co-authored-by: Mats <mats@Matss-Mac-mini.local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.
Follows #664, which added density-space Print Saturation.
Why
Dye Mute was never an operator. It multiplied a grade-coupled scalar into Lab Saturation:
One scalar on a CIELAB a*/b* scale, folded identically on the GPU. So its physical justification lived in the exposure stage (the green print-curve slope) while its effect landed post-decode in a perceptual space with no relationship to how the paper produces colour.
Print Saturation corrects the same phenomenon — a harder grade steepens each channel's slope, widening per-channel density spread and inflating chroma — in the domain where it originates.
migrations.pyalready recorded that the density-space damping prototypes were dropped as "mathematically redundant with density_saturation itself", but the Lab-space one kept shipping. Worse, the two couldn't see each other:print_slopesis computed from grade/density/trims only, so it never observeddensity_saturation. Pull both and you got uncoordinated double correction.What changed
Dye Mute now damps the global
density_saturation:Per-layer trims sit outside the damping — a crossover correction is a measured fix for one paper's diverging dye layers and must not shrink because the grade hardened.
Folded in at the processor on both paths.
slopesis already available before the curve call on CPU (exposure/processor.py:233) and GPU (gpu_engine.py:1172), so nothing new crosses into the kernel or a shader: no WGSL change, no new buffer, no bind-group arity change.LabConfig.chroma_damping→ExposureConfig.density_saturation_damping. Moving between sub-configs costs no migration at all —from_flat_dictroutes flat keys by which dataclass declares the field.Default is 0.0 (off), was 0.25 (on)
grade_saturation_damping(s, 0.0)returns exactly1.0for every slope, soresolve_saturation_matrixstill returnsNoneat default and #664's byte-exact default path is preserved — the 3×3 matmul stays off unless the user opts in.The old value is dropped, not carried
One
DROPPED_KEYSentry rather than aKEY_RENAMESline. 0.25 damping on CIELAB chroma and 0.25 damping on density separation are not the same effect magnitude — the density version self-tapers at toe and shoulder — so carrying the number would silently apply something the user never set. There is no honest 1:1 remap between the domains and a fudge factor would only hide that.This means anyone who deliberately cranked Dye Mute loses that setting and needs to re-dial it, in the domain where the number now means something.
Lab's "Saturation" is now "Chroma"
Two sliders named Saturation in two tabs was the exact ambiguity this change exists to remove. Chroma names what it does — an even CIELAB a*/b* scale after decode. Field stays
saturation; label, catalog row, shortcut description and tooltips updated. Both controls' tooltips now explain the contrast and point at each other.Also closes gaps left by #664
Print Saturation shipped with no
shortcut_registryentry, nosettings_catalogrow, noslider_shortcut_groupsentry and no tutorial mention. Its slider also stayed visible and live in B&W while the processor forced it to identity — a control that did nothing. Both fixed, and Dye Mute's four existing entries moved Lab → Exposure.Shortcut action ids and the slider group id are renamed to track the field. That discards any custom binding or step size for these two actions (both persist keyed by id, and unknown keys are dropped on merge). Accepted deliberately: they ship with no default key, and a permanent name mismatch across five files is the worse cost.
Look change
Default-config chroma rises, because Lab no longer carries the damping multiplier and Dye Mute now defaults off.
Verified as the only delta before regenerating the golden:
lab.saturation = 0.9210reproduces the old values to 2.7e-4 against a 1e-3 tolerance, and that number is exactlygrade_saturation_damping(2.780, 0.25). The large encoded swings in_GOLDEN(0.249 → 0.084) are the Adobe TRC amplifying small linear changes near black on the saturated test patches.test_characteristic_curvestayed green throughout, corroborating that the exposure kernel genuinely didn't move.Tests
test_chroma_damping.py→test_saturation_damping.py. Pure-function tests survive under the new name; added that strength 0 is exactly 1.0 for every slope (the guarantee the default rests on).print_slopessays, including when absent.chroma_dampingkey loads with no unknown-key warning and does not populate the new field.test_gpu_curve_parity.py::test_cpu_gpu_match_dye_mute— CPU/GPU parity at non-default damping against a real paper dye matrix, since the two paths compute the factor separately and could drift.2672 passed, 1 skipped. Lint and type clean;ruff formatleft all 456 files unchanged.Docs:
PIPELINE.md(§3 Dye Mute, §6 Global Saturation no longer grade-coupled) andUSER_GUIDE.md(Print Saturation + Dye Mute under Tone, Saturation → Chroma, cross-referenced both ways).