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    let mut arr = NDArray::new(dims, src.data.data_type());
267    arr.data = out_data;
268    arr.unique_id = src.unique_id;
269    arr.timestamp = src.timestamp;
270    arr.time_stamp = src.time_stamp;
271    arr.attributes = src.attributes.clone();
272    Some(arr)
273}
274
275/// Extract ROI sub-region from a 2D array.
276pub fn extract_roi_2d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
277    if src.dims.len() < 2 {
278        return None;
279    }
280
281    let src_x = src.dims[0].size;
282    let src_y = src.dims[1].size;
283
284    // Resolve effective min/size for one dimension, matching C++ NDPluginROI:
285    //   offset  = MAX(offset, 0); offset = MIN(offset, dimSize-1);
286    //   size    = autoSize ? dimSize : size;
287    //   size    = MAX(size, 1); size = MIN(size, dimSize - offset);
288    // When the dimension is disabled C++ uses offset=0, size=dimSize.
289    let resolve = |cfg: &ROIDimConfig, dim_size: usize| -> (usize, usize) {
290        if !cfg.enable || dim_size == 0 {
291            return (0, dim_size);
292        }
293        // offset clamped to [0, dimSize-1] (one past the last index is illegal).
294        let offset = cfg.min.min(dim_size - 1);
295        let size = if cfg.auto_size { dim_size } else { cfg.size };
296        // size clamped to [1, dimSize - offset].
297        let size = size.max(1).min(dim_size - offset);
298        (offset, size)
299    };
300    let (eff_x_min, eff_x_size) = resolve(&config.dims[0], src_x);
301    let (eff_y_min, eff_y_size) = resolve(&config.dims[1], src_y);
302
303    // Apply autocenter: shift ROI min so that the ROI is centered on the
304    // centroid or peak, keeping the effective size the same.
305    let (roi_x_min, roi_y_min) = match config.autocenter {
306        AutoCenter::None => (eff_x_min, eff_y_min),
307        AutoCenter::CenterOfMass => {
308            let (cx, cy) = find_centroid_2d(&src.data, src_x, src_y);
309            let mx = cx
310                .saturating_sub(eff_x_size / 2)
311                .min(src_x.saturating_sub(eff_x_size));
312            let my = cy
313                .saturating_sub(eff_y_size / 2)
314                .min(src_y.saturating_sub(eff_y_size));
315            (mx, my)
316        }
317        AutoCenter::PeakPosition => {
318            let (px, py) = find_peak_2d(&src.data, src_x, src_y);
319            let mx = px
320                .saturating_sub(eff_x_size / 2)
321                .min(src_x.saturating_sub(eff_x_size));
322            let my = py
323                .saturating_sub(eff_y_size / 2)
324                .min(src_y.saturating_sub(eff_y_size));
325            (mx, my)
326        }
327    };
328
329    let roi_x_size = eff_x_size;
330    let roi_y_size = eff_y_size;
331
332    if roi_x_size == 0 || roi_y_size == 0 {
333        return None;
334    }
335
336    // C++: binning = MAX(binning, 1); binning = MIN(binning, size).
337    // A bin larger than the ROI is clamped to the ROI size, yielding a
338    // 1-pixel output rather than collapsing to an empty (sink) result.
339    let bin_x = config.dims[0].bin.max(1).min(roi_x_size);
340    let bin_y = config.dims[1].bin.max(1).min(roi_y_size);
341    let out_x = roi_x_size / bin_x;
342    let out_y = roi_y_size / bin_y;
343
344    if out_x == 0 || out_y == 0 {
345        return None;
346    }
347
348    macro_rules! extract {
349        ($vec:expr, $T:ty, $zero:expr) => {{
350            let mut out = vec![$zero; out_x * out_y];
351            for oy in 0..out_y {
352                for ox in 0..out_x {
353                    let mut sum = 0.0f64;
354                    let mut _count = 0usize;
355                    for by in 0..bin_y {
356                        for bx in 0..bin_x {
357                            let sx = roi_x_min + ox * bin_x + bx;
358                            let sy = roi_y_min + oy * bin_y + by;
359                            if sx < src_x && sy < src_y {
360                                sum += $vec[sy * src_x + sx] as f64;
361                                _count += 1;
362                            }
363                        }
364                    }
365                    // C++ sums binned pixels (no averaging); scale is a divisor
366                    let val = sum;
367                    let idx = if config.dims[0].reverse {
368                        out_x - 1 - ox
369                    } else {
370                        ox
371                    } + if config.dims[1].reverse {
372                        out_y - 1 - oy
373                    } else {
374                        oy
375                    } * out_x;
376                    let scaled = if config.enable_scale && config.scale != 0.0 {
377                        val / config.scale
378                    } else {
379                        val
380                    };
381                    out[idx] = scaled as $T;
382                }
383            }
384            out
385        }};
386    }
387
388    let out_data = match &src.data {
389        NDDataBuffer::U8(v) => NDDataBuffer::U8(extract!(v, u8, 0)),
390        NDDataBuffer::U16(v) => NDDataBuffer::U16(extract!(v, u16, 0)),
391        NDDataBuffer::I8(v) => NDDataBuffer::I8(extract!(v, i8, 0)),
392        NDDataBuffer::I16(v) => NDDataBuffer::I16(extract!(v, i16, 0)),
393        NDDataBuffer::I32(v) => NDDataBuffer::I32(extract!(v, i32, 0)),
394        NDDataBuffer::U32(v) => NDDataBuffer::U32(extract!(v, u32, 0)),
395        NDDataBuffer::I64(v) => NDDataBuffer::I64(extract!(v, i64, 0)),
396        NDDataBuffer::U64(v) => NDDataBuffer::U64(extract!(v, u64, 0)),
397        NDDataBuffer::F32(v) => NDDataBuffer::F32(extract!(v, f32, 0.0)),
398        NDDataBuffer::F64(v) => NDDataBuffer::F64(extract!(v, f64, 0.0)),
399    };
400
401    let out_dims = if config.collapse_dims {
402        let all_dims = vec![NDDimension::new(out_x), NDDimension::new(out_y)];
403        let filtered: Vec<NDDimension> = all_dims.into_iter().filter(|d| d.size > 1).collect();
404        if filtered.is_empty() {
405            vec![NDDimension::new(out_x)]
406        } else {
407            filtered
408        }
409    } else {
410        vec![NDDimension::new(out_x), NDDimension::new(out_y)]
411    };
412
413    // Apply data type conversion if requested
414    let target_type = config.data_type.unwrap_or(src.data.data_type());
415
416    let mut arr = NDArray::new(out_dims, target_type);
417    if target_type == src.data.data_type() {
418        arr.data = out_data;
419    } else {
420        // Convert via color module
421        let mut temp = NDArray::new(arr.dims.clone(), src.data.data_type());
422        temp.data = out_data;
423        match ad_core_rs::color::convert_data_type(&temp, target_type) {
424            Ok(converted) => arr.data = converted.data,
425            Err(e) => {
426                // A data-type conversion failure must NOT publish an all-zero
427                // buffer as if it were valid ROI output. Drop the frame and
428                // log; the caller treats `None` as "no output this frame".
429                tracing::warn!(
430                    error = %e,
431                    from = ?src.data.data_type(),
432                    to = ?target_type,
433                    "ROI output data-type conversion failed; dropping frame"
434                );
435                return None;
436            }
437        }
438    }
439
440    arr.unique_id = src.unique_id;
441    arr.timestamp = src.timestamp;
442    arr.attributes = src.attributes.clone();
443    Some(arr)
444}
445
446/// Per-dimension param reasons.
447#[derive(Default, Clone, Copy)]
448pub struct ROIDimParams {
449    pub min: usize,
450    pub size: usize,
451    pub bin: usize,
452    pub reverse: usize,
453    pub enable: usize,
454    pub auto_size: usize,
455    pub max_size: usize,
456}
457
458/// Param reasons for all ROI params.
459#[derive(Default)]
460pub struct ROIParams {
461    pub dims: [ROIDimParams; 3],
462    pub enable_scale: usize,
463    pub scale: usize,
464    pub data_type: usize,
465    pub collapse_dims: usize,
466    pub name: usize,
467}
468
469/// Pure ROI processing logic.
470pub struct ROIProcessor {
471    config: ROIConfig,
472    params: ROIParams,
473}
474
475impl ROIProcessor {
476    pub fn new(config: ROIConfig) -> Self {
477        Self {
478            config,
479            params: ROIParams::default(),
480        }
481    }
482
483    /// Access the registered ROI param reasons.
484    pub fn params(&self) -> &ROIParams {
485        &self.params
486    }
487}
488
489impl NDPluginProcess for ROIProcessor {
490    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
491        // Report input array dimensions as MaxSize params
492        let mut updates = Vec::new();
493        for (i, dim_params) in self.params.dims.iter().enumerate() {
494            let dim_size = array.dims.get(i).map(|d| d.size as i32).unwrap_or(0);
495            updates.push(ParamUpdate::int32(dim_params.max_size, dim_size));
496        }
497
498        match extract_roi(array, &self.config) {
499            Some(roi_arr) => ProcessResult {
500                output_arrays: vec![Arc::new(roi_arr)],
501                param_updates: updates,
502                scatter_index: None,
503            },
504            None => ProcessResult::sink(updates),
505        }
506    }
507
508    fn plugin_type(&self) -> &str {
509        "NDPluginROI"
510    }
511
512    fn register_params(
513        &mut self,
514        base: &mut PortDriverBase,
515    ) -> Result<(), asyn_rs::error::AsynError> {
516        let dim_names = ["DIM0", "DIM1", "DIM2"];
517        for (i, prefix) in dim_names.iter().enumerate() {
518            self.params.dims[i].min =
519                base.create_param(&format!("{prefix}_MIN"), ParamType::Int32)?;
520            self.params.dims[i].size =
521                base.create_param(&format!("{prefix}_SIZE"), ParamType::Int32)?;
522            self.params.dims[i].bin =
523                base.create_param(&format!("{prefix}_BIN"), ParamType::Int32)?;
524            self.params.dims[i].reverse =
525                base.create_param(&format!("{prefix}_REVERSE"), ParamType::Int32)?;
526            self.params.dims[i].enable =
527                base.create_param(&format!("{prefix}_ENABLE"), ParamType::Int32)?;
528            self.params.dims[i].auto_size =
529                base.create_param(&format!("{prefix}_AUTO_SIZE"), ParamType::Int32)?;
530            self.params.dims[i].max_size =
531                base.create_param(&format!("{prefix}_MAX_SIZE"), ParamType::Int32)?;
532
533            // Set initial values from config
534            base.set_int32_param(self.params.dims[i].min, 0, self.config.dims[i].min as i32)?;
535            base.set_int32_param(self.params.dims[i].size, 0, self.config.dims[i].size as i32)?;
536            base.set_int32_param(self.params.dims[i].bin, 0, self.config.dims[i].bin as i32)?;
537            base.set_int32_param(
538                self.params.dims[i].reverse,
539                0,
540                self.config.dims[i].reverse as i32,
541            )?;
542            base.set_int32_param(
543                self.params.dims[i].enable,
544                0,
545                self.config.dims[i].enable as i32,
546            )?;
547            base.set_int32_param(
548                self.params.dims[i].auto_size,
549                0,
550                self.config.dims[i].auto_size as i32,
551            )?;
552        }
553        self.params.enable_scale = base.create_param("ENABLE_SCALE", ParamType::Int32)?;
554        self.params.scale = base.create_param("SCALE_VALUE", ParamType::Float64)?;
555        self.params.data_type = base.create_param("ROI_DATA_TYPE", ParamType::Int32)?;
556        self.params.collapse_dims = base.create_param("COLLAPSE_DIMS", ParamType::Int32)?;
557        self.params.name = base.create_param("NAME", ParamType::Octet)?;
558
559        base.set_int32_param(self.params.enable_scale, 0, self.config.enable_scale as i32)?;
560        base.set_float64_param(self.params.scale, 0, self.config.scale)?;
561        base.set_int32_param(self.params.data_type, 0, -1)?; // -1 = Automatic
562        base.set_int32_param(
563            self.params.collapse_dims,
564            0,
565            self.config.collapse_dims as i32,
566        )?;
567
568        Ok(())
569    }
570
571    fn on_param_change(
572        &mut self,
573        reason: usize,
574        snapshot: &PluginParamSnapshot,
575    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
576        let p = &self.params;
577        for i in 0..3 {
578            if reason == p.dims[i].min {
579                self.config.dims[i].min = snapshot.value.as_i32().max(0) as usize;
580                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
581            }
582            if reason == p.dims[i].size {
583                self.config.dims[i].size = snapshot.value.as_i32().max(0) as usize;
584                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
585            }
586            if reason == p.dims[i].bin {
587                self.config.dims[i].bin = snapshot.value.as_i32().max(1) as usize;
588                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
589            }
590            if reason == p.dims[i].reverse {
591                self.config.dims[i].reverse = snapshot.value.as_i32() != 0;
592                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
593            }
594            if reason == p.dims[i].enable {
595                self.config.dims[i].enable = snapshot.value.as_i32() != 0;
596                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
597            }
598            if reason == p.dims[i].auto_size {
599                self.config.dims[i].auto_size = snapshot.value.as_i32() != 0;
600                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
601            }
602        }
603        if reason == p.enable_scale {
604            self.config.enable_scale = snapshot.value.as_i32() != 0;
605        } else if reason == p.scale {
606            self.config.scale = snapshot.value.as_f64();
607        } else if reason == p.data_type {
608            let v = snapshot.value.as_i32();
609            self.config.data_type = if v < 0 {
610                None
611            } else {
612                NDDataType::from_ordinal(v as u8)
613            };
614        } else if reason == p.collapse_dims {
615            self.config.collapse_dims = snapshot.value.as_i32() != 0;
616        }
617        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
618    }
619}
620
621/// Create an ROI plugin runtime, returning the handle and param reasons.
622pub fn create_roi_runtime(
623    port_name: &str,
624    pool: Arc<NDArrayPool>,
625    queue_size: usize,
626    ndarray_port: &str,
627    wiring: Arc<ad_core_rs::plugin::wiring::WiringRegistry>,
628) -> (
629    ad_core_rs::plugin::runtime::PluginRuntimeHandle,
630    ROIParams,
631    std::thread::JoinHandle<()>,
632) {
633    let processor = ROIProcessor::new(ROIConfig::default());
634    let (handle, jh) = ad_core_rs::plugin::runtime::create_plugin_runtime(
635        port_name,
636        processor,
637        pool,
638        queue_size,
639        ndarray_port,
640        wiring,
641    );
642    // Recreate param layout on a scratch PortDriverBase to get matching reasons.
643    let params = {
644        let mut base =
645            asyn_rs::port::PortDriverBase::new("_scratch_", 1, asyn_rs::port::PortFlags::default());
646        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
647        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
648        let mut p = ROIParams::default();
649        let dim_names = ["DIM0", "DIM1", "DIM2"];
650        for (i, prefix) in dim_names.iter().enumerate() {
651            p.dims[i].min = base
652                .create_param(&format!("{prefix}_MIN"), asyn_rs::param::ParamType::Int32)
653                .unwrap();
654            p.dims[i].size = base
655                .create_param(&format!("{prefix}_SIZE"), asyn_rs::param::ParamType::Int32)
656                .unwrap();
657            p.dims[i].bin = base
658                .create_param(&format!("{prefix}_BIN"), asyn_rs::param::ParamType::Int32)
659                .unwrap();
660            p.dims[i].reverse = base
661                .create_param(
662                    &format!("{prefix}_REVERSE"),
663                    asyn_rs::param::ParamType::Int32,
664                )
665                .unwrap();
666            p.dims[i].enable = base
667                .create_param(
668                    &format!("{prefix}_ENABLE"),
669                    asyn_rs::param::ParamType::Int32,
670                )
671                .unwrap();
672            p.dims[i].auto_size = base
673                .create_param(
674                    &format!("{prefix}_AUTO_SIZE"),
675                    asyn_rs::param::ParamType::Int32,
676                )
677                .unwrap();
678            p.dims[i].max_size = base
679                .create_param(
680                    &format!("{prefix}_MAX_SIZE"),
681                    asyn_rs::param::ParamType::Int32,
682                )
683                .unwrap();
684        }
685        p.enable_scale = base
686            .create_param("ENABLE_SCALE", asyn_rs::param::ParamType::Int32)
687            .unwrap();
688        p.scale = base
689            .create_param("SCALE_VALUE", asyn_rs::param::ParamType::Float64)
690            .unwrap();
691        p.data_type = base
692            .create_param("ROI_DATA_TYPE", asyn_rs::param::ParamType::Int32)
693            .unwrap();
694        p.collapse_dims = base
695            .create_param("COLLAPSE_DIMS", asyn_rs::param::ParamType::Int32)
696            .unwrap();
697        p.name = base
698            .create_param("NAME", asyn_rs::param::ParamType::Octet)
699            .unwrap();
700        p
701    };
702    (handle, params, jh)
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    fn make_4x4_u8() -> NDArray {
710        let mut arr = NDArray::new(
711            vec![NDDimension::new(4), NDDimension::new(4)],
712            NDDataType::UInt8,
713        );
714        if let NDDataBuffer::U8(ref mut v) = arr.data {
715            for i in 0..16 {
716                v[i] = i as u8;
717            }
718        }
719        arr
720    }
721
722    #[test]
723    fn test_extract_sub_region() {
724        let arr = make_4x4_u8();
725        let mut config = ROIConfig::default();
726        config.dims[0] = ROIDimConfig {
727            min: 1,
728            size: 2,
729            bin: 1,
730            reverse: false,
731            enable: true,
732            auto_size: false,
733        };
734        config.dims[1] = ROIDimConfig {
735            min: 1,
736            size: 2,
737            bin: 1,
738            reverse: false,
739            enable: true,
740            auto_size: false,
741        };
742
743        let roi = extract_roi_2d(&arr, &config).unwrap();
744        assert_eq!(roi.dims[0].size, 2);
745        assert_eq!(roi.dims[1].size, 2);
746        if let NDDataBuffer::U8(ref v) = roi.data {
747            // row 1, cols 1-2: [5,6], row 2, cols 1-2: [9,10]
748            assert_eq!(v[0], 5);
749            assert_eq!(v[1], 6);
750            assert_eq!(v[2], 9);
751            assert_eq!(v[3], 10);
752        }
753    }
754
755    #[test]
756    fn test_binning_2x2() {
757        let arr = make_4x4_u8();
758        let mut config = ROIConfig::default();
759        config.dims[0] = ROIDimConfig {
760            min: 0,
761            size: 4,
762            bin: 2,
763            reverse: false,
764            enable: true,
765            auto_size: false,
766        };
767        config.dims[1] = ROIDimConfig {
768            min: 0,
769            size: 4,
770            bin: 2,
771            reverse: false,
772            enable: true,
773            auto_size: false,
774        };
775
776        let roi = extract_roi_2d(&arr, &config).unwrap();
777        assert_eq!(roi.dims[0].size, 2);
778        assert_eq!(roi.dims[1].size, 2);
779        if let NDDataBuffer::U8(ref v) = roi.data {
780            // top-left 2x2: sum = 0+1+4+5 = 10 (C++ sums, not averages)
781            assert_eq!(v[0], 10);
782        }
783    }
784
785    #[test]
786    fn test_reverse() {
787        let arr = make_4x4_u8();
788        let mut config = ROIConfig::default();
789        config.dims[0] = ROIDimConfig {
790            min: 0,
791            size: 4,
792            bin: 1,
793            reverse: true,
794            enable: true,
795            auto_size: false,
796        };
797        config.dims[1] = ROIDimConfig {
798            min: 0,
799            size: 1,
800            bin: 1,
801            reverse: false,
802            enable: true,
803            auto_size: false,
804        };
805
806        let roi = extract_roi_2d(&arr, &config).unwrap();
807        if let NDDataBuffer::U8(ref v) = roi.data {
808            assert_eq!(v[0], 3);
809            assert_eq!(v[1], 2);
810            assert_eq!(v[2], 1);
811            assert_eq!(v[3], 0);
812        }
813    }
814
815    #[test]
816    fn test_collapse_dims() {
817        let arr = make_4x4_u8();
818        let mut config = ROIConfig::default();
819        config.dims[0] = ROIDimConfig {
820            min: 0,
821            size: 4,
822            bin: 1,
823            reverse: false,
824            enable: true,
825            auto_size: false,
826        };
827        config.dims[1] = ROIDimConfig {
828            min: 0,
829            size: 1,
830            bin: 1,
831            reverse: false,
832            enable: true,
833            auto_size: false,
834        };
835        config.collapse_dims = true;
836
837        let roi = extract_roi_2d(&arr, &config).unwrap();
838        assert_eq!(roi.dims.len(), 1);
839        assert_eq!(roi.dims[0].size, 4);
840    }
841
842    #[test]
843    fn test_scale() {
844        let arr = make_4x4_u8();
845        let mut config = ROIConfig::default();
846        config.dims[0] = ROIDimConfig {
847            min: 0,
848            size: 2,
849            bin: 1,
850            reverse: false,
851            enable: true,
852            auto_size: false,
853        };
854        config.dims[1] = ROIDimConfig {
855            min: 0,
856            size: 1,
857            bin: 1,
858            reverse: false,
859            enable: true,
860            auto_size: false,
861        };
862        config.enable_scale = true;
863        config.scale = 2.0;
864
865        let roi = extract_roi_2d(&arr, &config).unwrap();
866        if let NDDataBuffer::U8(ref v) = roi.data {
867            // C++: scale is a divisor
868            assert_eq!(v[0], 0); // 0 / 2 = 0
869            assert_eq!(v[1], 0); // 1 / 2 = 0.5 → 0
870        }
871    }
872
873    #[test]
874    fn test_type_convert() {
875        let arr = make_4x4_u8();
876        let mut config = ROIConfig::default();
877        config.dims[0] = ROIDimConfig {
878            min: 0,
879            size: 2,
880            bin: 1,
881            reverse: false,
882            enable: true,
883            auto_size: false,
884        };
885        config.dims[1] = ROIDimConfig {
886            min: 0,
887            size: 1,
888            bin: 1,
889            reverse: false,
890            enable: true,
891            auto_size: false,
892        };
893        config.data_type = Some(NDDataType::UInt16);
894
895        let roi = extract_roi_2d(&arr, &config).unwrap();
896        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
897    }
898
899    // --- New ROIProcessor tests ---
900
901    #[test]
902    fn test_roi_processor() {
903        let mut config = ROIConfig::default();
904        config.dims[0] = ROIDimConfig {
905            min: 1,
906            size: 2,
907            bin: 1,
908            reverse: false,
909            enable: true,
910            auto_size: false,
911        };
912        config.dims[1] = ROIDimConfig {
913            min: 1,
914            size: 2,
915            bin: 1,
916            reverse: false,
917            enable: true,
918            auto_size: false,
919        };
920
921        let mut proc = ROIProcessor::new(config);
922        let pool = NDArrayPool::new(1_000_000);
923
924        let arr = make_4x4_u8();
925        let result = proc.process_array(&arr, &pool);
926        assert_eq!(result.output_arrays.len(), 1);
927        assert_eq!(result.output_arrays[0].dims[0].size, 2);
928        assert_eq!(result.output_arrays[0].dims[1].size, 2);
929    }
930
931    // --- Auto-size / dim-disable / autocenter tests ---
932
933    #[test]
934    fn test_auto_size() {
935        // 4x4 image, min_x=1 with auto_size => size_x = 4-1 = 3
936        let arr = make_4x4_u8();
937        let mut config = ROIConfig::default();
938        config.dims[0] = ROIDimConfig {
939            min: 1,
940            size: 0,
941            bin: 1,
942            reverse: false,
943            enable: true,
944            auto_size: true,
945        };
946        config.dims[1] = ROIDimConfig {
947            min: 0,
948            size: 0,
949            bin: 1,
950            reverse: false,
951            enable: true,
952            auto_size: true,
953        };
954
955        let roi = extract_roi_2d(&arr, &config).unwrap();
956        // C++: autoSize sets size = dimSize, then size = MIN(size, dimSize -
957        // offset). With offset_x = 1 the X size clamps to 4 - 1 = 3; the Y
958        // dimension with offset 0 stays at the full 4.
959        assert_eq!(roi.dims[0].size, 3);
960        assert_eq!(roi.dims[1].size, 4);
961    }
962
963    #[test]
964    fn test_dim_disable() {
965        // Disabled dim uses full range: min=0, size=src_dim
966        let arr = make_4x4_u8();
967        let mut config = ROIConfig::default();
968        config.dims[0] = ROIDimConfig {
969            min: 2,
970            size: 1,
971            bin: 1,
972            reverse: false,
973            enable: false,
974            auto_size: false,
975        };
976        config.dims[1] = ROIDimConfig {
977            min: 0,
978            size: 4,
979            bin: 1,
980            reverse: false,
981            enable: true,
982            auto_size: false,
983        };
984
985        let roi = extract_roi_2d(&arr, &config).unwrap();
986        // X dim disabled, so full range: size=4
987        assert_eq!(roi.dims[0].size, 4);
988        assert_eq!(roi.dims[1].size, 4);
989    }
990
991    #[test]
992    fn test_autocenter_peak() {
993        // Create 8x8 image with a peak at (6, 5)
994        let mut arr = NDArray::new(
995            vec![NDDimension::new(8), NDDimension::new(8)],
996            NDDataType::UInt8,
997        );
998        if let NDDataBuffer::U8(ref mut v) = arr.data {
999            for i in 0..64 {
1000                v[i] = 1;
1001            }
1002            // Place peak at x=6, y=5
1003            v[5 * 8 + 6] = 255;
1004        }
1005
1006        let mut config = ROIConfig::default();
1007        config.dims[0] = ROIDimConfig {
1008            min: 0,
1009            size: 4,
1010            bin: 1,
1011            reverse: false,
1012            enable: true,
1013            auto_size: false,
1014        };
1015        config.dims[1] = ROIDimConfig {
1016            min: 0,
1017            size: 4,
1018            bin: 1,
1019            reverse: false,
1020            enable: true,
1021            auto_size: false,
1022        };
1023        config.autocenter = AutoCenter::PeakPosition;
1024
1025        let roi = extract_roi_2d(&arr, &config).unwrap();
1026        assert_eq!(roi.dims[0].size, 4);
1027        assert_eq!(roi.dims[1].size, 4);
1028
1029        // ROI should be centered on peak (6,5) with size 4x4
1030        // min_x = 6 - 4/2 = 4, clamped to min(4, 8-4)=4
1031        // min_y = 5 - 4/2 = 3, clamped to min(3, 8-4)=3
1032        // So ROI covers x=[4..8), y=[3..7) and the peak at (6,5) should be inside
1033        // In the ROI, the peak is at local (6-4, 5-3) = (2, 2)
1034        if let NDDataBuffer::U8(ref v) = roi.data {
1035            assert_eq!(v[2 * 4 + 2], 255); // peak at local (2,2)
1036        }
1037    }
1038
1039    #[test]
1040    fn test_offset_clamp_to_last_column() {
1041        // Regression: an offset equal to the dim size must clamp to dimSize-1
1042        // and still produce a 1-pixel ROI (C++ MIN(offset, dimSize-1)),
1043        // instead of collapsing to an empty sink.
1044        let arr = make_4x4_u8();
1045        let mut config = ROIConfig::default();
1046        // min == src_x (4): one past the last valid index.
1047        config.dims[0] = ROIDimConfig {
1048            min: 4,
1049            size: 10,
1050            bin: 1,
1051            reverse: false,
1052            enable: true,
1053            auto_size: false,
1054        };
1055        config.dims[1] = ROIDimConfig {
1056            min: 0,
1057            size: 1,
1058            bin: 1,
1059            reverse: false,
1060            enable: true,
1061            auto_size: false,
1062        };
1063        let roi = extract_roi_2d(&arr, &config).unwrap();
1064        // offset clamps to 3, size clamps to 4-3 = 1.
1065        assert_eq!(roi.dims[0].size, 1);
1066        if let NDDataBuffer::U8(ref v) = roi.data {
1067            assert_eq!(v[0], 3); // last column of row 0
1068        }
1069    }
1070
1071    #[test]
1072    fn test_bin_larger_than_roi_clamps() {
1073        // Regression: a bin larger than the ROI is clamped to the ROI size
1074        // (C++ MIN(binning, size)), yielding a 1-pixel output instead of a
1075        // None sink.
1076        let arr = make_4x4_u8();
1077        let mut config = ROIConfig::default();
1078        config.dims[0] = ROIDimConfig {
1079            min: 0,
1080            size: 2,
1081            bin: 99, // far larger than the ROI
1082            reverse: false,
1083            enable: true,
1084            auto_size: false,
1085        };
1086        config.dims[1] = ROIDimConfig {
1087            min: 0,
1088            size: 1,
1089            bin: 1,
1090            reverse: false,
1091            enable: true,
1092            auto_size: false,
1093        };
1094        let roi = extract_roi_2d(&arr, &config).unwrap();
1095        // bin clamps to size 2 => out_x = 2/2 = 1.
1096        assert_eq!(roi.dims[0].size, 1);
1097        if let NDDataBuffer::U8(ref v) = roi.data {
1098            // sum of the 2-pixel bin: 0 + 1 = 1
1099            assert_eq!(v[0], 1);
1100        }
1101    }
1102
1103    /// 2x2 RGB1 image: index = y*6 + x*3 + c, value = 100*y + 10*x + c.
1104    fn make_rgb1_2x2() -> NDArray {
1105        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
1106        let mut arr = NDArray::new(
1107            vec![
1108                NDDimension::new(3),
1109                NDDimension::new(2),
1110                NDDimension::new(2),
1111            ],
1112            NDDataType::UInt8,
1113        );
1114        arr.attributes.add(NDAttribute::new_static(
1115            "ColorMode",
1116            "",
1117            NDAttrSource::Driver,
1118            NDAttrValue::Int32(ad_core_rs::color::NDColorMode::RGB1 as i32),
1119        ));
1120        if let NDDataBuffer::U8(ref mut v) = arr.data {
1121            for y in 0..2 {
1122                for x in 0..2 {
1123                    for c in 0..3 {
1124                        v[y * 6 + x * 3 + c] = (100 * y + 10 * x + c) as u8;
1125                    }
1126                }
1127            }
1128        }
1129        arr
1130    }
1131
1132    #[test]
1133    fn test_roi_3d_rgb1_x_subregion() {
1134        // ROI on an RGB1 image: Dim0 selects X, the color axis is preserved.
1135        let arr = make_rgb1_2x2();
1136        let mut config = ROIConfig::default();
1137        // X: take only column 1.
1138        config.dims[0] = ROIDimConfig {
1139            min: 1,
1140            size: 1,
1141            bin: 1,
1142            reverse: false,
1143            enable: true,
1144            auto_size: false,
1145        };
1146        config.dims[1] = ROIDimConfig {
1147            min: 0,
1148            size: 2,
1149            bin: 1,
1150            reverse: false,
1151            enable: true,
1152            auto_size: false,
1153        };
1154        config.dims[2] = ROIDimConfig {
1155            min: 0,
1156            size: 3,
1157            bin: 1,
1158            reverse: false,
1159            enable: true,
1160            auto_size: false,
1161        };
1162        let roi = extract_roi(&arr, &config).unwrap();
1163        // RGB1 layout: dims = [color=3, x=1, y=2].
1164        assert_eq!(roi.dims[0].size, 3);
1165        assert_eq!(roi.dims[1].size, 1);
1166        assert_eq!(roi.dims[2].size, 2);
1167        if let NDDataBuffer::U8(ref v) = roi.data {
1168            // pixel (x=1,y=0) channels => 10,11,12
1169            assert_eq!(&v[0..3], &[10, 11, 12]);
1170            // pixel (x=1,y=1) channels => 110,111,112
1171            assert_eq!(&v[3..6], &[110, 111, 112]);
1172        } else {
1173            panic!("not u8");
1174        }
1175    }
1176}