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).
146 lines
5.9 KiB
YAML
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"] }
|