Skip to main content

ad_plugins_rs/
roi_stat.rs

1//! NDPluginROIStat: computes basic statistics for multiple ROI regions on each array.
2//!
3//! Each ROI is a rectangular sub-region of a 2D image. For each enabled ROI,
4//! the plugin computes min, max, mean, total, and net (background-subtracted total).
5//! Optionally accumulates time series data in circular buffers.
6
7use std::sync::Arc;
8
9use ad_core_rs::ndarray::{NDArray, NDDataBuffer};
10use ad_core_rs::ndarray_pool::NDArrayPool;
11use ad_core_rs::plugin::runtime::{
12    NDPluginProcess, ParamUpdate, PluginParamSnapshot, PluginRuntimeHandle, ProcessResult,
13};
14use ad_core_rs::plugin::wiring::WiringRegistry;
15use asyn_rs::param::ParamType;
16use asyn_rs::port::PortDriverBase;
17use parking_lot::Mutex;
18
19#[cfg(feature = "parallel")]
20use crate::par_util;
21use crate::time_series::{TimeSeriesData, TimeSeriesSender};
22#[cfg(feature = "parallel")]
23use rayon::prelude::*;
24
25/// Configuration for a single ROI region.
26#[derive(Debug, Clone)]
27pub struct ROIStatROI {
28    pub enabled: bool,
29    /// Offset in pixels: [x, y].
30    pub offset: [usize; 2],
31    /// Size in pixels: [x, y].
32    pub size: [usize; 2],
33    /// Width of the background border (pixels). 0 = no background subtraction.
34    pub bgd_width: usize,
35}
36
37impl Default for ROIStatROI {
38    fn default() -> Self {
39        Self {
40            enabled: true,
41            offset: [0, 0],
42            size: [0, 0],
43            bgd_width: 0,
44        }
45    }
46}
47
48/// Statistics computed for a single ROI.
49#[derive(Debug, Clone, Default)]
50pub struct ROIStatResult {
51    pub min: f64,
52    pub max: f64,
53    pub mean: f64,
54    pub total: f64,
55    /// Net = total - background_average * roi_elements. Zero if bgd_width is 0.
56    pub net: f64,
57}
58
59/// Time-series acquisition mode.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum TSMode {
62    Idle,
63    Acquiring,
64}
65
66/// Number of statistics tracked per ROI (min, max, mean, total, net).
67const NUM_STATS: usize = 5;
68
69/// Per-ROI stat names used for time series channel naming.
70const ROI_STAT_NAMES: [&str; NUM_STATS] = ["MinValue", "MaxValue", "MeanValue", "Total", "Net"];
71
72/// Generate time series channel names for ROIStat with the given number of ROIs.
73/// Produces names like "TS1:MinValue", "TS1:MaxValue", ..., "TS2:MinValue", etc.
74pub fn roi_stat_ts_channel_names(num_rois: usize) -> Vec<String> {
75    let mut names = Vec::with_capacity(num_rois * NUM_STATS);
76    for roi_idx in 0..num_rois {
77        for stat_name in &ROI_STAT_NAMES {
78            names.push(format!("TS{}:{}", roi_idx + 1, stat_name));
79        }
80    }
81    names
82}
83
84/// Parameter indices for NDROIStat plugin-specific params.
85///
86/// Per-ROI params use a single index and are differentiated by asyn addr (0..N).
87#[derive(Clone, Copy, Default)]
88pub struct ROIStatParams {
89    // Global (addr 0)
90    pub reset_all: usize,
91    pub ts_control: usize,
92    pub ts_num_points: usize,
93    pub ts_current_point: usize,
94    pub ts_acquiring: usize,
95    // Per-ROI (same index, different addr)
96    pub use_: usize,
97    pub name: usize,
98    pub reset: usize,
99    pub bgd_width: usize,
100    pub dim0_min: usize,
101    pub dim1_min: usize,
102    pub dim0_size: usize,
103    pub dim1_size: usize,
104    pub dim0_max_size: usize,
105    pub dim1_max_size: usize,
106    pub min_value: usize,
107    pub max_value: usize,
108    pub mean_value: usize,
109    pub total: usize,
110    pub net: usize,
111}
112
113/// Processor that computes ROI statistics on 2D arrays.
114pub struct ROIStatProcessor {
115    rois: Vec<ROIStatROI>,
116    results: Vec<ROIStatResult>,
117    /// Time series buffers: [roi_index][stat_index][time_point].
118    ts_mode: TSMode,
119    ts_buffers: Vec<Vec<Vec<f64>>>,
120    ts_num_points: usize,
121    ts_current: usize,
122    /// Optional sender to push flattened stats to a TimeSeriesPortDriver.
123    ts_sender: Option<TimeSeriesSender>,
124    /// Registered asyn param indices.
125    params: ROIStatParams,
126    /// Shared cell to export params after register_params is called.
127    params_out: Arc<Mutex<ROIStatParams>>,
128}
129
130impl ROIStatProcessor {
131    /// Create a new processor with the given ROI definitions.
132    pub fn new(rois: Vec<ROIStatROI>, ts_num_points: usize) -> Self {
133        let n = rois.len();
134        let results = vec![ROIStatResult::default(); n];
135        let ts_buffers = vec![vec![Vec::new(); NUM_STATS]; n];
136        Self {
137            rois,
138            results,
139            ts_mode: TSMode::Idle,
140            ts_buffers,
141            ts_num_points,
142            ts_current: 0,
143            ts_sender: None,
144            params: ROIStatParams::default(),
145            params_out: Arc::new(Mutex::new(ROIStatParams::default())),
146        }
147    }
148
149    /// Get a shared handle to the params (populated after register_params is called).
150    pub fn params_handle(&self) -> Arc<Mutex<ROIStatParams>> {
151        self.params_out.clone()
152    }
153
154    /// Get the current results for all ROIs.
155    pub fn results(&self) -> &[ROIStatResult] {
156        &self.results
157    }
158
159    /// Get the ROI definitions.
160    pub fn rois(&self) -> &[ROIStatROI] {
161        &self.rois
162    }
163
164    /// Mutable access to ROI definitions.
165    pub fn rois_mut(&mut self) -> &mut Vec<ROIStatROI> {
166        &mut self.rois
167    }
168
169    /// Set the time series mode.
170    pub fn set_ts_mode(&mut self, mode: TSMode) {
171        if mode == TSMode::Acquiring && self.ts_mode != TSMode::Acquiring {
172            // Reset time series on start
173            for roi_bufs in &mut self.ts_buffers {
174                for stat_buf in roi_bufs.iter_mut() {
175                    stat_buf.clear();
176                }
177            }
178            self.ts_current = 0;
179        }
180        self.ts_mode = mode;
181    }
182
183    /// Get time series buffer for a specific ROI and stat index.
184    /// stat_index: 0=min, 1=max, 2=mean, 3=total, 4=net
185    pub fn ts_buffer(&self, roi_index: usize, stat_index: usize) -> &[f64] {
186        if roi_index < self.ts_buffers.len() && stat_index < NUM_STATS {
187            &self.ts_buffers[roi_index][stat_index]
188        } else {
189            &[]
190        }
191    }
192
193    /// Set the sender for pushing time series data to a TimeSeriesPortDriver.
194    pub fn set_ts_sender(&mut self, sender: TimeSeriesSender) {
195        self.ts_sender = Some(sender);
196    }
197
198    /// Compute statistics for a single ROI on a 2D data buffer.
199    pub fn compute_roi_stats(
200        data: &NDDataBuffer,
201        x_size: usize,
202        y_size: usize,
203        roi: &ROIStatROI,
204    ) -> ROIStatResult {
205        let roi_x = roi.offset[0];
206        let roi_y = roi.offset[1];
207        let roi_w = roi.size[0];
208        let roi_h = roi.size[1];
209
210        // Clamp ROI to image bounds
211        if roi_x >= x_size || roi_y >= y_size || roi_w == 0 || roi_h == 0 {
212            return ROIStatResult::default();
213        }
214        let roi_w = roi_w.min(x_size - roi_x);
215        let roi_h = roi_h.min(y_size - roi_y);
216
217        let mut min = f64::MAX;
218        let mut max = f64::MIN;
219        let mut total = 0.0f64;
220        let mut count = 0usize;
221
222        for iy in roi_y..(roi_y + roi_h) {
223            for ix in roi_x..(roi_x + roi_w) {
224                let idx = iy * x_size + ix;
225                if let Some(val) = data.get_as_f64(idx) {
226                    if val < min {
227                        min = val;
228                    }
229                    if val > max {
230                        max = val;
231                    }
232                    total += val;
233                    count += 1;
234                }
235            }
236        }
237
238        if count == 0 {
239            return ROIStatResult::default();
240        }
241
242        let mean = total / count as f64;
243
244        // Background subtraction
245        let net = if roi.bgd_width > 0 {
246            let bgd = Self::compute_background(data, x_size, y_size, roi);
247            total - bgd * count as f64
248        } else {
249            total
250        };
251
252        ROIStatResult {
253            min,
254            max,
255            mean,
256            total,
257            net,
258        }
259    }
260
261    /// Compute average background from the border of the ROI.
262    fn compute_background(
263        data: &NDDataBuffer,
264        x_size: usize,
265        y_size: usize,
266        roi: &ROIStatROI,
267    ) -> f64 {
268        let roi_x = roi.offset[0];
269        let roi_y = roi.offset[1];
270        let roi_w = roi.size[0].min(x_size.saturating_sub(roi_x));
271        let roi_h = roi.size[1].min(y_size.saturating_sub(roi_y));
272        let bw = roi.bgd_width;
273
274        if bw == 0 || roi_w == 0 || roi_h == 0 {
275            return 0.0;
276        }
277
278        let mut bgd_total = 0.0f64;
279        let mut bgd_count = 0usize;
280
281        for iy in roi_y..(roi_y + roi_h) {
282            for ix in roi_x..(roi_x + roi_w) {
283                // Check if this pixel is in the border region
284                let dx_from_left = ix - roi_x;
285                let dx_from_right = (roi_x + roi_w - 1) - ix;
286                let dy_from_top = iy - roi_y;
287                let dy_from_bottom = (roi_y + roi_h - 1) - iy;
288
289                let in_border = dx_from_left < bw
290                    || dx_from_right < bw
291                    || dy_from_top < bw
292                    || dy_from_bottom < bw;
293
294                if in_border {
295                    let idx = iy * x_size + ix;
296                    if let Some(val) = data.get_as_f64(idx) {
297                        bgd_total += val;
298                        bgd_count += 1;
299                    }
300                }
301            }
302        }
303
304        if bgd_count == 0 {
305            0.0
306        } else {
307            bgd_total / bgd_count as f64
308        }
309    }
310}
311
312impl NDPluginProcess for ROIStatProcessor {
313    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
314        let info = array.info();
315        let x_size = info.x_size;
316        let y_size = info.y_size;
317
318        // Ensure results vec matches rois
319        self.results
320            .resize(self.rois.len(), ROIStatResult::default());
321
322        #[cfg(feature = "parallel")]
323        {
324            let total_elements: usize = self
325                .rois
326                .iter()
327                .filter(|r| r.enabled)
328                .map(|r| r.size[0] * r.size[1])
329                .sum();
330
331            if par_util::should_parallelize(total_elements) {
332                let data = &array.data;
333                let rois = &self.rois;
334                let new_results: Vec<ROIStatResult> = par_util::thread_pool().install(|| {
335                    rois.par_iter()
336                        .map(|roi| {
337                            if roi.enabled {
338                                Self::compute_roi_stats(data, x_size, y_size, roi)
339                            } else {
340                                ROIStatResult::default()
341                            }
342                        })
343                        .collect()
344                });
345                self.results = new_results;
346            } else {
347                for (i, roi) in self.rois.iter().enumerate() {
348                    if !roi.enabled {
349                        self.results[i] = ROIStatResult::default();
350                        continue;
351                    }
352                    self.results[i] = Self::compute_roi_stats(&array.data, x_size, y_size, roi);
353                }
354            }
355        }
356
357        #[cfg(not(feature = "parallel"))]
358        for (i, roi) in self.rois.iter().enumerate() {
359            if !roi.enabled {
360                self.results[i] = ROIStatResult::default();
361                continue;
362            }
363            self.results[i] = Self::compute_roi_stats(&array.data, x_size, y_size, roi);
364        }
365
366        // Accumulate time series
367        if self.ts_mode == TSMode::Acquiring {
368            // Ensure ts_buffers match roi count
369            while self.ts_buffers.len() < self.rois.len() {
370                self.ts_buffers.push(vec![Vec::new(); NUM_STATS]);
371            }
372
373            for (i, result) in self.results.iter().enumerate() {
374                if i >= self.ts_buffers.len() {
375                    break;
376                }
377                let stats = [
378                    result.min,
379                    result.max,
380                    result.mean,
381                    result.total,
382                    result.net,
383                ];
384                for (s, &val) in stats.iter().enumerate() {
385                    let buf = &mut self.ts_buffers[i][s];
386                    if buf.len() >= self.ts_num_points && self.ts_num_points > 0 {
387                        // Circular: overwrite oldest
388                        let idx = self.ts_current % self.ts_num_points;
389                        if idx < buf.len() {
390                            buf[idx] = val;
391                        }
392                    } else {
393                        buf.push(val);
394                    }
395                }
396            }
397            self.ts_current += 1;
398        }
399
400        // Send flattened stats to TimeSeriesPortDriver if connected
401        if let Some(ref sender) = self.ts_sender {
402            let mut values = Vec::with_capacity(self.results.len() * NUM_STATS);
403            for result in &self.results {
404                values.push(result.min);
405                values.push(result.max);
406                values.push(result.mean);
407                values.push(result.total);
408                values.push(result.net);
409            }
410            let _ = sender.try_send(TimeSeriesData { values });
411        }
412
413        // Build per-ROI param updates
414        let p = &self.params;
415        let mut updates = Vec::new();
416        for (i, result) in self.results.iter().enumerate() {
417            let addr = i as i32;
418            updates.push(ParamUpdate::float64_addr(p.min_value, addr, result.min));
419            updates.push(ParamUpdate::float64_addr(p.max_value, addr, result.max));
420            updates.push(ParamUpdate::float64_addr(p.mean_value, addr, result.mean));
421            updates.push(ParamUpdate::float64_addr(p.total, addr, result.total));
422            updates.push(ParamUpdate::float64_addr(p.net, addr, result.net));
423            updates.push(ParamUpdate::int32_addr(
424                p.dim0_max_size,
425                addr,
426                x_size as i32,
427            ));
428            updates.push(ParamUpdate::int32_addr(
429                p.dim1_max_size,
430                addr,
431                y_size as i32,
432            ));
433        }
434        updates.push(ParamUpdate::int32(
435            p.ts_current_point,
436            self.ts_current as i32,
437        ));
438        updates.push(ParamUpdate::int32(
439            p.ts_acquiring,
440            if self.ts_mode == TSMode::Acquiring {
441                1
442            } else {
443                0
444            },
445        ));
446
447        ProcessResult::sink(updates)
448    }
449
450    fn plugin_type(&self) -> &str {
451        "NDPluginROIStat"
452    }
453
454    fn register_params(
455        &mut self,
456        base: &mut PortDriverBase,
457    ) -> Result<(), asyn_rs::error::AsynError> {
458        // Global params
459        self.params.reset_all = base.create_param("ROISTAT_RESETALL", ParamType::Int32)?;
460        self.params.ts_control = base.create_param("ROISTAT_TS_CONTROL", ParamType::Int32)?;
461        self.params.ts_num_points = base.create_param("ROISTAT_TS_NUM_POINTS", ParamType::Int32)?;
462        base.set_int32_param(self.params.ts_num_points, 0, self.ts_num_points as i32)?;
463        self.params.ts_current_point =
464            base.create_param("ROISTAT_TS_CURRENT_POINT", ParamType::Int32)?;
465        self.params.ts_acquiring = base.create_param("ROISTAT_TS_ACQUIRING", ParamType::Int32)?;
466
467        // Per-ROI params (single index, differentiated by addr)
468        self.params.use_ = base.create_param("ROISTAT_USE", ParamType::Int32)?;
469        self.params.name = base.create_param("ROISTAT_NAME", ParamType::Octet)?;
470        self.params.reset = base.create_param("ROISTAT_RESET", ParamType::Int32)?;
471        self.params.bgd_width = base.create_param("ROISTAT_BGD_WIDTH", ParamType::Int32)?;
472        self.params.dim0_min = base.create_param("ROISTAT_DIM0_MIN", ParamType::Int32)?;
473        self.params.dim1_min = base.create_param("ROISTAT_DIM1_MIN", ParamType::Int32)?;
474        self.params.dim0_size = base.create_param("ROISTAT_DIM0_SIZE", ParamType::Int32)?;
475        self.params.dim1_size = base.create_param("ROISTAT_DIM1_SIZE", ParamType::Int32)?;
476        self.params.dim0_max_size = base.create_param("ROISTAT_DIM0_MAX_SIZE", ParamType::Int32)?;
477        self.params.dim1_max_size = base.create_param("ROISTAT_DIM1_MAX_SIZE", ParamType::Int32)?;
478        self.params.min_value = base.create_param("ROISTAT_MIN_VALUE", ParamType::Float64)?;
479        self.params.max_value = base.create_param("ROISTAT_MAX_VALUE", ParamType::Float64)?;
480        self.params.mean_value = base.create_param("ROISTAT_MEAN_VALUE", ParamType::Float64)?;
481        self.params.total = base.create_param("ROISTAT_TOTAL", ParamType::Float64)?;
482        self.params.net = base.create_param("ROISTAT_NET", ParamType::Float64)?;
483
484        // Set initial per-ROI values
485        for (i, roi) in self.rois.iter().enumerate() {
486            let addr = i as i32;
487            base.set_int32_param(self.params.use_, addr, roi.enabled as i32)?;
488            base.set_int32_param(self.params.bgd_width, addr, roi.bgd_width as i32)?;
489            base.set_int32_param(self.params.dim0_min, addr, roi.offset[0] as i32)?;
490            base.set_int32_param(self.params.dim1_min, addr, roi.offset[1] as i32)?;
491            base.set_int32_param(self.params.dim0_size, addr, roi.size[0] as i32)?;
492            base.set_int32_param(self.params.dim1_size, addr, roi.size[1] as i32)?;
493        }
494
495        // Export params
496        *self.params_out.lock() = self.params;
497
498        Ok(())
499    }
500
501    fn on_param_change(
502        &mut self,
503        reason: usize,
504        snapshot: &PluginParamSnapshot,
505    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
506        let addr = snapshot.addr as usize;
507        let p = &self.params;
508
509        if reason == p.use_ && addr < self.rois.len() {
510            self.rois[addr].enabled = snapshot.value.as_i32() != 0;
511        } else if reason == p.dim0_min && addr < self.rois.len() {
512            self.rois[addr].offset[0] = snapshot.value.as_i32().max(0) as usize;
513        } else if reason == p.dim1_min && addr < self.rois.len() {
514            self.rois[addr].offset[1] = snapshot.value.as_i32().max(0) as usize;
515        } else if reason == p.dim0_size && addr < self.rois.len() {
516            self.rois[addr].size[0] = snapshot.value.as_i32().max(0) as usize;
517        } else if reason == p.dim1_size && addr < self.rois.len() {
518            self.rois[addr].size[1] = snapshot.value.as_i32().max(0) as usize;
519        } else if reason == p.bgd_width && addr < self.rois.len() {
520            self.rois[addr].bgd_width = snapshot.value.as_i32().max(0) as usize;
521        } else if reason == p.reset && addr < self.rois.len() {
522            self.results[addr] = ROIStatResult::default();
523        } else if reason == p.reset_all {
524            for r in &mut self.results {
525                *r = ROIStatResult::default();
526            }
527        } else if reason == p.ts_control {
528            let mode = if snapshot.value.as_i32() != 0 {
529                TSMode::Acquiring
530            } else {
531                TSMode::Idle
532            };
533            self.set_ts_mode(mode);
534        } else if reason == p.ts_num_points {
535            self.ts_num_points = snapshot.value.as_i32().max(0) as usize;
536        }
537        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
538    }
539}
540
541/// Create a ROIStat plugin runtime. The TS receiver is stored in the registry
542/// for later pickup by `NDTimeSeriesConfigure`.
543pub fn create_roi_stat_runtime(
544    port_name: &str,
545    pool: Arc<NDArrayPool>,
546    queue_size: usize,
547    ndarray_port: &str,
548    wiring: Arc<WiringRegistry>,
549    num_rois: usize,
550    ts_registry: &crate::time_series::TsReceiverRegistry,
551) -> (
552    PluginRuntimeHandle,
553    ROIStatParams,
554    std::thread::JoinHandle<()>,
555) {
556    let (ts_tx, ts_rx) = tokio::sync::mpsc::channel(256);
557
558    let rois: Vec<ROIStatROI> = (0..num_rois).map(|_| ROIStatROI::default()).collect();
559    let mut processor = ROIStatProcessor::new(rois, 2048);
560    processor.set_ts_sender(ts_tx);
561    let params_handle = processor.params_handle();
562
563    let (handle, data_jh) = ad_core_rs::plugin::runtime::create_plugin_runtime_multi_addr(
564        port_name,
565        processor,
566        pool,
567        queue_size,
568        ndarray_port,
569        wiring,
570        num_rois,
571    );
572
573    let roi_stat_params = *params_handle.lock();
574
575    // Store the TS receiver for NDTimeSeriesConfigure to pick up
576    let channel_names = roi_stat_ts_channel_names(num_rois);
577    ts_registry.store(port_name, ts_rx, channel_names);
578
579    (handle, roi_stat_params, data_jh)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use ad_core_rs::ndarray::{NDDataType, NDDimension};
586
587    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
588        let mut arr = NDArray::new(
589            vec![NDDimension::new(x), NDDimension::new(y)],
590            NDDataType::Float64,
591        );
592        if let NDDataBuffer::F64(ref mut v) = arr.data {
593            for iy in 0..y {
594                for ix in 0..x {
595                    v[iy * x + ix] = fill(ix, iy);
596                }
597            }
598        }
599        arr
600    }
601
602    #[test]
603    fn test_single_roi_full_image() {
604        let arr = make_2d_array(4, 4, |_x, _y| 10.0);
605        let rois = vec![ROIStatROI {
606            enabled: true,
607            offset: [0, 0],
608            size: [4, 4],
609            bgd_width: 0,
610        }];
611
612        let mut proc = ROIStatProcessor::new(rois, 0);
613        let pool = NDArrayPool::new(1_000_000);
614        proc.process_array(&arr, &pool);
615
616        let r = &proc.results()[0];
617        assert!((r.min - 10.0).abs() < 1e-10);
618        assert!((r.max - 10.0).abs() < 1e-10);
619        assert!((r.mean - 10.0).abs() < 1e-10);
620        assert!((r.total - 160.0).abs() < 1e-10);
621    }
622
623    #[test]
624    fn test_single_roi_subregion() {
625        // 8x8 image, values = x + y * 8
626        let arr = make_2d_array(8, 8, |x, y| (x + y * 8) as f64);
627
628        let rois = vec![ROIStatROI {
629            enabled: true,
630            offset: [2, 2],
631            size: [3, 3],
632            bgd_width: 0,
633        }];
634
635        let mut proc = ROIStatProcessor::new(rois, 0);
636        let pool = NDArrayPool::new(1_000_000);
637        proc.process_array(&arr, &pool);
638
639        let r = &proc.results()[0];
640        // ROI pixels: (2,2)=18, (3,2)=19, (4,2)=20, (2,3)=26, (3,3)=27, (4,3)=28, (2,4)=34, (3,4)=35, (4,4)=36
641        assert!((r.min - 18.0).abs() < 1e-10);
642        assert!((r.max - 36.0).abs() < 1e-10);
643        let expected_total = 18.0 + 19.0 + 20.0 + 26.0 + 27.0 + 28.0 + 34.0 + 35.0 + 36.0;
644        assert!((r.total - expected_total).abs() < 1e-10);
645        assert!((r.mean - expected_total / 9.0).abs() < 1e-10);
646    }
647
648    #[test]
649    fn test_multiple_rois() {
650        let arr = make_2d_array(8, 8, |x, _y| x as f64);
651
652        let rois = vec![
653            ROIStatROI {
654                enabled: true,
655                offset: [0, 0],
656                size: [4, 4],
657                bgd_width: 0,
658            },
659            ROIStatROI {
660                enabled: true,
661                offset: [4, 0],
662                size: [4, 4],
663                bgd_width: 0,
664            },
665        ];
666
667        let mut proc = ROIStatProcessor::new(rois, 0);
668        let pool = NDArrayPool::new(1_000_000);
669        proc.process_array(&arr, &pool);
670
671        let r0 = &proc.results()[0];
672        assert!((r0.min - 0.0).abs() < 1e-10);
673        assert!((r0.max - 3.0).abs() < 1e-10);
674
675        let r1 = &proc.results()[1];
676        assert!((r1.min - 4.0).abs() < 1e-10);
677        assert!((r1.max - 7.0).abs() < 1e-10);
678    }
679
680    #[test]
681    fn test_bgd_width() {
682        // 6x6 image, center 2x2 has value 100, border has value 10
683        let arr = make_2d_array(6, 6, |x, y| {
684            if x >= 2 && x < 4 && y >= 2 && y < 4 {
685                100.0
686            } else {
687                10.0
688            }
689        });
690
691        let rois = vec![ROIStatROI {
692            enabled: true,
693            offset: [1, 1],
694            size: [4, 4],
695            bgd_width: 1,
696        }];
697
698        let mut proc = ROIStatProcessor::new(rois, 0);
699        let pool = NDArrayPool::new(1_000_000);
700        proc.process_array(&arr, &pool);
701
702        let r = &proc.results()[0];
703        // ROI is 4x4 at (1,1): border pixels = 12 (all with value 10), center = 4 (value 100)
704        // bgd average = (12*10 + ... well, border includes some 100s)
705        // Actually border pixels at bgd_width=1: the outer ring of the 4x4 ROI
706        // That outer ring occupies 12 of 16 pixels
707        assert!(
708            r.net < r.total,
709            "net should be less than total with bgd subtraction"
710        );
711    }
712
713    #[test]
714    fn test_empty_roi() {
715        let arr = make_2d_array(4, 4, |_, _| 10.0);
716        let rois = vec![ROIStatROI {
717            enabled: true,
718            offset: [0, 0],
719            size: [0, 0],
720            bgd_width: 0,
721        }];
722
723        let mut proc = ROIStatProcessor::new(rois, 0);
724        let pool = NDArrayPool::new(1_000_000);
725        proc.process_array(&arr, &pool);
726
727        let r = &proc.results()[0];
728        assert!((r.total - 0.0).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_disabled_roi() {
733        let arr = make_2d_array(4, 4, |_, _| 10.0);
734        let rois = vec![ROIStatROI {
735            enabled: false,
736            offset: [0, 0],
737            size: [4, 4],
738            bgd_width: 0,
739        }];
740
741        let mut proc = ROIStatProcessor::new(rois, 0);
742        let pool = NDArrayPool::new(1_000_000);
743        proc.process_array(&arr, &pool);
744
745        let r = &proc.results()[0];
746        assert!(
747            (r.total - 0.0).abs() < 1e-10,
748            "disabled ROI should have zero stats"
749        );
750    }
751
752    #[test]
753    fn test_roi_out_of_bounds() {
754        let arr = make_2d_array(4, 4, |_, _| 10.0);
755        let rois = vec![ROIStatROI {
756            enabled: true,
757            offset: [10, 10],
758            size: [4, 4],
759            bgd_width: 0,
760        }];
761
762        let mut proc = ROIStatProcessor::new(rois, 0);
763        let pool = NDArrayPool::new(1_000_000);
764        proc.process_array(&arr, &pool);
765
766        let r = &proc.results()[0];
767        assert!(
768            (r.total - 0.0).abs() < 1e-10,
769            "out-of-bounds ROI should produce zero stats"
770        );
771    }
772
773    #[test]
774    fn test_roi_partially_out_of_bounds() {
775        let arr = make_2d_array(4, 4, |_, _| 5.0);
776        let rois = vec![ROIStatROI {
777            enabled: true,
778            offset: [2, 2],
779            size: [10, 10], // extends beyond image
780            bgd_width: 0,
781        }];
782
783        let mut proc = ROIStatProcessor::new(rois, 0);
784        let pool = NDArrayPool::new(1_000_000);
785        proc.process_array(&arr, &pool);
786
787        let r = &proc.results()[0];
788        // Should be clamped to 2x2 region
789        assert!((r.total - 20.0).abs() < 1e-10);
790        assert!((r.mean - 5.0).abs() < 1e-10);
791    }
792
793    #[test]
794    fn test_time_series() {
795        let rois = vec![ROIStatROI {
796            enabled: true,
797            offset: [0, 0],
798            size: [4, 4],
799            bgd_width: 0,
800        }];
801
802        let mut proc = ROIStatProcessor::new(rois, 100);
803        let pool = NDArrayPool::new(1_000_000);
804        proc.set_ts_mode(TSMode::Acquiring);
805
806        for i in 0..5 {
807            let arr = make_2d_array(4, 4, |_, _| (i + 1) as f64);
808            proc.process_array(&arr, &pool);
809        }
810
811        // Check mean time series (stat index 2)
812        let ts = proc.ts_buffer(0, 2);
813        assert_eq!(ts.len(), 5);
814        assert!((ts[0] - 1.0).abs() < 1e-10);
815        assert!((ts[4] - 5.0).abs() < 1e-10);
816    }
817
818    #[test]
819    fn test_u8_data() {
820        let mut arr = NDArray::new(
821            vec![NDDimension::new(4), NDDimension::new(4)],
822            NDDataType::UInt8,
823        );
824        if let NDDataBuffer::U8(ref mut v) = arr.data {
825            for (i, val) in v.iter_mut().enumerate() {
826                *val = (i + 1) as u8;
827            }
828        }
829
830        let rois = vec![ROIStatROI {
831            enabled: true,
832            offset: [0, 0],
833            size: [4, 4],
834            bgd_width: 0,
835        }];
836
837        let mut proc = ROIStatProcessor::new(rois, 0);
838        let pool = NDArrayPool::new(1_000_000);
839        proc.process_array(&arr, &pool);
840
841        let r = &proc.results()[0];
842        assert!((r.min - 1.0).abs() < 1e-10);
843        assert!((r.max - 16.0).abs() < 1e-10);
844    }
845
846    #[test]
847    fn test_ts_channel_names() {
848        let names = roi_stat_ts_channel_names(2);
849        assert_eq!(names.len(), 10); // 2 ROIs * 5 stats
850        assert_eq!(names[0], "TS1:MinValue");
851        assert_eq!(names[1], "TS1:MaxValue");
852        assert_eq!(names[4], "TS1:Net");
853        assert_eq!(names[5], "TS2:MinValue");
854        assert_eq!(names[9], "TS2:Net");
855    }
856
857    #[test]
858    fn test_ts_sender_integration() {
859        let (tx, mut rx) = tokio::sync::mpsc::channel::<TimeSeriesData>(16);
860
861        let rois = vec![
862            ROIStatROI {
863                enabled: true,
864                offset: [0, 0],
865                size: [4, 4],
866                bgd_width: 0,
867            },
868            ROIStatROI {
869                enabled: true,
870                offset: [0, 0],
871                size: [2, 2],
872                bgd_width: 0,
873            },
874        ];
875
876        let mut proc = ROIStatProcessor::new(rois, 0);
877        proc.set_ts_sender(tx);
878
879        let pool = NDArrayPool::new(1_000_000);
880        let arr = make_2d_array(4, 4, |_, _| 7.0);
881        proc.process_array(&arr, &pool);
882
883        let data = rx.try_recv().unwrap();
884        // 2 ROIs * 5 stats = 10 values
885        assert_eq!(data.values.len(), 10);
886        // ROI1: min=7, max=7, mean=7, total=112 (4*4*7), net=112
887        assert!((data.values[0] - 7.0).abs() < 1e-10); // min
888        assert!((data.values[1] - 7.0).abs() < 1e-10); // max
889        assert!((data.values[2] - 7.0).abs() < 1e-10); // mean
890        assert!((data.values[3] - 112.0).abs() < 1e-10); // total
891        // ROI2: 2x2 region, total=28 (2*2*7)
892        assert!((data.values[8] - 28.0).abs() < 1e-10); // total
893    }
894}