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