FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so.
115 lines
4.4 KiB
YAML
115 lines
4.4 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.
|
|
|
|
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] }
|