Skip to main content

rawshift_image/transforms/
ca_correction.rs

1//! Chromatic aberration correction.
2//!
3//! Lateral (geometric) chromatic aberration causes the R and B channels to be
4//! imaged at slightly different scales than the G channel, producing colour
5//! fringing near high-contrast edges. This module corrects the artefact by
6//! independently rescaling the R and B channels relative to the image centre,
7//! using bilinear interpolation to sample the shifted source positions.
8
9use crate::core::image::RgbImage;
10
11/// Apply lateral chromatic aberration correction by rescaling colour channels.
12///
13/// Each colour plane is scaled (zoomed) relative to the image centre
14/// independently. The G channel is used as the reference and is left untouched.
15/// When `red_scale == 1.0` and `blue_scale == 1.0` the output is identical to
16/// the input.
17///
18/// The mapping from output pixel (x, y) to source pixel (sx, sy) is:
19///
20/// ```text
21/// sx = cx + (x - cx) / scale
22/// sy = cy + (y - cy) / scale
23/// ```
24///
25/// where (cx, cy) is the image centre. Bilinear interpolation is used to
26/// evaluate the source at non-integer positions.
27///
28/// # Arguments
29/// * `image`      - RGB image to correct in-place.
30/// * `red_scale`  - Relative scale factor for the R channel (typical: 0.999–1.001).
31/// * `blue_scale` - Relative scale factor for the B channel (typical: 0.999–1.001).
32pub fn apply_ca_correction(image: &mut RgbImage, red_scale: f32, blue_scale: f32) {
33    let width = image.width() as usize;
34    let height = image.height() as usize;
35
36    if width == 0 || height == 0 {
37        return;
38    }
39
40    let cx = (width as f32 - 1.0) * 0.5;
41    let cy = (height as f32 - 1.0) * 0.5;
42
43    let input = image.data.clone();
44
45    let scales = [(0usize, red_scale), (2usize, blue_scale)];
46
47    for (channel, scale) in scales {
48        if (scale - 1.0_f32).abs() < 1e-6 {
49            continue; // identity – skip for speed
50        }
51
52        let inv_scale = 1.0_f32 / scale;
53
54        for y in 0..height {
55            for x in 0..width {
56                let sx = cx + (x as f32 - cx) * inv_scale;
57                let sy = cy + (y as f32 - cy) * inv_scale;
58
59                let value = bilinear_sample(&input, width, height, channel, sx, sy);
60                image.data[(y * width + x) * 3 + channel] = value;
61            }
62        }
63    }
64}
65
66/// Sample a single channel of an RGB image at a sub-pixel position using
67/// bilinear interpolation.
68///
69/// Positions outside the image boundary are clamped to the nearest valid pixel.
70#[inline]
71fn bilinear_sample(
72    data: &[u16],
73    width: usize,
74    height: usize,
75    channel: usize,
76    sx: f32,
77    sy: f32,
78) -> u16 {
79    // Clamp source coordinates to valid range.
80    let sx = sx.clamp(0.0, (width as f32) - 1.0);
81    let sy = sy.clamp(0.0, (height as f32) - 1.0);
82
83    let x0 = sx.floor() as usize;
84    let y0 = sy.floor() as usize;
85    let x1 = (x0 + 1).min(width - 1);
86    let y1 = (y0 + 1).min(height - 1);
87
88    let fx = sx - sx.floor();
89    let fy = sy - sy.floor();
90
91    let p00 = data[(y0 * width + x0) * 3 + channel] as f32;
92    let p10 = data[(y0 * width + x1) * 3 + channel] as f32;
93    let p01 = data[(y1 * width + x0) * 3 + channel] as f32;
94    let p11 = data[(y1 * width + x1) * 3 + channel] as f32;
95
96    let top = p00 * (1.0 - fx) + p10 * fx;
97    let bot = p01 * (1.0 - fx) + p11 * fx;
98    let result = top * (1.0 - fy) + bot * fy;
99
100    result.round() as u16
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn make_rgb(width: u32, height: u32, fill: u16) -> RgbImage {
108        let n = (width as usize) * (height as usize) * 3;
109        RgbImage::new(width, height, vec![fill; n])
110    }
111
112    #[test]
113    fn test_ca_correction_scale_1_unchanged() {
114        // Identity scales must produce bit-for-bit identical output.
115        let w = 8u32;
116        let h = 8u32;
117        let n = (w as usize) * (h as usize) * 3;
118        // Use a non-trivial pattern so any change would be visible.
119        let data: Vec<u16> = (0..n).map(|i| (i as u16).wrapping_mul(7)).collect();
120        let mut img = RgbImage::new(w, h, data.clone());
121        apply_ca_correction(&mut img, 1.0, 1.0);
122        assert_eq!(img.data, data, "scale 1.0 should leave image unchanged");
123    }
124
125    #[test]
126    fn test_ca_correction_no_crash_small() {
127        // Correction on very small images must not panic.
128        let mut img = make_rgb(2, 2, 1000);
129        apply_ca_correction(&mut img, 0.999, 1.001);
130        // Just verify the image is still 2×2 and no panic occurred.
131        assert_eq!(img.width(), 2);
132        assert_eq!(img.height(), 2);
133    }
134
135    #[test]
136    fn test_ca_correction_output_size_unchanged() {
137        let w = 16u32;
138        let h = 10u32;
139        let mut img = make_rgb(w, h, 500);
140        apply_ca_correction(&mut img, 1.002, 0.998);
141        assert_eq!(img.width(), w, "width must not change");
142        assert_eq!(img.height(), h, "height must not change");
143        assert_eq!(img.data.len(), (w as usize) * (h as usize) * 3);
144    }
145
146    #[test]
147    fn test_ca_correction_uniform_image_unchanged() {
148        // A flat image should remain flat regardless of scale.
149        let mut img = make_rgb(12, 12, 8000);
150        apply_ca_correction(&mut img, 1.005, 0.995);
151        assert!(img.data.iter().all(|&v| v == 8000));
152    }
153
154    #[test]
155    fn test_ca_correction_green_channel_unmodified() {
156        // The G channel (index 1) must always remain unchanged.
157        let w = 8u32;
158        let h = 8u32;
159        let n = (w as usize) * (h as usize) * 3;
160        let data: Vec<u16> = (0..n).map(|i| i as u16).collect();
161        let original_green: Vec<u16> = data.chunks_exact(3).map(|px| px[1]).collect();
162        let mut img = RgbImage::new(w, h, data);
163        apply_ca_correction(&mut img, 1.005, 0.995);
164        let corrected_green: Vec<u16> = img.data.chunks_exact(3).map(|px| px[1]).collect();
165        assert_eq!(
166            original_green, corrected_green,
167            "G channel must not be modified"
168        );
169    }
170
171    #[test]
172    fn test_ca_correction_1x1_image() {
173        let mut img = make_rgb(1, 1, 1234);
174        apply_ca_correction(&mut img, 1.01, 0.99);
175        // Only one pixel; it should remain at the clamped bilinear sample of itself.
176        assert_eq!(img.data[0], 1234);
177        assert_eq!(img.data[2], 1234);
178    }
179}