Skip to main content

ad_plugins_rs/
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_rs::ndarray::{NDArray, NDDataBuffer};
13use ad_core_rs::ndarray_pool::NDArrayPool;
14use ad_core_rs::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    file_name_idx: Option<usize>,
55}
56
57impl BadPixelProcessor {
58    /// Create a new processor from a list of bad pixels.
59    pub fn new(pixels: Vec<BadPixel>) -> Self {
60        let bad_set: HashSet<(usize, usize)> = pixels.iter().map(|p| (p.x, p.y)).collect();
61        Self {
62            pixels,
63            bad_set,
64            width: 0,
65            file_name_idx: None,
66        }
67    }
68
69    /// Parse a bad pixel list from a JSON string.
70    pub fn load_from_json(json_str: &str) -> Result<Vec<BadPixel>, serde_json::Error> {
71        let list: BadPixelList = serde_json::from_str(json_str)?;
72        Ok(list.bad_pixels)
73    }
74
75    /// Replace the bad pixel list.
76    pub fn set_pixels(&mut self, pixels: Vec<BadPixel>) {
77        self.bad_set = pixels.iter().map(|p| (p.x, p.y)).collect();
78        self.pixels = pixels;
79    }
80
81    /// Get the current bad pixel list.
82    pub fn pixels(&self) -> &[BadPixel] {
83        &self.pixels
84    }
85
86    /// Check if a coordinate is a bad pixel.
87    fn is_bad(&self, x: usize, y: usize) -> bool {
88        self.bad_set.contains(&(x, y))
89    }
90
91    /// Apply corrections to a mutable data buffer.
92    fn apply_corrections(&self, data: &mut NDDataBuffer, width: usize, height: usize) {
93        // We need to read original values for Replace/Median, so take a snapshot first.
94        // For Set mode, we could do it in-place, but for consistency we read from the
95        // original and write to a separate buffer when needed.
96
97        // Collect corrections to apply
98        let mut corrections: Vec<(usize, f64)> = Vec::with_capacity(self.pixels.len());
99
100        for bp in &self.pixels {
101            if bp.x >= width || bp.y >= height {
102                continue;
103            }
104
105            let value = match &bp.mode {
106                BadPixelMode::Set { value } => *value,
107
108                BadPixelMode::Replace { dx, dy } => {
109                    let nx = bp.x as i64 + *dx as i64;
110                    let ny = bp.y as i64 + *dy as i64;
111
112                    if nx < 0 || nx >= width as i64 || ny < 0 || ny >= height as i64 {
113                        continue; // replacement out of bounds, skip
114                    }
115
116                    let nx = nx as usize;
117                    let ny = ny as usize;
118
119                    // Skip if replacement pixel is also bad
120                    if self.is_bad(nx, ny) {
121                        continue;
122                    }
123
124                    let idx = ny * width + nx;
125                    match data.get_as_f64(idx) {
126                        Some(v) => v,
127                        None => continue,
128                    }
129                }
130
131                BadPixelMode::Median { kernel_x, kernel_y } => {
132                    let half_x = (*kernel_x / 2) as i64;
133                    let half_y = (*kernel_y / 2) as i64;
134                    let cx = bp.x as i64;
135                    let cy = bp.y as i64;
136
137                    let mut neighbors = Vec::new();
138                    for ky in (cy - half_y)..=(cy + half_y) {
139                        for kx in (cx - half_x)..=(cx + half_x) {
140                            if kx < 0 || kx >= width as i64 || ky < 0 || ky >= height as i64 {
141                                continue;
142                            }
143                            let kxu = kx as usize;
144                            let kyu = ky as usize;
145                            // Skip the bad pixel itself and other bad pixels
146                            if kxu == bp.x && kyu == bp.y {
147                                continue;
148                            }
149                            if self.is_bad(kxu, kyu) {
150                                continue;
151                            }
152                            let idx = kyu * width + kxu;
153                            if let Some(v) = data.get_as_f64(idx) {
154                                neighbors.push(v);
155                            }
156                        }
157                    }
158
159                    if neighbors.is_empty() {
160                        continue; // no valid neighbors
161                    }
162
163                    neighbors.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
164                    let mid = neighbors.len() / 2;
165                    if neighbors.len() % 2 == 0 {
166                        (neighbors[mid - 1] + neighbors[mid]) / 2.0
167                    } else {
168                        neighbors[mid]
169                    }
170                }
171            };
172
173            let idx = bp.y * width + bp.x;
174            corrections.push((idx, value));
175        }
176
177        // Apply all corrections
178        for (idx, value) in corrections {
179            data.set_from_f64(idx, value);
180        }
181    }
182}
183
184impl NDPluginProcess for BadPixelProcessor {
185    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
186        let info = array.info();
187        self.width = info.x_size;
188        let height = info.y_size;
189
190        if self.pixels.is_empty() {
191            // No corrections needed, pass through
192            return ProcessResult::arrays(vec![Arc::new(array.clone())]);
193        }
194
195        let mut out = array.clone();
196        self.apply_corrections(&mut out.data, self.width, height);
197        ProcessResult::arrays(vec![Arc::new(out)])
198    }
199
200    fn plugin_type(&self) -> &str {
201        "NDPluginBadPixel"
202    }
203
204    fn register_params(
205        &mut self,
206        base: &mut asyn_rs::port::PortDriverBase,
207    ) -> asyn_rs::error::AsynResult<()> {
208        use asyn_rs::param::ParamType;
209        base.create_param("BAD_PIXEL_FILE_NAME", ParamType::Octet)?;
210        self.file_name_idx = base.find_param("BAD_PIXEL_FILE_NAME");
211        Ok(())
212    }
213
214    fn on_param_change(
215        &mut self,
216        reason: usize,
217        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
218    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
219        use ad_core_rs::plugin::runtime::ParamChangeValue;
220
221        if Some(reason) == self.file_name_idx {
222            if let ParamChangeValue::Octet(path) = &params.value {
223                if !path.is_empty() {
224                    match std::fs::read_to_string(path) {
225                        Ok(json_str) => match Self::load_from_json(&json_str) {
226                            Ok(pixels) => {
227                                self.set_pixels(pixels);
228                                tracing::info!(
229                                    "BadPixel: loaded {} pixels from {}",
230                                    self.pixels.len(),
231                                    path
232                                );
233                            }
234                            Err(e) => {
235                                tracing::warn!("BadPixel: failed to parse {}: {}", path, e);
236                            }
237                        },
238                        Err(e) => {
239                            tracing::warn!("BadPixel: failed to read {}: {}", path, e);
240                        }
241                    }
242                }
243            }
244        }
245
246        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use ad_core_rs::ndarray::{NDDataType, NDDimension};
254
255    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
256        let mut arr = NDArray::new(
257            vec![NDDimension::new(x), NDDimension::new(y)],
258            NDDataType::Float64,
259        );
260        if let NDDataBuffer::F64(ref mut v) = arr.data {
261            for iy in 0..y {
262                for ix in 0..x {
263                    v[iy * x + ix] = fill(ix, iy);
264                }
265            }
266        }
267        arr
268    }
269
270    fn get_pixel(arr: &NDArray, x: usize, y: usize, width: usize) -> f64 {
271        arr.data.get_as_f64(y * width + x).unwrap()
272    }
273
274    #[test]
275    fn test_set_mode() {
276        let arr = make_2d_array(4, 4, |_, _| 100.0);
277        let pixels = vec![
278            BadPixel {
279                x: 1,
280                y: 1,
281                mode: BadPixelMode::Set { value: 0.0 },
282            },
283            BadPixel {
284                x: 3,
285                y: 2,
286                mode: BadPixelMode::Set { value: 42.0 },
287            },
288        ];
289
290        let mut proc = BadPixelProcessor::new(pixels);
291        let pool = NDArrayPool::new(1_000_000);
292        let result = proc.process_array(&arr, &pool);
293
294        assert_eq!(result.output_arrays.len(), 1);
295        let out = &result.output_arrays[0];
296        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
297        assert!((get_pixel(out, 3, 2, 4) - 42.0).abs() < 1e-10);
298        // Unaffected pixels stay at 100
299        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
300    }
301
302    #[test]
303    fn test_replace_mode() {
304        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
305        // Replace pixel (2,2) with value from (3,2)
306        let pixels = vec![BadPixel {
307            x: 2,
308            y: 2,
309            mode: BadPixelMode::Replace { dx: 1, dy: 0 },
310        }];
311
312        let mut proc = BadPixelProcessor::new(pixels);
313        let pool = NDArrayPool::new(1_000_000);
314        let result = proc.process_array(&arr, &pool);
315
316        let out = &result.output_arrays[0];
317        // (3,2) = 3 + 2*4 = 11
318        assert!((get_pixel(out, 2, 2, 4) - 11.0).abs() < 1e-10);
319    }
320
321    #[test]
322    fn test_replace_skip_bad_neighbor() {
323        let arr = make_2d_array(4, 4, |_, _| 50.0);
324        // Both (1,1) and (2,1) are bad. (1,1) tries to replace from (2,1), which is also bad.
325        let pixels = vec![
326            BadPixel {
327                x: 1,
328                y: 1,
329                mode: BadPixelMode::Replace { dx: 1, dy: 0 },
330            },
331            BadPixel {
332                x: 2,
333                y: 1,
334                mode: BadPixelMode::Set { value: 0.0 },
335            },
336        ];
337
338        let mut proc = BadPixelProcessor::new(pixels);
339        let pool = NDArrayPool::new(1_000_000);
340        let result = proc.process_array(&arr, &pool);
341
342        let out = &result.output_arrays[0];
343        // (1,1) should remain unchanged (50.0) since replacement source is bad
344        assert!((get_pixel(out, 1, 1, 4) - 50.0).abs() < 1e-10);
345        // (2,1) should be set to 0.0
346        assert!((get_pixel(out, 2, 1, 4) - 0.0).abs() < 1e-10);
347    }
348
349    #[test]
350    fn test_median_mode() {
351        // 5x5 image with one hot pixel at center
352        let arr = make_2d_array(5, 5, |x, y| if x == 2 && y == 2 { 1000.0 } else { 10.0 });
353
354        let pixels = vec![BadPixel {
355            x: 2,
356            y: 2,
357            mode: BadPixelMode::Median {
358                kernel_x: 3,
359                kernel_y: 3,
360            },
361        }];
362
363        let mut proc = BadPixelProcessor::new(pixels);
364        let pool = NDArrayPool::new(1_000_000);
365        let result = proc.process_array(&arr, &pool);
366
367        let out = &result.output_arrays[0];
368        // All 8 neighbors have value 10.0, so median = 10.0
369        assert!((get_pixel(out, 2, 2, 5) - 10.0).abs() < 1e-10);
370    }
371
372    #[test]
373    fn test_median_skips_bad_neighbors() {
374        let arr = make_2d_array(5, 5, |_, _| 10.0);
375        // Center and one neighbor are both bad
376        let pixels = vec![
377            BadPixel {
378                x: 2,
379                y: 2,
380                mode: BadPixelMode::Median {
381                    kernel_x: 3,
382                    kernel_y: 3,
383                },
384            },
385            BadPixel {
386                x: 1,
387                y: 2,
388                mode: BadPixelMode::Set { value: 999.0 },
389            },
390        ];
391
392        let mut proc = BadPixelProcessor::new(pixels);
393        let pool = NDArrayPool::new(1_000_000);
394        let result = proc.process_array(&arr, &pool);
395
396        let out = &result.output_arrays[0];
397        // 7 valid neighbors (excluding center and (1,2)), all have value 10.0
398        assert!((get_pixel(out, 2, 2, 5) - 10.0).abs() < 1e-10);
399    }
400
401    #[test]
402    fn test_boundary_pixel() {
403        let arr = make_2d_array(4, 4, |_, _| 20.0);
404        // Corner pixel with median filter
405        let pixels = vec![BadPixel {
406            x: 0,
407            y: 0,
408            mode: BadPixelMode::Median {
409                kernel_x: 3,
410                kernel_y: 3,
411            },
412        }];
413
414        let mut proc = BadPixelProcessor::new(pixels);
415        let pool = NDArrayPool::new(1_000_000);
416        let result = proc.process_array(&arr, &pool);
417
418        let out = &result.output_arrays[0];
419        // Only 3 valid neighbors: (1,0), (0,1), (1,1)
420        assert!((get_pixel(out, 0, 0, 4) - 20.0).abs() < 1e-10);
421    }
422
423    #[test]
424    fn test_replace_out_of_bounds() {
425        let arr = make_2d_array(4, 4, |_, _| 50.0);
426        // Try to replace (0,0) from (-1, 0) - out of bounds
427        let pixels = vec![BadPixel {
428            x: 0,
429            y: 0,
430            mode: BadPixelMode::Replace { dx: -1, dy: 0 },
431        }];
432
433        let mut proc = BadPixelProcessor::new(pixels);
434        let pool = NDArrayPool::new(1_000_000);
435        let result = proc.process_array(&arr, &pool);
436
437        let out = &result.output_arrays[0];
438        // Should be unchanged since replacement is out of bounds
439        assert!((get_pixel(out, 0, 0, 4) - 50.0).abs() < 1e-10);
440    }
441
442    #[test]
443    fn test_load_from_json() {
444        let json = r#"{"bad_pixels": [
445            {"x": 10, "y": 20, "mode": "set", "value": 0},
446            {"x": 5, "y": 3, "mode": "replace", "dx": 1, "dy": 0},
447            {"x": 7, "y": 8, "mode": "median", "kernel_x": 3, "kernel_y": 3}
448        ]}"#;
449
450        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
451        assert_eq!(pixels.len(), 3);
452        assert_eq!(pixels[0].x, 10);
453        assert_eq!(pixels[0].y, 20);
454        match &pixels[0].mode {
455            BadPixelMode::Set { value } => assert!((value - 0.0).abs() < 1e-10),
456            _ => panic!("expected Set mode"),
457        }
458        match &pixels[1].mode {
459            BadPixelMode::Replace { dx, dy } => {
460                assert_eq!(*dx, 1);
461                assert_eq!(*dy, 0);
462            }
463            _ => panic!("expected Replace mode"),
464        }
465        match &pixels[2].mode {
466            BadPixelMode::Median { kernel_x, kernel_y } => {
467                assert_eq!(*kernel_x, 3);
468                assert_eq!(*kernel_y, 3);
469            }
470            _ => panic!("expected Median mode"),
471        }
472    }
473
474    #[test]
475    fn test_no_bad_pixels_passthrough() {
476        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
477        let mut proc = BadPixelProcessor::new(vec![]);
478        let pool = NDArrayPool::new(1_000_000);
479        let result = proc.process_array(&arr, &pool);
480
481        assert_eq!(result.output_arrays.len(), 1);
482        // Data should be unchanged
483        for iy in 0..4 {
484            for ix in 0..4 {
485                let expected = (ix + iy * 4) as f64;
486                let actual = get_pixel(&result.output_arrays[0], ix, iy, 4);
487                assert!((actual - expected).abs() < 1e-10);
488            }
489        }
490    }
491
492    #[test]
493    fn test_bad_pixel_outside_image() {
494        let arr = make_2d_array(4, 4, |_, _| 10.0);
495        let pixels = vec![BadPixel {
496            x: 100,
497            y: 100,
498            mode: BadPixelMode::Set { value: 999.0 },
499        }];
500
501        let mut proc = BadPixelProcessor::new(pixels);
502        let pool = NDArrayPool::new(1_000_000);
503        let result = proc.process_array(&arr, &pool);
504
505        // Should not crash; all pixels remain at 10.0
506        let out = &result.output_arrays[0];
507        assert!((get_pixel(out, 0, 0, 4) - 10.0).abs() < 1e-10);
508    }
509
510    #[test]
511    fn test_u8_data() {
512        let mut arr = NDArray::new(
513            vec![NDDimension::new(4), NDDimension::new(4)],
514            NDDataType::UInt8,
515        );
516        if let NDDataBuffer::U8(ref mut v) = arr.data {
517            for val in v.iter_mut() {
518                *val = 100;
519            }
520        }
521
522        let pixels = vec![BadPixel {
523            x: 1,
524            y: 1,
525            mode: BadPixelMode::Set { value: 0.0 },
526        }];
527
528        let mut proc = BadPixelProcessor::new(pixels);
529        let pool = NDArrayPool::new(1_000_000);
530        let result = proc.process_array(&arr, &pool);
531
532        let out = &result.output_arrays[0];
533        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
534        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
535    }
536
537    #[test]
538    fn test_set_pixels() {
539        let mut proc = BadPixelProcessor::new(vec![]);
540        assert!(proc.pixels().is_empty());
541
542        let new_pixels = vec![BadPixel {
543            x: 0,
544            y: 0,
545            mode: BadPixelMode::Set { value: 0.0 },
546        }];
547        proc.set_pixels(new_pixels);
548        assert_eq!(proc.pixels().len(), 1);
549    }
550}