Add the develop pipeline: demosaic and seven raw adjustments
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>
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Exposure — a linear gain, expressed in stops.
|
||||
//!
|
||||
//! The simplest operation in the pipeline and the one that most justifies
|
||||
//! working in linear light: a stop is a doubling, so exposure is a single
|
||||
//! multiply. Applied to gamma-encoded data it would be neither a doubling nor
|
||||
//! reversible, which is why this stage sits where it does (ARCH §5.2).
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("exposure");
|
||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.exposure"),
|
||||
// ±5 stops. Wider than most edits need, but recovering a badly
|
||||
// underexposed frame is a real use and raw data often supports it.
|
||||
params: &[ParamDescriptor::stops(
|
||||
"exposure",
|
||||
"param.exposure",
|
||||
-5.0,
|
||||
5.0,
|
||||
)],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Exposure {
|
||||
stops: f32,
|
||||
}
|
||||
|
||||
impl Exposure {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The linear gain for the current setting.
|
||||
fn gain(&self) -> f32 {
|
||||
f32::exp2(self.stops)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Exposure {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
EXPOSURE => self.stops = value,
|
||||
_ => log::warn!("exposure: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
EXPOSURE => self.stops,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.stops != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * gain;".into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "gain",
|
||||
value: self.gain(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let e = Exposure::new();
|
||||
assert!(!e.is_active());
|
||||
assert_eq!(e.gain(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stop_is_a_doubling() {
|
||||
// The definition of a stop. If this is wrong, every exposure
|
||||
// adjustment is subtly off and no test of "looks right" would catch
|
||||
// it.
|
||||
let mut e = Exposure::new();
|
||||
e.set_param(EXPOSURE, 1.0);
|
||||
assert!((e.gain() - 2.0).abs() < 1e-6);
|
||||
|
||||
e.set_param(EXPOSURE, -1.0);
|
||||
assert!((e.gain() - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_compose_additively() {
|
||||
// +2 stops must equal +1 applied twice.
|
||||
let mut e = Exposure::new();
|
||||
e.set_param(EXPOSURE, 2.0);
|
||||
assert!((e.gain() - 4.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_covers_a_badly_exposed_frame() {
|
||||
let d = &DESCRIPTOR.params[0];
|
||||
assert_eq!(d.clamp(-9.0), -5.0);
|
||||
assert_eq!(d.clamp(9.0), 5.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! WGSL helper functions shared between operations.
|
||||
//!
|
||||
//! **Single source of truth.** Several operations need the same helpers, and
|
||||
//! each declares a `&'static [Helper]` naming the ones it uses. The composer
|
||||
//! deduplicates by *name*, so if two lists carried different source for the
|
||||
//! same name it would silently emit whichever came first — and two operations
|
||||
//! would compute, say, luminance differently depending on graph order. That
|
||||
//! is a genuinely hard bug to see, so the sources are defined exactly once
|
||||
//! here and referenced everywhere else.
|
||||
|
||||
use crate::operation::Helper;
|
||||
|
||||
/// Rec. 709 luminance.
|
||||
pub const LUMINANCE: Helper = Helper {
|
||||
name: "luminance",
|
||||
source: "\
|
||||
// Rec. 709 luminance, the weighting that matches sRGB primaries.
|
||||
//
|
||||
// Applied to camera-space values it is an approximation — the true weights
|
||||
// depend on the camera matrix — but using it here keeps the tonal operations
|
||||
// working on sensor-native data, where highlight headroom still exists.
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}",
|
||||
};
|
||||
|
||||
/// Linear luminance mapped to a perceptual 0..1 position.
|
||||
pub const TONE_POSITION: Helper = Helper {
|
||||
name: "tone_position",
|
||||
source: "\
|
||||
// Map linear luminance onto a perceptual 0..1 position.
|
||||
//
|
||||
// Tonal controls must feel evenly spaced to the eye, and linear light is
|
||||
// not: middle grey sits at 0.18, so a linear weight would call almost
|
||||
// everything a shadow. The cube root approximates lightness cheaply and
|
||||
// behaves well near zero, where a log would diverge.
|
||||
fn tone_position(luma: f32) -> f32 {
|
||||
return clamp(pow(max(luma, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
}",
|
||||
};
|
||||
|
||||
/// Hue-preserving gain.
|
||||
pub const APPLY_TONE_GAIN: Helper = Helper {
|
||||
name: "apply_tone_gain",
|
||||
source: "\
|
||||
// Scale a colour by a gain while preserving its hue.
|
||||
//
|
||||
// Multiplying the three channels equally keeps chromaticity fixed, so
|
||||
// lifting shadows does not desaturate them the way an additive lift would.
|
||||
fn apply_tone_gain(c: vec3<f32>, gain: f32) -> vec3<f32> {
|
||||
return c * gain;
|
||||
}",
|
||||
};
|
||||
|
||||
/// Distance from grey, as HSV chroma.
|
||||
pub const COLOUR_SATURATION: Helper = Helper {
|
||||
name: "colour_saturation",
|
||||
source: "\
|
||||
// How far a colour sits from grey, in 0..1.
|
||||
//
|
||||
// The max-minus-min definition (HSV chroma) rather than a standard
|
||||
// deviation: it matches what the eye reads as 'colourfulness' and it is what
|
||||
// makes vibrance's roll-off land where users expect.
|
||||
fn colour_saturation(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
if (hi <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (hi - lo) / hi;
|
||||
}",
|
||||
};
|
||||
|
||||
/// The set the tonal operations need.
|
||||
pub static TONE: &[Helper] = &[LUMINANCE, TONE_POSITION, APPLY_TONE_GAIN];
|
||||
|
||||
/// The set the colour operations need.
|
||||
pub static COLOUR: &[Helper] = &[LUMINANCE, TONE_POSITION, COLOUR_SATURATION];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_helper_defines_the_function_it_names() {
|
||||
// A mismatch between the dedup key and the function actually emitted
|
||||
// would produce either a duplicate definition or a missing one.
|
||||
for h in TONE.iter().chain(COLOUR.iter()) {
|
||||
assert!(
|
||||
h.source.contains(&format!("fn {}(", h.name)),
|
||||
"helper {} does not define fn {}",
|
||||
h.name,
|
||||
h.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_shared_between_sets_are_the_same_value() {
|
||||
// The drift this module exists to prevent: same name, different
|
||||
// source, and the composer silently picks one.
|
||||
let tone_luma = TONE.iter().find(|h| h.name == "luminance").unwrap();
|
||||
let colour_luma = COLOUR.iter().find(|h| h.name == "luminance").unwrap();
|
||||
assert_eq!(tone_luma.source, colour_luma.source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_set_lists_the_same_helper_twice() {
|
||||
for set in [TONE, COLOUR] {
|
||||
let mut names: Vec<&str> = set.iter().map(|h| h.name).collect();
|
||||
let before = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(before, names.len(), "a helper set lists a duplicate");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The develop operations.
|
||||
//!
|
||||
//! Each operation is a self-contained file implementing
|
||||
//! [`crate::operation::Operation`]. Adding one means writing that file and
|
||||
//! adding it to [`crate::graph::EditGraph::default_chain`] — no central
|
||||
//! shader to edit, no UI change (FR-DEV-3c).
|
||||
|
||||
pub mod colour;
|
||||
pub mod exposure;
|
||||
pub mod helpers;
|
||||
pub mod tone;
|
||||
pub mod white_balance;
|
||||
|
||||
pub use colour::{Brilliance, Saturation, Vibrance};
|
||||
pub use exposure::Exposure;
|
||||
pub use tone::{BlacksWhites, HighlightsShadows};
|
||||
pub use white_balance::WhiteBalance;
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Tonal range operations: highlights/shadows and blacks/whites.
|
||||
//!
|
||||
//! Both work by building a smooth weight over the luminance range and
|
||||
//! applying a gain where that weight is high. The distinction between the two
|
||||
//! pairs is *where* they act and *how sharply*:
|
||||
//!
|
||||
//! - **Highlights and shadows** are broad and overlapping, recovering detail
|
||||
//! across the upper and lower thirds. They are the controls used to tame a
|
||||
//! contrasty scene.
|
||||
//! - **Blacks and whites** act at the very ends, setting where the image
|
||||
//! clips. They are the controls used to place the endpoints.
|
||||
//!
|
||||
//! Weights are built from smoothstep rather than a hard threshold: a sharp
|
||||
//! boundary produces visible banding on a gradient — a sky is the worst case,
|
||||
//! and it is also the most common subject for these controls.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
|
||||
/// The helpers both tonal operations need.
|
||||
///
|
||||
/// Sources live in [`crate::ops::helpers`] — see that module for why they are
|
||||
/// defined exactly once.
|
||||
pub use crate::ops::helpers::TONE as TONE_HELPERS;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlights and shadows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const HIGHLIGHTS_SHADOWS_ID: OpId = OpId("highlights_shadows");
|
||||
pub const HIGHLIGHTS: ParamId = ParamId("highlights");
|
||||
pub const SHADOWS: ParamId = ParamId("shadows");
|
||||
|
||||
static HS_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: HIGHLIGHTS_SHADOWS_ID,
|
||||
label: LocalizedKey("op.highlights_shadows"),
|
||||
params: &[
|
||||
// Negative recovers highlights, the overwhelmingly common direction,
|
||||
// matching the convention every other developer uses.
|
||||
ParamDescriptor::amount("highlights", "param.highlights"),
|
||||
ParamDescriptor::amount("shadows", "param.shadows"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct HighlightsShadows {
|
||||
highlights: f32,
|
||||
shadows: f32,
|
||||
}
|
||||
|
||||
impl HighlightsShadows {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for HighlightsShadows {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&HS_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
HIGHLIGHTS => self.highlights = value,
|
||||
SHADOWS => self.shadows = value,
|
||||
_ => log::warn!("highlights_shadows: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
HIGHLIGHTS => self.highlights,
|
||||
SHADOWS => self.shadows,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.highlights != 0.0 || self.shadows != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Broad, overlapping weights. Highlights ramp in over the upper half,
|
||||
// shadows out over the lower half, so a mid-tone is barely touched by
|
||||
// either and the two controls blend rather than fighting at the join.
|
||||
let hi_w = smoothstep(0.5, 1.0, pos);
|
||||
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
|
||||
|
||||
// Each control contributes up to a stop of gain at full deflection.
|
||||
// exp2 keeps the effect symmetric: -100 and +100 are inverse.
|
||||
let hi_gain = exp2(hi_amount * hi_w);
|
||||
let lo_gain = exp2(lo_amount * lo_w);
|
||||
|
||||
c = apply_tone_gain(c, hi_gain * lo_gain);"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![
|
||||
Uniform {
|
||||
name: "hi_amount",
|
||||
// A full stop at the extreme; enough to recover a bright sky
|
||||
// without inverting the tonal relationship.
|
||||
value: self.highlights / 100.0,
|
||||
},
|
||||
Uniform {
|
||||
name: "lo_amount",
|
||||
value: self.shadows / 100.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blacks and whites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const BLACKS_WHITES_ID: OpId = OpId("blacks_whites");
|
||||
pub const BLACKS: ParamId = ParamId("blacks");
|
||||
pub const WHITES: ParamId = ParamId("whites");
|
||||
|
||||
static BW_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: BLACKS_WHITES_ID,
|
||||
label: LocalizedKey("op.blacks_whites"),
|
||||
params: &[
|
||||
ParamDescriptor::amount("blacks", "param.blacks"),
|
||||
ParamDescriptor::amount("whites", "param.whites"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BlacksWhites {
|
||||
blacks: f32,
|
||||
whites: f32,
|
||||
}
|
||||
|
||||
impl BlacksWhites {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for BlacksWhites {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&BW_DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
BLACKS => self.blacks = value,
|
||||
WHITES => self.whites = value,
|
||||
_ => log::warn!("blacks_whites: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
BLACKS => self.blacks,
|
||||
WHITES => self.whites,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.blacks != 0.0 || self.whites != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Narrow weights concentrated at each end — this is what separates these
|
||||
// controls from highlights/shadows, which are broad. Whites act only in the
|
||||
// top quarter, blacks only in the bottom quarter.
|
||||
let white_w = smoothstep(0.75, 1.0, pos);
|
||||
let black_w = 1.0 - smoothstep(0.0, 0.25, pos);
|
||||
|
||||
// Whites scale the top end multiplicatively, moving the clipping point.
|
||||
let white_gain = exp2(white_amount * white_w);
|
||||
c = apply_tone_gain(c, white_gain);
|
||||
|
||||
// Blacks shift the floor. This one is deliberately *additive*: the point of
|
||||
// a blacks control is to set where the image reaches zero, and a multiply
|
||||
// can never bring a non-zero value to zero nor lift a true black off it.
|
||||
c = c + vec3<f32>(black_amount * black_w);
|
||||
|
||||
// The subtractive direction can push below zero, which is not light.
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![
|
||||
Uniform {
|
||||
name: "white_amount",
|
||||
value: self.whites / 100.0,
|
||||
},
|
||||
Uniform {
|
||||
name: "black_amount",
|
||||
// A small linear offset. Scene-referred black sits near zero,
|
||||
// so the useful range here is far smaller than a stop — 0.02
|
||||
// is already a visible lift on a dark frame.
|
||||
value: self.blacks / 100.0 * 0.02,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
TONE_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_operations_start_neutral() {
|
||||
assert!(!HighlightsShadows::new().is_active());
|
||||
assert!(!BlacksWhites::new().is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_parameter_is_enough_to_activate() {
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(HIGHLIGHTS, -50.0);
|
||||
assert!(hs.is_active());
|
||||
|
||||
let mut bw = BlacksWhites::new();
|
||||
bw.set_param(WHITES, 20.0);
|
||||
assert!(bw.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_recovery_is_the_negative_direction() {
|
||||
// The convention users expect: dragging left recovers.
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(HIGHLIGHTS, -100.0);
|
||||
let u = hs.uniforms();
|
||||
assert!(
|
||||
u[0].value < 0.0,
|
||||
"negative highlights must produce a gain below 1"
|
||||
);
|
||||
assert!((u[0].value + 1.0).abs() < 1e-6, "full travel is one stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_amounts_are_symmetric() {
|
||||
let mut up = HighlightsShadows::new();
|
||||
up.set_param(SHADOWS, 100.0);
|
||||
let mut down = HighlightsShadows::new();
|
||||
down.set_param(SHADOWS, -100.0);
|
||||
// exp2 of equal and opposite exponents multiplies to 1.
|
||||
assert!((up.uniforms()[1].value + down.uniforms()[1].value).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blacks_offset_stays_small() {
|
||||
// Scene-referred black is near zero; a full-stop control here would
|
||||
// be unusable, moving the image to grey at a fraction of its travel.
|
||||
let mut bw = BlacksWhites::new();
|
||||
bw.set_param(BLACKS, 100.0);
|
||||
let offset = bw
|
||||
.uniforms()
|
||||
.iter()
|
||||
.find(|u| u.name == "black_amount")
|
||||
.expect("black_amount")
|
||||
.value;
|
||||
assert!(
|
||||
(0.0..=0.05).contains(&offset),
|
||||
"offset {offset} is too large for scene-referred data"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_operations_share_helpers_without_duplicating_them() {
|
||||
// Both request TONE_HELPERS; the composer must emit each once.
|
||||
let ops: Vec<Box<dyn Operation>> = vec![
|
||||
Box::new({
|
||||
let mut o = HighlightsShadows::new();
|
||||
o.set_param(HIGHLIGHTS, -30.0);
|
||||
o
|
||||
}),
|
||||
Box::new({
|
||||
let mut o = BlacksWhites::new();
|
||||
o.set_param(BLACKS, 30.0);
|
||||
o
|
||||
}),
|
||||
];
|
||||
let shader = crate::operation::compose(&ops);
|
||||
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
|
||||
assert_eq!(shader.source.matches("fn tone_position(").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameters_are_ignored_rather_than_panicking() {
|
||||
// A sidecar written by a newer version may name a parameter this
|
||||
// build does not have; the image must still open.
|
||||
let mut hs = HighlightsShadows::new();
|
||||
hs.set_param(ParamId("from_the_future"), 50.0);
|
||||
assert!(!hs.is_active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 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 here too, folded into the
|
||||
//! same multiply — they come from the uniform block rather than the fragment,
|
||||
//! because every image has them even when this operation is neutral.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("white_balance");
|
||||
pub const TEMPERATURE: ParamId = ParamId("temperature");
|
||||
pub const TINT: ParamId = ParamId("tint");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.white_balance"),
|
||||
params: &[
|
||||
// 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.
|
||||
ParamDescriptor::scalar(
|
||||
"temperature",
|
||||
"param.temperature",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::amount("tint", "param.tint"),
|
||||
],
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WhiteBalance {
|
||||
temperature: f32,
|
||||
tint: f32,
|
||||
}
|
||||
|
||||
impl WhiteBalance {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Per-channel multipliers for the current settings.
|
||||
///
|
||||
/// Temperature trades red against blue; tint trades green against
|
||||
/// magenta. Both are scaled so the full range is a strong but not
|
||||
/// destructive correction, and green is held near unity so the control
|
||||
/// does not double as an exposure slider.
|
||||
fn multipliers(&self) -> [f32; 3] {
|
||||
// ±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.
|
||||
let t = self.temperature / 100.0 * 0.5;
|
||||
let g = self.tint / 100.0 * 0.5;
|
||||
|
||||
[
|
||||
f32::exp2(t),
|
||||
f32::exp2(-g),
|
||||
// Blue moves opposite red, so a neutral grey stays grey as the
|
||||
// control moves.
|
||||
f32::exp2(-t),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for WhiteBalance {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
TEMPERATURE => self.temperature = value,
|
||||
TINT => self.tint = value,
|
||||
_ => log::warn!("white_balance: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
TEMPERATURE => self.temperature,
|
||||
TINT => self.tint,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.temperature != 0.0 || self.tint != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// as_shot_wb comes from the base uniform block: it applies to every
|
||||
// image regardless of whether this operation is active, so the adjust
|
||||
// pass folds it in separately. Here we apply only the user's offset.
|
||||
"c = c * vec3<f32>(mul_r, mul_g, mul_b);".into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let m = self.multipliers();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "mul_r",
|
||||
value: m[0],
|
||||
},
|
||||
Uniform {
|
||||
name: "mul_g",
|
||||
value: m[1],
|
||||
},
|
||||
Uniform {
|
||||
name: "mul_b",
|
||||
value: m[2],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn neutral_is_as_shot() {
|
||||
let wb = WhiteBalance::new();
|
||||
assert!(!wb.is_active(), "a fresh control must not alter the image");
|
||||
assert_eq!(wb.multipliers(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warming_raises_red_and_lowers_blue() {
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
let m = wb.multipliers();
|
||||
assert!(m[0] > 1.0, "red should rise, got {}", m[0]);
|
||||
assert!(m[2] < 1.0, "blue should fall, got {}", m[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooling_is_the_inverse_of_warming() {
|
||||
let mut warm = WhiteBalance::new();
|
||||
warm.set_param(TEMPERATURE, 60.0);
|
||||
let mut cool = WhiteBalance::new();
|
||||
cool.set_param(TEMPERATURE, -60.0);
|
||||
|
||||
let (w, c) = (warm.multipliers(), cool.multipliers());
|
||||
// Warming by n then cooling by n must return to neutral.
|
||||
assert!((w[0] * c[0] - 1.0).abs() < 1e-5);
|
||||
assert!((w[2] * c[2] - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_leaves_green_alone() {
|
||||
// Otherwise the control doubles as an exposure slider, because green
|
||||
// carries most of the luminance.
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
assert_eq!(wb.multipliers()[1], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_moves_green_against_magenta() {
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TINT, 100.0);
|
||||
let m = wb.multipliers();
|
||||
assert!(m[1] < 1.0, "positive tint reduces green (toward magenta)");
|
||||
assert_eq!(m[0], 1.0, "tint must not touch red");
|
||||
assert_eq!(m[2], 1.0, "tint must not touch blue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_extremes_stay_within_half_a_stop() {
|
||||
// A white balance control that can blow a channel by itself is a
|
||||
// trap; correction belongs in a range where highlights survive.
|
||||
let mut wb = WhiteBalance::new();
|
||||
wb.set_param(TEMPERATURE, 100.0);
|
||||
wb.set_param(TINT, 100.0);
|
||||
for m in wb.multipliers() {
|
||||
assert!(
|
||||
(0.70..=1.42).contains(&m),
|
||||
"multiplier {m} exceeds half a stop"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user