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