`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
309 lines
9.7 KiB
Rust
309 lines
9.7 KiB
Rust
//! Lateral chromatic aberration correction.
|
|
//!
|
|
//! The purple-and-green fringing on high-contrast edges toward the frame
|
|
//! corners. A lens focuses short wavelengths and long wavelengths at slightly
|
|
//! different magnifications, so the red, green and blue images it projects are
|
|
//! very slightly different sizes. Rescaling two of them about the optical axis
|
|
//! puts them back on top of each other.
|
|
//!
|
|
//! # Why this cannot be an `Operation`
|
|
//!
|
|
//! This is the correction that forced [`crate::lens::Warp`] to exist. An
|
|
//! [`crate::operation::Operation`] receives `c` — a colour already sampled,
|
|
//! with all three channels fetched from *one* coordinate. Lateral CA needs
|
|
//! three *different* coordinates, and by the time an operation runs, the
|
|
//! information needed to pick them is gone. So it declares
|
|
//! [`Warp::splits_channels`] and the composer emits the three-sample path.
|
|
//!
|
|
//! # The model
|
|
//!
|
|
//! Lensfun's `poly3`, reduced to its linear term: a per-channel radial scale
|
|
//! with green as the fixed reference.
|
|
//!
|
|
//! ```text
|
|
//! r_red = r · v_red
|
|
//! r_blue = r · v_blue
|
|
//! ```
|
|
//!
|
|
//! Green is never moved, and that is a deliberate asymmetry rather than an
|
|
//! arbitrary choice of reference. Green carries most of the luminance a Bayer
|
|
//! sensor records — twice the photosites of red or blue — so leaving it
|
|
//! untouched means a mis-set correction shifts the channels that contribute
|
|
//! least to perceived sharpness. Scaling all three about a virtual reference
|
|
//! would soften the image even when the correction is right.
|
|
|
|
use crate::descriptor::{
|
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
|
};
|
|
use crate::lens::Warp;
|
|
use crate::operation::{Helper, Uniform};
|
|
|
|
pub const ID: OpId = OpId("aberration");
|
|
pub const RED: ParamId = ParamId("red");
|
|
pub const BLUE: ParamId = ParamId("blue");
|
|
|
|
/// The radial scale at full slider travel, as a fraction.
|
|
///
|
|
/// Lateral CA is a tiny effect — the database's own coefficients sit within
|
|
/// ±0.1% — so a slider spanning ±0.5% covers every real lens with enough
|
|
/// resolution left to tune by eye at 100%.
|
|
const MAX_SCALE: f32 = 0.005;
|
|
|
|
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
|
attributes: &[Attribute::Optics],
|
|
id: ID,
|
|
label: LocalizedKey("op.aberration"),
|
|
params: &[
|
|
// Two independent controls rather than one: the red and blue
|
|
// displacements are caused by different ends of the spectrum and are
|
|
// not symmetric, so a single "fringing" slider could not remove both.
|
|
ParamDescriptor::scalar(
|
|
"red",
|
|
"param.aberration.red",
|
|
-100.0,
|
|
100.0,
|
|
0.0,
|
|
Unit::None,
|
|
Scale::Linear,
|
|
0,
|
|
),
|
|
ParamDescriptor::scalar(
|
|
"blue",
|
|
"param.aberration.blue",
|
|
-100.0,
|
|
100.0,
|
|
0.0,
|
|
Unit::None,
|
|
Scale::Linear,
|
|
0,
|
|
),
|
|
],
|
|
};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Aberration {
|
|
red: f32,
|
|
blue: f32,
|
|
/// Per-channel scales from a lens profile, when one is loaded.
|
|
profile: Option<(f32, f32)>,
|
|
}
|
|
|
|
impl Aberration {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Apply a profile's red and blue radial scales.
|
|
///
|
|
/// As with distortion, the sliders then trim rather than replace: CA
|
|
/// varies between copies of a lens and with focus distance, so a profile
|
|
/// gets close and the user finishes the job.
|
|
pub fn set_profile(&mut self, scales: Option<(f32, f32)>) {
|
|
self.profile = scales;
|
|
}
|
|
|
|
/// The effective per-channel scales, profile plus manual trim.
|
|
fn scales(&self) -> (f32, f32) {
|
|
let (base_r, base_b) = self.profile.unwrap_or((1.0, 1.0));
|
|
(
|
|
base_r + self.red / 100.0 * MAX_SCALE,
|
|
base_b + self.blue / 100.0 * MAX_SCALE,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Warp for Aberration {
|
|
fn descriptor(&self) -> &'static OpDescriptor {
|
|
&DESCRIPTOR
|
|
}
|
|
|
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
|
match id {
|
|
RED => self.red = value,
|
|
BLUE => self.blue = value,
|
|
_ => log::warn!("aberration: unknown parameter {id}"),
|
|
}
|
|
}
|
|
|
|
fn param(&self, id: ParamId) -> f32 {
|
|
match id {
|
|
RED => self.red,
|
|
BLUE => self.blue,
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
fn is_active(&self) -> bool {
|
|
let (r, b) = self.scales();
|
|
r != 1.0 || b != 1.0
|
|
}
|
|
|
|
fn wgsl_body(&self) -> String {
|
|
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
|
|
// Green is deliberately absent: it is the reference and never moves.
|
|
"\
|
|
p_r = p * ca_red;
|
|
p_b = p * ca_blue;"
|
|
.into()
|
|
}
|
|
|
|
fn uniforms(&self) -> Vec<Uniform> {
|
|
let (red, blue) = self.scales();
|
|
vec![
|
|
Uniform {
|
|
name: "ca_red",
|
|
value: red,
|
|
},
|
|
Uniform {
|
|
name: "ca_blue",
|
|
value: blue,
|
|
},
|
|
]
|
|
}
|
|
|
|
fn splits_channels(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn helpers(&self) -> &'static [Helper] {
|
|
&[]
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn neutral_does_nothing() {
|
|
let a = Aberration::new();
|
|
assert!(!a.is_active());
|
|
assert_eq!(a.scales(), (1.0, 1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_correction_leaves_every_channel_coincident() {
|
|
// If the channels diverge at neutral, an image with no CA correction
|
|
// is resampled into colour fringing that was not there.
|
|
let (r, b) = Aberration::new().scales();
|
|
assert_eq!(r, 1.0);
|
|
assert_eq!(b, 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_channels_are_controlled_independently() {
|
|
// Red and blue displacement have different causes and are not
|
|
// symmetric; one slider could not remove both.
|
|
let mut a = Aberration::new();
|
|
a.set_param(RED, 100.0);
|
|
let (r, b) = a.scales();
|
|
assert!(r > 1.0, "red should be scaled");
|
|
assert_eq!(b, 1.0, "blue must be untouched by the red control");
|
|
}
|
|
|
|
#[test]
|
|
fn green_is_never_scaled() {
|
|
// The reference channel. Asserted through the shader body, since that
|
|
// is where a stray green term would actually do damage.
|
|
let mut a = Aberration::new();
|
|
a.set_param(RED, 50.0);
|
|
a.set_param(BLUE, -50.0);
|
|
let body = a.wgsl_body();
|
|
assert!(body.contains("p_r"), "red must be displaced");
|
|
assert!(body.contains("p_b"), "blue must be displaced");
|
|
assert!(
|
|
!body.contains("p_g"),
|
|
"green is the reference and must never be displaced"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_centre_never_moves() {
|
|
// A radial scale about the optical axis leaves r = 0 fixed whatever
|
|
// the coefficients, which is why CA correction cannot shift a frame.
|
|
let mut a = Aberration::new();
|
|
a.set_param(RED, 100.0);
|
|
a.set_param(BLUE, -100.0);
|
|
let (r, b) = a.scales();
|
|
for scale in [r, b] {
|
|
assert_eq!(0.0 * scale, 0.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_correction_stays_subpixel_at_the_extremes() {
|
|
// Lateral CA is a fraction of a percent. If full travel displaced a
|
|
// corner by more than a pixel or two on a 6000px frame, the slider
|
|
// would be a smear control rather than a correction.
|
|
let mut a = Aberration::new();
|
|
a.set_param(RED, 100.0);
|
|
a.set_param(BLUE, -100.0);
|
|
let (r, b) = a.scales();
|
|
// Half-diagonal of a 6000x4000 frame, the worst case.
|
|
let half_diag = ((6000.0f32 / 2.0).powi(2) + (4000.0f32 / 2.0).powi(2)).sqrt();
|
|
for scale in [r, b] {
|
|
let px = (scale - 1.0).abs() * half_diag;
|
|
assert!(px < 25.0, "full travel displaces the corner by {px} px");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn it_always_requests_the_per_channel_path() {
|
|
// The declaration that makes the composer emit three samples. Without
|
|
// it the fragment would write `p_r`/`p_b` that nothing reads.
|
|
assert!(Aberration::new().splits_channels());
|
|
}
|
|
|
|
#[test]
|
|
fn a_profile_corrects_with_both_sliders_at_zero() {
|
|
let mut a = Aberration::new();
|
|
assert!(!a.is_active());
|
|
a.set_profile(Some((1.0003211, 1.0000667)));
|
|
assert!(a.is_active());
|
|
assert_eq!(a.param(RED), 0.0);
|
|
assert_eq!(a.param(BLUE), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_sliders_trim_a_loaded_profile() {
|
|
// CA varies between copies of a lens and with focus distance, so a
|
|
// profile must remain tunable rather than being all-or-nothing.
|
|
let profile = (1.0003, 1.0001);
|
|
let mut a = Aberration::new();
|
|
a.set_profile(Some(profile));
|
|
a.set_param(RED, 100.0);
|
|
|
|
let (r, b) = a.scales();
|
|
assert!((r - (profile.0 + MAX_SCALE)).abs() < 1e-9);
|
|
assert_eq!(b, profile.1, "the red trim must not disturb blue");
|
|
}
|
|
|
|
#[test]
|
|
fn a_profile_can_be_cleared() {
|
|
let mut a = Aberration::new();
|
|
a.set_profile(Some((1.0003, 1.0001)));
|
|
assert!(a.is_active());
|
|
a.set_profile(None);
|
|
assert!(!a.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn the_wgsl_body_reads_its_declared_uniforms() {
|
|
let mut a = Aberration::new();
|
|
a.set_param(RED, 50.0);
|
|
let body = a.wgsl_body();
|
|
for u in a.uniforms() {
|
|
assert!(body.contains(u.name), "{} is declared but unused", u.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_default_is_neutral() {
|
|
let mut a = Aberration::new();
|
|
for p in DESCRIPTOR.params {
|
|
a.set_param(p.id, p.default);
|
|
}
|
|
assert!(!a.is_active(), "descriptor defaults must be neutral");
|
|
}
|
|
}
|