Skip to content

Move Dye Mute into density space; rename Lab Saturation to Chroma - #667

Merged
marcinz606 merged 3 commits into
mainfrom
feat/dye-mute-density
Jul 29, 2026
Merged

Move Dye Mute into density space; rename Lab Saturation to Chroma#667
marcinz606 merged 3 commits into
mainfrom
feat/dye-mute-density

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Follows #664, which added density-space Print Saturation.

Why

Dye Mute was never an operator. It multiplied a grade-coupled scalar into Lab Saturation:

slopes = context.metrics.get("print_slopes")
damp = 1.0 if slopes is None else grade_chroma_damping(slopes[1], self.config.chroma_damping)
eff_sat = self.config.saturation * damp

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.py already 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_slopes is computed from grade/density/trims only, so it never observed density_saturation. Pull both and you got uncoordinated double correction.

What changed

Dye Mute now damps the global density_saturation:

k = density_saturation * (slope_min / slope_g) ** strength

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. slopes is 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_dampingExposureConfig.density_saturation_damping. Moving between sub-configs costs no migration at all — from_flat_dict routes flat keys by which dataclass declares the field.

Default is 0.0 (off), was 0.25 (on)

grade_saturation_damping(s, 0.0) returns exactly 1.0 for every slope, so resolve_saturation_matrix still returns None at 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_KEYS entry rather than a KEY_RENAMES line. 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_registry entry, no settings_catalog row, no slider_shortcut_groups entry 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.9210 reproduces the old values to 2.7e-4 against a 1e-3 tolerance, and that number is exactly grade_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_curve stayed green throughout, corroborating that the exposure kernel genuinely didn't move.

Tests

  • test_chroma_damping.pytest_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).
  • Lab Saturation is now grade-independent — renders identically whatever print_slopes says, including when absent.
  • Damping reduces chroma at a hard grade, and the effect grows as the grade hardens — the reason Dye Mute survives as its own control instead of folding into a static value.
  • Trims stay outside the damping; B&W is inert.
  • A legacy chroma_damping key 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 format left all 456 files unchanged.

Docs: PIPELINE.md (§3 Dye Mute, §6 Global Saturation no longer grade-coupled) and USER_GUIDE.md (Print Saturation + Dye Mute under Tone, Saturation → Chroma, cross-referenced both ways).

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.
@marcinz606
marcinz606 merged commit c0d6ff0 into main Jul 29, 2026
1 check passed
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>
@marcinz606
marcinz606 deleted the feat/dye-mute-density branch July 29, 2026 21:08
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.

1 participant