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 an ROI from an NDArray, dispatching on dimensionality.
111///
112/// 2-D arrays go through [`extract_roi_2d`]. 3-D color arrays
113/// (RGB1/RGB2/RGB3) are handled by [`extract_roi_3d`], which — like C++
114/// `NDPluginROI` via `userDims = {xDim, yDim, colorDim}` — keeps ROI Dim0/Dim1
115/// bound to the image X/Y axes and Dim2 to the color axis regardless of the
116/// physical dimension order.
117pub fn extract_roi(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
118    use ad_core_rs::color::NDColorMode;
119    if src.dims.len() >= 3 {
120        let info = src.info();
121        if matches!(
122            info.color_mode,
123            NDColorMode::RGB1 | NDColorMode::RGB2 | NDColorMode::RGB3
124        ) {
125            return extract_roi_3d(src, config);
126        }
127    }
128    extract_roi_2d(src, config)
129}
130
131/// Extract an ROI from a 3-D RGB color array.
132///
133/// Mirrors C++ `NDPluginROI`: ROI `dims[0]` selects the X axis, `dims[1]` the
134/// Y axis and `dims[2]` the color axis (`userDims = {xDim, yDim, colorDim}`),
135/// so the ROI geometry is independent of the RGB1/RGB2/RGB3 memory layout.
136/// Per-axis binning and reverse are applied; the output keeps the source
137/// color mode and dimension order.
138pub fn extract_roi_3d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
139    let info = src.info();
140    let (src_x, src_y, src_c) = (info.x_size, info.y_size, info.color_size.max(1));
141    if src_x == 0 || src_y == 0 || src_c == 0 {
142        return None;
143    }
144
145    // Resolve offset/size per axis (C++ NDPluginROI clamping).
146    let resolve = |cfg: &ROIDimConfig, dim_size: usize| -> (usize, usize) {
147        if !cfg.enable || dim_size == 0 {
148            return (0, dim_size);
149        }
150        let offset = cfg.min.min(dim_size - 1);
151        let size = if cfg.auto_size { dim_size } else { cfg.size };
152        let size = size.max(1).min(dim_size - offset);
153        (offset, size)
154    };
155    let (x_min, x_roi) = resolve(&config.dims[0], src_x);
156    let (y_min, y_roi) = resolve(&config.dims[1], src_y);
157    let (c_min, c_roi) = resolve(&config.dims[2], src_c);
158
159    let bin_x = config.dims[0].bin.max(1).min(x_roi);
160    let bin_y = config.dims[1].bin.max(1).min(y_roi);
161    let bin_c = config.dims[2].bin.max(1).min(c_roi);
162    let (out_x, out_y, out_c) = (x_roi / bin_x, y_roi / bin_y, c_roi / bin_c);
163    if out_x == 0 || out_y == 0 || out_c == 0 {
164        return None;
165    }
166
167    // Source strides (elements) for X/Y/color.
168    let (sxs, sys, scs) = (
169        info.x_stride.max(1),
170        info.y_stride.max(1),
171        info.color_stride.max(1),
172    );
173    // Destination layout keeps the source color mode.
174    let (dims, dxs, dys, dcs) = match info.color_mode {
175        ad_core_rs::color::NDColorMode::RGB1 => (
176            vec![
177                NDDimension::new(out_c),
178                NDDimension::new(out_x),
179                NDDimension::new(out_y),
180            ],
181            out_c,
182            out_x * out_c,
183            1usize,
184        ),
185        ad_core_rs::color::NDColorMode::RGB2 => (
186            vec![
187                NDDimension::new(out_x),
188                NDDimension::new(out_c),
189                NDDimension::new(out_y),
190            ],
191            1usize,
192            out_x * out_c,
193            out_x,
194        ),
195        _ => (
196            vec![
197                NDDimension::new(out_x),
198                NDDimension::new(out_y),
199                NDDimension::new(out_c),
200            ],
201            1usize,
202            out_x,
203            out_x * out_y,
204        ),
205    };
206    let total = out_x * out_y * out_c;
207
208    macro_rules! extract3d {
209        ($vec:expr, $T:ty, $zero:expr) => {{
210            let mut out = vec![$zero; total];
211            for oc in 0..out_c {
212                for oy in 0..out_y {
213                    for ox in 0..out_x {
214                        let mut sum = 0.0f64;
215                        for bc in 0..bin_c {
216                            for by in 0..bin_y {
217                                for bx in 0..bin_x {
218                                    let sx = x_min + ox * bin_x + bx;
219                                    let sy = y_min + oy * bin_y + by;
220                                    let sc = c_min + oc * bin_c + bc;
221                                    sum += $vec[sy * sys + sx * sxs + sc * scs] as f64;
222                                }
223                            }
224                        }
225                        let dx = if config.dims[0].reverse {
226                            out_x - 1 - ox
227                        } else {
228                            ox
229                        };
230                        let dy = if config.dims[1].reverse {
231                            out_y - 1 - oy
232                        } else {
233                            oy
234                        };
235                        let dc = if config.dims[2].reverse {
236                            out_c - 1 - oc
237                        } else {
238                            oc
239                        };
240                        let scaled = if config.enable_scale && config.scale != 0.0 {
241                            sum / config.scale
242                        } else {
243                            sum
244                        };
245                        out[dy * dys + dx * dxs + dc * dcs] = scaled as $T;
246                    }
247                }
248            }
249            out
250        }};
251    }
252
253    let out_data = match &src.data {
254        NDDataBuffer::U8(v) => NDDataBuffer::U8(extract3d!(v, u8, 0)),
255        NDDataBuffer::U16(v) => NDDataBuffer::U16(extract3d!(v, u16, 0)),
256        NDDataBuffer::I8(v) => NDDataBuffer::I8(extract3d!(v, i8, 0)),
257        NDDataBuffer::I16(v) => NDDataBuffer::I16(extract3d!(v, i16, 0)),
258        NDDataBuffer::I32(v) => NDDataBuffer::I32(extract3d!(v, i32, 0)),
259        NDDataBuffer::U32(v) => NDDataBuffer::U32(extract3d!(v, u32, 0)),
260        NDDataBuffer::I64(v) => NDDataBuffer::I64(extract3d!(v, i64, 0)),
261        NDDataBuffer::U64(v) => NDDataBuffer::U64(extract3d!(v, u64, 0)),
262        NDDataBuffer::F32(v) => NDDataBuffer::F32(extract3d!(v, f32, 0.0)),
263        NDDataBuffer::F64(v) => NDDataBuffer::F64(extract3d!(v, f64, 0.0)),
264    };
265
266    // Single-color selection: when the color axis collapses to 1 and the
267    // source is an RGB mode, C forces collapseDims and tags the output Mono
268    // (NDPluginROI.cpp:177-200). The user collapseDims param otherwise still
269    // collapses any size-1 dimension (NDPluginROI.cpp:202-215). When out_c == 1
270    // the extracted buffer is already in [x, y] row-major order for every RGB
271    // layout, so dropping the size-1 axes needs no data reordering.
272    let single_color = out_c == 1
273        && matches!(
274            info.color_mode,
275            ad_core_rs::color::NDColorMode::RGB1
276                | ad_core_rs::color::NDColorMode::RGB2
277                | ad_core_rs::color::NDColorMode::RGB3
278        );
279    let dims = if single_color || config.collapse_dims {
280        let collapsed: Vec<NDDimension> = dims.into_iter().filter(|d| d.size > 1).collect();
281        if collapsed.is_empty() {
282            vec![NDDimension::new(1)]
283        } else {
284            collapsed
285        }
286    } else {
287        dims
288    };
289
290    // Apply the requested output data type (C converts to ROIDataType, or the
291    // input type when ROIDataType == -1, NDPluginROI.cpp:144,166-174). Mirrors
292    // the 2-D path so a 3-D RGB ROI honours ROI_DATA_TYPE.
293    let target_type = config.data_type.unwrap_or(src.data.data_type());
294    let mut arr = NDArray::new(dims, target_type);
295    if target_type == src.data.data_type() {
296        arr.data = out_data;
297    } else {
298        let mut temp = NDArray::new(arr.dims.clone(), src.data.data_type());
299        temp.data = out_data;
300        match ad_core_rs::color::convert_data_type(&temp, target_type) {
301            Ok(converted) => arr.data = converted.data,
302            Err(e) => {
303                tracing::warn!(
304                    error = %e,
305                    from = ?src.data.data_type(),
306                    to = ?target_type,
307                    "ROI 3-D output data-type conversion failed; dropping frame"
308                );
309                return None;
310            }
311        }
312    }
313    arr.unique_id = src.unique_id;
314    arr.timestamp = src.timestamp;
315    arr.time_stamp = src.time_stamp;
316    arr.attributes = src.attributes.clone();
317    // A single selected color plane is mono (C NDPluginROI.cpp:185/192/199
318    // overrides the ColorMode attribute on the collapsed output).
319    if single_color {
320        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
321        arr.attributes.add(NDAttribute::new_static(
322            "ColorMode",
323            "Color mode",
324            NDAttrSource::Driver,
325            NDAttrValue::Int32(ad_core_rs::color::NDColorMode::Mono as i32),
326        ));
327    }
328    Some(arr)
329}
330
331/// Extract ROI sub-region from a 2D array.
332pub fn extract_roi_2d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
333    if src.dims.len() < 2 {
334        return None;
335    }
336
337    let src_x = src.dims[0].size;
338    let src_y = src.dims[1].size;
339
340    // Resolve effective min/size for one dimension, matching C++ NDPluginROI:
341    //   offset  = MAX(offset, 0); offset = MIN(offset, dimSize-1);
342    //   size    = autoSize ? dimSize : size;
343    //   size    = MAX(size, 1); size = MIN(size, dimSize - offset);
344    // When the dimension is disabled C++ uses offset=0, size=dimSize.
345    let resolve = |cfg: &ROIDimConfig, dim_size: usize| -> (usize, usize) {
346        if !cfg.enable || dim_size == 0 {
347            return (0, dim_size);
348        }
349        // offset clamped to [0, dimSize-1] (one past the last index is illegal).
350        let offset = cfg.min.min(dim_size - 1);
351        let size = if cfg.auto_size { dim_size } else { cfg.size };
352        // size clamped to [1, dimSize - offset].
353        let size = size.max(1).min(dim_size - offset);
354        (offset, size)
355    };
356    let (eff_x_min, eff_x_size) = resolve(&config.dims[0], src_x);
357    let (eff_y_min, eff_y_size) = resolve(&config.dims[1], src_y);
358
359    // Apply autocenter: shift ROI min so that the ROI is centered on the
360    // centroid or peak, keeping the effective size the same.
361    let (roi_x_min, roi_y_min) = match config.autocenter {
362        AutoCenter::None => (eff_x_min, eff_y_min),
363        AutoCenter::CenterOfMass => {
364            let (cx, cy) = find_centroid_2d(&src.data, src_x, src_y);
365            let mx = cx
366                .saturating_sub(eff_x_size / 2)
367                .min(src_x.saturating_sub(eff_x_size));
368            let my = cy
369                .saturating_sub(eff_y_size / 2)
370                .min(src_y.saturating_sub(eff_y_size));
371            (mx, my)
372        }
373        AutoCenter::PeakPosition => {
374            let (px, py) = find_peak_2d(&src.data, src_x, src_y);
375            let mx = px
376                .saturating_sub(eff_x_size / 2)
377                .min(src_x.saturating_sub(eff_x_size));
378            let my = py
379                .saturating_sub(eff_y_size / 2)
380                .min(src_y.saturating_sub(eff_y_size));
381            (mx, my)
382        }
383    };
384
385    let roi_x_size = eff_x_size;
386    let roi_y_size = eff_y_size;
387
388    if roi_x_size == 0 || roi_y_size == 0 {
389        return None;
390    }
391
392    // C++: binning = MAX(binning, 1); binning = MIN(binning, size).
393    // A bin larger than the ROI is clamped to the ROI size, yielding a
394    // 1-pixel output rather than collapsing to an empty (sink) result.
395    let bin_x = config.dims[0].bin.max(1).min(roi_x_size);
396    let bin_y = config.dims[1].bin.max(1).min(roi_y_size);
397    let out_x = roi_x_size / bin_x;
398    let out_y = roi_y_size / bin_y;
399
400    if out_x == 0 || out_y == 0 {
401        return None;
402    }
403
404    macro_rules! extract {
405        ($vec:expr, $T:ty, $zero:expr) => {{
406            let mut out = vec![$zero; out_x * out_y];
407            for oy in 0..out_y {
408                for ox in 0..out_x {
409                    let mut sum = 0.0f64;
410                    let mut _count = 0usize;
411                    for by in 0..bin_y {
412                        for bx in 0..bin_x {
413                            let sx = roi_x_min + ox * bin_x + bx;
414                            let sy = roi_y_min + oy * bin_y + by;
415                            if sx < src_x && sy < src_y {
416                                sum += $vec[sy * src_x + sx] as f64;
417                                _count += 1;
418                            }
419                        }
420                    }
421                    // C++ sums binned pixels (no averaging); scale is a divisor
422                    let val = sum;
423                    let idx = if config.dims[0].reverse {
424                        out_x - 1 - ox
425                    } else {
426                        ox
427                    } + if config.dims[1].reverse {
428                        out_y - 1 - oy
429                    } else {
430                        oy
431                    } * out_x;
432                    let scaled = if config.enable_scale && config.scale != 0.0 {
433                        val / config.scale
434                    } else {
435                        val
436                    };
437                    out[idx] = scaled as $T;
438                }
439            }
440            out
441        }};
442    }
443
444    let out_data = match &src.data {
445        NDDataBuffer::U8(v) => NDDataBuffer::U8(extract!(v, u8, 0)),
446        NDDataBuffer::U16(v) => NDDataBuffer::U16(extract!(v, u16, 0)),
447        NDDataBuffer::I8(v) => NDDataBuffer::I8(extract!(v, i8, 0)),
448        NDDataBuffer::I16(v) => NDDataBuffer::I16(extract!(v, i16, 0)),
449        NDDataBuffer::I32(v) => NDDataBuffer::I32(extract!(v, i32, 0)),
450        NDDataBuffer::U32(v) => NDDataBuffer::U32(extract!(v, u32, 0)),
451        NDDataBuffer::I64(v) => NDDataBuffer::I64(extract!(v, i64, 0)),
452        NDDataBuffer::U64(v) => NDDataBuffer::U64(extract!(v, u64, 0)),
453        NDDataBuffer::F32(v) => NDDataBuffer::F32(extract!(v, f32, 0.0)),
454        NDDataBuffer::F64(v) => NDDataBuffer::F64(extract!(v, f64, 0.0)),
455    };
456
457    let out_dims = if config.collapse_dims {
458        let all_dims = vec![NDDimension::new(out_x), NDDimension::new(out_y)];
459        let filtered: Vec<NDDimension> = all_dims.into_iter().filter(|d| d.size > 1).collect();
460        if filtered.is_empty() {
461            vec![NDDimension::new(out_x)]
462        } else {
463            filtered
464        }
465    } else {
466        vec![NDDimension::new(out_x), NDDimension::new(out_y)]
467    };
468
469    // Apply data type conversion if requested
470    let target_type = config.data_type.unwrap_or(src.data.data_type());
471
472    let mut arr = NDArray::new(out_dims, target_type);
473    if target_type == src.data.data_type() {
474        arr.data = out_data;
475    } else {
476        // Convert via color module
477        let mut temp = NDArray::new(arr.dims.clone(), src.data.data_type());
478        temp.data = out_data;
479        match ad_core_rs::color::convert_data_type(&temp, target_type) {
480            Ok(converted) => arr.data = converted.data,
481            Err(e) => {
482                // A data-type conversion failure must NOT publish an all-zero
483                // buffer as if it were valid ROI output. Drop the frame and
484                // log; the caller treats `None` as "no output this frame".
485                tracing::warn!(
486                    error = %e,
487                    from = ?src.data.data_type(),
488                    to = ?target_type,
489                    "ROI output data-type conversion failed; dropping frame"
490                );
491                return None;
492            }
493        }
494    }
495
496    arr.unique_id = src.unique_id;
497    arr.timestamp = src.timestamp;
498    arr.attributes = src.attributes.clone();
499    Some(arr)
500}
501
502/// Per-dimension param reasons.
503#[derive(Default, Clone, Copy)]
504pub struct ROIDimParams {
505    pub min: usize,
506    pub size: usize,
507    pub bin: usize,
508    pub reverse: usize,
509    pub enable: usize,
510    pub auto_size: usize,
511    pub max_size: usize,
512}
513
514/// Param reasons for all ROI params.
515#[derive(Default)]
516pub struct ROIParams {
517    pub dims: [ROIDimParams; 3],
518    pub enable_scale: usize,
519    pub scale: usize,
520    pub data_type: usize,
521    pub collapse_dims: usize,
522    pub name: usize,
523}
524
525/// Pure ROI processing logic.
526pub struct ROIProcessor {
527    config: ROIConfig,
528    params: ROIParams,
529}
530
531impl ROIProcessor {
532    pub fn new(config: ROIConfig) -> Self {
533        Self {
534            config,
535            params: ROIParams::default(),
536        }
537    }
538
539    /// Access the registered ROI param reasons.
540    pub fn params(&self) -> &ROIParams {
541        &self.params
542    }
543}
544
545impl NDPluginProcess for ROIProcessor {
546    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
547        // Report input array dimensions as MaxSize params
548        let mut updates = Vec::new();
549        for (i, dim_params) in self.params.dims.iter().enumerate() {
550            let dim_size = array.dims.get(i).map(|d| d.size as i32).unwrap_or(0);
551            updates.push(ParamUpdate::int32(dim_params.max_size, dim_size));
552        }
553
554        match extract_roi(array, &self.config) {
555            Some(roi_arr) => ProcessResult {
556                output_arrays: vec![Arc::new(roi_arr)],
557                param_updates: updates,
558                scatter: false,
559            },
560            None => ProcessResult::sink(updates),
561        }
562    }
563
564    fn plugin_type(&self) -> &str {
565        "NDPluginROI"
566    }
567
568    fn register_params(
569        &mut self,
570        base: &mut PortDriverBase,
571    ) -> Result<(), asyn_rs::error::AsynError> {
572        let dim_names = ["DIM0", "DIM1", "DIM2"];
573        for (i, prefix) in dim_names.iter().enumerate() {
574            self.params.dims[i].min =
575                base.create_param(&format!("{prefix}_MIN"), ParamType::Int32)?;
576            self.params.dims[i].size =
577                base.create_param(&format!("{prefix}_SIZE"), ParamType::Int32)?;
578            self.params.dims[i].bin =
579                base.create_param(&format!("{prefix}_BIN"), ParamType::Int32)?;
580            self.params.dims[i].reverse =
581                base.create_param(&format!("{prefix}_REVERSE"), ParamType::Int32)?;
582            self.params.dims[i].enable =
583                base.create_param(&format!("{prefix}_ENABLE"), ParamType::Int32)?;
584            self.params.dims[i].auto_size =
585                base.create_param(&format!("{prefix}_AUTO_SIZE"), ParamType::Int32)?;
586            self.params.dims[i].max_size =
587                base.create_param(&format!("{prefix}_MAX_SIZE"), ParamType::Int32)?;
588
589            // Set initial values from config
590            base.set_int32_param(self.params.dims[i].min, 0, self.config.dims[i].min as i32)?;
591            base.set_int32_param(self.params.dims[i].size, 0, self.config.dims[i].size as i32)?;
592            base.set_int32_param(self.params.dims[i].bin, 0, self.config.dims[i].bin as i32)?;
593            base.set_int32_param(
594                self.params.dims[i].reverse,
595                0,
596                self.config.dims[i].reverse as i32,
597            )?;
598            base.set_int32_param(
599                self.params.dims[i].enable,
600                0,
601                self.config.dims[i].enable as i32,
602            )?;
603            base.set_int32_param(
604                self.params.dims[i].auto_size,
605                0,
606                self.config.dims[i].auto_size as i32,
607            )?;
608        }
609        self.params.enable_scale = base.create_param("ENABLE_SCALE", ParamType::Int32)?;
610        self.params.scale = base.create_param("SCALE_VALUE", ParamType::Float64)?;
611        self.params.data_type = base.create_param("ROI_DATA_TYPE", ParamType::Int32)?;
612        self.params.collapse_dims = base.create_param("COLLAPSE_DIMS", ParamType::Int32)?;
613        self.params.name = base.create_param("NAME", ParamType::Octet)?;
614
615        base.set_int32_param(self.params.enable_scale, 0, self.config.enable_scale as i32)?;
616        base.set_float64_param(self.params.scale, 0, self.config.scale)?;
617        base.set_int32_param(self.params.data_type, 0, -1)?; // -1 = Automatic
618        base.set_int32_param(
619            self.params.collapse_dims,
620            0,
621            self.config.collapse_dims as i32,
622        )?;
623
624        Ok(())
625    }
626
627    fn on_param_change(
628        &mut self,
629        reason: usize,
630        snapshot: &PluginParamSnapshot,
631    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
632        let p = &self.params;
633        for i in 0..3 {
634            if reason == p.dims[i].min {
635                self.config.dims[i].min = snapshot.value.as_i32().max(0) as usize;
636                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
637            }
638            if reason == p.dims[i].size {
639                self.config.dims[i].size = snapshot.value.as_i32().max(0) as usize;
640                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
641            }
642            if reason == p.dims[i].bin {
643                self.config.dims[i].bin = snapshot.value.as_i32().max(1) as usize;
644                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
645            }
646            if reason == p.dims[i].reverse {
647                self.config.dims[i].reverse = snapshot.value.as_i32() != 0;
648                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
649            }
650            if reason == p.dims[i].enable {
651                self.config.dims[i].enable = snapshot.value.as_i32() != 0;
652                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
653            }
654            if reason == p.dims[i].auto_size {
655                self.config.dims[i].auto_size = snapshot.value.as_i32() != 0;
656                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
657            }
658        }
659        if reason == p.enable_scale {
660            self.config.enable_scale = snapshot.value.as_i32() != 0;
661        } else if reason == p.scale {
662            self.config.scale = snapshot.value.as_f64();
663        } else if reason == p.data_type {
664            let v = snapshot.value.as_i32();
665            self.config.data_type = if v < 0 {
666                None
667            } else {
668                NDDataType::from_ordinal(v as u8)
669            };
670        } else if reason == p.collapse_dims {
671            self.config.collapse_dims = snapshot.value.as_i32() != 0;
672        }
673        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
674    }
675}
676
677/// Create an ROI plugin runtime, returning the handle and param reasons.
678pub fn create_roi_runtime(
679    port_name: &str,
680    pool: Arc<NDArrayPool>,
681    queue_size: usize,
682    ndarray_port: &str,
683    wiring: Arc<ad_core_rs::plugin::wiring::WiringRegistry>,
684) -> (
685    ad_core_rs::plugin::runtime::PluginRuntimeHandle,
686    ROIParams,
687    std::thread::JoinHandle<()>,
688) {
689    let processor = ROIProcessor::new(ROIConfig::default());
690    let (handle, jh) = ad_core_rs::plugin::runtime::create_plugin_runtime(
691        port_name,
692        processor,
693        pool,
694        queue_size,
695        ndarray_port,
696        wiring,
697    );
698    // Recreate param layout on a scratch PortDriverBase to get matching reasons.
699    let params = {
700        let mut base =
701            asyn_rs::port::PortDriverBase::new("_scratch_", 1, asyn_rs::port::PortFlags::default());
702        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
703        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
704        let mut p = ROIParams::default();
705        let dim_names = ["DIM0", "DIM1", "DIM2"];
706        for (i, prefix) in dim_names.iter().enumerate() {
707            p.dims[i].min = base
708                .create_param(&format!("{prefix}_MIN"), asyn_rs::param::ParamType::Int32)
709                .unwrap();
710            p.dims[i].size = base
711                .create_param(&format!("{prefix}_SIZE"), asyn_rs::param::ParamType::Int32)
712                .unwrap();
713            p.dims[i].bin = base
714                .create_param(&format!("{prefix}_BIN"), asyn_rs::param::ParamType::Int32)
715                .unwrap();
716            p.dims[i].reverse = base
717                .create_param(
718                    &format!("{prefix}_REVERSE"),
719                    asyn_rs::param::ParamType::Int32,
720                )
721                .unwrap();
722            p.dims[i].enable = base
723                .create_param(
724                    &format!("{prefix}_ENABLE"),
725                    asyn_rs::param::ParamType::Int32,
726                )
727                .unwrap();
728            p.dims[i].auto_size = base
729                .create_param(
730                    &format!("{prefix}_AUTO_SIZE"),
731                    asyn_rs::param::ParamType::Int32,
732                )
733                .unwrap();
734            p.dims[i].max_size = base
735                .create_param(
736                    &format!("{prefix}_MAX_SIZE"),
737                    asyn_rs::param::ParamType::Int32,
738                )
739                .unwrap();
740        }
741        p.enable_scale = base
742            .create_param("ENABLE_SCALE", asyn_rs::param::ParamType::Int32)
743            .unwrap();
744        p.scale = base
745            .create_param("SCALE_VALUE", asyn_rs::param::ParamType::Float64)
746            .unwrap();
747        p.data_type = base
748            .create_param("ROI_DATA_TYPE", asyn_rs::param::ParamType::Int32)
749            .unwrap();
750        p.collapse_dims = base
751            .create_param("COLLAPSE_DIMS", asyn_rs::param::ParamType::Int32)
752            .unwrap();
753        p.name = base
754            .create_param("NAME", asyn_rs::param::ParamType::Octet)
755            .unwrap();
756        p
757    };
758    (handle, params, jh)
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    fn make_4x4_u8() -> NDArray {
766        let mut arr = NDArray::new(
767            vec![NDDimension::new(4), NDDimension::new(4)],
768            NDDataType::UInt8,
769        );
770        if let NDDataBuffer::U8(ref mut v) = arr.data {
771            for i in 0..16 {
772                v[i] = i as u8;
773            }
774        }
775        arr
776    }
777
778    #[test]
779    fn test_extract_sub_region() {
780        let arr = make_4x4_u8();
781        let mut config = ROIConfig::default();
782        config.dims[0] = ROIDimConfig {
783            min: 1,
784            size: 2,
785            bin: 1,
786            reverse: false,
787            enable: true,
788            auto_size: false,
789        };
790        config.dims[1] = ROIDimConfig {
791            min: 1,
792            size: 2,
793            bin: 1,
794            reverse: false,
795            enable: true,
796            auto_size: false,
797        };
798
799        let roi = extract_roi_2d(&arr, &config).unwrap();
800        assert_eq!(roi.dims[0].size, 2);
801        assert_eq!(roi.dims[1].size, 2);
802        if let NDDataBuffer::U8(ref v) = roi.data {
803            // row 1, cols 1-2: [5,6], row 2, cols 1-2: [9,10]
804            assert_eq!(v[0], 5);
805            assert_eq!(v[1], 6);
806            assert_eq!(v[2], 9);
807            assert_eq!(v[3], 10);
808        }
809    }
810
811    #[test]
812    fn test_binning_2x2() {
813        let arr = make_4x4_u8();
814        let mut config = ROIConfig::default();
815        config.dims[0] = ROIDimConfig {
816            min: 0,
817            size: 4,
818            bin: 2,
819            reverse: false,
820            enable: true,
821            auto_size: false,
822        };
823        config.dims[1] = ROIDimConfig {
824            min: 0,
825            size: 4,
826            bin: 2,
827            reverse: false,
828            enable: true,
829            auto_size: false,
830        };
831
832        let roi = extract_roi_2d(&arr, &config).unwrap();
833        assert_eq!(roi.dims[0].size, 2);
834        assert_eq!(roi.dims[1].size, 2);
835        if let NDDataBuffer::U8(ref v) = roi.data {
836            // top-left 2x2: sum = 0+1+4+5 = 10 (C++ sums, not averages)
837            assert_eq!(v[0], 10);
838        }
839    }
840
841    #[test]
842    fn test_reverse() {
843        let arr = make_4x4_u8();
844        let mut config = ROIConfig::default();
845        config.dims[0] = ROIDimConfig {
846            min: 0,
847            size: 4,
848            bin: 1,
849            reverse: true,
850            enable: true,
851            auto_size: false,
852        };
853        config.dims[1] = ROIDimConfig {
854            min: 0,
855            size: 1,
856            bin: 1,
857            reverse: false,
858            enable: true,
859            auto_size: false,
860        };
861
862        let roi = extract_roi_2d(&arr, &config).unwrap();
863        if let NDDataBuffer::U8(ref v) = roi.data {
864            assert_eq!(v[0], 3);
865            assert_eq!(v[1], 2);
866            assert_eq!(v[2], 1);
867            assert_eq!(v[3], 0);
868        }
869    }
870
871    #[test]
872    fn test_collapse_dims() {
873        let arr = make_4x4_u8();
874        let mut config = ROIConfig::default();
875        config.dims[0] = ROIDimConfig {
876            min: 0,
877            size: 4,
878            bin: 1,
879            reverse: false,
880            enable: true,
881            auto_size: false,
882        };
883        config.dims[1] = ROIDimConfig {
884            min: 0,
885            size: 1,
886            bin: 1,
887            reverse: false,
888            enable: true,
889            auto_size: false,
890        };
891        config.collapse_dims = true;
892
893        let roi = extract_roi_2d(&arr, &config).unwrap();
894        assert_eq!(roi.dims.len(), 1);
895        assert_eq!(roi.dims[0].size, 4);
896    }
897
898    #[test]
899    fn test_scale() {
900        let arr = make_4x4_u8();
901        let mut config = ROIConfig::default();
902        config.dims[0] = ROIDimConfig {
903            min: 0,
904            size: 2,
905            bin: 1,
906            reverse: false,
907            enable: true,
908            auto_size: false,
909        };
910        config.dims[1] = ROIDimConfig {
911            min: 0,
912            size: 1,
913            bin: 1,
914            reverse: false,
915            enable: true,
916            auto_size: false,
917        };
918        config.enable_scale = true;
919        config.scale = 2.0;
920
921        let roi = extract_roi_2d(&arr, &config).unwrap();
922        if let NDDataBuffer::U8(ref v) = roi.data {
923            // C++: scale is a divisor
924            assert_eq!(v[0], 0); // 0 / 2 = 0
925            assert_eq!(v[1], 0); // 1 / 2 = 0.5 → 0
926        }
927    }
928
929    #[test]
930    fn test_type_convert() {
931        let arr = make_4x4_u8();
932        let mut config = ROIConfig::default();
933        config.dims[0] = ROIDimConfig {
934            min: 0,
935            size: 2,
936            bin: 1,
937            reverse: false,
938            enable: true,
939            auto_size: false,
940        };
941        config.dims[1] = ROIDimConfig {
942            min: 0,
943            size: 1,
944            bin: 1,
945            reverse: false,
946            enable: true,
947            auto_size: false,
948        };
949        config.data_type = Some(NDDataType::UInt16);
950
951        let roi = extract_roi_2d(&arr, &config).unwrap();
952        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
953    }
954
955    // --- New ROIProcessor tests ---
956
957    #[test]
958    fn test_roi_processor() {
959        let mut config = ROIConfig::default();
960        config.dims[0] = ROIDimConfig {
961            min: 1,
962            size: 2,
963            bin: 1,
964            reverse: false,
965            enable: true,
966            auto_size: false,
967        };
968        config.dims[1] = ROIDimConfig {
969            min: 1,
970            size: 2,
971            bin: 1,
972            reverse: false,
973            enable: true,
974            auto_size: false,
975        };
976
977        let mut proc = ROIProcessor::new(config);
978        let pool = NDArrayPool::new(1_000_000);
979
980        let arr = make_4x4_u8();
981        let result = proc.process_array(&arr, &pool);
982        assert_eq!(result.output_arrays.len(), 1);
983        assert_eq!(result.output_arrays[0].dims[0].size, 2);
984        assert_eq!(result.output_arrays[0].dims[1].size, 2);
985    }
986
987    // --- Auto-size / dim-disable / autocenter tests ---
988
989    #[test]
990    fn test_auto_size() {
991        // 4x4 image, min_x=1 with auto_size => size_x = 4-1 = 3
992        let arr = make_4x4_u8();
993        let mut config = ROIConfig::default();
994        config.dims[0] = ROIDimConfig {
995            min: 1,
996            size: 0,
997            bin: 1,
998            reverse: false,
999            enable: true,
1000            auto_size: true,
1001        };
1002        config.dims[1] = ROIDimConfig {
1003            min: 0,
1004            size: 0,
1005            bin: 1,
1006            reverse: false,
1007            enable: true,
1008            auto_size: true,
1009        };
1010
1011        let roi = extract_roi_2d(&arr, &config).unwrap();
1012        // C++: autoSize sets size = dimSize, then size = MIN(size, dimSize -
1013        // offset). With offset_x = 1 the X size clamps to 4 - 1 = 3; the Y
1014        // dimension with offset 0 stays at the full 4.
1015        assert_eq!(roi.dims[0].size, 3);
1016        assert_eq!(roi.dims[1].size, 4);
1017    }
1018
1019    #[test]
1020    fn test_dim_disable() {
1021        // Disabled dim uses full range: min=0, size=src_dim
1022        let arr = make_4x4_u8();
1023        let mut config = ROIConfig::default();
1024        config.dims[0] = ROIDimConfig {
1025            min: 2,
1026            size: 1,
1027            bin: 1,
1028            reverse: false,
1029            enable: false,
1030            auto_size: false,
1031        };
1032        config.dims[1] = ROIDimConfig {
1033            min: 0,
1034            size: 4,
1035            bin: 1,
1036            reverse: false,
1037            enable: true,
1038            auto_size: false,
1039        };
1040
1041        let roi = extract_roi_2d(&arr, &config).unwrap();
1042        // X dim disabled, so full range: size=4
1043        assert_eq!(roi.dims[0].size, 4);
1044        assert_eq!(roi.dims[1].size, 4);
1045    }
1046
1047    #[test]
1048    fn test_autocenter_peak() {
1049        // Create 8x8 image with a peak at (6, 5)
1050        let mut arr = NDArray::new(
1051            vec![NDDimension::new(8), NDDimension::new(8)],
1052            NDDataType::UInt8,
1053        );
1054        if let NDDataBuffer::U8(ref mut v) = arr.data {
1055            for i in 0..64 {
1056                v[i] = 1;
1057            }
1058            // Place peak at x=6, y=5
1059            v[5 * 8 + 6] = 255;
1060        }
1061
1062        let mut config = ROIConfig::default();
1063        config.dims[0] = ROIDimConfig {
1064            min: 0,
1065            size: 4,
1066            bin: 1,
1067            reverse: false,
1068            enable: true,
1069            auto_size: false,
1070        };
1071        config.dims[1] = ROIDimConfig {
1072            min: 0,
1073            size: 4,
1074            bin: 1,
1075            reverse: false,
1076            enable: true,
1077            auto_size: false,
1078        };
1079        config.autocenter = AutoCenter::PeakPosition;
1080
1081        let roi = extract_roi_2d(&arr, &config).unwrap();
1082        assert_eq!(roi.dims[0].size, 4);
1083        assert_eq!(roi.dims[1].size, 4);
1084
1085        // ROI should be centered on peak (6,5) with size 4x4
1086        // min_x = 6 - 4/2 = 4, clamped to min(4, 8-4)=4
1087        // min_y = 5 - 4/2 = 3, clamped to min(3, 8-4)=3
1088        // So ROI covers x=[4..8), y=[3..7) and the peak at (6,5) should be inside
1089        // In the ROI, the peak is at local (6-4, 5-3) = (2, 2)
1090        if let NDDataBuffer::U8(ref v) = roi.data {
1091            assert_eq!(v[2 * 4 + 2], 255); // peak at local (2,2)
1092        }
1093    }
1094
1095    #[test]
1096    fn test_offset_clamp_to_last_column() {
1097        // Regression: an offset equal to the dim size must clamp to dimSize-1
1098        // and still produce a 1-pixel ROI (C++ MIN(offset, dimSize-1)),
1099        // instead of collapsing to an empty sink.
1100        let arr = make_4x4_u8();
1101        let mut config = ROIConfig::default();
1102        // min == src_x (4): one past the last valid index.
1103        config.dims[0] = ROIDimConfig {
1104            min: 4,
1105            size: 10,
1106            bin: 1,
1107            reverse: false,
1108            enable: true,
1109            auto_size: false,
1110        };
1111        config.dims[1] = ROIDimConfig {
1112            min: 0,
1113            size: 1,
1114            bin: 1,
1115            reverse: false,
1116            enable: true,
1117            auto_size: false,
1118        };
1119        let roi = extract_roi_2d(&arr, &config).unwrap();
1120        // offset clamps to 3, size clamps to 4-3 = 1.
1121        assert_eq!(roi.dims[0].size, 1);
1122        if let NDDataBuffer::U8(ref v) = roi.data {
1123            assert_eq!(v[0], 3); // last column of row 0
1124        }
1125    }
1126
1127    #[test]
1128    fn test_bin_larger_than_roi_clamps() {
1129        // Regression: a bin larger than the ROI is clamped to the ROI size
1130        // (C++ MIN(binning, size)), yielding a 1-pixel output instead of a
1131        // None sink.
1132        let arr = make_4x4_u8();
1133        let mut config = ROIConfig::default();
1134        config.dims[0] = ROIDimConfig {
1135            min: 0,
1136            size: 2,
1137            bin: 99, // far larger than the ROI
1138            reverse: false,
1139            enable: true,
1140            auto_size: false,
1141        };
1142        config.dims[1] = ROIDimConfig {
1143            min: 0,
1144            size: 1,
1145            bin: 1,
1146            reverse: false,
1147            enable: true,
1148            auto_size: false,
1149        };
1150        let roi = extract_roi_2d(&arr, &config).unwrap();
1151        // bin clamps to size 2 => out_x = 2/2 = 1.
1152        assert_eq!(roi.dims[0].size, 1);
1153        if let NDDataBuffer::U8(ref v) = roi.data {
1154            // sum of the 2-pixel bin: 0 + 1 = 1
1155            assert_eq!(v[0], 1);
1156        }
1157    }
1158
1159    /// 2x2 RGB1 image: index = y*6 + x*3 + c, value = 100*y + 10*x + c.
1160    fn make_rgb1_2x2() -> NDArray {
1161        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
1162        let mut arr = NDArray::new(
1163            vec![
1164                NDDimension::new(3),
1165                NDDimension::new(2),
1166                NDDimension::new(2),
1167            ],
1168            NDDataType::UInt8,
1169        );
1170        arr.attributes.add(NDAttribute::new_static(
1171            "ColorMode",
1172            "",
1173            NDAttrSource::Driver,
1174            NDAttrValue::Int32(ad_core_rs::color::NDColorMode::RGB1 as i32),
1175        ));
1176        if let NDDataBuffer::U8(ref mut v) = arr.data {
1177            for y in 0..2 {
1178                for x in 0..2 {
1179                    for c in 0..3 {
1180                        v[y * 6 + x * 3 + c] = (100 * y + 10 * x + c) as u8;
1181                    }
1182                }
1183            }
1184        }
1185        arr
1186    }
1187
1188    #[test]
1189    fn test_roi_3d_rgb1_x_subregion() {
1190        // ROI on an RGB1 image: Dim0 selects X, the color axis is preserved.
1191        let arr = make_rgb1_2x2();
1192        let mut config = ROIConfig::default();
1193        // X: take only column 1.
1194        config.dims[0] = ROIDimConfig {
1195            min: 1,
1196            size: 1,
1197            bin: 1,
1198            reverse: false,
1199            enable: true,
1200            auto_size: false,
1201        };
1202        config.dims[1] = ROIDimConfig {
1203            min: 0,
1204            size: 2,
1205            bin: 1,
1206            reverse: false,
1207            enable: true,
1208            auto_size: false,
1209        };
1210        config.dims[2] = ROIDimConfig {
1211            min: 0,
1212            size: 3,
1213            bin: 1,
1214            reverse: false,
1215            enable: true,
1216            auto_size: false,
1217        };
1218        let roi = extract_roi(&arr, &config).unwrap();
1219        // RGB1 layout: dims = [color=3, x=1, y=2].
1220        assert_eq!(roi.dims[0].size, 3);
1221        assert_eq!(roi.dims[1].size, 1);
1222        assert_eq!(roi.dims[2].size, 2);
1223        if let NDDataBuffer::U8(ref v) = roi.data {
1224            // pixel (x=1,y=0) channels => 10,11,12
1225            assert_eq!(&v[0..3], &[10, 11, 12]);
1226            // pixel (x=1,y=1) channels => 110,111,112
1227            assert_eq!(&v[3..6], &[110, 111, 112]);
1228        } else {
1229            panic!("not u8");
1230        }
1231    }
1232
1233    #[test]
1234    fn test_adp18_3d_rgb_honors_output_data_type() {
1235        // A 3-D RGB ROI with ROI_DATA_TYPE set must convert the output, like
1236        // the 2-D path (C NDPluginROI.cpp:144,166-174). The old 3-D path kept
1237        // the source type regardless of config.data_type.
1238        let arr = make_rgb1_2x2();
1239        let mut config = ROIConfig::default();
1240        config.dims[0] = ROIDimConfig {
1241            min: 0,
1242            size: 2,
1243            bin: 1,
1244            reverse: false,
1245            enable: true,
1246            auto_size: false,
1247        };
1248        config.dims[1] = ROIDimConfig {
1249            min: 0,
1250            size: 2,
1251            bin: 1,
1252            reverse: false,
1253            enable: true,
1254            auto_size: false,
1255        };
1256        config.dims[2] = ROIDimConfig {
1257            min: 0,
1258            size: 3,
1259            bin: 1,
1260            reverse: false,
1261            enable: true,
1262            auto_size: false,
1263        };
1264        config.data_type = Some(NDDataType::UInt16);
1265
1266        let roi = extract_roi(&arr, &config).unwrap();
1267        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
1268        // RGB1 dims preserved: [color=3, x=2, y=2].
1269        assert_eq!(roi.dims[0].size, 3);
1270        assert_eq!(roi.dims[1].size, 2);
1271        assert_eq!(roi.dims[2].size, 2);
1272        // Values preserved through the widening conversion: pixel (0,0) = 0,1,2.
1273        if let NDDataBuffer::U16(ref v) = roi.data {
1274            assert_eq!(&v[0..3], &[0, 1, 2]);
1275        } else {
1276            panic!("not u16");
1277        }
1278    }
1279
1280    #[test]
1281    fn test_adp19_single_color_collapses_to_2d_mono() {
1282        use ad_core_rs::color::NDColorMode;
1283        // Select a single color plane (channel 1) of an RGB1 image. C forces
1284        // collapseDims and sets ColorMode=Mono (NDPluginROI.cpp:177-215), so
1285        // the output is a 2-D mono [x,y] array, not a 3-D RGB1 with a size-1
1286        // color axis.
1287        let arr = make_rgb1_2x2();
1288        let mut config = ROIConfig::default();
1289        config.dims[0] = ROIDimConfig {
1290            min: 0,
1291            size: 2,
1292            bin: 1,
1293            reverse: false,
1294            enable: true,
1295            auto_size: false,
1296        };
1297        config.dims[1] = ROIDimConfig {
1298            min: 0,
1299            size: 2,
1300            bin: 1,
1301            reverse: false,
1302            enable: true,
1303            auto_size: false,
1304        };
1305        config.dims[2] = ROIDimConfig {
1306            min: 1,
1307            size: 1,
1308            bin: 1,
1309            reverse: false,
1310            enable: true,
1311            auto_size: false,
1312        };
1313
1314        let roi = extract_roi(&arr, &config).unwrap();
1315        // Collapsed to 2-D [x=2, y=2].
1316        assert_eq!(roi.dims.len(), 2);
1317        assert_eq!(roi.dims[0].size, 2);
1318        assert_eq!(roi.dims[1].size, 2);
1319        // ColorMode forced to Mono.
1320        let cm = roi
1321            .attributes
1322            .get("ColorMode")
1323            .and_then(|a| a.value.as_i64())
1324            .unwrap();
1325        assert_eq!(cm, NDColorMode::Mono as i64);
1326        // Channel-1 value of pixel (x,y) = 100*y + 10*x + 1, in [x,y] order.
1327        if let NDDataBuffer::U8(ref v) = roi.data {
1328            assert_eq!(v, &[1, 11, 101, 111]);
1329        } else {
1330            panic!("not u8");
1331        }
1332    }
1333}