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).
122 lines
4.8 KiB
YAML
122 lines
4.8 KiB
YAML
id: white_balance
|
|
label: op.white_balance
|
|
order: 10
|
|
attributes: [colour]
|
|
|
|
doc: |
|
|
White balance — temperature and tint, relative to as-shot.
|
|
|
|
Expressed as an offset from what the camera chose rather than an absolute
|
|
kelvin value. Neutral means "as shot", so the control starts where the
|
|
image already is and a reset returns there. An absolute scale would make
|
|
the neutral position depend on the file, which is exactly the confusion
|
|
Lightroom's temperature slider creates on non-raw files.
|
|
|
|
The as-shot multipliers themselves are applied by the composer's preamble
|
|
rather than here — every image has them even when this operation is
|
|
neutral, so they cannot live in a fragment that vanishes at neutral.
|
|
|
|
placement: |
|
|
First. It is a correction to how the scene was captured, and every tonal
|
|
operation after it should act on a correctly balanced image.
|
|
|
|
# In camera RGB, ahead of the camera matrix, and the only node there (D19).
|
|
# Its multipliers scale the sensor's own channels — that is what the as-shot
|
|
# ones are, and what the picker solves for — and a matrix that mixes the
|
|
# channels, which is every body's, would turn the same numbers into a
|
|
# different correction once it had run.
|
|
stage: camera
|
|
|
|
params:
|
|
temperature:
|
|
label: param.temperature
|
|
kind: amount
|
|
doc: |
|
|
Warmer is positive, matching every other raw developer: dragging right
|
|
makes the image warmer, even though that means *lowering* the colour
|
|
temperature being corrected for.
|
|
tint:
|
|
label: param.tint
|
|
kind: amount
|
|
|
|
# An eyedropper, where the frontend has a canvas to hang one on.
|
|
#
|
|
# Sampling a neutral is the first move of the global tonal pass — every colour
|
|
# judgement afterwards is measured against where the grey was put — and it is
|
|
# a thing you do by pointing at the photograph, not by guessing at two sliders
|
|
# until a wall stops looking green.
|
|
#
|
|
# A *hint*, on the usual terms: the two parameters below stay ordinary
|
|
# addressable scalars, and a frontend with nowhere to host a sampler renders
|
|
# them as the sliders they already were. This one is additive rather than a
|
|
# replacement — the picker writes temperature and tint and the photographer
|
|
# still nudges them afterwards — which is a difference the frontend draws for
|
|
# itself; nothing here has to say it.
|
|
#
|
|
# The order matters and is the widget kind's own contract: the first parameter
|
|
# trades red against blue, the second green against magenta.
|
|
presentation:
|
|
widgets: [white_point]
|
|
params: [temperature, tint]
|
|
demand:
|
|
# A neutral is a point on the picture, so both axes at once.
|
|
two_dimensional: true
|
|
# Deliberately false. A grey card, a cloud, a white wall — the things
|
|
# worth sampling are large, and FR-UI-7 grows the hit region to the
|
|
# modality in any case, so a thumb is as workable as a mouse.
|
|
precise_pointing: false
|
|
|
|
# Temperature trades red against blue; tint trades green against magenta.
|
|
# Both are scaled so the full range is a strong but not destructive
|
|
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
|
# which covers ordinary illuminant error without letting the slider blow a
|
|
# channel on its own.
|
|
uniforms:
|
|
mul_r: exp2(temperature / 100 * 0.5)
|
|
mul_g:
|
|
value: exp2(-(tint / 100 * 0.5))
|
|
doc: |
|
|
Green is held at unity by temperature, so the control does not double as
|
|
an exposure slider — green carries most of the luminance.
|
|
mul_b:
|
|
value: exp2(-(temperature / 100 * 0.5))
|
|
doc: Blue moves opposite red, so a neutral grey stays grey as the control moves.
|
|
|
|
wgsl: |
|
|
c = c * vec3<f32>(mul_r, mul_g, mul_b);
|
|
|
|
tests:
|
|
- name: neutral_is_as_shot
|
|
expect: { mul_r: 1.0, mul_g: 1.0, mul_b: 1.0 }
|
|
expect_active: false
|
|
|
|
- name: warming_raises_red_and_lowers_blue
|
|
set: { temperature: 100 }
|
|
expect: { mul_r: 1.4142135, mul_b: 0.70710677 }
|
|
|
|
- name: temperature_leaves_green_alone
|
|
why: |
|
|
Otherwise the control doubles as an exposure slider, because green
|
|
carries most of the luminance.
|
|
set: { temperature: 100 }
|
|
expect: { mul_g: 1.0 }
|
|
|
|
- name: tint_moves_green_against_magenta
|
|
why: Positive tint reduces green, and must not touch red or blue.
|
|
set: { tint: 100 }
|
|
expect: { mul_g: 0.70710677, mul_r: 1.0, mul_b: 1.0 }
|
|
|
|
- name: cooling_is_the_inverse_of_warming
|
|
why: |
|
|
Warming by n then cooling by n must return to neutral, so the two
|
|
directions have to be exact reciprocals rather than merely similar.
|
|
set: { temperature: -100 }
|
|
expect: { mul_r: 0.70710677, mul_b: 1.4142135 }
|
|
|
|
- name: the_extremes_stay_within_half_a_stop
|
|
why: |
|
|
A white balance control that can blow a channel by itself is a trap;
|
|
correction belongs in a range where highlights survive.
|
|
set: { temperature: 100, tint: 100 }
|
|
expect_range: { mul_r: [0.70, 1.42], mul_g: [0.70, 1.42], mul_b: [0.70, 1.42] }
|