Skip to main content

rawshift_image/transforms/
lens_correction.rs

1//! Lens distortion correction.
2//!
3//! Implements polynomial undistortion (WarpRectilinear as specified in DNG opcode 3).
4//!
5//! The model corrects barrel and pincushion distortion by computing, for each
6//! output pixel, the corresponding distorted source position and sampling it
7//! with bilinear interpolation.
8
9use crate::core::image::RgbImage;
10
11// ── Public API ────────────────────────────────────────────────────────────────
12
13/// Apply WarpRectilinear lens distortion correction.
14///
15/// Uses the polynomial model:
16/// ```text
17/// r_undist = r * (1 + k0*r² + k1*r⁴ + k2*r⁶ + k3*r⁸)
18/// ```
19/// where r is the normalised distance from the optical centre.
20///
21/// The algorithm iterates output pixels and maps them back to distorted source
22/// positions via the polynomial, then samples with bilinear interpolation.
23///
24/// # Arguments
25/// * `image` - RGB image to correct in-place.
26/// * `k`     - Radial distortion coefficients `[k0, k1, k2, k3]`.
27///   Barrel distortion uses negative values (e.g. `[-0.01, 0.0, 0.0, 0.0]`);
28///   pincushion uses positive ones.
29/// * `cx`    - Optical centre x as a fraction of image width (typically `0.5`).
30/// * `cy`    - Optical centre y as a fraction of image height (typically `0.5`).
31///
32/// When all `k` are zero the function is a no-op.
33pub fn apply_warp_rectilinear(image: &mut RgbImage, k: [f64; 4], cx: f64, cy: f64) {
34    apply_warp_rectilinear_tangential(image, k, [0.0, 0.0], cx, cy);
35}
36
37/// Apply WarpRectilinear with additional tangential distortion correction.
38///
39/// Tangential (de-centering) distortion is caused by lens elements not being
40/// perfectly aligned and is modelled by two additional coefficients `p0` and `p1`.
41///
42/// The full source mapping for output pixel `(xn, yn)` (normalised) is:
43/// ```text
44/// factor = 1 + k0*r² + k1*r⁴ + k2*r⁶ + k3*r⁸
45/// xs = xn * factor + 2*p0*xn*yn      + p1*(r² + 2*xn²)
46/// ys = yn * factor + p0*(r² + 2*yn²) + 2*p1*xn*yn
47/// ```
48///
49/// # Arguments
50/// * `image` - RGB image to correct in-place.
51/// * `k`     - Radial distortion coefficients `[k0, k1, k2, k3]`.
52/// * `p`     - Tangential distortion coefficients `[p0, p1]`.
53/// * `cx`    - Optical centre x as a fraction of image width.
54/// * `cy`    - Optical centre y as a fraction of image height.
55pub fn apply_warp_rectilinear_tangential(
56    image: &mut RgbImage,
57    k: [f64; 4],
58    p: [f64; 2],
59    cx: f64,
60    cy: f64,
61) {
62    let width = image.width() as usize;
63    let height = image.height() as usize;
64
65    if width == 0 || height == 0 {
66        return;
67    }
68
69    // Half-diagonal of the shorter dimension used for normalisation.
70    let norm = 0.5 * width.min(height) as f64;
71
72    let cx_px = cx * width as f64;
73    let cy_px = cy * height as f64;
74
75    // Snapshot the source data before modifying in-place.
76    let src = image.data.clone();
77
78    for y in 0..height {
79        for x in 0..width {
80            // Normalise output pixel to [-1, 1] relative to optical centre.
81            let xn = (x as f64 - cx_px) / norm;
82            let yn = (y as f64 - cy_px) / norm;
83
84            let r2 = xn * xn + yn * yn;
85            let r4 = r2 * r2;
86            let r6 = r4 * r2;
87            let r8 = r4 * r4;
88
89            // Radial distortion factor.
90            let factor = 1.0 + k[0] * r2 + k[1] * r4 + k[2] * r6 + k[3] * r8;
91
92            // Distorted (source) position in normalised coordinates.
93            let xs = xn * factor + 2.0 * p[0] * xn * yn + p[1] * (r2 + 2.0 * xn * xn);
94            let ys = yn * factor + p[0] * (r2 + 2.0 * yn * yn) + 2.0 * p[1] * xn * yn;
95
96            // Convert back to pixel coordinates.
97            let src_x = xs * norm + cx_px;
98            let src_y = ys * norm + cy_px;
99
100            // Write bilinear sample for each of R, G, B.
101            let dst = (y * width + x) * 3;
102            for ch in 0..3usize {
103                image.data[dst + ch] =
104                    bilinear_sample(&src, width, height, ch, src_x as f32, src_y as f32);
105            }
106        }
107    }
108}
109
110// ── Private helpers ───────────────────────────────────────────────────────────
111
112/// Sample a single channel of an RGB image at a sub-pixel position using
113/// bilinear interpolation. Positions outside the image boundary are clamped
114/// to the nearest valid pixel.
115#[inline]
116fn bilinear_sample(
117    data: &[u16],
118    width: usize,
119    height: usize,
120    channel: usize,
121    sx: f32,
122    sy: f32,
123) -> u16 {
124    // Clamp source coordinates to valid range.
125    let sx = sx.clamp(0.0, (width as f32) - 1.0);
126    let sy = sy.clamp(0.0, (height as f32) - 1.0);
127
128    let x0 = sx.floor() as usize;
129    let y0 = sy.floor() as usize;
130    let x1 = (x0 + 1).min(width - 1);
131    let y1 = (y0 + 1).min(height - 1);
132
133    let fx = sx - sx.floor();
134    let fy = sy - sy.floor();
135
136    let p00 = data[(y0 * width + x0) * 3 + channel] as f32;
137    let p10 = data[(y0 * width + x1) * 3 + channel] as f32;
138    let p01 = data[(y1 * width + x0) * 3 + channel] as f32;
139    let p11 = data[(y1 * width + x1) * 3 + channel] as f32;
140
141    let top = p00 * (1.0 - fx) + p10 * fx;
142    let bot = p01 * (1.0 - fx) + p11 * fx;
143    let result = top * (1.0 - fy) + bot * fy;
144
145    result.round() as u16
146}
147
148// ── Tests ─────────────────────────────────────────────────────────────────────
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn make_rgb(width: u32, height: u32, fill: u16) -> RgbImage {
155        let n = (width as usize) * (height as usize) * 3;
156        RgbImage::new(width, height, vec![fill; n])
157    }
158
159    fn make_gradient(width: u32, height: u32) -> RgbImage {
160        let n = (width as usize) * (height as usize) * 3;
161        let data: Vec<u16> = (0..n).map(|i| (i as u16).wrapping_mul(7)).collect();
162        RgbImage::new(width, height, data)
163    }
164
165    // ── apply_warp_rectilinear ────────────────────────────────────────────
166
167    #[test]
168    fn test_warp_zero_coefficients_unchanged() {
169        // k = [0, 0, 0, 0] with centre (0.5, 0.5) must produce a bit-for-bit
170        // identical image (the bilinear at exact integer positions is exact).
171        let w = 8u32;
172        let h = 8u32;
173        let original = make_gradient(w, h);
174        let mut img = original.clone();
175
176        apply_warp_rectilinear(&mut img, [0.0; 4], 0.5, 0.5);
177
178        assert_eq!(
179            img.data, original.data,
180            "zero coefficients must leave image unchanged"
181        );
182    }
183
184    #[test]
185    fn test_warp_output_size_unchanged() {
186        let w = 16u32;
187        let h = 10u32;
188        let mut img = make_gradient(w, h);
189
190        apply_warp_rectilinear(&mut img, [-0.01, 0.0, 0.0, 0.0], 0.5, 0.5);
191
192        assert_eq!(img.width(), w, "width must not change after warp");
193        assert_eq!(img.height(), h, "height must not change after warp");
194        assert_eq!(img.data.len(), (w as usize) * (h as usize) * 3);
195    }
196
197    #[test]
198    fn test_warp_center_pixel_unchanged() {
199        // For a symmetric distortion centred at (0.5, 0.5) the exact centre
200        // pixel maps to itself (r = 0, factor = 1).  With a uniform image the
201        // value stays constant; with a gradient we check the centre maps to
202        // the source centre position.
203        let w = 11u32; // odd dimension so there is an exact centre pixel
204        let h = 11u32;
205        let mut img = make_rgb(w, h, 32768);
206
207        let cx_idx = (h as usize / 2) * (w as usize) + (w as usize / 2);
208
209        apply_warp_rectilinear(&mut img, [-0.05, 0.002, 0.0, 0.0], 0.5, 0.5);
210
211        // The centre pixel of a uniform image is always 32768 regardless of distortion.
212        assert_eq!(img.data[cx_idx * 3], 32768, "R of centre pixel");
213        assert_eq!(img.data[cx_idx * 3 + 1], 32768, "G of centre pixel");
214        assert_eq!(img.data[cx_idx * 3 + 2], 32768, "B of centre pixel");
215    }
216
217    #[test]
218    fn test_warp_no_crash_small_image() {
219        // A 2×2 image must not panic regardless of coefficients.
220        let mut img = make_rgb(2, 2, 1000);
221        apply_warp_rectilinear(&mut img, [-0.1, 0.05, -0.01, 0.001], 0.5, 0.5);
222        assert_eq!(img.width(), 2);
223        assert_eq!(img.height(), 2);
224    }
225
226    #[test]
227    fn test_warp_no_crash_1x1_image() {
228        let mut img = make_rgb(1, 1, 5000);
229        apply_warp_rectilinear(&mut img, [-0.1, 0.0, 0.0, 0.0], 0.5, 0.5);
230        assert_eq!(img.data[0], 5000);
231    }
232
233    // ── apply_warp_rectilinear_tangential ─────────────────────────────────
234
235    #[test]
236    fn test_warp_tangential_zero_unchanged() {
237        let original = make_gradient(8, 8);
238        let mut img = original.clone();
239
240        apply_warp_rectilinear_tangential(&mut img, [0.0; 4], [0.0; 2], 0.5, 0.5);
241
242        assert_eq!(
243            img.data, original.data,
244            "all-zero tangential coefficients must leave image unchanged"
245        );
246    }
247
248    #[test]
249    fn test_warp_tangential_output_size_unchanged() {
250        let w = 12u32;
251        let h = 8u32;
252        let mut img = make_gradient(w, h);
253
254        apply_warp_rectilinear_tangential(
255            &mut img,
256            [-0.01, 0.0, 0.0, 0.0],
257            [0.001, -0.001],
258            0.5,
259            0.5,
260        );
261
262        assert_eq!(img.width(), w);
263        assert_eq!(img.height(), h);
264    }
265
266    #[test]
267    fn test_warp_uniform_image_stays_uniform() {
268        // A perfectly flat image must remain flat under any distortion because
269        // every bilinear sample returns the same value.
270        let mut img = make_rgb(10, 10, 8000);
271        apply_warp_rectilinear(&mut img, [-0.05, 0.02, -0.005, 0.001], 0.5, 0.5);
272        assert!(
273            img.data.iter().all(|&v| v == 8000),
274            "uniform image must remain uniform"
275        );
276    }
277}