Replace Dye Mute and Lab Vibrance with a signed Dye Separation - #676
Merged
Conversation
… 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.
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.
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:
Negative vibrance:
Same scale constant, same mean-preserving rescale of
e_i = dens_i - d_min_i. SettingV = -Dgives 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 - sand acts on pixels whose three dye densities sit close together, negative usessand 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_dampingandgrade_saturation_damping()— Dye Mute's frame-uniform, grade-coupled damp, on both the CPU and GPU pathsdensity_damping_spatial— the toggle that only chose between uniform and per-pixelapply_vibrance, the model field, the shader branch, the slider, and itsAlt+4shortcut pair. Its job was selective saturation; Dye Separation now does that in density space, where it responds to the paper profile.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 thestruct.packingpu_engine.pydrops to nine floats and a_pad3holds the block at 48 bytes. A mistake here would silently corrupt sharpening and denoise parameters rather than fail loudly.tests/test_pipeline_parity.pyandtests/test_gpu_curve_parity.pyboth ran against a real GPU (77 and 7 passed, no skips), so CPU and GPU agree on the new layout.CLAUDE.mdclaimedLabUniformslives in 7 shaders includingrl_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_vibrancerenames todye_separationrather 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+4are unbound rather than renumbered, so existing muscle memory for the other Lab keys still works.Docs
PIPELINE.md§3 and §6 andUSER_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_LIMIT = 45.0)EXPORT_COLOR_SPACESexcludes both, but the guide listed themoverride.tomlkeys were missingAll 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 warningtests/test_gpu_curve_parity.py— CPU/GPU parity for both signstests/test_saturation_damping.pydeleted; its one surviving guard (the Lab stage ignoringprint_slopes) moved totests/test_density_saturation.pymake lint,make type, and the full suite are green: 2728 passed, 5 skipped.Open question
dye_separation_spread_scale = 0.4is 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.