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 in the AreaDetector C++ format:
4//!
5//! ```json
6//! {"Bad pixels": [
7//!   {"Pixel": [x, y], "Set": value},
8//!   {"Pixel": [x, y], "Replace": [dx, dy]},
9//!   {"Pixel": [x, y], "Median": [mx, my]}
10//! ]}
11//! ```
12//!
13//! Each bad pixel specifies its sensor-space `Pixel` `[x, y]` coordinate and
14//! exactly one correction key:
15//! - **Set**: replace with a fixed value.
16//! - **Replace**: copy from a neighbor at relative offset `[dx, dy]`.
17//! - **Median**: median of a `(2*mx+1) x (2*my+1)` kernel around the pixel
18//!   (the `Median` values are half-extents, matching C++ `medianCoordinate`).
19
20use std::collections::HashSet;
21use std::sync::Arc;
22
23use ad_core_rs::ndarray::{NDArray, NDDataBuffer};
24use ad_core_rs::ndarray_pool::NDArrayPool;
25use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
26use serde::Deserialize;
27
28/// The correction mode for a bad pixel.
29#[derive(Debug, Clone, PartialEq)]
30pub enum BadPixelMode {
31    /// Replace the pixel with a fixed value.
32    Set { value: f64 },
33    /// Replace the pixel by copying from a neighbor at relative offset (dx, dy).
34    Replace { dx: i32, dy: i32 },
35    /// Replace the pixel with the median of a kernel. `half_x`/`half_y` are the
36    /// kernel half-extents (C++ `medianCoordinate`); the kernel spans
37    /// `(2*half_x+1) x (2*half_y+1)` pixels.
38    Median { half_x: i64, half_y: i64 },
39}
40
41/// A single bad pixel definition (sensor-space coordinate + correction mode).
42#[derive(Debug, Clone, PartialEq)]
43pub struct BadPixel {
44    pub x: i64,
45    pub y: i64,
46    pub mode: BadPixelMode,
47}
48
49/// Raw JSON shape of a single bad-pixel entry, matching the C++
50/// `readBadPixelFile` schema. `Pixel` is required; exactly one of `Set` /
51/// `Replace` / `Median` selects the correction mode.
52#[derive(Debug, Deserialize)]
53struct BadPixelJson {
54    #[serde(rename = "Pixel")]
55    pixel: [i64; 2],
56    #[serde(rename = "Set", default)]
57    set: Option<f64>,
58    #[serde(rename = "Replace", default)]
59    replace: Option<[i64; 2]>,
60    #[serde(rename = "Median", default)]
61    median: Option<[i64; 2]>,
62}
63
64/// Container for deserializing the C++ bad-pixel file: `{"Bad pixels": [...]}`.
65#[derive(Debug, Deserialize)]
66struct BadPixelFileJson {
67    #[serde(rename = "Bad pixels")]
68    bad_pixels: Vec<BadPixelJson>,
69}
70
71/// Processor that corrects bad pixels in incoming arrays.
72pub struct BadPixelProcessor {
73    pixels: Vec<BadPixel>,
74    /// Set of sensor-space (x, y) for fast bad-pixel lookup, matching the C++
75    /// `badPixelList` which is keyed on the sensor `coordinate`.
76    bad_set: HashSet<(i64, i64)>,
77    /// Cached image width from the last array.
78    width: usize,
79    file_name_idx: Option<usize>,
80}
81
82impl BadPixelProcessor {
83    /// Create a new processor from a list of bad pixels.
84    pub fn new(pixels: Vec<BadPixel>) -> Self {
85        let bad_set: HashSet<(i64, i64)> = pixels.iter().map(|p| (p.x, p.y)).collect();
86        Self {
87            pixels,
88            bad_set,
89            width: 0,
90            file_name_idx: None,
91        }
92    }
93
94    /// Parse a bad pixel list from a JSON string in the C++ AreaDetector
95    /// `{"Bad pixels": [...]}` format.
96    ///
97    /// As in C++ `readBadPixelFile`, when multiple correction keys are present
98    /// the precedence is Median, then Set, then Replace (the later key wins).
99    /// An entry with no correction key defaults to `Set { value: 0.0 }`.
100    pub fn load_from_json(json_str: &str) -> Result<Vec<BadPixel>, serde_json::Error> {
101        let file: BadPixelFileJson = serde_json::from_str(json_str)?;
102        Ok(file
103            .bad_pixels
104            .into_iter()
105            .map(|e| {
106                // C++ checks Median, then Set, then Replace; the last present
107                // key wins.
108                let mut mode = BadPixelMode::Set { value: 0.0 };
109                if let Some(m) = e.median {
110                    mode = BadPixelMode::Median {
111                        half_x: m[0],
112                        half_y: m[1],
113                    };
114                }
115                if let Some(v) = e.set {
116                    mode = BadPixelMode::Set { value: v };
117                }
118                if let Some(r) = e.replace {
119                    mode = BadPixelMode::Replace {
120                        dx: r[0] as i32,
121                        dy: r[1] as i32,
122                    };
123                }
124                BadPixel {
125                    x: e.pixel[0],
126                    y: e.pixel[1],
127                    mode,
128                }
129            })
130            .collect())
131    }
132
133    /// Replace the bad pixel list.
134    pub fn set_pixels(&mut self, pixels: Vec<BadPixel>) {
135        self.bad_set = pixels.iter().map(|p| (p.x, p.y)).collect();
136        self.pixels = pixels;
137    }
138
139    /// Get the current bad pixel list.
140    pub fn pixels(&self) -> &[BadPixel] {
141        &self.pixels
142    }
143
144    /// Check if a sensor-space coordinate is a registered bad pixel.
145    fn is_bad(&self, x: i64, y: i64) -> bool {
146        self.bad_set.contains(&(x, y))
147    }
148
149    /// Apply corrections to a mutable data buffer.
150    ///
151    /// Mirrors C++ `fixBadPixelsT`: bad-pixel coordinates and Replace/Median
152    /// neighbor coordinates are all expressed in sensor space and converted to
153    /// an array offset via [`Self::pixel_offset`] (C++ `computePixelOffset`).
154    /// Replace/Median offsets are scaled by the array binning (`scaleX`,
155    /// `scaleY`), and the "is the neighbor also bad" test queries the bad set
156    /// in sensor space.
157    #[allow(clippy::too_many_arguments)]
158    fn apply_corrections(
159        &self,
160        data: &mut NDDataBuffer,
161        width: usize,
162        height: usize,
163        offset_x: i64,
164        offset_y: i64,
165        binning_x: i64,
166        binning_y: i64,
167    ) {
168        let scale_x = binning_x.max(1);
169        let scale_y = binning_y.max(1);
170
171        // Convert a sensor-space coordinate to a flat array offset, or None if
172        // it falls outside the readout window (C++ computePixelOffset).
173        //
174        // The division must FLOOR, not truncate toward zero: with binning > 1
175        // a sensor coordinate just left/above the readout window has a
176        // negative numerator (`sx - offset_x`). Plain `/` truncates e.g.
177        // `-1 / 2` to `0`, which would alias an out-of-window pixel onto array
178        // index 0. `div_euclid` with a positive divisor floors, so the result
179        // stays negative and is correctly rejected by the `>= 0` bounds test.
180        let pixel_offset = |sx: i64, sy: i64| -> Option<usize> {
181            let x = (sx - offset_x).div_euclid(binning_x.max(1));
182            let y = (sy - offset_y).div_euclid(binning_y.max(1));
183            if x >= 0 && y >= 0 && x < width as i64 && y < height as i64 {
184                Some(y as usize * width + x as usize)
185            } else {
186                None
187            }
188        };
189
190        // Collect corrections, then apply (Replace reads the original buffer).
191        let mut corrections: Vec<(usize, f64)> = Vec::with_capacity(self.pixels.len());
192
193        for bp in &self.pixels {
194            let Some(offset) = pixel_offset(bp.x, bp.y) else {
195                continue;
196            };
197
198            let value = match &bp.mode {
199                BadPixelMode::Set { value } => *value,
200
201                BadPixelMode::Replace { dx, dy } => {
202                    // Neighbor coordinate in SENSOR space, scaled by binning.
203                    let nx = bp.x + (*dx as i64) * scale_x;
204                    let ny = bp.y + (*dy as i64) * scale_y;
205                    // Skip if the replacement pixel is also a bad pixel.
206                    if self.is_bad(nx, ny) {
207                        continue;
208                    }
209                    let Some(replace_offset) = pixel_offset(nx, ny) else {
210                        continue;
211                    };
212                    match data.get_as_f64(replace_offset) {
213                        Some(v) => v,
214                        None => continue,
215                    }
216                }
217
218                BadPixelMode::Median { half_x, half_y } => {
219                    // Kernel half-extents: spans (2*half_x+1) x (2*half_y+1).
220                    let mut neighbors = Vec::new();
221                    for i in -*half_y..=*half_y {
222                        let cy = bp.y + i * scale_y;
223                        for j in -*half_x..=*half_x {
224                            if i == 0 && j == 0 {
225                                continue; // skip the bad pixel itself
226                            }
227                            let cx = bp.x + j * scale_x;
228                            // Skip other bad pixels (sensor-space lookup).
229                            if self.is_bad(cx, cy) {
230                                continue;
231                            }
232                            let Some(idx) = pixel_offset(cx, cy) else {
233                                continue;
234                            };
235                            if let Some(v) = data.get_as_f64(idx) {
236                                neighbors.push(v);
237                            }
238                        }
239                    }
240
241                    if neighbors.is_empty() {
242                        continue; // no valid neighbors
243                    }
244
245                    neighbors.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
246                    let mid = neighbors.len() / 2;
247                    if neighbors.len() % 2 == 0 {
248                        (neighbors[mid - 1] + neighbors[mid]) / 2.0
249                    } else {
250                        neighbors[mid]
251                    }
252                }
253            };
254
255            corrections.push((offset, value));
256        }
257
258        // Apply all corrections
259        for (idx, value) in corrections {
260            data.set_from_f64(idx, value);
261        }
262    }
263}
264
265impl NDPluginProcess for BadPixelProcessor {
266    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
267        let info = array.info();
268        self.width = info.x_size;
269        let height = info.y_size;
270
271        if self.pixels.is_empty() {
272            // No corrections needed, pass through
273            return ProcessResult::arrays(vec![Arc::new(array.clone())]);
274        }
275
276        // C `NDPluginBadPixel.cpp:99-109` reads the detector offset/binning from
277        // the axis the image info names — `pArray->dims[pArrayInfo->xDim]` and
278        // `[pArrayInfo->yDim]` — not from the physical dims[0]/dims[1]. They
279        // differ for every non-RGB3 color layout (RGB1 keeps X in dims[1]).
280        // Y falls back to offset 0 / binning 1 when `ndims <= 1` (`:105-108`).
281        let [x_dim, y_dim, _] = info.user_dims();
282        let offset_x = array.dims.get(x_dim).map_or(0, |d| d.offset as i64);
283        let binning_x = array.dims.get(x_dim).map_or(1, |d| d.binning.max(1) as i64);
284        let (offset_y, binning_y) = if array.dims.len() > 1 {
285            let d = &array.dims[y_dim];
286            (d.offset as i64, d.binning.max(1) as i64)
287        } else {
288            (0, 1)
289        };
290
291        let mut out = array.clone();
292        self.apply_corrections(
293            &mut out.data,
294            self.width,
295            height,
296            offset_x,
297            offset_y,
298            binning_x,
299            binning_y,
300        );
301        ProcessResult::arrays(vec![Arc::new(out)])
302    }
303
304    fn plugin_type(&self) -> &str {
305        "NDPluginBadPixel"
306    }
307
308    fn register_params(
309        &mut self,
310        base: &mut asyn_rs::port::PortDriverBase,
311    ) -> asyn_rs::error::AsynResult<()> {
312        use asyn_rs::param::ParamType;
313        base.create_param("BAD_PIXEL_FILE_NAME", ParamType::Octet)?;
314        self.file_name_idx = base.find_param("BAD_PIXEL_FILE_NAME");
315        Ok(())
316    }
317
318    fn on_param_change(
319        &mut self,
320        reason: usize,
321        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
322    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
323        use ad_core_rs::plugin::runtime::ParamChangeValue;
324
325        if Some(reason) == self.file_name_idx {
326            if let ParamChangeValue::Octet(path) = &params.value {
327                if !path.is_empty() {
328                    match std::fs::read_to_string(path) {
329                        Ok(json_str) => match Self::load_from_json(&json_str) {
330                            Ok(pixels) => {
331                                self.set_pixels(pixels);
332                                tracing::info!(
333                                    "BadPixel: loaded {} pixels from {}",
334                                    self.pixels.len(),
335                                    path
336                                );
337                            }
338                            Err(e) => {
339                                tracing::warn!("BadPixel: failed to parse {}: {}", path, e);
340                            }
341                        },
342                        Err(e) => {
343                            tracing::warn!("BadPixel: failed to read {}: {}", path, e);
344                        }
345                    }
346                }
347            }
348        }
349
350        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use ad_core_rs::ndarray::{NDDataType, NDDimension};
358
359    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
360        let mut arr = NDArray::new(
361            vec![NDDimension::new(x), NDDimension::new(y)],
362            NDDataType::Float64,
363        );
364        if let NDDataBuffer::F64(ref mut v) = arr.data {
365            for iy in 0..y {
366                for ix in 0..x {
367                    v[iy * x + ix] = fill(ix, iy);
368                }
369            }
370        }
371        arr
372    }
373
374    fn get_pixel(arr: &NDArray, x: usize, y: usize, width: usize) -> f64 {
375        arr.data.get_as_f64(y * width + x).unwrap()
376    }
377
378    fn set(x: i64, y: i64, value: f64) -> BadPixel {
379        BadPixel {
380            x,
381            y,
382            mode: BadPixelMode::Set { value },
383        }
384    }
385
386    #[test]
387    fn test_set_mode() {
388        let arr = make_2d_array(4, 4, |_, _| 100.0);
389        let pixels = vec![set(1, 1, 0.0), set(3, 2, 42.0)];
390
391        let mut proc = BadPixelProcessor::new(pixels);
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        let out = &result.output_arrays[0];
397        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
398        assert!((get_pixel(out, 3, 2, 4) - 42.0).abs() < 1e-10);
399        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
400    }
401
402    #[test]
403    fn test_r9_67_readout_offset_comes_from_the_user_dims_axis() {
404        // R9-67 family. C takes the detector readout offset/binning from the axis
405        // the image info names — `pArray->dims[pArrayInfo->xDim]` /
406        // `[pArrayInfo->yDim]` (NDPluginBadPixel.cpp:99-104) — while the port read
407        // the physical dims[0]/dims[1]. On RGB1 (`[color, x, y]`) dims[0] is the
408        // COLOUR axis, so the port used the colour axis's offset as the X readout
409        // offset and corrected the wrong pixel.
410        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
411        use ad_core_rs::color::NDColorMode;
412
413        // RGB1: dims = [color=3, x=4, y=2]. The X axis (dims[1]) carries a
414        // readout offset of 2; the colour axis carries none.
415        let mut arr = NDArray::new(
416            vec![
417                NDDimension::new(3),
418                NDDimension::new(4),
419                NDDimension::new(2),
420            ],
421            NDDataType::Float64,
422        );
423        arr.dims[1].offset = 2;
424        arr.attributes.add(NDAttribute::new_static(
425            "ColorMode",
426            "",
427            NDAttrSource::Driver,
428            NDAttrValue::Int32(NDColorMode::RGB1 as i32),
429        ));
430        if let NDDataBuffer::F64(ref mut v) = arr.data {
431            v.iter_mut().for_each(|x| *x = 100.0);
432        }
433
434        // Sensor coordinate (3, 0). C: x = 3 - offsetX(2) = 1, y = 0
435        //   → buffer offset y * xSize + x = 1.
436        // Pre-fix the port took offsetX from dims[0] (the colour axis, offset 0)
437        //   → x = 3, buffer offset 3.
438        let mut proc = BadPixelProcessor::new(vec![set(3, 0, 7.0)]);
439        let pool = NDArrayPool::new(1_000_000);
440        let result = proc.process_array(&arr, &pool);
441        let out = &result.output_arrays[0];
442
443        assert_eq!(out.data.get_as_f64(1), Some(7.0), "corrected element 1");
444        assert_eq!(
445            out.data.get_as_f64(3),
446            Some(100.0),
447            "element 3 must be untouched — that is where the physical-index bug wrote"
448        );
449    }
450
451    #[test]
452    fn test_replace_mode() {
453        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
454        // Replace pixel (2,2) with value from (3,2)
455        let pixels = vec![BadPixel {
456            x: 2,
457            y: 2,
458            mode: BadPixelMode::Replace { dx: 1, dy: 0 },
459        }];
460
461        let mut proc = BadPixelProcessor::new(pixels);
462        let pool = NDArrayPool::new(1_000_000);
463        let result = proc.process_array(&arr, &pool);
464
465        let out = &result.output_arrays[0];
466        // (3,2) = 3 + 2*4 = 11
467        assert!((get_pixel(out, 2, 2, 4) - 11.0).abs() < 1e-10);
468    }
469
470    #[test]
471    fn test_replace_skip_bad_neighbor() {
472        let arr = make_2d_array(4, 4, |_, _| 50.0);
473        // Both (1,1) and (2,1) are bad. (1,1) tries to replace from (2,1), also bad.
474        let pixels = vec![
475            BadPixel {
476                x: 1,
477                y: 1,
478                mode: BadPixelMode::Replace { dx: 1, dy: 0 },
479            },
480            set(2, 1, 0.0),
481        ];
482
483        let mut proc = BadPixelProcessor::new(pixels);
484        let pool = NDArrayPool::new(1_000_000);
485        let result = proc.process_array(&arr, &pool);
486
487        let out = &result.output_arrays[0];
488        // (1,1) unchanged (50.0) since replacement source is bad
489        assert!((get_pixel(out, 1, 1, 4) - 50.0).abs() < 1e-10);
490        // (2,1) set to 0.0
491        assert!((get_pixel(out, 2, 1, 4) - 0.0).abs() < 1e-10);
492    }
493
494    #[test]
495    fn test_median_mode() {
496        // 7x7 image with one hot pixel at center; half-extent 1 => 3x3 kernel.
497        let arr = make_2d_array(7, 7, |x, y| if x == 3 && y == 3 { 1000.0 } else { 10.0 });
498
499        let pixels = vec![BadPixel {
500            x: 3,
501            y: 3,
502            mode: BadPixelMode::Median {
503                half_x: 1,
504                half_y: 1,
505            },
506        }];
507
508        let mut proc = BadPixelProcessor::new(pixels);
509        let pool = NDArrayPool::new(1_000_000);
510        let result = proc.process_array(&arr, &pool);
511
512        let out = &result.output_arrays[0];
513        // All 8 neighbors have value 10.0, so median = 10.0
514        assert!((get_pixel(out, 3, 3, 7) - 10.0).abs() < 1e-10);
515    }
516
517    #[test]
518    fn test_median_half_extent_kernel_size() {
519        // Regression: Median[mx,my] is a HALF-EXTENT; half_x=3 must sample a
520        // 7x7 neighborhood, not 3x3. A 9x9 image with a ring of hot pixels at
521        // radius 3 (only reachable by a 7x7 kernel) shifts the median.
522        let arr = make_2d_array(9, 9, |x, y| {
523            let dx = x as i64 - 4;
524            let dy = y as i64 - 4;
525            // Hot pixels on the kernel boundary at distance 3.
526            if dx.abs() == 3 || dy.abs() == 3 {
527                100.0
528            } else {
529                10.0
530            }
531        });
532
533        // half extent 3 => 7x7 kernel reaches the radius-3 ring.
534        let pixels = vec![BadPixel {
535            x: 4,
536            y: 4,
537            mode: BadPixelMode::Median {
538                half_x: 3,
539                half_y: 3,
540            },
541        }];
542        let mut proc = BadPixelProcessor::new(pixels);
543        let pool = NDArrayPool::new(1_000_000);
544        let result = proc.process_array(&arr, &pool);
545        let out = &result.output_arrays[0];
546        // 7x7 kernel minus center = 48 pixels. The radius-3 ring contributes
547        // 24 hot (100.0) pixels and the interior 24 are 10.0; sorted median of
548        // 48 values lands at the 10.0/100.0 boundary => (10+100)/2 = 55.0.
549        assert!((get_pixel(out, 4, 4, 9) - 55.0).abs() < 1e-10);
550
551        // With a half-extent of 1 (3x3 kernel) the ring is NOT sampled and
552        // the median stays at 10.0 — proving the kernel size depends on the
553        // half-extent.
554        let pixels = vec![BadPixel {
555            x: 4,
556            y: 4,
557            mode: BadPixelMode::Median {
558                half_x: 1,
559                half_y: 1,
560            },
561        }];
562        let mut proc = BadPixelProcessor::new(pixels);
563        let result = proc.process_array(&arr, &pool);
564        let out = &result.output_arrays[0];
565        assert!((get_pixel(out, 4, 4, 9) - 10.0).abs() < 1e-10);
566    }
567
568    #[test]
569    fn test_median_skips_bad_neighbors() {
570        let arr = make_2d_array(7, 7, |_, _| 10.0);
571        // Center and one neighbor are both bad
572        let pixels = vec![
573            BadPixel {
574                x: 3,
575                y: 3,
576                mode: BadPixelMode::Median {
577                    half_x: 1,
578                    half_y: 1,
579                },
580            },
581            set(2, 3, 999.0),
582        ];
583
584        let mut proc = BadPixelProcessor::new(pixels);
585        let pool = NDArrayPool::new(1_000_000);
586        let result = proc.process_array(&arr, &pool);
587
588        let out = &result.output_arrays[0];
589        // 7 valid neighbors (excluding center and (2,3)), all 10.0
590        assert!((get_pixel(out, 3, 3, 7) - 10.0).abs() < 1e-10);
591    }
592
593    #[test]
594    fn test_boundary_pixel() {
595        let arr = make_2d_array(4, 4, |_, _| 20.0);
596        let pixels = vec![BadPixel {
597            x: 0,
598            y: 0,
599            mode: BadPixelMode::Median {
600                half_x: 1,
601                half_y: 1,
602            },
603        }];
604
605        let mut proc = BadPixelProcessor::new(pixels);
606        let pool = NDArrayPool::new(1_000_000);
607        let result = proc.process_array(&arr, &pool);
608
609        let out = &result.output_arrays[0];
610        // Only 3 valid neighbors: (1,0), (0,1), (1,1)
611        assert!((get_pixel(out, 0, 0, 4) - 20.0).abs() < 1e-10);
612    }
613
614    #[test]
615    fn test_replace_out_of_bounds() {
616        let arr = make_2d_array(4, 4, |_, _| 50.0);
617        // Replace (0,0) from (-1,0) - out of bounds
618        let pixels = vec![BadPixel {
619            x: 0,
620            y: 0,
621            mode: BadPixelMode::Replace { dx: -1, dy: 0 },
622        }];
623
624        let mut proc = BadPixelProcessor::new(pixels);
625        let pool = NDArrayPool::new(1_000_000);
626        let result = proc.process_array(&arr, &pool);
627
628        let out = &result.output_arrays[0];
629        assert!((get_pixel(out, 0, 0, 4) - 50.0).abs() < 1e-10);
630    }
631
632    #[test]
633    fn test_load_from_json_cpp_schema() {
634        // C++ AreaDetector bad-pixel file format.
635        let json = r#"{"Bad pixels": [
636            {"Pixel": [10, 20], "Set": 0},
637            {"Pixel": [5, 3], "Replace": [1, 0]},
638            {"Pixel": [7, 8], "Median": [3, 3]}
639        ]}"#;
640
641        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
642        assert_eq!(pixels.len(), 3);
643        assert_eq!(pixels[0].x, 10);
644        assert_eq!(pixels[0].y, 20);
645        assert_eq!(pixels[0].mode, BadPixelMode::Set { value: 0.0 });
646        assert_eq!(pixels[1].mode, BadPixelMode::Replace { dx: 1, dy: 0 });
647        assert_eq!(
648            pixels[2].mode,
649            BadPixelMode::Median {
650                half_x: 3,
651                half_y: 3
652            }
653        );
654    }
655
656    #[test]
657    fn test_load_from_json_no_key_defaults_to_set_zero() {
658        // An entry with only "Pixel" defaults to Set { value: 0.0 } (C++ leaves
659        // the mode at its default badPixelModeSet with setValue 0).
660        let json = r#"{"Bad pixels": [{"Pixel": [1, 2]}]}"#;
661        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
662        assert_eq!(pixels.len(), 1);
663        assert_eq!(pixels[0].mode, BadPixelMode::Set { value: 0.0 });
664    }
665
666    #[test]
667    fn test_no_bad_pixels_passthrough() {
668        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
669        let mut proc = BadPixelProcessor::new(vec![]);
670        let pool = NDArrayPool::new(1_000_000);
671        let result = proc.process_array(&arr, &pool);
672
673        assert_eq!(result.output_arrays.len(), 1);
674        for iy in 0..4 {
675            for ix in 0..4 {
676                let expected = (ix + iy * 4) as f64;
677                let actual = get_pixel(&result.output_arrays[0], ix, iy, 4);
678                assert!((actual - expected).abs() < 1e-10);
679            }
680        }
681    }
682
683    #[test]
684    fn test_bad_pixel_outside_image() {
685        let arr = make_2d_array(4, 4, |_, _| 10.0);
686        let pixels = vec![set(100, 100, 999.0)];
687
688        let mut proc = BadPixelProcessor::new(pixels);
689        let pool = NDArrayPool::new(1_000_000);
690        let result = proc.process_array(&arr, &pool);
691
692        let out = &result.output_arrays[0];
693        assert!((get_pixel(out, 0, 0, 4) - 10.0).abs() < 1e-10);
694    }
695
696    #[test]
697    fn test_u8_data() {
698        let mut arr = NDArray::new(
699            vec![NDDimension::new(4), NDDimension::new(4)],
700            NDDataType::UInt8,
701        );
702        if let NDDataBuffer::U8(ref mut v) = arr.data {
703            for val in v.iter_mut() {
704                *val = 100;
705            }
706        }
707
708        let pixels = vec![set(1, 1, 0.0)];
709
710        let mut proc = BadPixelProcessor::new(pixels);
711        let pool = NDArrayPool::new(1_000_000);
712        let result = proc.process_array(&arr, &pool);
713
714        let out = &result.output_arrays[0];
715        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
716        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
717    }
718
719    #[test]
720    fn test_set_pixels() {
721        let mut proc = BadPixelProcessor::new(vec![]);
722        assert!(proc.pixels().is_empty());
723
724        proc.set_pixels(vec![set(0, 0, 0.0)]);
725        assert_eq!(proc.pixels().len(), 1);
726    }
727}