Tool tabs need a taxonomy, and the taxonomy was the problem: a table in `ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks what `ui-refinement.md` condemned `starts-group` for — the core deciding where the panel draws things. `Attribute` threads the needle. It says what an operation *is* — tone, colour, detail, optics, geometry, effect — which is the same category as `ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is drawn, where it sits and whether it is visible stay the frontend's. There is no attribute for "the third tab", the enum's order is declaration order rather than screen order, and a frontend may render these as tabs, as headings, or ignore them. The payoff is that a tab strip can be *derived*: the groups are the attributes present in the capability list, so the interface names no operation and needs no table to keep in step. An operation joins the right group by declaring what it is, which is the one thing its author is well placed to say. Plural, because the tone curve is genuinely both — an RGB curve is tonal and the per-channel curves are chromatic, and filing it under one would hide it from half the people looking for it. Required and non-empty, enforced in `build.rs`, and the failure was checked by removing the line rather than assumed. An operation with no attribute is invisible to a panel that groups by them; a build that stops costs ten seconds, a control nobody can find costs more. The vocabulary is closed for the same reason: a typo would otherwise invent a category holding exactly one operation, which looks like a deliberate one until somebody counts. Six tests over the real chain, including the hand-written operations that `build.rs` never sees and so cannot check.
112 lines
4.0 KiB
YAML
112 lines
4.0 KiB
YAML
id: contrast
|
|
label: op.contrast
|
|
order: 30
|
|
attributes: [tone]
|
|
|
|
doc: |
|
|
Contrast — an S-curve about a fixed mid-point.
|
|
|
|
Pushes tones away from middle grey (positive) or toward it (negative),
|
|
pivoting where the eye reads "neither light nor dark". In linear light
|
|
that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
|
|
stop and a half above middle grey, and pivoting there would darken almost
|
|
every photograph.
|
|
|
|
The curve is applied in a perceptual domain rather than directly to linear
|
|
values. Applied linearly, an S-curve crushes shadows far harder than it
|
|
lifts highlights, because linear light devotes most of its range to the
|
|
brightest stop.
|
|
|
|
placement: |
|
|
After the capture corrections, before the region controls: it is the broad
|
|
tonal statement those controls then refine.
|
|
|
|
params:
|
|
contrast:
|
|
label: param.contrast
|
|
kind: amount
|
|
|
|
uniforms:
|
|
amount:
|
|
value: contrast / 100
|
|
doc: The shader's curve expects -1..1; the descriptor speaks -100..100.
|
|
|
|
helpers: [luminance, apply_tone_gain]
|
|
|
|
define:
|
|
contrast_curve: |
|
|
// A symmetric S-curve on a 0..1 perceptual position.
|
|
//
|
|
// `amount` above zero steepens, below zero flattens. The smoothstep form is
|
|
// used for the steepening direction because it has zero gradient at both
|
|
// ends, so the curve cannot invert however hard it is pushed — the failure
|
|
// that makes naive gain-about-a-pivot unusable past moderate settings.
|
|
fn contrast_curve(x: f32, amount: f32) -> f32 {
|
|
let clamped = clamp(x, 0.0, 1.0);
|
|
if (amount >= 0.0) {
|
|
// Blend toward a smoothstep, which is the S.
|
|
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
|
return mix(clamped, s, amount);
|
|
}
|
|
// Flattening: pull toward the mid-point. At amount = -1 every tone
|
|
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
|
|
return mix(clamped, 0.5, -amount);
|
|
}
|
|
|
|
wgsl: |
|
|
let luma = luminance(c);
|
|
if (luma > 0.0001) {
|
|
// Work on luminance and rescale the colour by the ratio, rather than
|
|
// curving each channel independently. Per-channel contrast shifts hue
|
|
// wherever the channels differ — the classic symptom being skies going
|
|
// cyan as contrast rises.
|
|
//
|
|
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
|
|
// curve operates on luma/(2*0.18) so that middle grey lands at the
|
|
// curve's own 0.5 pivot.
|
|
let pos = clamp(luma / 0.36, 0.0, 1.0);
|
|
let curved = contrast_curve(pos, amount);
|
|
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
|
// parse error in generated code rather than anywhere a reader would look.
|
|
let curved_luma = curved * 0.36;
|
|
c = apply_tone_gain(c, curved_luma / luma);
|
|
}
|
|
c = max(c, vec3<f32>(0.0));
|
|
|
|
tests:
|
|
- name: neutral_does_nothing
|
|
expect: { amount: 0.0 }
|
|
expect_active: false
|
|
|
|
- name: the_amount_is_normalised_to_unit_range
|
|
set: { contrast: 100 }
|
|
expect: { amount: 1.0 }
|
|
|
|
- name: the_negative_direction_normalises_too
|
|
set: { contrast: -100 }
|
|
expect: { amount: -1.0 }
|
|
|
|
- name: contrast_works_on_luminance_not_per_channel
|
|
why: |
|
|
Curving each channel separately shifts hue; the ratio form is what keeps
|
|
a blue sky blue as contrast rises.
|
|
expect_wgsl: ["luminance(c)", "apply_tone_gain"]
|
|
|
|
- name: the_pivot_is_middle_grey_not_half
|
|
why: |
|
|
Pivoting at 0.5 in linear light would darken nearly every image:
|
|
scene-referred 0.5 is well above what the eye calls mid-tone.
|
|
expect_wgsl: ["0.36"]
|
|
|
|
- name: a_division_by_luminance_is_guarded
|
|
why: |
|
|
A black pixel has zero luminance; dividing by it would produce NaN and
|
|
propagate through everything downstream.
|
|
expect_wgsl: ["luma > 0.0001"]
|
|
|
|
- name: the_curve_cannot_invert
|
|
why: |
|
|
A gain-about-a-pivot form produces a non-monotonic curve past moderate
|
|
settings, which inverts tones. smoothstep cannot.
|
|
expect_helper_wgsl: { contrast_curve: ["3.0 - 2.0 * clamped"] }
|