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.
321 lines
12 KiB
YAML
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);"]
|