Files
dtourolle 7c3e1d2c54 Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only
turn a hue that is already in the frame. Ask it for cool shadows against warm
highlights and it has nothing to take hold of: the shadows of a correctly
balanced photograph are near enough neutral that there is no band there to
turn, and a monochrome conversion hands it a picture with no hue in it at all.
Split toning is the oldest look in the book and every developer worth comparing
against ships it; there was no way to reach it from here.

So colour_grading, declared like any other node — a hue and a strength for the
shadows, the midtones and the highlights, and a global cast over the frame. It
targets a tonal range rather than a hue, which is the whole difference between
the two controls: it puts colour where none was rather than turning what it
finds. It sits at 105, after the mixer has had the last word on the colours
that are in the picture and before the detail stage.

The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel
written directly as an RGB direction, and their sum is zero at every angle, so
exp2 turns them into three gains whose product is exactly one — a cast tilts
the balance without moving the level. A grade that doubled as an exposure
change is the failure that has the photographer chasing brightness with a
colour slider, and it is corrected with a control that cannot reach it. The
three tonal weights partition the scale rather than overlapping, the midtones
being whatever the two ends leave, so setting all three to one hue is exactly
the global cast and a split tone does not colour its own midtones as a side
effect of its halves meeting. Full strength is half a stop on the leading
channel, the ceiling white balance already holds itself to.

Neutral is declared rather than inferred, which is what `active:` is for. A hue
with no strength behind it is a direction with no distance, so under the
default rule nudging one would have put the node into every fused shader for a
change nobody can see. Summing the strengths is zero exactly when all four are,
and they cannot go negative to cancel each other. The opposite reading —
neutral as "nothing has been touched" — fails the other way round: red is hue
zero, so a grade toward red never moves a hue off its default and would never
have been applied at all.

It asks for a colour wheel, the widget the descriptor vocabulary has been
carrying with no operation behind it. Nothing draws one yet, and that is fine
by construction: the panel takes the first widget it implements and falls
through to sliders otherwise, so this arrives as eight ordinary controls that
work. Each parameter is named for its own range for exactly that reason — in a
flat list, four sliders called "Hue" are four controls nobody can tell apart.

FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a
requirement that is not defined there is an orphan, and the traceability gate
fails on those rather than quietly counting them. The label catalogue gets one
line for the operation's display name; the eight parameters derive correctly
and are left to.
2026-09-06 18:51:40 +02:00

321 lines
12 KiB
YAML

# TRACES: FR-DEV-12
id: colour_grading
label: op.colour_grading
order: 105
attributes: [colour]
doc: |
Colour grading — a hue and a strength for the shadows, the midtones and the
highlights, and one more cast over the whole frame.
The distinction from [`colour_mixer`](colour_mixer.yaml) is the whole reason
this exists. The mixer reaches for a hue that is *already in the picture*:
it will turn the greens that are there, and it can do nothing at all where
there are none. This reaches for a tonal *range* and casts colour into it
whether any was there or not — which makes it the only control that can warm
an already-neutral highlight, and the only one that can tone a monochrome
conversion, since a picture with no hue left in it gives the mixer nothing to
find.
Split toning is the case worth naming: cool shadows against warm highlights,
which is what a print toned in two baths did and what most of the looks sold
since are built on.
placement: |
After every colour correction, the mixer included. Grading is the closing
statement rather than a correction, so it should land on the colours the
photographer has already settled rather than be argued with by a control
further down the chain. Before the detail stage, which is where every
neighbourhood operation runs whatever this file says.
params:
shadow_hue:
label: param.shadow_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
doc: |
Degrees around the hue wheel, red at zero — the same units the straighten
control reports, and for the same reason: a photographer reading "210"
knows where on the wheel that is, where a normalised 0..1 has to be
translated first.
The two ends of the travel are the same colour. That is a fact about
hue rather than a defect, and it is what a wheel makes obvious and a
slider cannot — one of the reasons `presentation:` below asks for one.
shadow_strength:
label: param.shadow_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
How far from neutral, which is the wheel's radius. It does not go
negative: a negative radius is the hue opposite, which the hue control
already says, and two ways to spell one colour is how a preset comes
back looking like its own complement.
midtone_hue:
label: param.midtone_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
midtone_strength:
label: param.midtone_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
The midtones are where skin lives, so this is the range that goes wrong
first and the one most often left at zero. It is offered anyway because
a grade that can only reach the ends of the scale cannot answer a cast
that sits in the middle of it.
highlight_hue:
label: param.highlight_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
highlight_strength:
label: param.highlight_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
global_hue:
label: param.global_hue
kind: scalar
min: 0
max: 360
default: 0
unit: none
scale: linear
precision: 0
global_strength:
label: param.global_strength
kind: scalar
min: 0
max: 100
default: 0
unit: percent
scale: linear
precision: 0
doc: |
The cast that carries no tonal weight — it applies equally at every
brightness. Without it, a photographer wanting one colour everywhere and
a second in one range has to set all three ranges to the first hue, and
can then no longer move any one of them without disturbing the other
two.
# The neutral is *no strength anywhere*, not "nothing has been touched".
#
# A hue with no strength behind it is a direction with no distance: the
# picture is identical, and every uniform below is zero. Under the default
# rule, nudging a hue while the strength sat at zero would make the node
# active, and it would then cost a uniform block, a helper and a block in the
# fused shader for a change nobody can see. Strengths cannot go negative, so
# their sum is zero exactly when every one of them is.
active: shadow_strength + midtone_strength + highlight_strength + global_strength
uniforms:
shadow_angle:
value: shadow_hue * 0.017453292
doc: |
Degrees to radians, here rather than in the shader. The wheel is a slider
on the photographer's side and a cosine on the GPU's, and the conversion
belongs at the seam between them — the fragment then has one meaning for
an angle rather than two.
shadow_amount:
value: shadow_strength / 100 * 0.5
doc: |
Full strength is half a stop of shift on the leading channel, the same
ceiling white balance holds itself to and for the same reason: a colour
control that can blow a channel on its own is a trap. Four of these can
stack, so the honest worst case is a stop, which takes both a maximum
global cast and a maximum range on top of it.
midtone_angle: midtone_hue * 0.017453292
midtone_amount: midtone_strength / 100 * 0.5
highlight_angle: highlight_hue * 0.017453292
highlight_amount: highlight_strength / 100 * 0.5
global_angle: global_hue * 0.017453292
global_amount: global_strength / 100 * 0.5
helpers: [luminance, tone_position]
define:
hue_cast: |
// A per-channel gain carrying a hue, centred on no change at all.
//
// The three channels are cosines 120 degrees apart, which is the hue wheel
// written directly as an RGB direction — a round trip through HSV would
// buy nothing here and would have to decide what to do with a colour that
// has no hue. Their sum is zero at every angle, so exp2 turns them into
// three gains whose product is exactly one: the cast tilts the balance
// without moving the overall level. That property is what keeps grading
// from doubling as an exposure control, which is the failure that has the
// photographer chasing brightness with a colour slider.
fn hue_cast(angle: f32, amount: f32) -> vec3<f32> {
let tilt = vec3<f32>(cos(angle), cos(angle - 2.0943951), cos(angle + 2.0943951));
return exp2(tilt * amount);
}
wgsl: |
let pos = tone_position(luminance(c));
// Three weights that partition the tonal scale: at every luminance they sum
// to exactly one. The two ends use the same 0.5 midpoint as
// highlights_shadows, so "shadows" means the same range of the picture in
// both places, and the midtones are defined as whatever the ends leave.
//
// The partition is what makes an equal setting on all three identical to
// the global cast. Weights that overlapped would make the join between two
// ranges stronger than either of them, so a split tone would darken or
// colour its own midtones as a side effect of the two settings meeting —
// an interaction with no control over it.
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
let hi_w = smoothstep(0.5, 1.0, pos);
let mid_w = 1.0 - lo_w - hi_w;
// The ranges compose by multiplication rather than by mixing, because a
// product of luminance-neutral triples is another one — so three casts and
// a global still leave the tonal relationships the tone controls
// established. The global cast takes no weight: it is the whole frame.
c = c * hue_cast(shadow_angle, shadow_amount * lo_w)
* hue_cast(midtone_angle, midtone_amount * mid_w)
* hue_cast(highlight_angle, highlight_amount * hi_w)
* hue_cast(global_angle, global_amount);
presentation:
# Four wheels, each a hue and a distance from the centre. Named in pairs
# because that is the order a wheel wants them; a frontend that draws none
# of these renders eight ordinary sliders and the edit is unchanged, which
# is the state this ships in.
#
# That fallback is why each parameter carries its range in its own name
# instead of leaning on the wheel to say which one it belongs to. A flat
# list is what the panel produces today, and four sliders all called "Hue"
# would be four controls nobody can tell apart.
widgets: [colour_wheel]
demand:
two_dimensional: true
# A hue a few degrees off is a slightly different warm, not a wrong
# answer, so this is usable with a fingertip. Saying otherwise would take
# the wheel away from touch to protect an accuracy nobody needs from it.
precise_pointing: false
params:
- shadow_hue
- shadow_strength
- midtone_hue
- midtone_strength
- highlight_hue
- highlight_strength
- global_hue
- global_strength
tests:
- name: it_starts_neutral
why: |
Neutral means absent: at defaults the node must contribute no code, no
uniform and no branch to the fused shader. Every angle and amount being
zero is the arithmetic half of that; `expect_active` is the half that
keeps it out of the shader at all.
expect:
shadow_angle: 0.0
shadow_amount: 0.0
midtone_amount: 0.0
highlight_amount: 0.0
global_amount: 0.0
expect_active: false
- name: a_hue_with_no_strength_is_still_neutral
why: |
What `active:` buys. A hue is a direction and a strength is the distance
travelled along it, so a hue moved on its own changes nothing — but the
default rule ("some parameter has moved") would call the node active and
make every fused shader carry it for nothing.
set: { shadow_hue: 240, global_hue: 40 }
expect_active: false
- name: strength_alone_reaches_the_shader
why: |
The complement, and the reason the neutral cannot simply be "nothing
touched": red is hue zero, so a grade toward red never moves a hue
slider off its default and would otherwise never be applied.
set: { shadow_strength: 20 }
expect_active: true
- name: hue_is_converted_to_radians
why: |
The shader's cosines take radians and this uniform is the only place the
conversion happens. Degrees arriving unconverted would be an angle 57
times too large — it would wrap the wheel several times and land on a
colour with no relation to the one under the pointer, which reads as the
control being broken rather than as a missing constant.
set: { shadow_hue: 180 }
expect: { shadow_angle: 3.1415926 }
- name: full_strength_is_half_a_stop
why: |
The ceiling on one range's contribution, matching white balance's. A
grade that could take a channel to clipping by itself would make the
strength slider unusable over its top third.
set: { highlight_strength: 100 }
expect: { highlight_amount: 0.5 }
- name: strength_maps_linearly_onto_the_shift
why: |
Half the slider must be half the shift. A curve here would make the
wheel's radius mean something different at each distance from the
centre, and a wheel is read as a distance.
set: { midtone_strength: 50 }
expect: { midtone_amount: 0.25 }
- name: the_three_ranges_partition_the_tones
why: |
The midtone weight is defined as what the other two leave, so the three
sum to one at every luminance. Computed independently they would overlap
at the joins, and a split tone would then colour its own midtones as a
side effect of the shadow and highlight settings meeting.
expect_wgsl: ["let mid_w = 1.0 - lo_w - hi_w;"]
- name: the_global_cast_takes_no_tonal_weight
why: |
It is the one that means "everywhere". Weighted like the others it would
land mostly on the midtones, since that weight is the largest across the
range an ordinary photograph occupies, and "global" would quietly become
a fourth midtone control.
expect_wgsl: ["hue_cast(global_angle, global_amount)"]
- name: a_cast_does_not_change_the_level
why: |
The cosines sum to zero at every angle, so the three gains multiply to
one and a cast tilts the balance without lifting or dropping the
picture. Built any other way — a clamp, an added tint, an HSV round trip
— a strong grade would double as an exposure change, and the
photographer would correct it with a control that cannot reach it.
expect_helper_wgsl:
hue_cast: ["return exp2(tilt * amount);"]