Files
DarkRoom/core/dr-pipeline/ops/white_balance.yaml
T
dtourolle db7b84795c Convert to the working space before the edits, not after
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).
2026-09-27 16:52:53 -04:00

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] }