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