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        let offset_x = array.dims.first().map_or(0, |d| d.offset as i64);
277        let offset_y = array.dims.get(1).map_or(0, |d| d.offset as i64);
278        let binning_x = array.dims.first().map_or(1, |d| d.binning.max(1) as i64);
279        let binning_y = array.dims.get(1).map_or(1, |d| d.binning.max(1) as i64);
280
281        let mut out = array.clone();
282        self.apply_corrections(
283            &mut out.data,
284            self.width,
285            height,
286            offset_x,
287            offset_y,
288            binning_x,
289            binning_y,
290        );
291        ProcessResult::arrays(vec![Arc::new(out)])
292    }
293
294    fn plugin_type(&self) -> &str {
295        "NDPluginBadPixel"
296    }
297
298    fn register_params(
299        &mut self,
300        base: &mut asyn_rs::port::PortDriverBase,
301    ) -> asyn_rs::error::AsynResult<()> {
302        use asyn_rs::param::ParamType;
303        base.create_param("BAD_PIXEL_FILE_NAME", ParamType::Octet)?;
304        self.file_name_idx = base.find_param("BAD_PIXEL_FILE_NAME");
305        Ok(())
306    }
307
308    fn on_param_change(
309        &mut self,
310        reason: usize,
311        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
312    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
313        use ad_core_rs::plugin::runtime::ParamChangeValue;
314
315        if Some(reason) == self.file_name_idx {
316            if let ParamChangeValue::Octet(path) = &params.value {
317                if !path.is_empty() {
318                    match std::fs::read_to_string(path) {
319                        Ok(json_str) => match Self::load_from_json(&json_str) {
320                            Ok(pixels) => {
321                                self.set_pixels(pixels);
322                                tracing::info!(
323                                    "BadPixel: loaded {} pixels from {}",
324                                    self.pixels.len(),
325                                    path
326                                );
327                            }
328                            Err(e) => {
329                                tracing::warn!("BadPixel: failed to parse {}: {}", path, e);
330                            }
331                        },
332                        Err(e) => {
333                            tracing::warn!("BadPixel: failed to read {}: {}", path, e);
334                        }
335                    }
336                }
337            }
338        }
339
340        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use ad_core_rs::ndarray::{NDDataType, NDDimension};
348
349    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
350        let mut arr = NDArray::new(
351            vec![NDDimension::new(x), NDDimension::new(y)],
352            NDDataType::Float64,
353        );
354        if let NDDataBuffer::F64(ref mut v) = arr.data {
355            for iy in 0..y {
356                for ix in 0..x {
357                    v[iy * x + ix] = fill(ix, iy);
358                }
359            }
360        }
361        arr
362    }
363
364    fn get_pixel(arr: &NDArray, x: usize, y: usize, width: usize) -> f64 {
365        arr.data.get_as_f64(y * width + x).unwrap()
366    }
367
368    fn set(x: i64, y: i64, value: f64) -> BadPixel {
369        BadPixel {
370            x,
371            y,
372            mode: BadPixelMode::Set { value },
373        }
374    }
375
376    #[test]
377    fn test_set_mode() {
378        let arr = make_2d_array(4, 4, |_, _| 100.0);
379        let pixels = vec![set(1, 1, 0.0), set(3, 2, 42.0)];
380
381        let mut proc = BadPixelProcessor::new(pixels);
382        let pool = NDArrayPool::new(1_000_000);
383        let result = proc.process_array(&arr, &pool);
384
385        assert_eq!(result.output_arrays.len(), 1);
386        let out = &result.output_arrays[0];
387        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
388        assert!((get_pixel(out, 3, 2, 4) - 42.0).abs() < 1e-10);
389        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
390    }
391
392    #[test]
393    fn test_replace_mode() {
394        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
395        // Replace pixel (2,2) with value from (3,2)
396        let pixels = vec![BadPixel {
397            x: 2,
398            y: 2,
399            mode: BadPixelMode::Replace { dx: 1, dy: 0 },
400        }];
401
402        let mut proc = BadPixelProcessor::new(pixels);
403        let pool = NDArrayPool::new(1_000_000);
404        let result = proc.process_array(&arr, &pool);
405
406        let out = &result.output_arrays[0];
407        // (3,2) = 3 + 2*4 = 11
408        assert!((get_pixel(out, 2, 2, 4) - 11.0).abs() < 1e-10);
409    }
410
411    #[test]
412    fn test_replace_skip_bad_neighbor() {
413        let arr = make_2d_array(4, 4, |_, _| 50.0);
414        // Both (1,1) and (2,1) are bad. (1,1) tries to replace from (2,1), also bad.
415        let pixels = vec![
416            BadPixel {
417                x: 1,
418                y: 1,
419                mode: BadPixelMode::Replace { dx: 1, dy: 0 },
420            },
421            set(2, 1, 0.0),
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        // (1,1) unchanged (50.0) since replacement source is bad
430        assert!((get_pixel(out, 1, 1, 4) - 50.0).abs() < 1e-10);
431        // (2,1) set to 0.0
432        assert!((get_pixel(out, 2, 1, 4) - 0.0).abs() < 1e-10);
433    }
434
435    #[test]
436    fn test_median_mode() {
437        // 7x7 image with one hot pixel at center; half-extent 1 => 3x3 kernel.
438        let arr = make_2d_array(7, 7, |x, y| if x == 3 && y == 3 { 1000.0 } else { 10.0 });
439
440        let pixels = vec![BadPixel {
441            x: 3,
442            y: 3,
443            mode: BadPixelMode::Median {
444                half_x: 1,
445                half_y: 1,
446            },
447        }];
448
449        let mut proc = BadPixelProcessor::new(pixels);
450        let pool = NDArrayPool::new(1_000_000);
451        let result = proc.process_array(&arr, &pool);
452
453        let out = &result.output_arrays[0];
454        // All 8 neighbors have value 10.0, so median = 10.0
455        assert!((get_pixel(out, 3, 3, 7) - 10.0).abs() < 1e-10);
456    }
457
458    #[test]
459    fn test_median_half_extent_kernel_size() {
460        // Regression: Median[mx,my] is a HALF-EXTENT; half_x=3 must sample a
461        // 7x7 neighborhood, not 3x3. A 9x9 image with a ring of hot pixels at
462        // radius 3 (only reachable by a 7x7 kernel) shifts the median.
463        let arr = make_2d_array(9, 9, |x, y| {
464            let dx = x as i64 - 4;
465            let dy = y as i64 - 4;
466            // Hot pixels on the kernel boundary at distance 3.
467            if dx.abs() == 3 || dy.abs() == 3 {
468                100.0
469            } else {
470                10.0
471            }
472        });
473
474        // half extent 3 => 7x7 kernel reaches the radius-3 ring.
475        let pixels = vec![BadPixel {
476            x: 4,
477            y: 4,
478            mode: BadPixelMode::Median {
479                half_x: 3,
480                half_y: 3,
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        let out = &result.output_arrays[0];
487        // 7x7 kernel minus center = 48 pixels. The radius-3 ring contributes
488        // 24 hot (100.0) pixels and the interior 24 are 10.0; sorted median of
489        // 48 values lands at the 10.0/100.0 boundary => (10+100)/2 = 55.0.
490        assert!((get_pixel(out, 4, 4, 9) - 55.0).abs() < 1e-10);
491
492        // With a half-extent of 1 (3x3 kernel) the ring is NOT sampled and
493        // the median stays at 10.0 — proving the kernel size depends on the
494        // half-extent.
495        let pixels = vec![BadPixel {
496            x: 4,
497            y: 4,
498            mode: BadPixelMode::Median {
499                half_x: 1,
500                half_y: 1,
501            },
502        }];
503        let mut proc = BadPixelProcessor::new(pixels);
504        let result = proc.process_array(&arr, &pool);
505        let out = &result.output_arrays[0];
506        assert!((get_pixel(out, 4, 4, 9) - 10.0).abs() < 1e-10);
507    }
508
509    #[test]
510    fn test_median_skips_bad_neighbors() {
511        let arr = make_2d_array(7, 7, |_, _| 10.0);
512        // Center and one neighbor are both bad
513        let pixels = vec![
514            BadPixel {
515                x: 3,
516                y: 3,
517                mode: BadPixelMode::Median {
518                    half_x: 1,
519                    half_y: 1,
520                },
521            },
522            set(2, 3, 999.0),
523        ];
524
525        let mut proc = BadPixelProcessor::new(pixels);
526        let pool = NDArrayPool::new(1_000_000);
527        let result = proc.process_array(&arr, &pool);
528
529        let out = &result.output_arrays[0];
530        // 7 valid neighbors (excluding center and (2,3)), all 10.0
531        assert!((get_pixel(out, 3, 3, 7) - 10.0).abs() < 1e-10);
532    }
533
534    #[test]
535    fn test_boundary_pixel() {
536        let arr = make_2d_array(4, 4, |_, _| 20.0);
537        let pixels = vec![BadPixel {
538            x: 0,
539            y: 0,
540            mode: BadPixelMode::Median {
541                half_x: 1,
542                half_y: 1,
543            },
544        }];
545
546        let mut proc = BadPixelProcessor::new(pixels);
547        let pool = NDArrayPool::new(1_000_000);
548        let result = proc.process_array(&arr, &pool);
549
550        let out = &result.output_arrays[0];
551        // Only 3 valid neighbors: (1,0), (0,1), (1,1)
552        assert!((get_pixel(out, 0, 0, 4) - 20.0).abs() < 1e-10);
553    }
554
555    #[test]
556    fn test_replace_out_of_bounds() {
557        let arr = make_2d_array(4, 4, |_, _| 50.0);
558        // Replace (0,0) from (-1,0) - out of bounds
559        let pixels = vec![BadPixel {
560            x: 0,
561            y: 0,
562            mode: BadPixelMode::Replace { dx: -1, dy: 0 },
563        }];
564
565        let mut proc = BadPixelProcessor::new(pixels);
566        let pool = NDArrayPool::new(1_000_000);
567        let result = proc.process_array(&arr, &pool);
568
569        let out = &result.output_arrays[0];
570        assert!((get_pixel(out, 0, 0, 4) - 50.0).abs() < 1e-10);
571    }
572
573    #[test]
574    fn test_load_from_json_cpp_schema() {
575        // C++ AreaDetector bad-pixel file format.
576        let json = r#"{"Bad pixels": [
577            {"Pixel": [10, 20], "Set": 0},
578            {"Pixel": [5, 3], "Replace": [1, 0]},
579            {"Pixel": [7, 8], "Median": [3, 3]}
580        ]}"#;
581
582        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
583        assert_eq!(pixels.len(), 3);
584        assert_eq!(pixels[0].x, 10);
585        assert_eq!(pixels[0].y, 20);
586        assert_eq!(pixels[0].mode, BadPixelMode::Set { value: 0.0 });
587        assert_eq!(pixels[1].mode, BadPixelMode::Replace { dx: 1, dy: 0 });
588        assert_eq!(
589            pixels[2].mode,
590            BadPixelMode::Median {
591                half_x: 3,
592                half_y: 3
593            }
594        );
595    }
596
597    #[test]
598    fn test_load_from_json_no_key_defaults_to_set_zero() {
599        // An entry with only "Pixel" defaults to Set { value: 0.0 } (C++ leaves
600        // the mode at its default badPixelModeSet with setValue 0).
601        let json = r#"{"Bad pixels": [{"Pixel": [1, 2]}]}"#;
602        let pixels = BadPixelProcessor::load_from_json(json).unwrap();
603        assert_eq!(pixels.len(), 1);
604        assert_eq!(pixels[0].mode, BadPixelMode::Set { value: 0.0 });
605    }
606
607    #[test]
608    fn test_no_bad_pixels_passthrough() {
609        let arr = make_2d_array(4, 4, |x, y| (x + y * 4) as f64);
610        let mut proc = BadPixelProcessor::new(vec![]);
611        let pool = NDArrayPool::new(1_000_000);
612        let result = proc.process_array(&arr, &pool);
613
614        assert_eq!(result.output_arrays.len(), 1);
615        for iy in 0..4 {
616            for ix in 0..4 {
617                let expected = (ix + iy * 4) as f64;
618                let actual = get_pixel(&result.output_arrays[0], ix, iy, 4);
619                assert!((actual - expected).abs() < 1e-10);
620            }
621        }
622    }
623
624    #[test]
625    fn test_bad_pixel_outside_image() {
626        let arr = make_2d_array(4, 4, |_, _| 10.0);
627        let pixels = vec![set(100, 100, 999.0)];
628
629        let mut proc = BadPixelProcessor::new(pixels);
630        let pool = NDArrayPool::new(1_000_000);
631        let result = proc.process_array(&arr, &pool);
632
633        let out = &result.output_arrays[0];
634        assert!((get_pixel(out, 0, 0, 4) - 10.0).abs() < 1e-10);
635    }
636
637    #[test]
638    fn test_u8_data() {
639        let mut arr = NDArray::new(
640            vec![NDDimension::new(4), NDDimension::new(4)],
641            NDDataType::UInt8,
642        );
643        if let NDDataBuffer::U8(ref mut v) = arr.data {
644            for val in v.iter_mut() {
645                *val = 100;
646            }
647        }
648
649        let pixels = vec![set(1, 1, 0.0)];
650
651        let mut proc = BadPixelProcessor::new(pixels);
652        let pool = NDArrayPool::new(1_000_000);
653        let result = proc.process_array(&arr, &pool);
654
655        let out = &result.output_arrays[0];
656        assert!((get_pixel(out, 1, 1, 4) - 0.0).abs() < 1e-10);
657        assert!((get_pixel(out, 0, 0, 4) - 100.0).abs() < 1e-10);
658    }
659
660    #[test]
661    fn test_set_pixels() {
662        let mut proc = BadPixelProcessor::new(vec![]);
663        assert!(proc.pixels().is_empty());
664
665        proc.set_pixels(vec![set(0, 0, 0.0)]);
666        assert_eq!(proc.pixels().len(), 1);
667    }
668}