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/// Resolve one ROI axis to C's clamped `(offset, size, binning)` — C++
132/// `NDPluginROI` (`NDPluginROI.cpp:85-103`).
133///
134/// C decides all THREE values in one branch, keyed on that axis's Enable flag:
135///
136/// * enabled (`:87-97`):
137///   `offset = MAX(offset, 0); offset = MIN(offset, dimSize-1);`
138///   `size = autoSize ? dimSize : size;`
139///   `size = MAX(size, 1); size = MIN(size, dimSize - offset);`
140///   `binning = MAX(binning, 1); binning = MIN(binning, size);`
141/// * disabled (`:98-102`): `offset = 0; size = dimSize; binning = 1;`
142///
143/// The binning belongs to that same decision, so it is resolved here and nowhere
144/// else. Deriving it separately in the callers is what let a *disabled* axis keep
145/// a stale `DimNBin > 1` and emit a shrunken axis of bin-sums where C outputs the
146/// full-resolution axis (R9-66).
147fn resolve_axis(cfg: &ROIDimConfig, dim_size: usize) -> (usize, usize, usize) {
148    if !cfg.enable || dim_size == 0 {
149        return (0, dim_size, 1);
150    }
151    let offset = cfg.min.min(dim_size - 1);
152    let size = if cfg.auto_size { dim_size } else { cfg.size };
153    let size = size.max(1).min(dim_size - offset);
154    // C++: binning = MAX(binning, 1); binning = MIN(binning, size). A bin larger
155    // than the ROI is clamped to the ROI size, yielding a 1-pixel output rather
156    // than collapsing to an empty (sink) result.
157    let binning = cfg.bin.max(1).min(size);
158    (offset, size, binning)
159}
160
161/// Run C's ROI extraction: `pNDArrayPool->convert()` with the ROI's
162/// offset/size/binning/reverse per dimension (`NDPluginROI.cpp:166-175`).
163///
164/// The pixel math is NOT open-coded here — it is
165/// [`ad_core_rs::convert::convert_dims`], the single owner of C's
166/// `convertDim` semantics (bin sums accumulate in the OUTPUT type and wrap
167/// modulo its width; casts are C casts, never clamped/saturated).
168///
169/// C picks between two paths, and the choice is observable:
170/// * `enableScale && scale != 0 && scale != 1` (`:160`): convert to Float64
171///   *first* (so the bin sum is exact), divide by scale, then cast to the
172///   output type. This is the only path that avoids the modulo wrap.
173/// * otherwise (`:174`): convert straight to the output type — the bin sum
174///   accumulates in that type and wraps (UInt8 3x3 bin of 100s -> 900 % 256
175///   == 132).
176fn convert_roi(src: &NDArray, dims_out: &[NDDimension], config: &ROIConfig) -> Option<NDArray> {
177    use ad_core_rs::convert::{convert_dims, convert_type};
178
179    let target_type = config.data_type.unwrap_or(src.data.data_type());
180    let scaled = config.enable_scale && config.scale != 0.0 && config.scale != 1.0;
181
182    let result = if scaled {
183        convert_dims(src, dims_out, NDDataType::Float64).and_then(|mut scratch| {
184            if let NDDataBuffer::F64(v) = &mut scratch.data {
185                for x in v.iter_mut() {
186                    *x /= config.scale;
187                }
188            }
189            convert_type(&scratch, target_type)
190        })
191    } else {
192        convert_dims(src, dims_out, target_type)
193    };
194
195    match result {
196        Ok(arr) => Some(arr),
197        Err(e) => {
198            // A conversion failure must NOT publish an all-zero buffer as if
199            // it were valid ROI output. Drop the frame; the caller treats
200            // `None` as "no output this frame".
201            tracing::warn!(
202                error = %e,
203                from = ?src.data.data_type(),
204                to = ?target_type,
205                "ROI extraction failed; dropping frame"
206            );
207            None
208        }
209    }
210}
211
212/// Extract an ROI from a 3-D RGB color array.
213///
214/// Mirrors C++ `NDPluginROI`: ROI `dims[0]` selects the X axis, `dims[1]` the
215/// Y axis and `dims[2]` the color axis (`userDims = {xDim, yDim, colorDim}`),
216/// so the ROI geometry is independent of the RGB1/RGB2/RGB3 memory layout.
217/// Per-axis binning and reverse are applied; the output keeps the source
218/// color mode and dimension order.
219pub fn extract_roi_3d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
220    let info = src.info();
221    let (src_x, src_y, src_c) = (info.x_size, info.y_size, info.color_size.max(1));
222    if src_x == 0 || src_y == 0 || src_c == 0 {
223        return None;
224    }
225
226    let (x_min, x_roi, bin_x) = resolve_axis(&config.dims[0], src_x);
227    let (y_min, y_roi, bin_y) = resolve_axis(&config.dims[1], src_y);
228    let (c_min, c_roi, bin_c) = resolve_axis(&config.dims[2], src_c);
229
230    let (out_x, out_y, out_c) = (x_roi / bin_x, y_roi / bin_y, c_roi / bin_c);
231    if out_x == 0 || out_y == 0 || out_c == 0 {
232        return None;
233    }
234
235    // C `userDims = {xDim, yDim, colorDim}` (NDPluginROI.cpp:80-82): the ROI
236    // axes are written into the ARRAY dimension slots the image info reports,
237    // so the geometry is layout-independent.
238    let user_dims = info.user_dims();
239    let mut dims_out: Vec<NDDimension> = src.dims.clone();
240    for d in dims_out.iter_mut() {
241        d.offset = 0;
242        d.binning = 1;
243        d.reverse = false;
244    }
245    for (roi_dim, (min, size, bin)) in [
246        (x_min, x_roi, bin_x),
247        (y_min, y_roi, bin_y),
248        (c_min, c_roi, bin_c),
249    ]
250    .into_iter()
251    .enumerate()
252    {
253        let d = dims_out.get_mut(user_dims[roi_dim])?;
254        d.offset = min;
255        d.size = size;
256        d.binning = bin;
257        d.reverse = config.dims[roi_dim].reverse;
258    }
259
260    let mut arr = convert_roi(src, &dims_out, config)?;
261
262    // Single-color selection: when the color axis collapses to 1 and the
263    // source is an RGB mode, C forces collapseDims and tags the output Mono
264    // (NDPluginROI.cpp:177-200). The user collapseDims param otherwise still
265    // collapses any size-1 dimension (NDPluginROI.cpp:202-215). When out_c == 1
266    // the extracted buffer is already in [x, y] row-major order for every RGB
267    // layout, so dropping the size-1 axes needs no data reordering.
268    let single_color = out_c == 1
269        && matches!(
270            info.color_mode,
271            ad_core_rs::color::NDColorMode::RGB1
272                | ad_core_rs::color::NDColorMode::RGB2
273                | ad_core_rs::color::NDColorMode::RGB3
274        );
275    if single_color || config.collapse_dims {
276        let collapsed: Vec<NDDimension> = arr.dims.iter().filter(|d| d.size > 1).cloned().collect();
277        arr.dims = if collapsed.is_empty() {
278            vec![NDDimension::new(1)]
279        } else {
280            collapsed
281        };
282    }
283
284    arr.unique_id = src.unique_id;
285    // A single selected color plane is mono (C NDPluginROI.cpp:185/192/199
286    // overrides the ColorMode attribute on the collapsed output).
287    if single_color {
288        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
289        arr.attributes.add(NDAttribute::new_static(
290            "ColorMode",
291            "Color mode",
292            NDAttrSource::Driver,
293            NDAttrValue::Int32(ad_core_rs::color::NDColorMode::Mono as i32),
294        ));
295    }
296    Some(arr)
297}
298
299/// Extract ROI sub-region from a 2-D (mono) array.
300pub fn extract_roi_2d(src: &NDArray, config: &ROIConfig) -> Option<NDArray> {
301    if src.dims.len() < 2 {
302        return None;
303    }
304
305    let src_x = src.dims[0].size;
306    let src_y = src.dims[1].size;
307
308    let (eff_x_min, eff_x_size, bin_x) = resolve_axis(&config.dims[0], src_x);
309    let (eff_y_min, eff_y_size, bin_y) = resolve_axis(&config.dims[1], src_y);
310
311    // Apply autocenter: shift ROI min so that the ROI is centered on the
312    // centroid or peak, keeping the effective size the same.
313    let (roi_x_min, roi_y_min) = match config.autocenter {
314        AutoCenter::None => (eff_x_min, eff_y_min),
315        AutoCenter::CenterOfMass => {
316            let (cx, cy) = find_centroid_2d(&src.data, src_x, src_y);
317            let mx = cx
318                .saturating_sub(eff_x_size / 2)
319                .min(src_x.saturating_sub(eff_x_size));
320            let my = cy
321                .saturating_sub(eff_y_size / 2)
322                .min(src_y.saturating_sub(eff_y_size));
323            (mx, my)
324        }
325        AutoCenter::PeakPosition => {
326            let (px, py) = find_peak_2d(&src.data, src_x, src_y);
327            let mx = px
328                .saturating_sub(eff_x_size / 2)
329                .min(src_x.saturating_sub(eff_x_size));
330            let my = py
331                .saturating_sub(eff_y_size / 2)
332                .min(src_y.saturating_sub(eff_y_size));
333            (mx, my)
334        }
335    };
336
337    if eff_x_size == 0 || eff_y_size == 0 {
338        return None;
339    }
340
341    if eff_x_size / bin_x == 0 || eff_y_size / bin_y == 0 {
342        return None;
343    }
344
345    // Dimensions beyond X/Y (a non-RGB array with ndims > 2) pass through
346    // whole and unbinned, as C's `for (dim=0; dim<pArray->ndims; dim++)` loop
347    // leaves any dimension without an ROI axis.
348    let mut dims_out: Vec<NDDimension> = src.dims.clone();
349    for d in dims_out.iter_mut() {
350        d.offset = 0;
351        d.binning = 1;
352        d.reverse = false;
353    }
354    dims_out[0] = NDDimension {
355        size: eff_x_size,
356        offset: roi_x_min,
357        binning: bin_x,
358        reverse: config.dims[0].reverse,
359    };
360    dims_out[1] = NDDimension {
361        size: eff_y_size,
362        offset: roi_y_min,
363        binning: bin_y,
364        reverse: config.dims[1].reverse,
365    };
366
367    let mut arr = convert_roi(src, &dims_out, config)?;
368
369    if config.collapse_dims {
370        let collapsed: Vec<NDDimension> = arr.dims.iter().filter(|d| d.size > 1).cloned().collect();
371        arr.dims = if collapsed.is_empty() {
372            vec![NDDimension::new(arr.dims[0].size)]
373        } else {
374            collapsed
375        };
376    }
377
378    arr.unique_id = src.unique_id;
379    Some(arr)
380}
381
382/// Per-dimension param reasons.
383#[derive(Default, Clone, Copy)]
384pub struct ROIDimParams {
385    pub min: usize,
386    pub size: usize,
387    pub bin: usize,
388    pub reverse: usize,
389    pub enable: usize,
390    pub auto_size: usize,
391    pub max_size: usize,
392}
393
394/// Param reasons for all ROI params.
395#[derive(Default)]
396pub struct ROIParams {
397    pub dims: [ROIDimParams; 3],
398    pub enable_scale: usize,
399    pub scale: usize,
400    pub data_type: usize,
401    pub collapse_dims: usize,
402    pub name: usize,
403}
404
405/// Pure ROI processing logic.
406pub struct ROIProcessor {
407    config: ROIConfig,
408    params: ROIParams,
409}
410
411impl ROIProcessor {
412    pub fn new(config: ROIConfig) -> Self {
413        Self {
414            config,
415            params: ROIParams::default(),
416        }
417    }
418
419    /// Access the registered ROI param reasons.
420    pub fn params(&self) -> &ROIParams {
421        &self.params
422    }
423}
424
425impl NDPluginProcess for ROIProcessor {
426    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
427        // C `NDPluginROI.cpp:105-131`: DimNMaxSize is the size of the axis ROI
428        // dim N *controls*, i.e. `pArray->dims[userDims[N]].size` with
429        // `userDims = {xDim, yDim, colorDim}` (`:80-82`) — the same logical
430        // mapping the ROI geometry itself uses. It is 0 for a dim beyond
431        // `pArray->ndims` (`:105-107` zero all three first, `:108/:117/:126`
432        // only override within ndims).
433        let user_dims = array.info().user_dims();
434        let mut updates = Vec::new();
435        for (i, dim_params) in self.params.dims.iter().enumerate() {
436            let dim_size = if i < array.dims.len() {
437                array.dims[user_dims[i]].size as i32
438            } else {
439                0
440            };
441            updates.push(ParamUpdate::int32(dim_params.max_size, dim_size));
442        }
443
444        match extract_roi(array, &self.config) {
445            Some(roi_arr) => ProcessResult {
446                output_arrays: vec![Arc::new(roi_arr)],
447                param_updates: updates,
448                scatter: false,
449            },
450            None => ProcessResult::sink(updates),
451        }
452    }
453
454    fn plugin_type(&self) -> &str {
455        "NDPluginROI"
456    }
457
458    fn register_params(
459        &mut self,
460        base: &mut PortDriverBase,
461    ) -> Result<(), asyn_rs::error::AsynError> {
462        let dim_names = ["DIM0", "DIM1", "DIM2"];
463        for (i, prefix) in dim_names.iter().enumerate() {
464            self.params.dims[i].min =
465                base.create_param(&format!("{prefix}_MIN"), ParamType::Int32)?;
466            self.params.dims[i].size =
467                base.create_param(&format!("{prefix}_SIZE"), ParamType::Int32)?;
468            self.params.dims[i].bin =
469                base.create_param(&format!("{prefix}_BIN"), ParamType::Int32)?;
470            self.params.dims[i].reverse =
471                base.create_param(&format!("{prefix}_REVERSE"), ParamType::Int32)?;
472            self.params.dims[i].enable =
473                base.create_param(&format!("{prefix}_ENABLE"), ParamType::Int32)?;
474            self.params.dims[i].auto_size =
475                base.create_param(&format!("{prefix}_AUTO_SIZE"), ParamType::Int32)?;
476            self.params.dims[i].max_size =
477                base.create_param(&format!("{prefix}_MAX_SIZE"), ParamType::Int32)?;
478
479            // Set initial values from config
480            base.set_int32_param(self.params.dims[i].min, 0, self.config.dims[i].min as i32)?;
481            base.set_int32_param(self.params.dims[i].size, 0, self.config.dims[i].size as i32)?;
482            base.set_int32_param(self.params.dims[i].bin, 0, self.config.dims[i].bin as i32)?;
483            base.set_int32_param(
484                self.params.dims[i].reverse,
485                0,
486                self.config.dims[i].reverse as i32,
487            )?;
488            base.set_int32_param(
489                self.params.dims[i].enable,
490                0,
491                self.config.dims[i].enable as i32,
492            )?;
493            base.set_int32_param(
494                self.params.dims[i].auto_size,
495                0,
496                self.config.dims[i].auto_size as i32,
497            )?;
498        }
499        self.params.enable_scale = base.create_param("ENABLE_SCALE", ParamType::Int32)?;
500        self.params.scale = base.create_param("SCALE_VALUE", ParamType::Float64)?;
501        self.params.data_type = base.create_param("ROI_DATA_TYPE", ParamType::Int32)?;
502        self.params.collapse_dims = base.create_param("COLLAPSE_DIMS", ParamType::Int32)?;
503        self.params.name = base.create_param("NAME", ParamType::Octet)?;
504
505        base.set_int32_param(self.params.enable_scale, 0, self.config.enable_scale as i32)?;
506        base.set_float64_param(self.params.scale, 0, self.config.scale)?;
507        base.set_int32_param(self.params.data_type, 0, -1)?; // -1 = Automatic
508        base.set_int32_param(
509            self.params.collapse_dims,
510            0,
511            self.config.collapse_dims as i32,
512        )?;
513
514        Ok(())
515    }
516
517    fn on_param_change(
518        &mut self,
519        reason: usize,
520        snapshot: &PluginParamSnapshot,
521    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
522        let p = &self.params;
523        for i in 0..3 {
524            if reason == p.dims[i].min {
525                self.config.dims[i].min = snapshot.value.as_i32().max(0) as usize;
526                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
527            }
528            if reason == p.dims[i].size {
529                self.config.dims[i].size = snapshot.value.as_i32().max(0) as usize;
530                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
531            }
532            if reason == p.dims[i].bin {
533                self.config.dims[i].bin = snapshot.value.as_i32().max(1) as usize;
534                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
535            }
536            if reason == p.dims[i].reverse {
537                self.config.dims[i].reverse = snapshot.value.as_i32() != 0;
538                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
539            }
540            if reason == p.dims[i].enable {
541                self.config.dims[i].enable = snapshot.value.as_i32() != 0;
542                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
543            }
544            if reason == p.dims[i].auto_size {
545                self.config.dims[i].auto_size = snapshot.value.as_i32() != 0;
546                return ad_core_rs::plugin::runtime::ParamChangeResult::empty();
547            }
548        }
549        if reason == p.enable_scale {
550            self.config.enable_scale = snapshot.value.as_i32() != 0;
551        } else if reason == p.scale {
552            self.config.scale = snapshot.value.as_f64();
553        } else if reason == p.data_type {
554            let v = snapshot.value.as_i32();
555            self.config.data_type = if v < 0 {
556                None
557            } else {
558                NDDataType::from_ordinal(v as u8)
559            };
560        } else if reason == p.collapse_dims {
561            self.config.collapse_dims = snapshot.value.as_i32() != 0;
562        }
563        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
564    }
565}
566
567/// Create an ROI plugin runtime, returning the handle and param reasons.
568pub fn create_roi_runtime(
569    port_name: &str,
570    pool: Arc<NDArrayPool>,
571    queue_size: usize,
572    ndarray_port: &str,
573    wiring: Arc<ad_core_rs::plugin::wiring::WiringRegistry>,
574) -> (
575    ad_core_rs::plugin::runtime::PluginRuntimeHandle,
576    ROIParams,
577    std::thread::JoinHandle<()>,
578) {
579    let processor = ROIProcessor::new(ROIConfig::default());
580    let (handle, jh) = ad_core_rs::plugin::runtime::create_plugin_runtime(
581        port_name,
582        processor,
583        pool,
584        queue_size,
585        ndarray_port,
586        wiring,
587    );
588    // Recreate param layout on a scratch PortDriverBase to get matching reasons.
589    let params = {
590        let mut base =
591            asyn_rs::port::PortDriverBase::new("_scratch_", 1, asyn_rs::port::PortFlags::default());
592        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
593        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
594        let mut p = ROIParams::default();
595        let dim_names = ["DIM0", "DIM1", "DIM2"];
596        for (i, prefix) in dim_names.iter().enumerate() {
597            p.dims[i].min = base
598                .create_param(&format!("{prefix}_MIN"), asyn_rs::param::ParamType::Int32)
599                .unwrap();
600            p.dims[i].size = base
601                .create_param(&format!("{prefix}_SIZE"), asyn_rs::param::ParamType::Int32)
602                .unwrap();
603            p.dims[i].bin = base
604                .create_param(&format!("{prefix}_BIN"), asyn_rs::param::ParamType::Int32)
605                .unwrap();
606            p.dims[i].reverse = base
607                .create_param(
608                    &format!("{prefix}_REVERSE"),
609                    asyn_rs::param::ParamType::Int32,
610                )
611                .unwrap();
612            p.dims[i].enable = base
613                .create_param(
614                    &format!("{prefix}_ENABLE"),
615                    asyn_rs::param::ParamType::Int32,
616                )
617                .unwrap();
618            p.dims[i].auto_size = base
619                .create_param(
620                    &format!("{prefix}_AUTO_SIZE"),
621                    asyn_rs::param::ParamType::Int32,
622                )
623                .unwrap();
624            p.dims[i].max_size = base
625                .create_param(
626                    &format!("{prefix}_MAX_SIZE"),
627                    asyn_rs::param::ParamType::Int32,
628                )
629                .unwrap();
630        }
631        p.enable_scale = base
632            .create_param("ENABLE_SCALE", asyn_rs::param::ParamType::Int32)
633            .unwrap();
634        p.scale = base
635            .create_param("SCALE_VALUE", asyn_rs::param::ParamType::Float64)
636            .unwrap();
637        p.data_type = base
638            .create_param("ROI_DATA_TYPE", asyn_rs::param::ParamType::Int32)
639            .unwrap();
640        p.collapse_dims = base
641            .create_param("COLLAPSE_DIMS", asyn_rs::param::ParamType::Int32)
642            .unwrap();
643        p.name = base
644            .create_param("NAME", asyn_rs::param::ParamType::Octet)
645            .unwrap();
646        p
647    };
648    (handle, params, jh)
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    fn make_4x4_u8() -> NDArray {
656        let mut arr = NDArray::new(
657            vec![NDDimension::new(4), NDDimension::new(4)],
658            NDDataType::UInt8,
659        );
660        if let NDDataBuffer::U8(ref mut v) = arr.data {
661            for i in 0..16 {
662                v[i] = i as u8;
663            }
664        }
665        arr
666    }
667
668    #[test]
669    fn test_r9_66_disabled_dimension_is_not_binned() {
670        // R9-66. C's per-axis loop decides offset/size/binning together from the
671        // Enable flag, and the DISABLED branch forces all three
672        // (NDPluginROI.cpp:98-102): offset = 0, size = the full axis, binning = 1.
673        // A leftover DimNBin > 1 on a disabled axis is therefore ignored by C. The
674        // port resolved offset/size through resolve_axis but re-derived the binning
675        // outside it, straight from the config, so a disabled axis was still binned:
676        // a shrunken axis of bin-sums instead of the full-resolution axis.
677        let arr = make_4x4_u8();
678        let mut config = ROIConfig::default();
679        // Dim0 disabled, but with stale min/size/bin left over from an earlier ROI.
680        config.dims[0] = ROIDimConfig {
681            min: 2,
682            size: 1,
683            bin: 2,
684            reverse: false,
685            enable: false,
686            auto_size: false,
687        };
688        config.dims[1] = ROIDimConfig {
689            min: 0,
690            size: 4,
691            bin: 1,
692            reverse: false,
693            enable: true,
694            auto_size: false,
695        };
696
697        let roi = extract_roi_2d(&arr, &config).unwrap();
698        // Disabled → the whole 4-wide axis at full resolution, NOT 4/2 == 2 bins,
699        // and the stale min = 2 is ignored.
700        assert_eq!(roi.dims[0].size, 4, "disabled axis keeps its full size");
701        assert_eq!(roi.dims[1].size, 4);
702        if let NDDataBuffer::U8(ref v) = roi.data {
703            // Row 0 of the source, unbinned and unoffset: 0,1,2,3.
704            assert_eq!(&v[0..4], &[0, 1, 2, 3], "disabled axis must not bin-sum");
705        } else {
706            panic!("expected U8");
707        }
708
709        // Boundary: the SAME stale bin on an ENABLED axis must still bin (C :96-97),
710        // so the fix is keyed on Enable and has not simply dropped binning.
711        config.dims[0].enable = true;
712        config.dims[0].min = 0;
713        config.dims[0].auto_size = true; // size = full axis, bin = 2
714        let roi = extract_roi_2d(&arr, &config).unwrap();
715        assert_eq!(
716            roi.dims[0].size, 2,
717            "enabled axis with bin=2 halves the axis"
718        );
719        if let NDDataBuffer::U8(ref v) = roi.data {
720            // Row 0 binned by 2: 0+1 = 1, 2+3 = 5.
721            assert_eq!(&v[0..2], &[1, 5], "enabled axis bin-sums");
722        } else {
723            panic!("expected U8");
724        }
725    }
726
727    #[test]
728    fn test_r9_66_disabled_color_axis_is_not_binned() {
729        // The 3-D path took the binning from the config too (roi.rs:216-218).
730        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
731        use ad_core_rs::color::NDColorMode;
732
733        // RGB1: dims are [color, x, y].
734        let mut arr = NDArray::new(
735            vec![
736                NDDimension::new(3),
737                NDDimension::new(4),
738                NDDimension::new(2),
739            ],
740            NDDataType::UInt8,
741        );
742        arr.attributes.add(NDAttribute::new_static(
743            "ColorMode",
744            "",
745            NDAttrSource::Driver,
746            NDAttrValue::Int32(NDColorMode::RGB1 as i32),
747        ));
748        let mut config = ROIConfig::default();
749        // Dim2 is the COLOR axis (userDims[2] = colorDim): disabled, stale bin = 3.
750        config.dims[2] = ROIDimConfig {
751            min: 0,
752            size: 3,
753            bin: 3,
754            reverse: false,
755            enable: false,
756            auto_size: false,
757        };
758        for i in 0..2 {
759            config.dims[i] = ROIDimConfig {
760                min: 0,
761                size: 0,
762                bin: 1,
763                reverse: false,
764                enable: true,
765                auto_size: true,
766            };
767        }
768
769        let roi = extract_roi_3d(&arr, &config).unwrap();
770        // Disabled colour axis: all 3 planes survive, unbinned. With the stale
771        // bin = 3 applied it would have collapsed to a single summed plane.
772        assert_eq!(
773            roi.dims[0].size, 3,
774            "disabled colour axis keeps all 3 planes"
775        );
776        assert_eq!(roi.dims[1].size, 4);
777        assert_eq!(roi.dims[2].size, 2);
778    }
779
780    #[test]
781    fn test_extract_sub_region() {
782        let arr = make_4x4_u8();
783        let mut config = ROIConfig::default();
784        config.dims[0] = ROIDimConfig {
785            min: 1,
786            size: 2,
787            bin: 1,
788            reverse: false,
789            enable: true,
790            auto_size: false,
791        };
792        config.dims[1] = ROIDimConfig {
793            min: 1,
794            size: 2,
795            bin: 1,
796            reverse: false,
797            enable: true,
798            auto_size: false,
799        };
800
801        let roi = extract_roi_2d(&arr, &config).unwrap();
802        assert_eq!(roi.dims[0].size, 2);
803        assert_eq!(roi.dims[1].size, 2);
804        if let NDDataBuffer::U8(ref v) = roi.data {
805            // row 1, cols 1-2: [5,6], row 2, cols 1-2: [9,10]
806            assert_eq!(v[0], 5);
807            assert_eq!(v[1], 6);
808            assert_eq!(v[2], 9);
809            assert_eq!(v[3], 10);
810        }
811    }
812
813    #[test]
814    fn test_binning_2x2() {
815        let arr = make_4x4_u8();
816        let mut config = ROIConfig::default();
817        config.dims[0] = ROIDimConfig {
818            min: 0,
819            size: 4,
820            bin: 2,
821            reverse: false,
822            enable: true,
823            auto_size: false,
824        };
825        config.dims[1] = ROIDimConfig {
826            min: 0,
827            size: 4,
828            bin: 2,
829            reverse: false,
830            enable: true,
831            auto_size: false,
832        };
833
834        let roi = extract_roi_2d(&arr, &config).unwrap();
835        assert_eq!(roi.dims[0].size, 2);
836        assert_eq!(roi.dims[1].size, 2);
837        if let NDDataBuffer::U8(ref v) = roi.data {
838            // top-left 2x2: sum = 0+1+4+5 = 10 (C++ sums, not averages)
839            assert_eq!(v[0], 10);
840        }
841    }
842
843    #[test]
844    fn test_reverse() {
845        let arr = make_4x4_u8();
846        let mut config = ROIConfig::default();
847        config.dims[0] = ROIDimConfig {
848            min: 0,
849            size: 4,
850            bin: 1,
851            reverse: true,
852            enable: true,
853            auto_size: false,
854        };
855        config.dims[1] = ROIDimConfig {
856            min: 0,
857            size: 1,
858            bin: 1,
859            reverse: false,
860            enable: true,
861            auto_size: false,
862        };
863
864        let roi = extract_roi_2d(&arr, &config).unwrap();
865        if let NDDataBuffer::U8(ref v) = roi.data {
866            assert_eq!(v[0], 3);
867            assert_eq!(v[1], 2);
868            assert_eq!(v[2], 1);
869            assert_eq!(v[3], 0);
870        }
871    }
872
873    #[test]
874    fn test_collapse_dims() {
875        let arr = make_4x4_u8();
876        let mut config = ROIConfig::default();
877        config.dims[0] = ROIDimConfig {
878            min: 0,
879            size: 4,
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.collapse_dims = true;
894
895        let roi = extract_roi_2d(&arr, &config).unwrap();
896        assert_eq!(roi.dims.len(), 1);
897        assert_eq!(roi.dims[0].size, 4);
898    }
899
900    #[test]
901    fn test_scale() {
902        let arr = make_4x4_u8();
903        let mut config = ROIConfig::default();
904        config.dims[0] = ROIDimConfig {
905            min: 0,
906            size: 2,
907            bin: 1,
908            reverse: false,
909            enable: true,
910            auto_size: false,
911        };
912        config.dims[1] = ROIDimConfig {
913            min: 0,
914            size: 1,
915            bin: 1,
916            reverse: false,
917            enable: true,
918            auto_size: false,
919        };
920        config.enable_scale = true;
921        config.scale = 2.0;
922
923        let roi = extract_roi_2d(&arr, &config).unwrap();
924        if let NDDataBuffer::U8(ref v) = roi.data {
925            // C++: scale is a divisor
926            assert_eq!(v[0], 0); // 0 / 2 = 0
927            assert_eq!(v[1], 0); // 1 / 2 = 0.5 → 0
928        }
929    }
930
931    #[test]
932    fn test_type_convert() {
933        let arr = make_4x4_u8();
934        let mut config = ROIConfig::default();
935        config.dims[0] = ROIDimConfig {
936            min: 0,
937            size: 2,
938            bin: 1,
939            reverse: false,
940            enable: true,
941            auto_size: false,
942        };
943        config.dims[1] = ROIDimConfig {
944            min: 0,
945            size: 1,
946            bin: 1,
947            reverse: false,
948            enable: true,
949            auto_size: false,
950        };
951        config.data_type = Some(NDDataType::UInt16);
952
953        let roi = extract_roi_2d(&arr, &config).unwrap();
954        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
955    }
956
957    // --- New ROIProcessor tests ---
958
959    #[test]
960    fn test_roi_processor() {
961        let mut config = ROIConfig::default();
962        config.dims[0] = ROIDimConfig {
963            min: 1,
964            size: 2,
965            bin: 1,
966            reverse: false,
967            enable: true,
968            auto_size: false,
969        };
970        config.dims[1] = ROIDimConfig {
971            min: 1,
972            size: 2,
973            bin: 1,
974            reverse: false,
975            enable: true,
976            auto_size: false,
977        };
978
979        let mut proc = ROIProcessor::new(config);
980        let pool = NDArrayPool::new(1_000_000);
981
982        let arr = make_4x4_u8();
983        let result = proc.process_array(&arr, &pool);
984        assert_eq!(result.output_arrays.len(), 1);
985        assert_eq!(result.output_arrays[0].dims[0].size, 2);
986        assert_eq!(result.output_arrays[0].dims[1].size, 2);
987    }
988
989    #[test]
990    fn test_r9_67_max_size_uses_the_user_dims_mapping() {
991        // R9-67. C reports DimNMaxSize as `pArray->dims[userDims[N]].size` with
992        // `userDims = {xDim, yDim, colorDim}` (NDPluginROI.cpp:80-82, :111/:120/:129),
993        // i.e. the LOGICAL axis ROI dim N controls. The port indexed the physical
994        // dims slot N, so on an RGB1 array (`[color, x, y]`) Dim0MaxSize reported
995        // the colour axis (3) instead of the image width, and every DimNMaxSize
996        // was rotated one slot — the operator's ROI limits described the wrong axes.
997        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
998        use ad_core_rs::color::NDColorMode;
999        use asyn_rs::port::{PortDriverBase, PortFlags};
1000
1001        // RGB1: dims = [color=3, x=8, y=5]; xDim=1, yDim=2, colorDim=0.
1002        let mut arr = NDArray::new(
1003            vec![
1004                NDDimension::new(3),
1005                NDDimension::new(8),
1006                NDDimension::new(5),
1007            ],
1008            NDDataType::UInt8,
1009        );
1010        arr.attributes.add(NDAttribute::new_static(
1011            "ColorMode",
1012            "",
1013            NDAttrSource::Driver,
1014            NDAttrValue::Int32(NDColorMode::RGB1 as i32),
1015        ));
1016
1017        let mut proc = ROIProcessor::new(ROIConfig::default());
1018        let mut base = PortDriverBase::new("R9_67", 1, PortFlags::default());
1019        proc.register_params(&mut base).unwrap();
1020        let reasons = [
1021            proc.params().dims[0].max_size,
1022            proc.params().dims[1].max_size,
1023            proc.params().dims[2].max_size,
1024        ];
1025
1026        let pool = NDArrayPool::new(1_000_000);
1027        let result = proc.process_array(&arr, &pool);
1028        let max_size = |reason: usize| {
1029            result
1030                .param_updates
1031                .iter()
1032                .find_map(|u| match u {
1033                    ParamUpdate::Int32 {
1034                        reason: r, value, ..
1035                    } if *r == reason => Some(*value),
1036                    _ => None,
1037                })
1038                .expect("MaxSize update")
1039        };
1040
1041        assert_eq!(
1042            max_size(reasons[0]),
1043            8,
1044            "Dim0MaxSize is the X axis (dims[1])"
1045        );
1046        assert_eq!(
1047            max_size(reasons[1]),
1048            5,
1049            "Dim1MaxSize is the Y axis (dims[2])"
1050        );
1051        assert_eq!(
1052            max_size(reasons[2]),
1053            3,
1054            "Dim2MaxSize is the colour axis (dims[0])"
1055        );
1056
1057        // Control: on a mono 2-D array userDims = {0, 1, 0} and the logical and
1058        // physical indices coincide, so the readback is unchanged — and Dim2, past
1059        // ndims, stays 0 (C zeroes all three at :105-107 and only overrides within
1060        // ndims).
1061        let arr2d = NDArray::new(
1062            vec![NDDimension::new(6), NDDimension::new(4)],
1063            NDDataType::UInt8,
1064        );
1065        let result = proc.process_array(&arr2d, &pool);
1066        let max_size = |reason: usize| {
1067            result
1068                .param_updates
1069                .iter()
1070                .find_map(|u| match u {
1071                    ParamUpdate::Int32 {
1072                        reason: r, value, ..
1073                    } if *r == reason => Some(*value),
1074                    _ => None,
1075                })
1076                .expect("MaxSize update")
1077        };
1078        assert_eq!(max_size(reasons[0]), 6);
1079        assert_eq!(max_size(reasons[1]), 4);
1080        assert_eq!(max_size(reasons[2]), 0);
1081    }
1082
1083    // --- Auto-size / dim-disable / autocenter tests ---
1084
1085    #[test]
1086    fn test_auto_size() {
1087        // 4x4 image, min_x=1 with auto_size => size_x = 4-1 = 3
1088        let arr = make_4x4_u8();
1089        let mut config = ROIConfig::default();
1090        config.dims[0] = ROIDimConfig {
1091            min: 1,
1092            size: 0,
1093            bin: 1,
1094            reverse: false,
1095            enable: true,
1096            auto_size: true,
1097        };
1098        config.dims[1] = ROIDimConfig {
1099            min: 0,
1100            size: 0,
1101            bin: 1,
1102            reverse: false,
1103            enable: true,
1104            auto_size: true,
1105        };
1106
1107        let roi = extract_roi_2d(&arr, &config).unwrap();
1108        // C++: autoSize sets size = dimSize, then size = MIN(size, dimSize -
1109        // offset). With offset_x = 1 the X size clamps to 4 - 1 = 3; the Y
1110        // dimension with offset 0 stays at the full 4.
1111        assert_eq!(roi.dims[0].size, 3);
1112        assert_eq!(roi.dims[1].size, 4);
1113    }
1114
1115    #[test]
1116    fn test_dim_disable() {
1117        // Disabled dim uses full range: min=0, size=src_dim
1118        let arr = make_4x4_u8();
1119        let mut config = ROIConfig::default();
1120        config.dims[0] = ROIDimConfig {
1121            min: 2,
1122            size: 1,
1123            bin: 1,
1124            reverse: false,
1125            enable: false,
1126            auto_size: false,
1127        };
1128        config.dims[1] = ROIDimConfig {
1129            min: 0,
1130            size: 4,
1131            bin: 1,
1132            reverse: false,
1133            enable: true,
1134            auto_size: false,
1135        };
1136
1137        let roi = extract_roi_2d(&arr, &config).unwrap();
1138        // X dim disabled, so full range: size=4
1139        assert_eq!(roi.dims[0].size, 4);
1140        assert_eq!(roi.dims[1].size, 4);
1141    }
1142
1143    #[test]
1144    fn test_autocenter_peak() {
1145        // Create 8x8 image with a peak at (6, 5)
1146        let mut arr = NDArray::new(
1147            vec![NDDimension::new(8), NDDimension::new(8)],
1148            NDDataType::UInt8,
1149        );
1150        if let NDDataBuffer::U8(ref mut v) = arr.data {
1151            for i in 0..64 {
1152                v[i] = 1;
1153            }
1154            // Place peak at x=6, y=5
1155            v[5 * 8 + 6] = 255;
1156        }
1157
1158        let mut config = ROIConfig::default();
1159        config.dims[0] = ROIDimConfig {
1160            min: 0,
1161            size: 4,
1162            bin: 1,
1163            reverse: false,
1164            enable: true,
1165            auto_size: false,
1166        };
1167        config.dims[1] = ROIDimConfig {
1168            min: 0,
1169            size: 4,
1170            bin: 1,
1171            reverse: false,
1172            enable: true,
1173            auto_size: false,
1174        };
1175        config.autocenter = AutoCenter::PeakPosition;
1176
1177        let roi = extract_roi_2d(&arr, &config).unwrap();
1178        assert_eq!(roi.dims[0].size, 4);
1179        assert_eq!(roi.dims[1].size, 4);
1180
1181        // ROI should be centered on peak (6,5) with size 4x4
1182        // min_x = 6 - 4/2 = 4, clamped to min(4, 8-4)=4
1183        // min_y = 5 - 4/2 = 3, clamped to min(3, 8-4)=3
1184        // So ROI covers x=[4..8), y=[3..7) and the peak at (6,5) should be inside
1185        // In the ROI, the peak is at local (6-4, 5-3) = (2, 2)
1186        if let NDDataBuffer::U8(ref v) = roi.data {
1187            assert_eq!(v[2 * 4 + 2], 255); // peak at local (2,2)
1188        }
1189    }
1190
1191    #[test]
1192    fn test_offset_clamp_to_last_column() {
1193        // Regression: an offset equal to the dim size must clamp to dimSize-1
1194        // and still produce a 1-pixel ROI (C++ MIN(offset, dimSize-1)),
1195        // instead of collapsing to an empty sink.
1196        let arr = make_4x4_u8();
1197        let mut config = ROIConfig::default();
1198        // min == src_x (4): one past the last valid index.
1199        config.dims[0] = ROIDimConfig {
1200            min: 4,
1201            size: 10,
1202            bin: 1,
1203            reverse: false,
1204            enable: true,
1205            auto_size: false,
1206        };
1207        config.dims[1] = ROIDimConfig {
1208            min: 0,
1209            size: 1,
1210            bin: 1,
1211            reverse: false,
1212            enable: true,
1213            auto_size: false,
1214        };
1215        let roi = extract_roi_2d(&arr, &config).unwrap();
1216        // offset clamps to 3, size clamps to 4-3 = 1.
1217        assert_eq!(roi.dims[0].size, 1);
1218        if let NDDataBuffer::U8(ref v) = roi.data {
1219            assert_eq!(v[0], 3); // last column of row 0
1220        }
1221    }
1222
1223    #[test]
1224    fn test_bin_larger_than_roi_clamps() {
1225        // Regression: a bin larger than the ROI is clamped to the ROI size
1226        // (C++ MIN(binning, size)), yielding a 1-pixel output instead of a
1227        // None sink.
1228        let arr = make_4x4_u8();
1229        let mut config = ROIConfig::default();
1230        config.dims[0] = ROIDimConfig {
1231            min: 0,
1232            size: 2,
1233            bin: 99, // far larger than the ROI
1234            reverse: false,
1235            enable: true,
1236            auto_size: false,
1237        };
1238        config.dims[1] = ROIDimConfig {
1239            min: 0,
1240            size: 1,
1241            bin: 1,
1242            reverse: false,
1243            enable: true,
1244            auto_size: false,
1245        };
1246        let roi = extract_roi_2d(&arr, &config).unwrap();
1247        // bin clamps to size 2 => out_x = 2/2 = 1.
1248        assert_eq!(roi.dims[0].size, 1);
1249        if let NDDataBuffer::U8(ref v) = roi.data {
1250            // sum of the 2-pixel bin: 0 + 1 = 1
1251            assert_eq!(v[0], 1);
1252        }
1253    }
1254
1255    /// 2x2 RGB1 image: index = y*6 + x*3 + c, value = 100*y + 10*x + c.
1256    fn make_rgb1_2x2() -> NDArray {
1257        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
1258        let mut arr = NDArray::new(
1259            vec![
1260                NDDimension::new(3),
1261                NDDimension::new(2),
1262                NDDimension::new(2),
1263            ],
1264            NDDataType::UInt8,
1265        );
1266        arr.attributes.add(NDAttribute::new_static(
1267            "ColorMode",
1268            "",
1269            NDAttrSource::Driver,
1270            NDAttrValue::Int32(ad_core_rs::color::NDColorMode::RGB1 as i32),
1271        ));
1272        if let NDDataBuffer::U8(ref mut v) = arr.data {
1273            for y in 0..2 {
1274                for x in 0..2 {
1275                    for c in 0..3 {
1276                        v[y * 6 + x * 3 + c] = (100 * y + 10 * x + c) as u8;
1277                    }
1278                }
1279            }
1280        }
1281        arr
1282    }
1283
1284    #[test]
1285    fn test_roi_3d_rgb1_x_subregion() {
1286        // ROI on an RGB1 image: Dim0 selects X, the color axis is preserved.
1287        let arr = make_rgb1_2x2();
1288        let mut config = ROIConfig::default();
1289        // X: take only column 1.
1290        config.dims[0] = ROIDimConfig {
1291            min: 1,
1292            size: 1,
1293            bin: 1,
1294            reverse: false,
1295            enable: true,
1296            auto_size: false,
1297        };
1298        config.dims[1] = ROIDimConfig {
1299            min: 0,
1300            size: 2,
1301            bin: 1,
1302            reverse: false,
1303            enable: true,
1304            auto_size: false,
1305        };
1306        config.dims[2] = ROIDimConfig {
1307            min: 0,
1308            size: 3,
1309            bin: 1,
1310            reverse: false,
1311            enable: true,
1312            auto_size: false,
1313        };
1314        let roi = extract_roi(&arr, &config).unwrap();
1315        // RGB1 layout: dims = [color=3, x=1, y=2].
1316        assert_eq!(roi.dims[0].size, 3);
1317        assert_eq!(roi.dims[1].size, 1);
1318        assert_eq!(roi.dims[2].size, 2);
1319        if let NDDataBuffer::U8(ref v) = roi.data {
1320            // pixel (x=1,y=0) channels => 10,11,12
1321            assert_eq!(&v[0..3], &[10, 11, 12]);
1322            // pixel (x=1,y=1) channels => 110,111,112
1323            assert_eq!(&v[3..6], &[110, 111, 112]);
1324        } else {
1325            panic!("not u8");
1326        }
1327    }
1328
1329    #[test]
1330    fn test_adp18_3d_rgb_honors_output_data_type() {
1331        // A 3-D RGB ROI with ROI_DATA_TYPE set must convert the output, like
1332        // the 2-D path (C NDPluginROI.cpp:144,166-174). The old 3-D path kept
1333        // the source type regardless of config.data_type.
1334        let arr = make_rgb1_2x2();
1335        let mut config = ROIConfig::default();
1336        config.dims[0] = ROIDimConfig {
1337            min: 0,
1338            size: 2,
1339            bin: 1,
1340            reverse: false,
1341            enable: true,
1342            auto_size: false,
1343        };
1344        config.dims[1] = ROIDimConfig {
1345            min: 0,
1346            size: 2,
1347            bin: 1,
1348            reverse: false,
1349            enable: true,
1350            auto_size: false,
1351        };
1352        config.dims[2] = ROIDimConfig {
1353            min: 0,
1354            size: 3,
1355            bin: 1,
1356            reverse: false,
1357            enable: true,
1358            auto_size: false,
1359        };
1360        config.data_type = Some(NDDataType::UInt16);
1361
1362        let roi = extract_roi(&arr, &config).unwrap();
1363        assert_eq!(roi.data.data_type(), NDDataType::UInt16);
1364        // RGB1 dims preserved: [color=3, x=2, y=2].
1365        assert_eq!(roi.dims[0].size, 3);
1366        assert_eq!(roi.dims[1].size, 2);
1367        assert_eq!(roi.dims[2].size, 2);
1368        // Values preserved through the widening conversion: pixel (0,0) = 0,1,2.
1369        if let NDDataBuffer::U16(ref v) = roi.data {
1370            assert_eq!(&v[0..3], &[0, 1, 2]);
1371        } else {
1372            panic!("not u16");
1373        }
1374    }
1375
1376    #[test]
1377    fn test_adp19_single_color_collapses_to_2d_mono() {
1378        use ad_core_rs::color::NDColorMode;
1379        // Select a single color plane (channel 1) of an RGB1 image. C forces
1380        // collapseDims and sets ColorMode=Mono (NDPluginROI.cpp:177-215), so
1381        // the output is a 2-D mono [x,y] array, not a 3-D RGB1 with a size-1
1382        // color axis.
1383        let arr = make_rgb1_2x2();
1384        let mut config = ROIConfig::default();
1385        config.dims[0] = ROIDimConfig {
1386            min: 0,
1387            size: 2,
1388            bin: 1,
1389            reverse: false,
1390            enable: true,
1391            auto_size: false,
1392        };
1393        config.dims[1] = ROIDimConfig {
1394            min: 0,
1395            size: 2,
1396            bin: 1,
1397            reverse: false,
1398            enable: true,
1399            auto_size: false,
1400        };
1401        config.dims[2] = ROIDimConfig {
1402            min: 1,
1403            size: 1,
1404            bin: 1,
1405            reverse: false,
1406            enable: true,
1407            auto_size: false,
1408        };
1409
1410        let roi = extract_roi(&arr, &config).unwrap();
1411        // Collapsed to 2-D [x=2, y=2].
1412        assert_eq!(roi.dims.len(), 2);
1413        assert_eq!(roi.dims[0].size, 2);
1414        assert_eq!(roi.dims[1].size, 2);
1415        // ColorMode forced to Mono.
1416        let cm = roi
1417            .attributes
1418            .get("ColorMode")
1419            .and_then(|a| a.value.as_i64())
1420            .unwrap();
1421        assert_eq!(cm, NDColorMode::Mono as i64);
1422        // Channel-1 value of pixel (x,y) = 100*y + 10*x + 1, in [x,y] order.
1423        if let NDDataBuffer::U8(ref v) = roi.data {
1424            assert_eq!(v, &[1, 11, 101, 111]);
1425        } else {
1426            panic!("not u8");
1427        }
1428    }
1429    // ---- R7-62: C truncating casts, not Rust saturating `as` ----
1430
1431    /// C `NDPluginROI.cpp:174` (no scaling) calls
1432    /// `pNDArrayPool->convert(pArray, &pOutput, dataType, dims)`, whose
1433    /// `convertDim` kernel accumulates the bin sum **in the output type**
1434    /// (`*pDOut += (dataTypeOut)*pDIn`, NDArrayPool.cpp:465). A UInt8 image of
1435    /// 100s binned 3x3 sums to 900, which wraps: 900 % 256 == 132. The port
1436    /// must not saturate to 255.
1437    #[test]
1438    fn test_bin_sum_wraps_modulo_output_type() {
1439        let mut arr = NDArray::new(
1440            vec![NDDimension::new(3), NDDimension::new(3)],
1441            NDDataType::UInt8,
1442        );
1443        if let NDDataBuffer::U8(ref mut v) = arr.data {
1444            v.iter_mut().for_each(|p| *p = 100);
1445        }
1446
1447        let mut config = ROIConfig {
1448            enable_scale: false,
1449            ..Default::default()
1450        };
1451        for d in 0..2 {
1452            config.dims[d] = ROIDimConfig {
1453                min: 0,
1454                size: 3,
1455                bin: 3,
1456                reverse: false,
1457                enable: true,
1458                auto_size: false,
1459            };
1460        }
1461
1462        let roi = extract_roi_2d(&arr, &config).unwrap();
1463        if let NDDataBuffer::U8(ref v) = roi.data {
1464            assert_eq!(v[0], 132, "900 % 256 == 132 (C wraps), not 255");
1465        } else {
1466            panic!("not u8");
1467        }
1468    }
1469
1470    /// The EnableScale path (C `NDPluginROI.cpp:160-171`) is the *only* one
1471    /// that escapes the wrap: it converts to Float64 first, so the 3x3 bin of
1472    /// 100s sums exactly to 900, and 900/9 == 100 lands in UInt8 unwrapped.
1473    #[test]
1474    fn test_bin_sum_with_scale_uses_float64_intermediate() {
1475        let mut arr = NDArray::new(
1476            vec![NDDimension::new(3), NDDimension::new(3)],
1477            NDDataType::UInt8,
1478        );
1479        if let NDDataBuffer::U8(ref mut v) = arr.data {
1480            v.iter_mut().for_each(|p| *p = 100);
1481        }
1482
1483        let mut config = ROIConfig {
1484            enable_scale: true,
1485            scale: 9.0,
1486            ..Default::default()
1487        };
1488        for d in 0..2 {
1489            config.dims[d] = ROIDimConfig {
1490                min: 0,
1491                size: 3,
1492                bin: 3,
1493                reverse: false,
1494                enable: true,
1495                auto_size: false,
1496            };
1497        }
1498
1499        let roi = extract_roi_2d(&arr, &config).unwrap();
1500        if let NDDataBuffer::U8(ref v) = roi.data {
1501            assert_eq!(v[0], 100, "900/9 via the Float64 path");
1502        } else {
1503            panic!("not u8");
1504        }
1505    }
1506
1507    /// A non-scale ROI with a narrowing output type converts through the same
1508    /// C cast: UInt16 300 -> UInt8 is 300 % 256 == 44, not a clamp to 255.
1509    #[test]
1510    fn test_narrowing_output_type_wraps() {
1511        let mut arr = NDArray::new(
1512            vec![NDDimension::new(2), NDDimension::new(1)],
1513            NDDataType::UInt16,
1514        );
1515        if let NDDataBuffer::U16(ref mut v) = arr.data {
1516            v[0] = 300;
1517            v[1] = 70;
1518        }
1519
1520        let mut config = ROIConfig {
1521            data_type: Some(NDDataType::UInt8),
1522            ..Default::default()
1523        };
1524        config.dims[0] = ROIDimConfig {
1525            min: 0,
1526            size: 2,
1527            bin: 1,
1528            reverse: false,
1529            enable: true,
1530            auto_size: false,
1531        };
1532        config.dims[1] = ROIDimConfig {
1533            min: 0,
1534            size: 1,
1535            bin: 1,
1536            reverse: false,
1537            enable: true,
1538            auto_size: false,
1539        };
1540
1541        let roi = extract_roi_2d(&arr, &config).unwrap();
1542        assert_eq!(roi.data.data_type(), NDDataType::UInt8);
1543        if let NDDataBuffer::U8(ref v) = roi.data {
1544            assert_eq!(v[0], 44, "(epicsUInt8)300 == 44");
1545            assert_eq!(v[1], 70);
1546        } else {
1547            panic!("not u8");
1548        }
1549    }
1550
1551    /// C `NDPluginROI.cpp:174` binning into a WIDER output type accumulates in
1552    /// that wider type: UInt8 100s, 3x3 bin, ROIDataType=UInt16 -> 900 (no
1553    /// wrap). The port must not sum in the source type and then widen.
1554    #[test]
1555    fn test_bin_sum_accumulates_in_the_output_type() {
1556        let mut arr = NDArray::new(
1557            vec![NDDimension::new(3), NDDimension::new(3)],
1558            NDDataType::UInt8,
1559        );
1560        if let NDDataBuffer::U8(ref mut v) = arr.data {
1561            v.iter_mut().for_each(|p| *p = 100);
1562        }
1563
1564        let mut config = ROIConfig {
1565            data_type: Some(NDDataType::UInt16),
1566            ..Default::default()
1567        };
1568        for d in 0..2 {
1569            config.dims[d] = ROIDimConfig {
1570                min: 0,
1571                size: 3,
1572                bin: 3,
1573                reverse: false,
1574                enable: true,
1575                auto_size: false,
1576            };
1577        }
1578
1579        let roi = extract_roi_2d(&arr, &config).unwrap();
1580        if let NDDataBuffer::U16(ref v) = roi.data {
1581            assert_eq!(v[0], 900, "the bin sum accumulates in UInt16");
1582        } else {
1583            panic!("not u16");
1584        }
1585    }
1586}