Skip to main content

rawshift_image/transforms/
bad_pixel.rs

1//! Bad pixel correction for CFA sensor data.
2//!
3//! Bad (hot or dead) pixels appear as abnormally bright or dark spots.
4//! This module provides detection and correction algorithms that operate
5//! directly on raw CFA data before demosaicing.
6
7use crate::core::image::{CfaPattern, RawImage};
8
9/// Bad pixel correction modes.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum BadPixelCorrectionMode {
13    /// Median of same-color neighbors in a 5×5 window.
14    Median,
15    /// Average of same-color neighbors in a 5×5 window.
16    Average,
17}
18
19/// Return the CFA color index (0=R, 1=G_r, 2=B, 3=G_b) for a given pixel position.
20///
21/// Uses the same color assignment as the demosaic code.
22#[inline]
23fn cfa_color(x: u32, y: u32, pattern: CfaPattern) -> u8 {
24    match pattern {
25        CfaPattern::Rggb => match (x % 2, y % 2) {
26            (0, 0) => 0,
27            (1, 0) => 1,
28            (0, 1) => 3,
29            _ => 2,
30        },
31        CfaPattern::Grbg => match (x % 2, y % 2) {
32            (0, 0) => 1,
33            (1, 0) => 0,
34            (0, 1) => 2,
35            _ => 3,
36        },
37        CfaPattern::Bggr => match (x % 2, y % 2) {
38            (0, 0) => 2,
39            (1, 0) => 3,
40            (0, 1) => 1,
41            _ => 0,
42        },
43        CfaPattern::Gbrg => match (x % 2, y % 2) {
44            (0, 0) => 3,
45            (1, 0) => 2,
46            (0, 1) => 0,
47            _ => 1,
48        },
49    }
50}
51
52/// Collect all same-CFA-color neighbors of (cx, cy) within a 5×5 window.
53///
54/// The center pixel itself is excluded.
55fn collect_same_color_neighbors(raw: &RawImage, cx: u32, cy: u32) -> Vec<u16> {
56    let center_color = cfa_color(cx, cy, raw.cfa_pattern());
57    let width = raw.width();
58    let height = raw.height();
59
60    let x_min = cx.saturating_sub(2);
61    let x_max = (cx + 2).min(width - 1);
62    let y_min = cy.saturating_sub(2);
63    let y_max = (cy + 2).min(height - 1);
64
65    let mut neighbors = Vec::with_capacity(12);
66    for ny in y_min..=y_max {
67        for nx in x_min..=x_max {
68            if nx == cx && ny == cy {
69                continue;
70            }
71            if cfa_color(nx, ny, raw.cfa_pattern()) == center_color {
72                let idx = (ny as usize) * (width as usize) + (nx as usize);
73                neighbors.push(raw.data[idx]);
74            }
75        }
76    }
77    neighbors
78}
79
80/// Compute the median of a mutable slice of u16 values.
81///
82/// Returns 0 if the slice is empty.
83fn median(values: &mut [u16]) -> u16 {
84    if values.is_empty() {
85        return 0;
86    }
87    values.sort_unstable();
88    let mid = values.len() / 2;
89    if values.len().is_multiple_of(2) {
90        // average of two middle values
91        let a = values[mid - 1] as u32;
92        let b = values[mid] as u32;
93        ((a + b) / 2) as u16
94    } else {
95        values[mid]
96    }
97}
98
99/// Compute the average of a slice of u16 values.
100///
101/// Returns 0 if the slice is empty.
102fn average(values: &[u16]) -> u16 {
103    if values.is_empty() {
104        return 0;
105    }
106    let sum: u64 = values.iter().map(|&v| v as u64).sum();
107    (sum / values.len() as u64) as u16
108}
109
110/// Detect candidate bad pixels using a threshold relative to local median.
111///
112/// For each pixel the median of its same-CFA-color neighbors in a 5×5 window
113/// is computed. If `|pixel - median| > threshold_factor * median` the pixel is
114/// considered bad and its coordinates are appended to the returned list.
115///
116/// A typical value for `threshold_factor` is `0.5`, meaning the pixel must
117/// deviate more than 50% from the local neighborhood median.
118///
119/// Returns a list of `(x, y)` coordinates of suspected bad pixels.
120pub fn detect_bad_pixels(raw: &RawImage, threshold_factor: f32) -> Vec<(u32, u32)> {
121    let width = raw.width();
122    let height = raw.height();
123    let mut bad = Vec::new();
124
125    for y in 0..height {
126        for x in 0..width {
127            let mut neighbors = collect_same_color_neighbors(raw, x, y);
128            if neighbors.is_empty() {
129                continue;
130            }
131            let med = median(&mut neighbors) as f32;
132            let pixel = raw.data[(y as usize) * (width as usize) + (x as usize)] as f32;
133            if med > 0.0 && (pixel - med).abs() > threshold_factor * med {
134                bad.push((x, y));
135            }
136        }
137    }
138
139    bad
140}
141
142/// Correct bad pixels in-place using the median replacement strategy.
143///
144/// For each bad pixel coordinate, replaces the pixel value with the median
145/// of same-CFA-color neighbors within a 5×5 window.
146pub fn correct_bad_pixels(raw: &mut RawImage, bad_pixels: &[(u32, u32)]) {
147    let replacements: Vec<(u32, u32, u16)> = bad_pixels
148        .iter()
149        .map(|&(x, y)| {
150            let mut neighbors = collect_same_color_neighbors(raw, x, y);
151            let replacement = median(&mut neighbors);
152            (x, y, replacement)
153        })
154        .collect();
155
156    for (x, y, value) in replacements {
157        raw.set_pixel(x, y, value);
158    }
159}
160
161/// Detect and correct bad pixels in one pass.
162///
163/// Convenience wrapper that calls [`detect_bad_pixels`] and then either
164/// [`correct_bad_pixels`] (median) or an average-based correction depending on
165/// `mode`.
166pub fn apply_bad_pixel_correction(
167    raw: &mut RawImage,
168    mode: BadPixelCorrectionMode,
169    threshold_factor: f32,
170) {
171    let bad_pixels = detect_bad_pixels(raw, threshold_factor);
172
173    let replacements: Vec<(u32, u32, u16)> = bad_pixels
174        .iter()
175        .map(|&(x, y)| {
176            let neighbors = collect_same_color_neighbors(raw, x, y);
177            let replacement = match mode {
178                BadPixelCorrectionMode::Median => {
179                    let mut n = neighbors;
180                    median(&mut n)
181                }
182                BadPixelCorrectionMode::Average => average(&neighbors),
183            };
184            (x, y, replacement)
185        })
186        .collect();
187
188    for (x, y, value) in replacements {
189        raw.set_pixel(x, y, value);
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::core::image::{Rect, Size};
197
198    /// Build a minimal RawImage filled with a uniform value.
199    fn make_raw(width: u32, height: u32, fill: u16) -> RawImage {
200        let size = Size::new(width, height);
201        let active = Rect::from_coords(0, 0, width, height);
202        let mut img = RawImage::new(size, active, 14, CfaPattern::Rggb);
203        for v in img.data.iter_mut() {
204            *v = fill;
205        }
206        img
207    }
208
209    #[test]
210    fn test_no_bad_pixels_uniform() {
211        // A perfectly uniform image should have zero bad pixels detected.
212        let raw = make_raw(10, 10, 1000);
213        let bad = detect_bad_pixels(&raw, 0.5);
214        assert!(bad.is_empty(), "expected no bad pixels, got {}", bad.len());
215    }
216
217    #[test]
218    fn test_single_hot_pixel_detected() {
219        // Place one pixel 10× brighter than its neighbors.
220        let mut raw = make_raw(10, 10, 1000);
221        raw.set_pixel(5, 4, 10000); // hot pixel at (5,4), same color as (5,4) in RGGB = (1%2,0%2)=(1,0) → G_r
222        let bad = detect_bad_pixels(&raw, 0.5);
223        assert!(
224            bad.contains(&(5, 4)),
225            "hot pixel at (5,4) should be detected; found: {:?}",
226            bad
227        );
228    }
229
230    #[test]
231    fn test_correction_replaces_bad_pixel() {
232        // The bad pixel should be replaced by the neighborhood median (≈1000).
233        let mut raw = make_raw(10, 10, 1000);
234        raw.set_pixel(5, 4, 10000);
235
236        let bad = detect_bad_pixels(&raw, 0.5);
237        assert!(bad.contains(&(5, 4)));
238        correct_bad_pixels(&mut raw, &bad);
239
240        let corrected = raw.get_pixel(5, 4).unwrap();
241        // After correction the value should be close to the neighbor median (1000).
242        assert!(
243            corrected < 2000,
244            "corrected value {} should be near 1000",
245            corrected
246        );
247    }
248
249    #[test]
250    fn test_correct_bad_pixels_empty_list() {
251        // Passing an empty list must not crash or change any pixels.
252        let mut raw = make_raw(8, 8, 500);
253        correct_bad_pixels(&mut raw, &[]);
254        assert!(raw.data.iter().all(|&v| v == 500));
255    }
256
257    #[test]
258    fn test_detect_empty_image() {
259        // A 2×2 image (minimal RGGB tile) should not crash.
260        let raw = make_raw(2, 2, 800);
261        let bad = detect_bad_pixels(&raw, 0.5);
262        // With only one same-color neighbor in the window the detection may or
263        // may not fire, but it must not panic.
264        let _ = bad;
265    }
266
267    #[test]
268    fn test_apply_bad_pixel_correction_average_mode() {
269        let mut raw = make_raw(10, 10, 1000);
270        raw.set_pixel(5, 4, 10000);
271        apply_bad_pixel_correction(&mut raw, BadPixelCorrectionMode::Average, 0.5);
272        let corrected = raw.get_pixel(5, 4).unwrap();
273        assert!(
274            corrected < 2000,
275            "average-corrected value {corrected} should be near 1000"
276        );
277    }
278
279    #[test]
280    fn test_cfa_color_rggb() {
281        assert_eq!(cfa_color(0, 0, CfaPattern::Rggb), 0); // R
282        assert_eq!(cfa_color(1, 0, CfaPattern::Rggb), 1); // G_r
283        assert_eq!(cfa_color(0, 1, CfaPattern::Rggb), 3); // G_b
284        assert_eq!(cfa_color(1, 1, CfaPattern::Rggb), 2); // B
285    }
286}