Skip to main content

ad_plugins/
roi.rs

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