Every point operation ran in camera RGB and the camera matrix came after them all, against the order ARCH §5.2 draws. So `luminance()` applied Rec.709 weights to a body's own primaries, a band in the colour mixer was a different hue on every make of sensor, and the vibrance skin guard tested channel order in a space where skin does not have one. Operations now declare a `Stage`. White balance is the only camera-stage node — its multipliers scale the sensor's channels, and after a matrix that mixes them the same numbers are a different correction — and says so with `stage: camera` in its YAML, a key both the build-time generator and the load-time declared op read. The composer emits the camera nodes, then the matrix, then the rest, each group in graph order; an empty chain still gets the matrix. Film simulation stops converting out of camera space itself, since it is now handed working-space colour like every other scene node. The base curve stays where it was, after the operations, and so now acts on working-space colour; the next commit replaces it (D19).
147 lines
6.0 KiB
YAML
147 lines
6.0 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
|
|
// and the camera matrix, which carries a balanced neutral to equal
|
|
// channels.
|
|
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"] }
|