Skip to main content

ad_plugins_rs/
roi.rs

1use std::sync::Arc;
2
3use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
4use ad_core_rs::ndarray_pool::NDArrayPool;
5use ad_core_rs::plugin::runtime::{
6    NDPluginProcess, ParamUpdate, PluginParamSnapshot, ProcessResult,
7};
8use asyn_rs::param::ParamType;
9use asyn_rs::port::PortDriverBase;
10
11/// Per-dimension ROI configuration.
12#[derive(Debug, Clone)]
13pub struct ROIDimConfig {
14    pub min: usize,
15    pub size: usize,
16    pub bin: usize,
17    pub reverse: bool,
18    pub enable: bool,
19    /// If true, size is computed as src_dim - min.
20    pub auto_size: bool,
21}
22
23impl Default for ROIDimConfig {
24    fn default() -> Self {
25        Self {
26            min: 0,
27            size: 0,
28            bin: 1,
29            reverse: false,
30            enable: true,
31            auto_size: false,
32        }
33    }
34}
35
36/// Auto-centering mode for ROI.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AutoCenter {
39    None,
40    CenterOfMass,
41    PeakPosition,
42}
43
44/// ROI plugin configuration.
45#[derive(Debug, Clone)]
46pub struct ROIConfig {
47    pub dims: [ROIDimConfig; 3],
48    pub data_type: Option<NDDataType>,
49    pub enable_scale: bool,
50    pub scale: f64,
51    pub collapse_dims: bool,
52    pub autocenter: AutoCenter,
53}
54
55impl Default for ROIConfig {
56    fn default() -> Self {
57        Self {
58            dims: [
59                ROIDimConfig::default(),
60                ROIDimConfig::default(),
61                ROIDimConfig::default(),
62            ],
63            data_type: None,
64            enable_scale: false,
65            scale: 1.0,
66            collapse_dims: false,
67            autocenter: AutoCenter::None,
68        }
69    }
70}
71
72/// Compute the centroid (center of mass) of a 2D image.
73fn find_centroid_2d(data: &NDDataBuffer, x_size: usize, y_size: usize) -> (usize, usize) {
74    let mut cx = 0.0f64;
75    let mut cy = 0.0f64;
76    let mut total = 0.0f64;
77    for iy in 0..y_size {
78        for ix in 0..x_size {
79            let val = data.get_as_f64(iy * x_size + ix).unwrap_or(0.0);
80            total += val;
81            cx += val * ix as f64;
82            cy += val * iy as f64;
83        }
84    }
85    if total > 0.0 {
86        ((cx / total) as usize, (cy / total) as usize)
87    } else {
88        (x_size / 2, y_size / 2)
89    }
90}
91
92/// Find the position of the maximum value in a 2D image.
93fn find_peak_2d(data: &NDDataBuffer, x_size: usize, y_size: usize) -> (usize, usize) {
94    let mut max_val = f64::NEG_INFINITY;
95    let mut max_x = 0;
96    let mut max_y = 0;
97    for iy in 0..y_size {
98        for ix in 0..x_size {
99            let val = data.get_as_f64(iy * x_size + ix).unwrap_or(0.0);
100            if val > max_val {
101                max_val = val;
102                max_x = ix;
103                max_y = iy;
104            }
105        }
106    }
107    (max_x, max_y)
108}
109
110/// Extract ROI sub-region from a 2D array.
111pub fn extract_roi_2d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
112    if src.dims.len() < 2 {
113        return None;
114    }
115
116    let src_x = src.dims[0].size;
117    let src_y = src.dims[1].size;
118
119    // Resolve effective min/size for X dimension
120    let (eff_x_min, eff_x_size) = if !config.dims[0].enable {
121        (0, src_x)
122    } else if config.dims[0].auto_size {
123        let min = config.dims[0].min.min(src_x);
124        (min, src_x.saturating_sub(min))
125    } else {
126        let min = config.dims[0].min.min(src_x);
127        let size = config.dims[0].size.min(src_x - min);
128        (min, size)
129    };
130
131    // Resolve effective min/size for Y dimension
132    let (eff_y_min, eff_y_size) = if !config.dims[1].enable {
133        (0, src_y)
134    } else if config.dims[1].auto_size {
135        let min = config.dims[1].min.min(src_y);
136        (min, src_y.saturating_sub(min))
137    } else {
138        let min = config.dims[1].min.min(src_y);
139        let size = config.dims[1].size.min(src_y - min);
140        (min, size)
141    };
142
143    // Apply autocenter: shift ROI min so that the ROI is centered on the
144    // centroid or peak, keeping the effective size the same.
145    let (roi_x_min, roi_y_min) = match config.autocenter {
146        AutoCenter::None => (eff_x_min, eff_y_min),
147        AutoCenter::CenterOfMass => {
148            let (cx, cy) = find_centroid_2d(&src.data, src_x, src_y);
149            let mx = cx
150                .saturating_sub(eff_x_size / 2)
151                .min(src_x.saturating_sub(eff_x_size));
152            let my = cy
153                .saturating_sub(eff_y_size / 2)
154                .min(src_y.saturating_sub(eff_y_size));
155            (mx, my)
156        }
157        AutoCenter::PeakPosition => {
158            let (px, py) = find_peak_2d(&src.data, src_x, src_y);
159            let mx = px
160                .saturating_sub(eff_x_size / 2)
161                .min(src_x.saturating_sub(eff_x_size));
162            let my = py
163                .saturating_sub(eff_y_size / 2)
164                .min(src_y.saturating_sub(eff_y_size));
165            (mx, my)
166        }
167    };
168
169    let roi_x_size = eff_x_size;
170    let roi_y_size = eff_y_size;
171
172    if roi_x_size == 0 || roi_y_size == 0 {
173        return None;
174    }
175
176    let bin_x = config.dims[0].bin.max(1);
177    let bin_y = config.dims[1].bin.max(1);
178    let out_x = roi_x_size / bin_x;
179    let out_y = roi_y_size / bin_y;
180
181    if out_x == 0 || out_y == 0 {
182        return None;
183    }
184
185    macro_rules! extract {
186        ($vec:expr, $T:ty, $zero:expr) => {{
187            let mut out = vec![$zero; out_x * out_y];
188            for oy in 0..out_y {
189                for ox in 0..out_x {
190                    let mut sum = 0.0f64;
191                    let mut count = 0usize;
192                    for by in 0..bin_y {
193                        for bx in 0..bin_x {
194                            let sx = roi_x_min + ox * bin_x + bx;
195                            let sy = roi_y_min + oy * bin_y + by;
196                            if sx < src_x && sy < src_y {
197                                sum += $vec[sy * src_x + sx] as f64;
198                                count += 1;
199                            }
200                        }
201                    }
202                    let val = if count > 0 { sum / count as f64 } else { 0.0 };
203                    let idx = if config.dims[0].reverse {
204                        out_x - 1 - ox
205                    } else {
206                        ox
207                    } + if config.dims[1].reverse {
208                        out_y - 1 - oy
209                    } else {
210                        oy
211                    } * out_x;
212                    let scaled = if config.enable_scale {
213                        val * config.scale
214                    } else {
215                        val
216                    };
217                    out[idx] = scaled as $T;
218                }
219            }
220            out
221        }};
222    }
223
224    let out_data = match &src.data {
225        NDDataBuffer::U8(v) => NDDataBuffer::U8(extract!(v, u8, 0)),
226        NDDataBuffer::U16(v) => NDDataBuffer::U16(extract!(v, u16, 0)),
227        NDDataBuffer::I8(v) => NDDataBuffer::I8(extract!(v, i8, 0)),
228        NDDataBuffer::I16(v) => NDDataBuffer::I16(extract!(v, i16, 0)),
229        NDDataBuffer::I32(v) => NDDataBuffer::I32(extract!(v, i32, 0)),
230        NDDataBuffer::U32(v) => NDDataBuffer::U32(extract!(v, u32, 0)),
231        NDDataBuffer::I64(v) => NDDataBuffer::I64(extract!(v, i64, 0)),
232        NDDataBuffer::U64(v) => NDDataBuffer::U64(extract!(v, u64, 0)),
233        NDDataBuffer::F32(v) => NDDataBuffer::F32(extract!(v, f32, 0.0)),
234        NDDataBuffer::F64(v) => NDDataBuffer::F64(extract!(v, f64, 0.0)),
235    };
236
237    let out_dims = if config.collapse_dims && out_y == 1 {
238        vec![NDDimension::new(out_x)]
239    } else {
240        vec![NDDimension::new(out_x), NDDimension::new(out_y)]
241    };
242
243    // Apply data type conversion if requested
244    let target_type = config.data_type.unwrap_or(src.data.data_type());
245
246    let mut arr = NDArray::new(out_dims, target_type);
247    if target_type == src.data.data_type() {
248        arr.data = out_data;
249    } else {
250        // Convert via color module
251        let mut temp = NDArray::new(arr.dims.clone(), src.data.data_type());
252        temp.data = out_data;
253        if let Ok(converted) = ad_core_rs::color::convert_data_type(&temp, target_type) {
254            arr.data = converted.data;
255        } else {
256            arr.data = out_data_fallback(&temp.data, target_type, temp.data.len());
257        }
258    }
259
260    arr.unique_id = src.unique_id;
261    arr.timestamp = src.timestamp;
262    arr.attributes = src.attributes.clone();
263    Some(arr)
264}
265
266fn out_data_fallback(_src: &NDDataBuffer, target: NDDataType, len: usize) -> NDDataBuffer {
267    NDDataBuffer::zeros(target, len)
268}
269
270/// Per-dimension param reasons.
271#[derive(Default, Clone, Copy)]
272pub struct ROIDimParams {
273    pub min: usize,
274    pub size: usize,
275    pub bin: usize,
276    pub reverse: usize,
277    pub enable: usize,
278    pub auto_size: usize,
279    pub max_size: usize,
280}
281
282/// Param reasons for all ROI params.
283#[derive(Default)]
284pub struct ROIParams {
285    pub dims: [ROIDimParams; 3],
286    pub enable_scale: usize,
287    pub scale: usize,
288    pub data_type: usize,
289    pub collapse_dims: usize,
290    pub name: usize,
291}
292
293/// Pure ROI processing logic.
294pub struct ROIProcessor {
295    config: ROIConfig,
296    params: ROIParams,
297}
298
299impl ROIProcessor {
300    pub fn new(config: ROIConfig) -> Self {
301        Self {
302            config,
303            params: ROIParams::default(),
304        }
305    }
306
307    /// Access the registered ROI param reasons.
308    pub fn params(&self) -> &ROIParams {
309        &self.params
310    }
311}
312
313impl NDPluginProcess for ROIProcessor {
314    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
315        // Report input array dimensions as MaxSize params
316        let mut updates = Vec::new();
317        for (i, dim_params) in self.params.dims.iter().enumerate() {
318            let dim_size = array.dims.get(i).map(|d| d.size as i32).unwrap_or(0);
319            updates.push(ParamUpdate::int32(dim_params.max_size, dim_size));
320        }
321
322        match extract_roi_2d(array, &self.config) {
323            Some(roi_arr) => ProcessResult {
324                output_arrays: vec![Arc::new(roi_arr)],
325                param_updates: updates,
326                scatter_index: None,
327            },
328            None => ProcessResult::sink(updates),
329        }
330    }
331
332    fn plugin_type(&self) -> &str {
333        "NDPluginROI"
334    }
335
336    fn register_params(
337        &mut self,
338        base: &mut PortDriverBase,
339    ) -> Result<(), asyn_rs::error::AsynError> {
340        let dim_names = ["DIM0", "DIM1", "DIM2"];
341        for (i, prefix) in dim_names.iter().enumerate() {
342            self.params.dims[i].min =
343                base.create_param(&format!("{prefix}_MIN"), ParamType::Int32)?;
344            self.params.dims[i].size =
345                base.create_param(&format!("{prefix}_SIZE"), ParamType::Int32)?;
346            self.params.dims[i].bin =
347                base.create_param(&format!("{prefix}_BIN"), ParamType::Int32)?;
348            self.params.dims[i].reverse =
349                base.create_param(&format!("{prefix}_REVERSE"), ParamType::Int32)?;
350            self.params.dims[i].enable =
351                base.create_param(&format!("{prefix}_ENABLE"), ParamType::Int32)?;
352            self.params.dims[i].auto_size =
353                base.create_param(&format!("{prefix}_AUTO_SIZE"), ParamType::Int32)?;
354            self.params.dims[i].max_size =
355                base.create_param(&format!("{prefix}_MAX_SIZE"), ParamType::Int32)?;
356
357            // Set initial values from config
358            base.set_int32_param(self.params.dims[i].min, 0, self.config.dims[i].min as i32)?;
359            base.set_int32_param(self.params.dims[i].size, 0, self.config.dims[i].size as i32)?;
360            base.set_int32_param(self.params.dims[i].bin, 0, self.config.dims[i].bin as i32)?;
361            base.set_int32_param(
362                self.params.dims[i].reverse,
363                0,
364                self.config.dims[i].reverse as i32,
365            )?;
366            base.set_int32_param(
367                self.params.dims[i].enable,
368                0,
369                self.config.dims[i].enable as i32,
370            )?;
371            base.set_int32_param(
372                self.params.dims[i].auto_size,
373                0,
374                self.config.dims[i].auto_size as i32,
375            )?;
376        }
377        self.params.enable_scale = base.create_param("ENABLE_SCALE", ParamType::Int32)?;
378        self.params.scale = base.create_param("SCALE_VALUE", ParamType::Float64)?;
379        self.params.data_type = base.create_param("ROI_DATA_TYPE", ParamType::Int32)?;
380        self.params.collapse_dims = base.create_param("COLLAPSE_DIMS", ParamType::Int32)?;
381        self.params.name = base.create_param("NAME", ParamType::Octet)?;
382
383        base.set_int32_param(self.params.enable_scale, 0, self.config.enable_scale as i32)?;
384        base.set_float64_param(self.params.scale, 0, self.config.scale)?;
385        base.set_int32_param(self.params.data_type, 0, -1)?; // -1 = Automatic
386        base.set_int32_param(
387            self.params.collapse_dims,
388            0,
389            self.config.collapse_dims as i32,
390        )?;
391
392        Ok(())
393    }
394
395    fn on_param_change(
396        &mut self,
397        reason: usize,
398        snapshot: &PluginParamSnapshot,
399    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
400        let p = &self.params;
401        for i in 0..3 {
402            if reason == p.dims[i].min {
403                self.config.dims[i].min = snapshot.value.as_i32().max(0) as usize;
404                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
405            }
406            if reason == p.dims[i].size {
407                self.config.dims[i].size = snapshot.value.as_i32().max(0) as usize;
408                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
409            }
410            if reason == p.dims[i].bin {
411                self.config.dims[i].bin = snapshot.value.as_i32().max(1) as usize;
412                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
413            }
414            if reason == p.dims[i].reverse {
415                self.config.dims[i].reverse = snapshot.value.as_i32() != 0;
416                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
417            }
418            if reason == p.dims[i].enable {
419                self.config.dims[i].enable = snapshot.value.as_i32() != 0;
420                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
421            }
422            if reason == p.dims[i].auto_size {
423                self.config.dims[i].auto_size = snapshot.value.as_i32() != 0;
424                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
425            }
426        }
427        if reason == p.enable_scale {
428            self.config.enable_scale = snapshot.value.as_i32() != 0;
429        } else if reason == p.scale {
430            self.config.scale = snapshot.value.as_f64();
431        } else if reason == p.data_type {
432            let v = snapshot.value.as_i32();
433            self.config.data_type = if v < 0 {
434                None
435            } else {
436                NDDataType::from_ordinal(v as u8)
437            };
438        } else if reason == p.collapse_dims {
439            self.config.collapse_dims = snapshot.value.as_i32() != 0;
440        }
441        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
442    }
443}
444
445/// Create an ROI plugin runtime, returning the handle and param reasons.
446pub fn create_roi_runtime(
447    port_name: &str,
448    pool: Arc<NDArrayPool>,
449    queue_size: usize,
450    ndarray_port: &str,
451    wiring: Arc<ad_core_rs::plugin::wiring::WiringRegistry>,
452) -> (
453    ad_core_rs::plugin::runtime::PluginRuntimeHandle,
454    ROIParams,
455    std::thread::JoinHandle<()>,
456) {
457    let processor = ROIProcessor::new(ROIConfig::default());
458    let (handle, jh) = ad_core_rs::plugin::runtime::create_plugin_runtime(
459        port_name,
460        processor,
461        pool,
462        queue_size,
463        ndarray_port,
464        wiring,
465    );
466    // Recreate param layout on a scratch PortDriverBase to get matching reasons.
467    let params = {
468        let mut base =
469            asyn_rs::port::PortDriverBase::new("_scratch_", 1, asyn_rs::port::PortFlags::default());
470        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
471        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
472        let mut p = ROIParams::default();
473        let dim_names = ["DIM0", "DIM1", "DIM2"];
474        for (i, prefix) in dim_names.iter().enumerate() {
475            p.dims[i].min = base
476                .create_param(&format!("{prefix}_MIN"), asyn_rs::param::ParamType::Int32)
477                .unwrap();
478            p.dims[i].size = base
479                .create_param(&format!("{prefix}_SIZE"), asyn_rs::param::ParamType::Int32)
480                .unwrap();
481            p.dims[i].bin = base
482                .create_param(&format!("{prefix}_BIN"), asyn_rs::param::ParamType::Int32)
483                .unwrap();
484            p.dims[i].reverse = base
485                .create_param(
486                    &format!("{prefix}_REVERSE"),
487                    asyn_rs::param::ParamType::Int32,
488                )
489                .unwrap();
490            p.dims[i].enable = base
491                .create_param(
492                    &format!("{prefix}_ENABLE"),
493                    asyn_rs::param::ParamType::Int32,
494                )
495                .unwrap();
496            p.dims[i].auto_size = base
497                .create_param(
498                    &format!("{prefix}_AUTO_SIZE"),
499                    asyn_rs::param::ParamType::Int32,
500                )
501                .unwrap();
502            p.dims[i].max_size = base
503                .create_param(
504                    &format!("{prefix}_MAX_SIZE"),
505                    asyn_rs::param::ParamType::Int32,
506                )
507                .unwrap();
508        }
509        p.enable_scale = base
510            .create_param("ENABLE_SCALE", asyn_rs::param::ParamType::Int32)
511            .unwrap();
512        p.scale = base
513            .create_param("SCALE_VALUE", asyn_rs::param::ParamType::Float64)
514            .unwrap();
515        p.data_type = base
516            .create_param("ROI_DATA_TYPE", asyn_rs::param::ParamType::Int32)
517            .unwrap();
518        p.collapse_dims = base
519            .create_param("COLLAPSE_DIMS", asyn_rs::param::ParamType::Int32)
520            .unwrap();
521        p.name = base
522            .create_param("NAME", asyn_rs::param::ParamType::Octet)
523            .unwrap();
524        p
525    };
526    (handle, params, jh)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn make_4x4_u8() -> NDArray {
534        let mut arr = NDArray::new(
535            vec![NDDimension::new(4), NDDimension::new(4)],
536            NDDataType::UInt8,
537        );
538        if let NDDataBuffer::U8(ref mut v) = arr.data {
539            for i in 0..16 {
540                v[i] = i as u8;
541            }
542        }
543        arr
544    }
545
546    #[test]
547    fn test_extract_sub_region() {
548        let arr = make_4x4_u8();
549        let mut config = ROIConfig::default();
550        config.dims[0] = ROIDimConfig {
551            min: 1,
552            size: 2,
553            bin: 1,
554            reverse: false,
555            enable: true,
556            auto_size: false,
557        };
558        config.dims[1] = ROIDimConfig {
559            min: 1,
560            size: 2,
561            bin: 1,
562            reverse: false,
563            enable: true,
564            auto_size: false,
565        };
566
567        let roi = extract_roi_2d(&arr, &config).unwrap();
568        assert_eq!(roi.dims[0].size, 2);
569        assert_eq!(roi.dims[1].size, 2);
570        if let NDDataBuffer::U8(ref v) = roi.data {
571            // row 1, cols 1-2: [5,6], row 2, cols 1-2: [9,10]
572            assert_eq!(v[0], 5);
573            assert_eq!(v[1], 6);
574            assert_eq!(v[2], 9);
575            assert_eq!(v[3], 10);
576        }
577    }
578
579    #[test]
580    fn test_binning_2x2() {
581        let arr = make_4x4_u8();
582        let mut config = ROIConfig::default();
583        config.dims[0] = ROIDimConfig {
584            min: 0,
585            size: 4,
586            bin: 2,
587            reverse: false,
588            enable: true,
589            auto_size: false,
590        };
591        config.dims[1] = ROIDimConfig {
592            min: 0,
593            size: 4,
594            bin: 2,
595            reverse: false,
596            enable: true,
597            auto_size: false,
598        };
599
600        let roi = extract_roi_2d(&arr, &config).unwrap();
601        assert_eq!(roi.dims[0].size, 2);
602        assert_eq!(roi.dims[1].size, 2);
603        if let NDDataBuffer::U8(ref v) = roi.data {
604            // top-left 2x2: (0+1+4+5)/4 = 2.5 → 2
605            assert_eq!(v[0], 2);
606        }
607    }
608
609    #[test]
610    fn test_reverse() {
611        let arr = make_4x4_u8();
612        let mut config = ROIConfig::default();
613        config.dims[0] = ROIDimConfig {
614            min: 0,
615            size: 4,
616            bin: 1,
617            reverse: true,
618            enable: true,
619            auto_size: false,
620        };
621        config.dims[1] = ROIDimConfig {
622            min: 0,
623            size: 1,
624            bin: 1,
625            reverse: false,
626            enable: true,
627            auto_size: false,
628        };
629
630        let roi = extract_roi_2d(&arr, &config).unwrap();
631        if let NDDataBuffer::U8(ref v) = roi.data {
632            assert_eq!(v[0], 3);
633            assert_eq!(v[1], 2);
634            assert_eq!(v[2], 1);
635            assert_eq!(v[3], 0);
636        }
637    }
638
639    #[test]
640    fn test_collapse_dims() {
641        let arr = make_4x4_u8();
642        let mut config = ROIConfig::default();
643        config.dims[0] = ROIDimConfig {
644            min: 0,
645            size: 4,
646            bin: 1,
647            reverse: false,
648            enable: true,
649            auto_size: false,
650        };
651        config.dims[1] = ROIDimConfig {
652            min: 0,
653            size: 1,
654            bin: 1,
655            reverse: false,
656            enable: true,
657            auto_size: false,
658        };
659        config.collapse_dims = true;
660
661        let roi = extract_roi_2d(&arr, &config).unwrap();
662        assert_eq!(roi.dims.len(), 1);
663        assert_eq!(roi.dims[0].size, 4);
664    }
665
666    #[test]
667    fn test_scale() {
668        let arr = make_4x4_u8();
669        let mut config = ROIConfig::default();
670        config.dims[0] = ROIDimConfig {
671            min: 0,
672            size: 2,
673            bin: 1,
674            reverse: false,
675            enable: true,
676            auto_size: false,
677        };
678        config.dims[1] = ROIDimConfig {
679            min: 0,
680            size: 1,
681            bin: 1,
682            reverse: false,
683            enable: true,
684            auto_size: false,
685        };
686        config.enable_scale = true;
687        config.scale = 2.0;
688
689        let roi = extract_roi_2d(&arr, &config).unwrap();
690        if let NDDataBuffer::U8(ref v) = roi.data {
691            assert_eq!(v[0], 0); // 0 * 2 = 0
692            assert_eq!(v[1], 2); // 1 * 2 = 2
693        }
694    }
695
696    #[test]
697    fn test_type_convert() {
698        let arr = make_4x4_u8();
699        let mut config = ROIConfig::default();
700        config.dims[0] = ROIDimConfig {
701            min: 0,
702            size: 2,
703            bin: 1,
704            reverse: false,
705            enable: true,
706            auto_size: false,
707        };
708        config.dims[1] = ROIDimConfig {
709            min: 0,
710            size: 1,
711            bin: 1,
712            reverse: false,
713            enable: true,
714            auto_size: false,
715        };
716        config.data_type = Some(NDDataType::UInt16);
717
718        let roi = extract_roi_2d(&arr, &config).unwrap();
719        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
720    }
721
722    // --- New ROIProcessor tests ---
723
724    #[test]
725    fn test_roi_processor() {
726        let mut config = ROIConfig::default();
727        config.dims[0] = ROIDimConfig {
728            min: 1,
729            size: 2,
730            bin: 1,
731            reverse: false,
732            enable: true,
733            auto_size: false,
734        };
735        config.dims[1] = ROIDimConfig {
736            min: 1,
737            size: 2,
738            bin: 1,
739            reverse: false,
740            enable: true,
741            auto_size: false,
742        };
743
744        let mut proc = ROIProcessor::new(config);
745        let pool = NDArrayPool::new(1_000_000);
746
747        let arr = make_4x4_u8();
748        let result = proc.process_array(&arr, &pool);
749        assert_eq!(result.output_arrays.len(), 1);
750        assert_eq!(result.output_arrays[0].dims[0].size, 2);
751        assert_eq!(result.output_arrays[0].dims[1].size, 2);
752    }
753
754    // --- Auto-size / dim-disable / autocenter tests ---
755
756    #[test]
757    fn test_auto_size() {
758        // 4x4 image, min_x=1 with auto_size => size_x = 4-1 = 3
759        let arr = make_4x4_u8();
760        let mut config = ROIConfig::default();
761        config.dims[0] = ROIDimConfig {
762            min: 1,
763            size: 0,
764            bin: 1,
765            reverse: false,
766            enable: true,
767            auto_size: true,
768        };
769        config.dims[1] = ROIDimConfig {
770            min: 0,
771            size: 0,
772            bin: 1,
773            reverse: false,
774            enable: true,
775            auto_size: true,
776        };
777
778        let roi = extract_roi_2d(&arr, &config).unwrap();
779        assert_eq!(roi.dims[0].size, 3); // 4 - 1 = 3
780        assert_eq!(roi.dims[1].size, 4); // 4 - 0 = 4
781
782        if let NDDataBuffer::U8(ref v) = roi.data {
783            // First row (y=0): pixels at x=1,2,3 => values 1,2,3
784            assert_eq!(v[0], 1);
785            assert_eq!(v[1], 2);
786            assert_eq!(v[2], 3);
787        }
788    }
789
790    #[test]
791    fn test_dim_disable() {
792        // Disabled dim uses full range: min=0, size=src_dim
793        let arr = make_4x4_u8();
794        let mut config = ROIConfig::default();
795        config.dims[0] = ROIDimConfig {
796            min: 2,
797            size: 1,
798            bin: 1,
799            reverse: false,
800            enable: false,
801            auto_size: false,
802        };
803        config.dims[1] = ROIDimConfig {
804            min: 0,
805            size: 4,
806            bin: 1,
807            reverse: false,
808            enable: true,
809            auto_size: false,
810        };
811
812        let roi = extract_roi_2d(&arr, &config).unwrap();
813        // X dim disabled, so full range: size=4
814        assert_eq!(roi.dims[0].size, 4);
815        assert_eq!(roi.dims[1].size, 4);
816    }
817
818    #[test]
819    fn test_autocenter_peak() {
820        // Create 8x8 image with a peak at (6, 5)
821        let mut arr = NDArray::new(
822            vec![NDDimension::new(8), NDDimension::new(8)],
823            NDDataType::UInt8,
824        );
825        if let NDDataBuffer::U8(ref mut v) = arr.data {
826            for i in 0..64 {
827                v[i] = 1;
828            }
829            // Place peak at x=6, y=5
830            v[5 * 8 + 6] = 255;
831        }
832
833        let mut config = ROIConfig::default();
834        config.dims[0] = ROIDimConfig {
835            min: 0,
836            size: 4,
837            bin: 1,
838            reverse: false,
839            enable: true,
840            auto_size: false,
841        };
842        config.dims[1] = ROIDimConfig {
843            min: 0,
844            size: 4,
845            bin: 1,
846            reverse: false,
847            enable: true,
848            auto_size: false,
849        };
850        config.autocenter = AutoCenter::PeakPosition;
851
852        let roi = extract_roi_2d(&arr, &config).unwrap();
853        assert_eq!(roi.dims[0].size, 4);
854        assert_eq!(roi.dims[1].size, 4);
855
856        // ROI should be centered on peak (6,5) with size 4x4
857        // min_x = 6 - 4/2 = 4, clamped to min(4, 8-4)=4
858        // min_y = 5 - 4/2 = 3, clamped to min(3, 8-4)=3
859        // So ROI covers x=[4..8), y=[3..7) and the peak at (6,5) should be inside
860        // In the ROI, the peak is at local (6-4, 5-3) = (2, 2)
861        if let NDDataBuffer::U8(ref v) = roi.data {
862            assert_eq!(v[2 * 4 + 2], 255); // peak at local (2,2)
863        }
864    }
865}