Skip to content

Replace Dye Mute and Lab Vibrance with a signed Dye Separation - #676

Merged
marcinz606 merged 5 commits into
mainfrom
experiment/density-vibrance
Jul 29, 2026
Merged

Replace Dye Mute and Lab Vibrance with a signed Dye Separation#676
marcinz606 merged 5 commits into
mainfrom
experiment/density-vibrance

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Supersedes #675 (@thetalkingdrum's draft), and builds directly on its four prototype commits, which are included in this branch. Extends #667.

What changed

#675 proposed two controls: Density Vibrance (a signed per-pixel spread rescale) and Spatial Damping (a toggle applying Dye Mute's strength through the same per-pixel mask). Reading them side by side, they compute the same thing.

Spatial damping:

d_mask = 2*sigmoid(spread/0.4) - 1
d_k    = 1 + (damp_global - 1) * d_mask,   damp_global = max(0, 1 - D)
       = 1 - min(1, D) * d_mask

Negative vibrance:

s = 2*sigmoid(spread/0.4) - 1
k = 1 + V*s = 1 - |V|*s

Same scale constant, same mean-preserving rescale of e_i = dens_i - d_min_i. Setting V = -D gives bit-identical output. So the toggle was a second spelling of the slider's negative half.

This collapses them into one control, Dye Separation (exposure.dye_separation, -0.5..0.5, 0 = off).

The sign selects which pixels the mask hits rather than just the direction: positive uses 1 - s and acts on pixels whose three dye densities sit close together, negative uses s and acts on already-separated ones. Neither end touches the other's population, which is what makes it read as selective rather than as a saturation slider. Flipping only the formula's sign would push and pull the same muted pixels and never reach the separated ones.

Removed

  • density_saturation_damping and grade_saturation_damping() — Dye Mute's frame-uniform, grade-coupled damp, on both the CPU and GPU paths
  • density_damping_spatial — the toggle that only chose between uniform and per-pixel
  • Lab Vibranceapply_vibrance, the model field, the shader branch, the slider, and its Alt+4 shortcut pair. Its job was selective saturation; Dye Separation now does that in density space, where it responds to the paper profile.
  • Draft: density-space Vibrance/anti-vibrance + spatially-modulated Dye Mute (extends #667) #675's A/B comparison row, which existed to compare against Lab Vibrance

Net: +279 / -369 across 35 files.

The risky part

Lab Vibrance had a field in LabUniforms, declared identically in six shaders (lab, lab_sharpen_h/v, rl_blur_h, rl_div_v, rl_mult_v). Removing it shifts the offset of every field after it, so the struct.pack in gpu_engine.py drops to nine floats and a _pad3 holds the block at 48 bytes. A mistake here would silently corrupt sharpening and denoise parameters rather than fail loudly.

tests/test_pipeline_parity.py and tests/test_gpu_curve_parity.py both ran against a real GPU (77 and 7 passed, no skips), so CPU and GPU agree on the new layout.

CLAUDE.md claimed LabUniforms lives in 7 shaders including rl_init.wgsl. It binds no uniform at all — the auto layout prunes it — so that is corrected to 6.

Compatibility

Retired keys drop silently through migrations.py, so no saved edit warns on load. density_vibrance renames to dye_separation rather than dropping, so edits saved during the prototype keep their look. Default is 0.0 (identity), so no golden values shifted.

UI

Dye Separation sits in the Tone panel where Dye Mute was, beside Print Saturation. Chroma Denoise moves up into the Lab panel's COLOUR row to fill the slot Vibrance left; CLAHE is now full-width in DETAIL. Alt+4 / Alt+Shift+4 are unbound rather than renumbered, so existing muscle memory for the other Lab keys still works.

Docs

PIPELINE.md §3 and §6 and USER_GUIDE.md §6.2 and §7.1 updated. An audit of every documented slider range and constant against the code also turned up four errors that predate this branch:

  • Fine Rotation is ±45°, documented as ±5° (FINE_ROTATION_LIMIT = 45.0)
  • Print Saturation is 0.5–1.5, documented as 0.3–1.6
  • Export offers neither ACES nor XYZ — EXPORT_COLOR_SPACES excludes both, but the guide listed them
  • The Sensor Calibration panel was entirely undocumented, and two override.toml keys were missing

All fixed here.

Testing

  • tests/test_dye_separation.py — identity no-op, both directions change output, the two signs are not mirror images, B&W inertness, and that the retired keys load without warning
  • tests/test_gpu_curve_parity.py — CPU/GPU parity for both signs
  • tests/test_saturation_damping.py deleted; its one surviving guard (the Lab stage ignoring print_slopes) moved to tests/test_density_saturation.py

make lint, make type, and the full suite are green: 2728 passed, 5 skipped.

Open question

dye_separation_spread_scale = 0.4 is still #675's unvalidated starting guess for where "muted" ends. It now drives one control instead of three, so tuning it no longer retunes unrelated behaviour, but it has not been checked by eye against real frames.

Mats and others added 5 commits July 29, 2026 13:16
… 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>
-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>
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>
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>
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.
@marcinz606
marcinz606 merged commit de79e13 into main Jul 29, 2026
1 check passed
@marcinz606
marcinz606 deleted the experiment/density-vibrance branch July 29, 2026 17:13
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