Decode through display, on the GPU: black/white normalisation, Bayer demosaic, camera colour transform, and the first seven adjustment operations — white balance, exposure, highlights/shadows, blacks/whites, brilliance, vibrance, saturation. Composable shaders. Each operation contributes a WGSL fragment rather than owning a pass, and dr-pipeline fuses the *active* ones into a single compute shader. One texture read and one write per frame regardless of how many adjustments are in play, while the operations stay independent in Rust — adding one is a new file, with no central shader to edit. An operation at neutral settings contributes no code, no uniform and no branch. Uniforms are prefixed per operation so two may both declare `amount`; helpers dedupe by name from a single source of truth. Pipelines cache on a structure hash covering the op-set and its order but not the values, so dragging a slider uploads uniforms and reuses the compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one pipeline compiled across ten slider positions. The UI is generated, not written. EditGraph::capabilities() reports parameters with their kinds, ranges, defaults and current values; the panel builds one control per entry chosen by ParamKind. No file in ui/ names an operation, and dr-pipeline has no wgpu dependency, so codegen is testable without a device (ARCH §6.5a). Three defects found against real files, each silent: - rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer populated. The live matrices are in `color_matrix`, keyed by illuminant. Reading the old field yields no colour transform at all. - `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it divides each of four rows by its own sum, and the unused fourth (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it. `wb_coeffs[3]` is NaN for the same reason and is normalised at decode. - As-shot white balance reached the uniform block but no shader read it, so the first render of a real CR2 came out violently green. Green photosites collect roughly twice the signal of red and blue. Now applied unconditionally before any operation, with tests on ordering. Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves visible zippering on any high-contrast edge at 1:1. Two of the four packed CFA constants were wrong on the first attempt, so all four layouts are asserted to reconstruct the same colour. Crop origins at odd coordinates re-phase the pattern; without that, red and blue swap. X-Trans reports GpuError::UnsupportedCfa rather than approximating with the Bayer path, which would look like a corrupt file. 206 tests, including GPU tests proving every operation and the full seven-operation chain generate compilable WGSL. Known gaps: the display path still reads back to the CPU each frame, which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the `readback` feature and waits on spike S1 wiring Slint's texture import. Curve shapes are a first draft and want tuning against real photographs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
398 lines
12 KiB
Rust
398 lines
12 KiB
Rust
//! Colour operations: vibrance, saturation, and brilliance.
|
|
//!
|
|
//! # Vibrance versus saturation
|
|
//!
|
|
//! Saturation scales every colour's distance from grey equally. Vibrance
|
|
//! scales it *more for muted colours than for already-saturated ones*, and
|
|
//! protects skin tones. The difference matters: pushing saturation on a
|
|
//! portrait turns faces orange long before the background improves, which is
|
|
//! precisely the problem vibrance was invented to solve.
|
|
//!
|
|
//! # Brilliance
|
|
//!
|
|
//! Apple's control, and a genuinely different idea from either: it lifts
|
|
//! shadows and pulls highlights *simultaneously*, applying the opposite
|
|
//! correction at each end of the range while leaving mid-tones alone. The
|
|
//! result reads as "more light in the scene" rather than "less contrast",
|
|
//! because local relationships survive where a plain contrast reduction
|
|
//! flattens them.
|
|
//!
|
|
//! It overlaps with highlights/shadows deliberately — one control doing both
|
|
//! in a fixed relationship is easier to reach for than two controls needing
|
|
//! to be balanced against each other.
|
|
|
|
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
|
use crate::operation::{Helper, Operation, Uniform};
|
|
use crate::ops::tone::TONE_HELPERS;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saturation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub const SATURATION_ID: OpId = OpId("saturation");
|
|
pub const SATURATION: ParamId = ParamId("saturation");
|
|
|
|
static SAT_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
|
id: SATURATION_ID,
|
|
label: LocalizedKey("op.saturation"),
|
|
params: &[ParamDescriptor::amount("saturation", "param.saturation")],
|
|
};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Saturation {
|
|
amount: f32,
|
|
}
|
|
|
|
impl Saturation {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
impl Operation for Saturation {
|
|
fn descriptor(&self) -> &'static OpDescriptor {
|
|
&SAT_DESCRIPTOR
|
|
}
|
|
|
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
|
match id {
|
|
SATURATION => self.amount = value,
|
|
_ => log::warn!("saturation: unknown parameter {id}"),
|
|
}
|
|
}
|
|
|
|
fn param(&self, id: ParamId) -> f32 {
|
|
match id {
|
|
SATURATION => self.amount,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
self.amount != 0.0
|
|
}
|
|
|
|
fn wgsl_body(&self) -> String {
|
|
"\
|
|
// Interpolate away from the luminance-preserving grey. A factor of 0 is
|
|
// monochrome, 1 is unchanged, above 1 is more saturated.
|
|
let luma = luminance(c);
|
|
c = mix(vec3<f32>(luma), c, factor);
|
|
c = max(c, vec3<f32>(0.0));"
|
|
.into()
|
|
}
|
|
|
|
fn uniforms(&self) -> Vec<Uniform> {
|
|
// -100 reaches exactly monochrome; +100 doubles the distance from
|
|
// grey. The floor at zero matters: a negative factor would push a
|
|
// colour past grey into its complement, inverting hues.
|
|
vec![Uniform {
|
|
name: "factor",
|
|
value: (1.0 + self.amount / 100.0).max(0.0),
|
|
}]
|
|
}
|
|
|
|
fn helpers(&self) -> &'static [Helper] {
|
|
TONE_HELPERS
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vibrance
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub const VIBRANCE_ID: OpId = OpId("vibrance");
|
|
pub const VIBRANCE: ParamId = ParamId("vibrance");
|
|
|
|
static VIB_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
|
id: VIBRANCE_ID,
|
|
label: LocalizedKey("op.vibrance"),
|
|
params: &[ParamDescriptor::amount("vibrance", "param.vibrance")],
|
|
};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Vibrance {
|
|
amount: f32,
|
|
}
|
|
|
|
impl Vibrance {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
impl Operation for Vibrance {
|
|
fn descriptor(&self) -> &'static OpDescriptor {
|
|
&VIB_DESCRIPTOR
|
|
}
|
|
|
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
|
match id {
|
|
VIBRANCE => self.amount = value,
|
|
_ => log::warn!("vibrance: unknown parameter {id}"),
|
|
}
|
|
}
|
|
|
|
fn param(&self, id: ParamId) -> f32 {
|
|
match id {
|
|
VIBRANCE => self.amount,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
self.amount != 0.0
|
|
}
|
|
|
|
fn wgsl_body(&self) -> String {
|
|
"\
|
|
let luma = luminance(c);
|
|
let sat = colour_saturation(c);
|
|
|
|
// The vibrance curve: full effect on grey, tapering to nothing on colours
|
|
// that are already saturated. Squaring the falloff keeps the mid-range
|
|
// responsive while still protecting the extremes.
|
|
let falloff = (1.0 - sat) * (1.0 - sat);
|
|
|
|
// Skin protection. Skin sits in a narrow band of hue where red leads green
|
|
// leads blue; pushing it is what makes vibrance look wrong on portraits.
|
|
// Detected by channel ordering rather than a hue angle, which costs a
|
|
// conversion and buys nothing here.
|
|
let is_skin = f32(c.r > c.g && c.g > c.b);
|
|
let skin_guard = 1.0 - is_skin * 0.5;
|
|
|
|
let strength = amount * falloff * skin_guard;
|
|
c = mix(vec3<f32>(luma), c, 1.0 + strength);
|
|
c = max(c, vec3<f32>(0.0));"
|
|
.into()
|
|
}
|
|
|
|
fn uniforms(&self) -> Vec<Uniform> {
|
|
vec![Uniform {
|
|
name: "amount",
|
|
value: self.amount / 100.0,
|
|
}]
|
|
}
|
|
|
|
fn helpers(&self) -> &'static [Helper] {
|
|
// Needs both luminance (from tone) and the saturation measure.
|
|
// Duplicates across the two lists are deduplicated by the composer.
|
|
COLOUR_AND_TONE
|
|
}
|
|
}
|
|
|
|
/// The helper set vibrance needs: luminance plus the saturation measure.
|
|
static COLOUR_AND_TONE: &[Helper] = crate::ops::helpers::COLOUR;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Brilliance
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub const BRILLIANCE_ID: OpId = OpId("brilliance");
|
|
pub const BRILLIANCE: ParamId = ParamId("brilliance");
|
|
|
|
static BRIL_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
|
id: BRILLIANCE_ID,
|
|
label: LocalizedKey("op.brilliance"),
|
|
params: &[ParamDescriptor::amount("brilliance", "param.brilliance")],
|
|
};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Brilliance {
|
|
amount: f32,
|
|
}
|
|
|
|
impl Brilliance {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
}
|
|
|
|
impl Operation for Brilliance {
|
|
fn descriptor(&self) -> &'static OpDescriptor {
|
|
&BRIL_DESCRIPTOR
|
|
}
|
|
|
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
|
match id {
|
|
BRILLIANCE => self.amount = value,
|
|
_ => log::warn!("brilliance: unknown parameter {id}"),
|
|
}
|
|
}
|
|
|
|
fn param(&self, id: ParamId) -> f32 {
|
|
match id {
|
|
BRILLIANCE => self.amount,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
self.amount != 0.0
|
|
}
|
|
|
|
fn wgsl_body(&self) -> String {
|
|
"\
|
|
let luma = luminance(c);
|
|
let pos = tone_position(luma);
|
|
|
|
// Opposite corrections at the two ends: shadows up, highlights down, both
|
|
// tapering to nothing at the mid-point. This is what separates brilliance
|
|
// from a contrast control — mid-tones keep their local relationships, so
|
|
// the image gains apparent light rather than losing structure.
|
|
let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount;
|
|
let pull = smoothstep(0.5, 1.0, pos) * amount;
|
|
|
|
// A mild saturation compensation. Flattening the tonal range washes colour
|
|
// out; without this, brilliance looks faded at useful settings.
|
|
let gain = exp2(lift - pull);
|
|
c = c * gain;
|
|
|
|
let luma_after = luminance(c);
|
|
c = mix(vec3<f32>(luma_after), c, 1.0 + max(amount, 0.0) * 0.15);
|
|
c = max(c, vec3<f32>(0.0));"
|
|
.into()
|
|
}
|
|
|
|
fn uniforms(&self) -> Vec<Uniform> {
|
|
vec![Uniform {
|
|
name: "amount",
|
|
// Half a stop at each end at full travel — the two ends move
|
|
// apart by a stop in total, which is a strong but not
|
|
// destructive flattening.
|
|
value: self.amount / 100.0 * 0.5,
|
|
}]
|
|
}
|
|
|
|
fn helpers(&self) -> &'static [Helper] {
|
|
TONE_HELPERS
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::operation::compose;
|
|
|
|
#[test]
|
|
fn all_three_start_neutral() {
|
|
assert!(!Saturation::new().is_active());
|
|
assert!(!Vibrance::new().is_active());
|
|
assert!(!Brilliance::new().is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn full_negative_saturation_reaches_monochrome() {
|
|
// The property that makes -100 meaningful: it must land exactly on
|
|
// grey, not merely near it.
|
|
let mut s = Saturation::new();
|
|
s.set_param(SATURATION, -100.0);
|
|
assert_eq!(s.uniforms()[0].value, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn positive_saturation_increases_the_factor() {
|
|
let mut s = Saturation::new();
|
|
s.set_param(SATURATION, 100.0);
|
|
assert!((s.uniforms()[0].value - 2.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn the_saturation_factor_never_goes_negative() {
|
|
// A negative factor would invert hues — a colour past monochrome
|
|
// becomes its complement, which is never wanted here.
|
|
let mut s = Saturation::new();
|
|
s.set_param(SATURATION, -200.0);
|
|
assert!(s.uniforms()[0].value >= 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn vibrance_protects_skin_in_its_fragment() {
|
|
// The distinguishing behaviour; if the guard is dropped, portraits
|
|
// go orange and the control is indistinguishable from saturation.
|
|
let mut v = Vibrance::new();
|
|
v.set_param(VIBRANCE, 50.0);
|
|
let body = v.wgsl_body();
|
|
assert!(body.contains("skin_guard"), "skin protection must survive");
|
|
assert!(
|
|
body.contains("falloff"),
|
|
"the roll-off is what makes it vibrance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vibrance_and_saturation_are_distinct_operations() {
|
|
// They must not share an id, or the composer would emit one and the
|
|
// UI would show one control for two behaviours.
|
|
assert_ne!(VIBRANCE_ID, SATURATION_ID);
|
|
}
|
|
|
|
#[test]
|
|
fn brilliance_moves_the_ends_in_opposite_directions() {
|
|
let mut b = Brilliance::new();
|
|
b.set_param(BRILLIANCE, 100.0);
|
|
let body = b.wgsl_body();
|
|
assert!(body.contains("lift"), "shadows must rise");
|
|
assert!(body.contains("pull"), "highlights must fall");
|
|
assert!(
|
|
body.contains("lift - pull"),
|
|
"the two must oppose, or this is just an exposure control"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn brilliance_travel_is_bounded() {
|
|
let mut b = Brilliance::new();
|
|
b.set_param(BRILLIANCE, 100.0);
|
|
let v = b.uniforms()[0].value;
|
|
assert!((0.0..=0.6).contains(&v), "amount {v} is too aggressive");
|
|
}
|
|
|
|
#[test]
|
|
fn helpers_are_shared_across_every_colour_operation() {
|
|
// Vibrance declares its own helper list; if its luminance source
|
|
// drifted from tone's, the composer would emit whichever came first
|
|
// and the two operations would disagree about luminance.
|
|
let ops: Vec<Box<dyn Operation>> = vec![
|
|
Box::new({
|
|
let mut o = Vibrance::new();
|
|
o.set_param(VIBRANCE, 40.0);
|
|
o
|
|
}),
|
|
Box::new({
|
|
let mut o = Saturation::new();
|
|
o.set_param(SATURATION, 20.0);
|
|
o
|
|
}),
|
|
Box::new({
|
|
let mut o = Brilliance::new();
|
|
o.set_param(BRILLIANCE, 30.0);
|
|
o
|
|
}),
|
|
];
|
|
let shader = compose(&ops);
|
|
assert_eq!(
|
|
shader.source.matches("fn luminance(").count(),
|
|
1,
|
|
"luminance must be declared exactly once"
|
|
);
|
|
assert_eq!(shader.source.matches("fn colour_saturation(").count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn tone_and_colour_agree_on_luminance() {
|
|
// Both sets reference the shared definition. If someone reintroduces
|
|
// a local copy, the composer would emit whichever operation came
|
|
// first and the two would compute luminance differently.
|
|
let from_tone = TONE_HELPERS
|
|
.iter()
|
|
.find(|h| h.name == "luminance")
|
|
.expect("tone declares luminance");
|
|
let from_colour = COLOUR_AND_TONE
|
|
.iter()
|
|
.find(|h| h.name == "luminance")
|
|
.expect("colour declares luminance");
|
|
assert_eq!(from_tone.source, from_colour.source);
|
|
}
|
|
}
|