Skip to content

Draft: density-space Vibrance/anti-vibrance + spatially-modulated Dye Mute (extends #667) - #675

Closed
thetalkingdrum wants to merge 4 commits into
marcinz606:mainfrom
thetalkingdrum:experiment/density-vibrance
Closed

Draft: density-space Vibrance/anti-vibrance + spatially-modulated Dye Mute (extends #667)#675
thetalkingdrum wants to merge 4 commits into
marcinz606:mainfrom
thetalkingdrum:experiment/density-vibrance

Conversation

@thetalkingdrum

Copy link
Copy Markdown
Contributor

Draft: density-space Vibrance/anti-vibrance + spatially-modulated Dye Mute (extends #667)

What

Two related, additive controls, both off by default:

  • Density Vibrance — a signed per-pixel Vibrance/anti-vibrance control in density space (ExposureConfig.density_vibrance).
  • Spatial Damping — an alternate, per-pixel application mode for Dye Mute (ExposureConfig.density_damping_spatial), using the same masking mechanism as anti-vibrance.

This is a draft — I'm looking for your read on whether the direction is worth continuing, not asking you to merge as-is. I already had some A/B testing built in that I decided to keep as it might make your testing easier. Happy to close and keep iterating if it doesn't look right, or rip out some of the A/B features.

Why

#667 moved Dye Mute into density space as a single frame-wide scalar, grade-coupled via slope_g. That fixed the domain problem (density vs. post-decode Lab), but the correction is still uniform — it can't distinguish a near-neutral pixel from an already-separated one.

Anti-vibrance needed the same distinction (compress already-vivid pixels, leave muted ones alone), so one per-pixel masking mechanism ended up answering both:

  • Applied on its own — Density Vibrance, sign-flippable, boost muted or compress vivid.
  • Applied to Dye Mute's strength instead of the Vibrance slider's — Spatial Damping, the same correction redistributed by how separated each pixel already is, instead of applied uniformly.

Same math, two applications — not three separate ideas.

What each control does

Dye Mute — unchanged: #667 exactly as shipped, one flat (slope_min/slope_g)^strength damp applied uniformly, grade-coupled. This PR doesn't touch its default behavior.

Spatial Damping (new toggle, off by default) — Dye Mute run through anti-vibrance's math instead of the uniform formula. Same strength value, same field, redistributed per-pixel via d_k = 1 + (damp_global - 1) * mask — the same shape as the anti-vibrance formula below, just fed by Dye Mute's strength.

  • Off — byte-identical to #667 (verified, see Testing).
  • On — same strength, scaled by how separated each pixel already is: near-neutral pixels get none of it, already-separated pixels get up to full strength. Replaces the uniform formula rather than layering on it — see "Replace vs. layer" below.

Doesn't touch the Vibrance slider or Print Saturation — a second application of the masking trick, not a shared control.

Density Vibrance (new slider, -1.0 to 1.0, 0 = off):

  • Positive — classic Vibrance: boosts muted pixels (mask targets low-spread pixels).
  • Negative — real anti-vibrance: compresses vivid pixels (mask targets high-spread pixels — the same mask Spatial Damping reuses), leaves muted pixels alone. Lab's Vibrance has no equivalent here — its sub-1.0 branch just desaturates the muted population further.

The mask targets a genuinely different population depending on sign — not just a negated output.

A/B row (Off / Density +0.5 / Lab 1.50 / Density -0.3 / Lab 0.33) — left over from my own testing against Lab Vibrance, not built specifically for review, but there if useful. The boost pair is a fair comparison (both target muted pixels, matched by chroma effect). The compress pair is magnitude-matched only — Lab's 0.33 desaturates muted pixels further, Density's -0.3 compresses vivid pixels, so they'll look different even at "equal" strength.

Shared constant to flag: all three per-pixel behaviors — Vibrance boost, anti-vibrance, Spatial Damping — share one tunable, vibrance_spread_scale = 0.4 (exposure/models.py), controlling how much per-pixel separation counts as "vivid enough" to affect. It's an unvalidated starting guess. Tuning it for one effect will retune all three at once — that's the main risk of the unified-mask approach, and worth deciding now whether that coupling is acceptable or whether it needs decoupling before this ships.

Honest note on the boost direction

Density Vibrance's boost direction converged with Lab Vibrance at realistic settings in my testing, only diverging at extremes — I can't confidently say one is better. Including it anyway since it costs nothing and the A/B row makes it checkable, but the real case for this PR is the compress direction, which Lab has no equivalent of at all.

Replace vs. layer

Spatial Damping replaces the uniform grade_saturation_damping pre-multiply when it's on, rather than layering on top. I lean toward replace for three reasons: your own #667 PR already diagnosed the failure mode of uncoordinated stacking (Lab Saturation vs. old Dye Mute fighting each other); and two of my own earlier prototypes (a global chroma-damping signal, then a per-channel-divergence version — both dropped, see DROPPED_KEYS) turned out mathematically redundant with density_saturation once measured. Flagging this as a decision point rather than deciding it unilaterally — layer is a reasonable position if you'd rather.

Risk / compatibility

Both fields default to off/identity (density_vibrance=0.0, density_damping_spatial=False) — can't affect any existing saved edit. With Spatial Damping off, Dye Mute is unchanged from #667, verified bit-exact (test_off_by_default_matches_uniform_damping_bit_exact).

Implemented on CPU and GPU, mirrored in the WGSL shader — no CPU-only fallback.

Testing

  • tests/test_density_vibrance.py — identity/no-op, boost and compress each change output and aren't a simple sign flip of each other, B&W inertness, bit-exact proof Spatial Damping off matches #667's uniform path.
  • tests/test_gpu_curve_parity.py — new CPU/GPU parity for Density Vibrance (both directions) and Spatial Damping — neither covered by the existing test_cpu_gpu_match_dye_mute, which only exercises the uniform path.

Mats and others added 4 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-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>
-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 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>
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>
@thetalkingdrum
thetalkingdrum marked this pull request as draft July 29, 2026 13:05
@marcinz606

Copy link
Copy Markdown
Owner

I played around and I like the effect, i think it should replace the dye mute. But for consistency/simplification I would maybe name it "Dye Separation" instead of "density vibrance". I can make a changes on top of your branch

@marcinz606

Copy link
Copy Markdown
Owner

Also I think i will drop vibrance from lab panel completely after that

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

Stoked to hear that you will include it :) Yeah, after having played around with the density vibrance and density saturation in tandem the last few days I also really started liking it. feels like a quite powerful but subtle tool for getting the saturation you want. And looks natural.

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

No objections on calling it Dye separation:)

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

So go ahead and make the chances you want. The only thing that could be explored more imo is if some other value than 0.4 gives a better result. But that can be done over a time period as well. Don't need to land that right away.

@marcinz606

Copy link
Copy Markdown
Owner

created new branch/pr on top of your commits here #676, i will close this one :)

@marcinz606 marcinz606 closed this Jul 29, 2026
@marcinz606

Copy link
Copy Markdown
Owner

I'm playing with spread scale and i think smaller values work better, with current 0.4 behaviour is pretty close to print saturation

@marcinz606

Copy link
Copy Markdown
Owner

something in 0.1-0.2 range seems way more selective

@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

i synced with main now. Dye separation seems make almost no visible change between 0 and 1? Going the other direction works well though. 0 to -1

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

like the histogram changes but i barely see any visual change

@marcinz606

Copy link
Copy Markdown
Owner

i reduced the k 0.4 -> 0.12, might need some more tweaking

@marcinz606

Copy link
Copy Markdown
Owner

now the positive effect more heavily depends on negative

@marcinz606

Copy link
Copy Markdown
Owner

but I agree it's weird that negative effect is more pronounced, i havent changed anything other than the 0.4 const

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

I'm heading out for a bit so will look more later. But yeah, on the test negative going in the positive direction made almost no difference

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

might be possible to use different k number on the two directions as well, if that works better idk?

@marcinz606

Copy link
Copy Markdown
Owner

Yes, that's what I'm considering, different k depending on direction

@marcinz606

Copy link
Copy Markdown
Owner

After playing a bit more I'm thinking that when effect is made stronger again it's almost indistinguishable from print saturation, formulas are almost the same after all. I'm considering consolidating it into one slider to make things simpler

@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

@thetalkingdrum

thetalkingdrum commented Jul 29, 2026 via email

Copy link
Copy Markdown
Contributor Author

@marcinz606

Copy link
Copy Markdown
Owner

I've made a hybrid now, one 'Dye Saturation' slider but you can dial in the "k" using separate slider. Gives a lot of flexibility

@thetalkingdrum

thetalkingdrum commented Jul 30, 2026 via email

Copy link
Copy Markdown
Contributor Author

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

Seems to work well and is flexible as you say. It reacts a bit differently on different frames, so I have to recalibrate my brain:)

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

I'm convinced. Gives you very fine saturation control over the whole image.

@thetalkingdrum

Copy link
Copy Markdown
Contributor Author

Since Chroma is still in the lab panel I made some changes to it so it behaves a bit better.

@thetalkingdrum

thetalkingdrum commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Test with some skin tones while boosting chroma for best a/b comparison

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