Skip to main content

ad_plugins/
bad_pixel.rs

1//! NDPluginBadPixel: replaces bad pixels using one of three correction modes.
2//!
3//! Bad pixel definitions are loaded from JSON. Each bad pixel specifies its (x, y)
4//! coordinate and a correction mode:
5//! - **Set**: replace with a fixed value.
6//! - **Replace**: copy from a neighbor at offset (dx, dy).
7//! - **Median**: compute the median of a rectangular kernel around the pixel.
8
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use ad_core::ndarray::{NDArray, NDDataBuffer};
13use ad_core::ndarray_pool::NDArrayPool;
14use ad_core::plugin::runtime::{NDPluginProcess, ProcessResult};
15use serde::Deserialize;
16
17/// The correction mode for a bad pixel.
18#[derive(Debug, Clone, Deserialize)]
19#[serde(tag = "mode")]
20pub enum BadPixelMode {
21    /// Replace the pixel with a fixed value.
22    #[serde(rename = "set")]
23    Set { value: f64 },
24    /// Replace the pixel by copying from a neighbor at relative offset (dx, dy).
25    #[serde(rename = "replace")]
26    Replace { dx: i32, dy: i32 },
27    /// Replace the pixel with the median of a rectangular kernel.
28    #[serde(rename = "median")]
29    Median { kernel_x: usize, kernel_y: usize },
30}
31
32/// A single bad pixel definition.
33#[derive(Debug, Clone, Deserialize)]
34pub struct BadPixel {
35    pub x: usize,
36    pub y: usize,
37    #[serde(flatten)]
38    pub mode: BadPixelMode,
39}
40
41/// Container for deserializing a list of bad pixels from JSON.
42#[derive(Debug, Clone, Deserialize)]
43pub struct BadPixelList {
44    pub bad_pixels: Vec<BadPixel>,
45}
46
47/// Processor that corrects bad pixels in incoming arrays.
48pub struct BadPixelProcessor {
49    pixels: Vec<BadPixel>,
50    /// Set of (x, y) for fast bad-pixel lookup.
51    bad_set: HashSet<(usize, usize)>,
52    /// Cached image width from the last array.
53    width: usize,
54}
55
56impl BadPixelProcessor {
57    /// Create a new processor from a list of bad pixels.
58    pub fn new(pixels: Vec<BadPixel>) -> Self {
59        let bad_set: HashSet<(usize, usize)> = pixels.iter().map(|p| (p.x, p.y)).collect();
60        Self {
61            pixels,
62            bad_set,
63            width: 0,
64        }
65    }
66
67    /// Parse a bad pixel list from a JSON string.
68    pub fn load_from_json(json_str: &str) -> Result<Vec<BadPixel>, serde_json::Error> {
69        let list: BadPixelList = serde_json::from_str(json_str)?;
70        Ok(list.bad_pixels)
71    }
72
73    /// Replace the bad pixel list.
74    pub fn set_pixels(&mut self, pixels: Vec<BadPixel>) {
75        self.bad_set = pixels.iter().map(|p| (p.x, p.y)).collect();
76        self.pixels = pixels;
77    }
78
79    /// Get the current bad pixel list.
80    pub fn pixels(&self) -> &[BadPixel] {
81        &self.pixels
82    }
83
84    /// Check if a coordinate is a bad pixel.
85    fn is_bad(&self, x: usize, y: usize) -> bool {
86        self.bad_set.contains(&(x, y))
87    }
88
89    /// Apply corrections to a mutable data buffer.
90    fn apply_corrections(&self, data: &mut NDDataBuffer, width: usize, height: usize) {
91        // We need to read original values for Replace/Median, so take a snapshot first.
92        // For Set mode, we could do it in-place, but for consistency we read from the
93        // original and write to a separate buffer when needed.
94
95        // Collect corrections to apply
96        let mut corrections: Vec<(usize, f64)> = Vec::with_capacity(self.pixels.len());
97
98        for bp in &self.pixels {
99            if bp.x >= width || bp.y >= height {
100                continue;
101            }
102
103            let value = match &bp.mode {
104                BadPixelMode::Set { value } => *value,
105
106                BadPixelMode::Replace { dx, dy } => {
107                    let nx = bp.x as i64 + *dx as i64;
108                    let ny = bp.y as i64 + *dy as i64;
109
110                    if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
111                        continue; // replacement out of bounds, skip
112                    }
113
114                    let nx = nx as usize;
115                    let ny = ny as usize;
116
117                    // Skip if replacement pixel is also bad
118                    if self.is_bad(nx, ny) {
119                        continue;
120                    }
121
122                    let idx = ny * width + nx;
123                    match data.get_as_f64(idx) {
124                        Some(v) => v,
125                        None => continue,
126                    }
127                }
128
129                BadPixelMode::Median { kernel_x, kernel_y } => {
130                    let half_x = (*kernel_x / 2) as i64;
131                    let half_y = (*kernel_y / 2) as i64;
132                    let cx = bp.x as i64;
133                    let cy = bp.y as i64;
134
135                    let mut neighbors = Vec::new();
136                    for ky in (cy - half_y)..=(cy + half_y) {
137                        for kx in (cx - half_x)..=(cx + half_x) {
138                            if kx < 0 || kx >= width as i64 || ky < 0 || ky >= height as i64 {
139                                continue;
140                            }
141                            let kxu = kx as usize;
142                            let kyu = ky as usize;
143                            // Skip the bad pixel itself and other bad pixels
144                            if kxu == bp.x && kyu == bp.y {
145                                continue;
146                            }
147                            if self.is_bad(kxu, kyu) {
148                                continue;
149                            }
150                            let idx = kyu * width + kxu;
151                            if let Some(v) = data.get_as_f64(idx) {
152                                neighbors.push(v);
153                            }
154                        }
155                    }
156
157                    if neighbors.is_empty() {
158                        continue; // no valid neighbors
159                    }
160
161                    neighbors.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
162                    let mid = neighbors.len() / 2;
163                    if neighbors.len() % 2 == 0 {
164                        (neighbors[mid - 1] + neighbors[mid]) / 2.0
165                    } else {
166                        neighbors[mid]
167                    }
168                }
169            };
170
171            let idx = bp.y * width + bp.x;
172            corrections.push((idx, value));
173        }
174
175        // Apply all corrections
176        for (idx, value) in corrections {
177            data.set_from_f64(idx, value);
178        }
179    }
180}
181
182impl NDPluginProcess for BadPixelProcessor {
183    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
184        let info = array.info();
185        self.width = info.x_size;
186        let height = info.y_size;
187
188        if self.pixels.is_empty() {
189            // No corrections needed, pass through
190            return ProcessResult::arrays(vec![Arc::new(array.clone())]);
191        }
192
193        let mut out = array.clone();
194        self.apply_corrections(&mut out.data, self.width, height);
195        ProcessResult::arrays(vec![Arc::new(out)])
196    }
197
198    fn plugin_type(&self) -> &str {
199        "NDPluginBadPixel"
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use ad_core::ndarray::{NDDataType, NDDimension};
207
208    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
209        let mut arr = NDArray::new(
210            vec![NDDimension::new(x), NDDimension::new(y)],
211            NDDataType::Float64,
212        );
213        if let NDDataBuffer::F64(ref mut v) = arr.data {
214            for iy in 0..y {
215                for ix in 0..x {
216                    v[iy * x + ix] = fill(ix, iy);
217                }
218            }
219        }
220        arr
221    }
222
223    fn get_pixel(arr: &NDArray, x: usize, y: usize, width: usize) -> f64 {
224        arr.data.get_as_f64(y * width + x).unwrap()
225    }
226
227    #[test]
228    fn test_set_mode() {
229        let arr = make_2d_array(4, 4, |_, _| 100.0);
230        let pixels = vec![
231            BadPixel { x: 1, y: 1, mode: BadPixelMode::Set { value: 0.0 } },
232            BadPixel { x: 3, y: 2, mode: BadPixelMode::Set { value: 42.0 } },
233        ];
234
235        let mut proc = BadPixelProcessor::new(pixels);
236        let pool = NDArrayPool::new(1_000_000);
237        let result = proc.process_array(&arr, &pool);
238
239        assert_eq!(result.output_arrays.len(), 1);
240        let out = &result.output_arrays[0];
241        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
242        assert!((get_pixel(out, 3, 2, 4) - 42.0).abs() < 1e-10);
243        // Unaffected pixels stay at 100
244        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
245    }
246
247    #[test]
248    fn test_replace_mode() {
249        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
250        // Replace pixel (2,2) with value from (3,2)
251        let pixels = vec![
252            BadPixel { x: 2, y: 2, mode: BadPixelMode::Replace { dx: 1, dy: 0 } },
253        ];
254
255        let mut proc = BadPixelProcessor::new(pixels);
256        let pool = NDArrayPool::new(1_000_000);
257        let result = proc.process_array(&arr, &pool);
258
259        let out = &result.output_arrays[0];
260        // (3,2) = 3 + 2*4 = 11
261        assert!((get_pixel(out, 2, 2, 4) - 11.0).abs() < 1e-10);
262    }
263
264    #[test]
265    fn test_replace_skip_bad_neighbor() {
266        let arr = make_2d_array(4, 4, |_, _| 50.0);
267        // Both (1,1) and (2,1) are bad. (1,1) tries to replace from (2,1), which is also bad.
268        let pixels = vec![
269            BadPixel { x: 1, y: 1, mode: BadPixelMode::Replace { dx: 1, dy: 0 } },
270            BadPixel { x: 2, y: 1, mode: BadPixelMode::Set { value: 0.0 } },
271        ];
272
273        let mut proc = BadPixelProcessor::new(pixels);
274        let pool = NDArrayPool::new(1_000_000);
275        let result = proc.process_array(&arr, &pool);
276
277        let out = &result.output_arrays[0];
278        // (1,1) should remain unchanged (50.0) since replacement source is bad
279        assert!((get_pixel(out, 1, 1, 4) - 50.0).abs() < 1e-10);
280        // (2,1) should be set to 0.0
281        assert!((get_pixel(out, 2, 1, 4) - 0.0).abs() < 1e-10);
282    }
283
284    #[test]
285    fn test_median_mode() {
286        // 5x5 image with one hot pixel at center
287        let arr = make_2d_array(5, 5, |x, y| {
288            if x == 2 && y == 2 { 1000.0 } else { 10.0 }
289        });
290
291        let pixels = vec![
292            BadPixel { x: 2, y: 2, mode: BadPixelMode::Median { kernel_x: 3, kernel_y: 3 } },
293        ];
294
295        let mut proc = BadPixelProcessor::new(pixels);
296        let pool = NDArrayPool::new(1_000_000);
297        let result = proc.process_array(&arr, &pool);
298
299        let out = &result.output_arrays[0];
300        // All 8 neighbors have value 10.0, so median = 10.0
301        assert!((get_pixel(out, 2, 2, 5) - 10.0).abs() < 1e-10);
302    }
303
304    #[test]
305    fn test_median_skips_bad_neighbors() {
306        let arr = make_2d_array(5, 5, |_, _| 10.0);
307        // Center and one neighbor are both bad
308        let pixels = vec![
309            BadPixel { x: 2, y: 2, mode: BadPixelMode::Median { kernel_x: 3, kernel_y: 3 } },
310            BadPixel { x: 1, y: 2, mode: BadPixelMode::Set { value: 999.0 } },
311        ];
312
313        let mut proc = BadPixelProcessor::new(pixels);
314        let pool = NDArrayPool::new(1_000_000);
315        let result = proc.process_array(&arr, &pool);
316
317        let out = &result.output_arrays[0];
318        // 7 valid neighbors (excluding center and (1,2)), all have value 10.0
319        assert!((get_pixel(out, 2, 2, 5) - 10.0).abs() < 1e-10);
320    }
321
322    #[test]
323    fn test_boundary_pixel() {
324        let arr = make_2d_array(4, 4, |_, _| 20.0);
325        // Corner pixel with median filter
326        let pixels = vec![
327            BadPixel { x: 0, y: 0, mode: BadPixelMode::Median { kernel_x: 3, kernel_y: 3 } },
328        ];
329
330        let mut proc = BadPixelProcessor::new(pixels);
331        let pool = NDArrayPool::new(1_000_000);
332        let result = proc.process_array(&arr, &pool);
333
334        let out = &result.output_arrays[0];
335        // Only 3 valid neighbors: (1,0), (0,1), (1,1)
336        assert!((get_pixel(out, 0, 0, 4) - 20.0).abs() < 1e-10);
337    }
338
339    #[test]
340    fn test_replace_out_of_bounds() {
341        let arr = make_2d_array(4, 4, |_, _| 50.0);
342        // Try to replace (0,0) from (-1, 0) - out of bounds
343        let pixels = vec![
344            BadPixel { x: 0, y: 0, mode: BadPixelMode::Replace { dx: -1, dy: 0 } },
345        ];
346
347        let mut proc = BadPixelProcessor::new(pixels);
348        let pool = NDArrayPool::new(1_000_000);
349        let result = proc.process_array(&arr, &pool);
350
351        let out = &result.output_arrays[0];
352        // Should be unchanged since replacement is out of bounds
353        assert!((get_pixel(out, 0, 0, 4) - 50.0).abs() < 1e-10);
354    }
355
356    #[test]
357    fn test_load_from_json() {
358        let json = r#"{"bad_pixels": [
359            {"x": 10, "y": 20, "mode": "set", "value": 0},
360            {"x": 5, "y": 3, "mode": "replace", "dx": 1, "dy": 0},
361            {"x": 7, "y": 8, "mode": "median", "kernel_x": 3, "kernel_y": 3}
362        ]}"#;
363
364        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
365        assert_eq!(pixels.len(), 3);
366        assert_eq!(pixels[0].x, 10);
367        assert_eq!(pixels[0].y, 20);
368        match &pixels[0].mode {
369            BadPixelMode::Set { value } => assert!((value - 0.0).abs() < 1e-10),
370            _ => panic!("expected Set mode"),
371        }
372        match &pixels[1].mode {
373            BadPixelMode::Replace { dx, dy } => {
374                assert_eq!(*dx, 1);
375                assert_eq!(*dy, 0);
376            }
377            _ => panic!("expected Replace mode"),
378        }
379        match &pixels[2].mode {
380            BadPixelMode::Median { kernel_x, kernel_y } => {
381                assert_eq!(*kernel_x, 3);
382                assert_eq!(*kernel_y, 3);
383            }
384            _ => panic!("expected Median mode"),
385        }
386    }
387
388    #[test]
389    fn test_no_bad_pixels_passthrough() {
390        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
391        let mut proc = BadPixelProcessor::new(vec![]);
392        let pool = NDArrayPool::new(1_000_000);
393        let result = proc.process_array(&arr, &pool);
394
395        assert_eq!(result.output_arrays.len(), 1);
396        // Data should be unchanged
397        for iy in 0..4 {
398            for ix in 0..4 {
399                let expected = (ix + iy * 4) as f64;
400                let actual = get_pixel(&result.output_arrays[0], ix, iy, 4);
401                assert!((actual - expected).abs() < 1e-10);
402            }
403        }
404    }
405
406    #[test]
407    fn test_bad_pixel_outside_image() {
408        let arr = make_2d_array(4, 4, |_, _| 10.0);
409        let pixels = vec![
410            BadPixel { x: 100, y: 100, mode: BadPixelMode::Set { value: 999.0 } },
411        ];
412
413        let mut proc = BadPixelProcessor::new(pixels);
414        let pool = NDArrayPool::new(1_000_000);
415        let result = proc.process_array(&arr, &pool);
416
417        // Should not crash; all pixels remain at 10.0
418        let out = &result.output_arrays[0];
419        assert!((get_pixel(out, 0, 0, 4) - 10.0).abs() < 1e-10);
420    }
421
422    #[test]
423    fn test_u8_data() {
424        let mut arr = NDArray::new(
425            vec![NDDimension::new(4), NDDimension::new(4)],
426            NDDataType::UInt8,
427        );
428        if let NDDataBuffer::U8(ref mut v) = arr.data {
429            for val in v.iter_mut() {
430                *val = 100;
431            }
432        }
433
434        let pixels = vec![
435            BadPixel { x: 1, y: 1, mode: BadPixelMode::Set { value: 0.0 } },
436        ];
437
438        let mut proc = BadPixelProcessor::new(pixels);
439        let pool = NDArrayPool::new(1_000_000);
440        let result = proc.process_array(&arr, &pool);
441
442        let out = &result.output_arrays[0];
443        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
444        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
445    }
446
447    #[test]
448    fn test_set_pixels() {
449        let mut proc = BadPixelProcessor::new(vec![]);
450        assert!(proc.pixels().is_empty());
451
452        let new_pixels = vec![
453            BadPixel { x: 0, y: 0, mode: BadPixelMode::Set { value: 0.0 } },
454        ];
455        proc.set_pixels(new_pixels);
456        assert_eq!(proc.pixels().len(), 1);
457    }
458}