Files
DarkRoom/core/dr-pipeline/ops/contrast.yaml
T
dtourolle 8392cf772e Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The
fragment lifted each pixel's luminance to its target by multiplying the
colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is
a gain of ninety, and in the deepest shadows the channels are sensor
noise: after white balance the red and blue noise sits above the green,
their multipliers being nearly twice its, so ninety times that noise is
magenta.

Flattening now mixes the colour toward middle grey, which gives the
same luminance and adds the lift as a neutral. A black goes to grey and
its noise stays the size it was.

The same fragment clamped luma/0.36 into the curve's 0..1 domain, which
scaled every tone above twice middle grey down to 0.36 in either
direction: contrast +10 took a 230 grey to 162. Those tones are now left
where they are, which is continuous with the curve's top (value 1,
slope 0).
2026-09-26 20:43:02 -04:00

146 lines
5.9 KiB
YAML

id: contrast
label: op.contrast
order: 30
attributes: [tone]
doc: |
Contrast — an S-curve about a fixed mid-point.
Pushes tones away from middle grey (positive) or toward it (negative),
pivoting where the eye reads "neither light nor dark". In linear light
that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
stop and a half above middle grey, and pivoting there would darken almost
every photograph.
The curve is applied in a perceptual domain rather than directly to linear
values. Applied linearly, an S-curve crushes shadows far harder than it
lifts highlights, because linear light devotes most of its range to the
brightest stop.
placement: |
After the capture corrections, before the region controls: it is the broad
tonal statement those controls then refine.
params:
contrast:
label: param.contrast
kind: amount
uniforms:
amount:
value: contrast / 100
doc: The shader's curve expects -1..1; the descriptor speaks -100..100.
helpers: [luminance, apply_tone_gain]
define:
contrast_curve: |
// The steepening S, on a 0..1 perceptual position.
//
// Blends toward a smoothstep, which has zero gradient at both ends, so the
// curve cannot invert however hard it is pushed — the failure that makes
// naive gain-about-a-pivot unusable past moderate settings. Only the
// positive direction comes here: flattening is not a curve at all (see
// the fragment).
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
wgsl: |
if (amount < 0.0) {
// **Flattening mixes toward middle grey; it does not scale.**
//
// Every tone moves the same fraction of the way to 0.18, which at -1
// collapses the picture to grey — the meaningful limit of 'no contrast'.
// In luminance this is exactly what the ratio form below would compute,
// but the ratio form reaches it by multiplying: a pixel at 0.001 has to
// be lifted to 0.09, a gain of ninety, and in the deepest shadows the
// channels are sensor noise, not a colour. After white balance the red
// and blue noise sits above the green (their multipliers are nearly
// twice its), so ninety times that noise is magenta — every black in the
// frame turned pink. Mixing adds the lift as a neutral, so a black goes
// to grey and its noise stays the size it was.
//
// The grey is (1, 1, 1) scaled, because this runs after white balance
// in the camera's space, where that is what neutral is.
c = mix(c, vec3<f32>(0.18), -amount);
} else {
let luma = luminance(c);
// Only up to twice middle grey, which is the curve's whole domain. Above
// it the curve's value is 1 and its slope 0, so leaving those tones
// alone is the continuous continuation — where scaling them to the
// curve's top, as this once did through a clamp, pinned every highlight
// in the photograph to 0.36 at the smallest touch of the slider.
if (luma > 0.0001 && luma < 0.36) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises. Safe here where it was not for flattening:
// the S only ever pulls a shadow down, so the gain is at most one
// below the pivot and noise is never amplified.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
// The curve operates on luma/(2*0.18) so that middle grey lands at
// the curve's own 0.5 pivot.
let pos = luma / 0.36;
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
}
c = max(c, vec3<f32>(0.0));
tests:
- name: neutral_does_nothing
expect: { amount: 0.0 }
expect_active: false
- name: the_amount_is_normalised_to_unit_range
set: { contrast: 100 }
expect: { amount: 1.0 }
- name: the_negative_direction_normalises_too
set: { contrast: -100 }
expect: { amount: -1.0 }
- name: contrast_works_on_luminance_not_per_channel
why: |
Curving each channel separately shifts hue; the ratio form is what keeps
a blue sky blue as contrast rises.
expect_wgsl: ["luminance(c)", "apply_tone_gain"]
- name: the_pivot_is_middle_grey_not_half
why: |
Pivoting at 0.5 in linear light would darken nearly every image:
scene-referred 0.5 is well above what the eye calls mid-tone.
expect_wgsl: ["0.36"]
- name: a_division_by_luminance_is_guarded
why: |
A black pixel has zero luminance; dividing by it would produce NaN and
propagate through everything downstream.
expect_wgsl: ["luma > 0.0001"]
- name: flattening_mixes_toward_grey_rather_than_scaling
why: |
Lifting a shadow by a luminance ratio multiplies its noise by the same
ratio — ninety at the bottom of a night photograph — and after white
balance that noise is magenta. A mix adds the lift as a neutral.
expect_wgsl: ["mix(c, vec3<f32>(0.18), -amount)"]
- name: highlights_are_not_pinned_to_the_top_of_the_curve
why: |
The curve covers 0..0.36. A clamp into that range scaled every brighter
pixel down to 0.36; tones above it are left as they are.
expect_wgsl: ["luma < 0.36"]
- name: the_curve_cannot_invert
why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate
settings, which inverts tones. smoothstep cannot.
expect_helper_wgsl: { contrast_curve: ["3.0 - 2.0 * clamped"] }