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    // Time series waveform arrays (per-ROI, differentiated by addr)
112    pub ts_total: usize,
113    pub ts_net: usize,
114    pub ts_mean_value: usize,
115    pub ts_min_value: usize,
116    pub ts_max_value: usize,
117    pub ts_timestamp: usize,
118}
119
120/// Processor that computes ROI statistics on 2D arrays.
121pub struct ROIStatProcessor {
122    rois: Vec<ROIStatROI>,
123    results: Vec<ROIStatResult>,
124    /// Time series buffers: [roi_index][stat_index][time_point].
125    ts_mode: TSMode,
126    ts_buffers: Vec<Vec<Vec<f64>>>,
127    ts_num_points: usize,
128    ts_current: usize,
129    /// Optional sender to push flattened stats to a TimeSeriesPortDriver.
130    ts_sender: Option<TimeSeriesSender>,
131    /// Registered asyn param indices.
132    params: ROIStatParams,
133    /// Shared cell to export params after register_params is called.
134    params_out: Arc<Mutex<ROIStatParams>>,
135}
136
137impl ROIStatProcessor {
138    /// Create a new processor with the given ROI definitions.
139    pub fn new(rois: Vec<ROIStatROI>, ts_num_points: usize) -> Self {
140        let n = rois.len();
141        let results = vec![ROIStatResult::default(); n];
142        let ts_buffers = vec![vec![Vec::new(); NUM_STATS]; n];
143        Self {
144            rois,
145            results,
146            ts_mode: TSMode::Idle,
147            ts_buffers,
148            ts_num_points,
149            ts_current: 0,
150            ts_sender: None,
151            params: ROIStatParams::default(),
152            params_out: Arc::new(Mutex::new(ROIStatParams::default())),
153        }
154    }
155
156    /// Get a shared handle to the params (populated after register_params is called).
157    pub fn params_handle(&self) -> Arc<Mutex<ROIStatParams>> {
158        self.params_out.clone()
159    }
160
161    /// Get the current results for all ROIs.
162    pub fn results(&self) -> &[ROIStatResult] {
163        &self.results
164    }
165
166    /// Get the ROI definitions.
167    pub fn rois(&self) -> &[ROIStatROI] {
168        &self.rois
169    }
170
171    /// Mutable access to ROI definitions.
172    pub fn rois_mut(&mut self) -> &mut Vec<ROIStatROI> {
173        &mut self.rois
174    }
175
176    /// Set the time series mode.
177    pub fn set_ts_mode(&mut self, mode: TSMode) {
178        if mode == TSMode::Acquiring && self.ts_mode != TSMode::Acquiring {
179            // Reset time series on start
180            for roi_bufs in &mut self.ts_buffers {
181                for stat_buf in roi_bufs.iter_mut() {
182                    stat_buf.clear();
183                }
184            }
185            self.ts_current = 0;
186        }
187        self.ts_mode = mode;
188    }
189
190    /// Get time series buffer for a specific ROI and stat index.
191    /// stat_index: 0=min, 1=max, 2=mean, 3=total, 4=net
192    pub fn ts_buffer(&self, roi_index: usize, stat_index: usize) -> &[f64] {
193        if roi_index < self.ts_buffers.len() && stat_index < NUM_STATS {
194            &self.ts_buffers[roi_index][stat_index]
195        } else {
196            &[]
197        }
198    }
199
200    /// Set the sender for pushing time series data to a TimeSeriesPortDriver.
201    pub fn set_ts_sender(&mut self, sender: TimeSeriesSender) {
202        self.ts_sender = Some(sender);
203    }
204
205    /// Clamp a ROI's geometry to the array dimensions, mirroring the C
206    /// NDPluginROIStat clamp loop (NDPluginROIStat.cpp:241-247): for each
207    /// dimension present in the array, `offset ∈ [0, dim-1]` and
208    /// `size ∈ [1, dim-offset]`. Offsets are already non-negative (the
209    /// param-change handler clamps at 0), so only the upper bounds apply.
210    /// `dims` holds the array's [X, Y] sizes; `ndims` selects how many of
211    /// them are real (1 or 2). A clamped size is always ≥ 1, so an
212    /// out-of-range or zero-size ROI collapses to a single edge pixel
213    /// rather than yielding zero stats. Returns `None` only for a
214    /// degenerate zero-length dimension (empty array).
215    fn clamp_roi_geometry(
216        roi: &ROIStatROI,
217        ndims: usize,
218        dims: [usize; 2],
219    ) -> Option<([usize; 2], [usize; 2])> {
220        let mut offset = roi.offset;
221        let mut size = roi.size;
222        for d in 0..ndims.min(2) {
223            let dim = dims[d];
224            if dim == 0 {
225                return None;
226            }
227            offset[d] = offset[d].min(dim - 1);
228            size[d] = size[d].max(1).min(dim - offset[d]);
229        }
230        Some((offset, size))
231    }
232
233    /// Compute statistics for one already-clamped ROI, mirroring the C
234    /// `doComputeStatistics` (NDPluginROIStat.cpp:30-139). `array_size_x`
235    /// is the array's X dimension (the row stride, = C `arraySize[0]`).
236    /// `ndims` selects the 1-D (single X strip) or 2-D (rectangle) layout.
237    /// Background pixels are summed exactly as C does: for 1-D the two
238    /// X-end strips, for 2-D the four-edge border. Both can double-count in
239    /// the degenerate thick-border case (`2*bgd_width > size`), matching C.
240    pub fn compute_roi_stats(
241        data: &NDDataBuffer,
242        ndims: usize,
243        array_size_x: usize,
244        offset: [usize; 2],
245        size: [usize; 2],
246        bgd_width: usize,
247    ) -> ROIStatResult {
248        let offset_x = offset[0];
249        let size_x = size[0];
250
251        let mut min = f64::MAX;
252        let mut max = f64::MIN;
253        let mut total = 0.0f64;
254        let mut bgd = 0.0f64;
255        let mut n_bgd = 0usize;
256        let n_elements;
257
258        if ndims == 1 {
259            if size_x == 0 {
260                return ROIStatResult::default();
261            }
262            n_elements = size_x;
263            for x in offset_x..offset_x + size_x {
264                let v = data.get_as_f64(x).unwrap_or(0.0);
265                min = min.min(v);
266                max = max.max(v);
267                total += v;
268            }
269            if bgd_width > 0 {
270                let bw_x = bgd_width.min(size_x);
271                for x in offset_x..offset_x + bw_x {
272                    n_bgd += 1;
273                    bgd += data.get_as_f64(x).unwrap_or(0.0);
274                }
275                for x in (offset_x + size_x - bw_x)..(offset_x + size_x) {
276                    n_bgd += 1;
277                    bgd += data.get_as_f64(x).unwrap_or(0.0);
278                }
279            }
280        } else if ndims == 2 {
281            let offset_y = offset[1];
282            let size_y = size[1];
283            if size_x == 0 || size_y == 0 {
284                return ROIStatResult::default();
285            }
286            n_elements = size_x * size_y;
287            for y in offset_y..offset_y + size_y {
288                let row = y * array_size_x;
289                for x in offset_x..offset_x + size_x {
290                    let v = data.get_as_f64(row + x).unwrap_or(0.0);
291                    min = min.min(v);
292                    max = max.max(v);
293                    total += v;
294                }
295            }
296            if bgd_width > 0 {
297                let bw_x = bgd_width.min(size_x);
298                let bw_y = bgd_width.min(size_y);
299                // Top and bottom bw_y rows (full ROI width).
300                for y in offset_y..offset_y + bw_y {
301                    let row = y * array_size_x;
302                    for x in offset_x..offset_x + size_x {
303                        n_bgd += 1;
304                        bgd += data.get_as_f64(row + x).unwrap_or(0.0);
305                    }
306                }
307                for y in (offset_y + size_y - bw_y)..(offset_y + size_y) {
308                    let row = y * array_size_x;
309                    for x in offset_x..offset_x + size_x {
310                        n_bgd += 1;
311                        bgd += data.get_as_f64(row + x).unwrap_or(0.0);
312                    }
313                }
314                // Left and right bw_x columns of the middle rows.
315                for y in (offset_y + bw_y)..(offset_y + size_y - bw_y) {
316                    let row = y * array_size_x;
317                    for x in offset_x..offset_x + bw_x {
318                        n_bgd += 1;
319                        bgd += data.get_as_f64(row + x).unwrap_or(0.0);
320                    }
321                    for x in (offset_x + size_x - bw_x)..(offset_x + size_x) {
322                        n_bgd += 1;
323                        bgd += data.get_as_f64(row + x).unwrap_or(0.0);
324                    }
325                }
326            }
327        } else {
328            return ROIStatResult::default();
329        }
330
331        if n_elements == 0 {
332            return ROIStatResult::default();
333        }
334
335        // C (NDPluginROIStat.cpp:128-135):
336        //   if (nBgd > 0) bgd = bgd/nBgd * nElements;
337        //   net  = total - bgd;          (bgd stays 0 when bgdWidth == 0)
338        //   mean = total / nElements;
339        let bgd_scaled = if n_bgd > 0 {
340            bgd / n_bgd as f64 * n_elements as f64
341        } else {
342            0.0
343        };
344        ROIStatResult {
345            min,
346            max,
347            mean: total / n_elements as f64,
348            total,
349            net: total - bgd_scaled,
350        }
351    }
352}
353
354impl NDPluginProcess for ROIStatProcessor {
355    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
356        // NDPluginROIStat operates on the raw array dimensions like the C
357        // plugin (NDPluginROIStat.cpp): dims[0] = X, dims[1] = Y. Only 1-D
358        // or 2-D arrays are supported; C errors and yields zero stats for
359        // any other rank.
360        let ndims = array.dims.len();
361        let dims = [
362            array.dims.first().map(|d| d.size).unwrap_or(0),
363            array.dims.get(1).map(|d| d.size).unwrap_or(0),
364        ];
365        let array_size_x = dims[0];
366        let supported = ndims == 1 || ndims == 2;
367
368        // Ensure results vec matches rois
369        self.results
370            .resize(self.rois.len(), ROIStatResult::default());
371
372        // Clamp each enabled ROI's geometry to the array bounds (C clamp
373        // loop). `None` for disabled ROIs or unsupported ranks — those keep
374        // zero stats and skip the geometry write-back.
375        let clamped: Vec<Option<([usize; 2], [usize; 2])>> = self
376            .rois
377            .iter()
378            .map(|roi| {
379                if roi.enabled && supported {
380                    Self::clamp_roi_geometry(roi, ndims, dims)
381                } else {
382                    None
383                }
384            })
385            .collect();
386
387        #[cfg(feature = "parallel")]
388        {
389            let total_elements: usize = clamped
390                .iter()
391                .flatten()
392                .map(|(_, size)| {
393                    if ndims == 1 {
394                        size[0]
395                    } else {
396                        size[0] * size[1]
397                    }
398                })
399                .sum();
400
401            if par_util::should_parallelize(total_elements) {
402                let data = &array.data;
403                let rois = &self.rois;
404                let new_results: Vec<ROIStatResult> = par_util::thread_pool().install(|| {
405                    rois.par_iter()
406                        .zip(clamped.par_iter())
407                        .map(|(roi, clamp)| match clamp {
408                            Some((offset, size)) => Self::compute_roi_stats(
409                                data,
410                                ndims,
411                                array_size_x,
412                                *offset,
413                                *size,
414                                roi.bgd_width,
415                            ),
416                            None => ROIStatResult::default(),
417                        })
418                        .collect()
419                });
420                self.results = new_results;
421            } else {
422                for (i, (roi, clamp)) in self.rois.iter().zip(clamped.iter()).enumerate() {
423                    self.results[i] = match clamp {
424                        Some((offset, size)) => Self::compute_roi_stats(
425                            &array.data,
426                            ndims,
427                            array_size_x,
428                            *offset,
429                            *size,
430                            roi.bgd_width,
431                        ),
432                        None => ROIStatResult::default(),
433                    };
434                }
435            }
436        }
437
438        #[cfg(not(feature = "parallel"))]
439        for (i, (roi, clamp)) in self.rois.iter().zip(clamped.iter()).enumerate() {
440            self.results[i] = match clamp {
441                Some((offset, size)) => Self::compute_roi_stats(
442                    &array.data,
443                    ndims,
444                    array_size_x,
445                    *offset,
446                    *size,
447                    roi.bgd_width,
448                ),
449                None => ROIStatResult::default(),
450            };
451        }
452
453        // Accumulate time series (fixed-length: stop when full)
454        if self.ts_mode == TSMode::Acquiring {
455            if self.ts_num_points > 0 && self.ts_current >= self.ts_num_points {
456                // Buffer full — stop acquiring
457                self.ts_mode = TSMode::Idle;
458            } else {
459                // Ensure ts_buffers match roi count
460                while self.ts_buffers.len() < self.rois.len() {
461                    self.ts_buffers.push(vec![Vec::new(); NUM_STATS]);
462                }
463
464                for (i, result) in self.results.iter().enumerate() {
465                    if i >= self.ts_buffers.len() {
466                        break;
467                    }
468                    let stats = [
469                        result.min,
470                        result.max,
471                        result.mean,
472                        result.total,
473                        result.net,
474                    ];
475                    for (s, &val) in stats.iter().enumerate() {
476                        let buf = &mut self.ts_buffers[i][s];
477                        buf.push(val);
478                    }
479                }
480                self.ts_current += 1;
481            }
482        }
483
484        // Send flattened stats to TimeSeriesPortDriver if connected
485        if let Some(ref sender) = self.ts_sender {
486            let mut values = Vec::with_capacity(self.results.len() * NUM_STATS);
487            for result in &self.results {
488                values.push(result.min);
489                values.push(result.max);
490                values.push(result.mean);
491                values.push(result.total);
492                values.push(result.net);
493            }
494            let _ = sender.try_send(TimeSeriesData { values });
495        }
496
497        // Build per-ROI param updates (only for enabled ROIs)
498        let p = &self.params;
499        let mut updates = Vec::new();
500        for (i, roi) in self.rois.iter().enumerate() {
501            if !roi.enabled {
502                continue;
503            }
504            let result = &self.results[i];
505            let addr = i as i32;
506            updates.push(ParamUpdate::float64_addr(p.min_value, addr, result.min));
507            updates.push(ParamUpdate::float64_addr(p.max_value, addr, result.max));
508            updates.push(ParamUpdate::float64_addr(p.mean_value, addr, result.mean));
509            updates.push(ParamUpdate::float64_addr(p.total, addr, result.total));
510            updates.push(ParamUpdate::float64_addr(p.net, addr, result.net));
511
512            // Write back array sizes and the clamped geometry, matching the
513            // C clamp loop's setIntegerParam calls (NDPluginROIStat.cpp:250-261).
514            // MaxSize is 0 for an absent dimension; Dim*Min/Size readbacks
515            // reflect the clamped values for supported ranks.
516            updates.push(ParamUpdate::int32_addr(
517                p.dim0_max_size,
518                addr,
519                if ndims >= 1 { dims[0] as i32 } else { 0 },
520            ));
521            updates.push(ParamUpdate::int32_addr(
522                p.dim1_max_size,
523                addr,
524                if ndims >= 2 { dims[1] as i32 } else { 0 },
525            ));
526            if let Some((offset, size)) = clamped[i] {
527                if ndims >= 1 {
528                    updates.push(ParamUpdate::int32_addr(p.dim0_min, addr, offset[0] as i32));
529                    updates.push(ParamUpdate::int32_addr(p.dim0_size, addr, size[0] as i32));
530                }
531                if ndims >= 2 {
532                    updates.push(ParamUpdate::int32_addr(p.dim1_min, addr, offset[1] as i32));
533                    updates.push(ParamUpdate::int32_addr(p.dim1_size, addr, size[1] as i32));
534                }
535            }
536        }
537        updates.push(ParamUpdate::int32(
538            p.ts_current_point,
539            self.ts_current as i32,
540        ));
541        updates.push(ParamUpdate::int32(
542            p.ts_acquiring,
543            if self.ts_mode == TSMode::Acquiring {
544                1
545            } else {
546                0
547            },
548        ));
549
550        // Write time series buffers to params for waveform readback
551        for (i, roi) in self.rois.iter().enumerate() {
552            if !roi.enabled || i >= self.ts_buffers.len() {
553                continue;
554            }
555            let addr = i as i32;
556            let bufs = &self.ts_buffers[i];
557            // stat order: min=0, max=1, mean=2, total=3, net=4
558            if !bufs.is_empty() {
559                updates.push(ParamUpdate::float64_array_addr(
560                    p.ts_min_value,
561                    addr,
562                    bufs[0].clone(),
563                ));
564            }
565            if bufs.len() > 1 {
566                updates.push(ParamUpdate::float64_array_addr(
567                    p.ts_max_value,
568                    addr,
569                    bufs[1].clone(),
570                ));
571            }
572            if bufs.len() > 2 {
573                updates.push(ParamUpdate::float64_array_addr(
574                    p.ts_mean_value,
575                    addr,
576                    bufs[2].clone(),
577                ));
578            }
579            if bufs.len() > 3 {
580                updates.push(ParamUpdate::float64_array_addr(
581                    p.ts_total,
582                    addr,
583                    bufs[3].clone(),
584                ));
585            }
586            if bufs.len() > 4 {
587                updates.push(ParamUpdate::float64_array_addr(
588                    p.ts_net,
589                    addr,
590                    bufs[4].clone(),
591                ));
592            }
593        }
594
595        ProcessResult::sink(updates)
596    }
597
598    fn plugin_type(&self) -> &str {
599        "NDPluginROIStat"
600    }
601
602    fn register_params(
603        &mut self,
604        base: &mut PortDriverBase,
605    ) -> Result<(), asyn_rs::error::AsynError> {
606        // Global params
607        self.params.reset_all = base.create_param("ROISTAT_RESETALL", ParamType::Int32)?;
608        self.params.ts_control = base.create_param("ROISTAT_TS_CONTROL", ParamType::Int32)?;
609        self.params.ts_num_points = base.create_param("ROISTAT_TS_NUM_POINTS", ParamType::Int32)?;
610        base.set_int32_param(self.params.ts_num_points, 0, self.ts_num_points as i32)?;
611        self.params.ts_current_point =
612            base.create_param("ROISTAT_TS_CURRENT_POINT", ParamType::Int32)?;
613        self.params.ts_acquiring = base.create_param("ROISTAT_TS_ACQUIRING", ParamType::Int32)?;
614
615        // Per-ROI params (single index, differentiated by addr)
616        self.params.use_ = base.create_param("ROISTAT_USE", ParamType::Int32)?;
617        self.params.name = base.create_param("ROISTAT_NAME", ParamType::Octet)?;
618        self.params.reset = base.create_param("ROISTAT_RESET", ParamType::Int32)?;
619        self.params.bgd_width = base.create_param("ROISTAT_BGD_WIDTH", ParamType::Int32)?;
620        self.params.dim0_min = base.create_param("ROISTAT_DIM0_MIN", ParamType::Int32)?;
621        self.params.dim1_min = base.create_param("ROISTAT_DIM1_MIN", ParamType::Int32)?;
622        self.params.dim0_size = base.create_param("ROISTAT_DIM0_SIZE", ParamType::Int32)?;
623        self.params.dim1_size = base.create_param("ROISTAT_DIM1_SIZE", ParamType::Int32)?;
624        self.params.dim0_max_size = base.create_param("ROISTAT_DIM0_MAX_SIZE", ParamType::Int32)?;
625        self.params.dim1_max_size = base.create_param("ROISTAT_DIM1_MAX_SIZE", ParamType::Int32)?;
626        self.params.min_value = base.create_param("ROISTAT_MIN_VALUE", ParamType::Float64)?;
627        self.params.max_value = base.create_param("ROISTAT_MAX_VALUE", ParamType::Float64)?;
628        self.params.mean_value = base.create_param("ROISTAT_MEAN_VALUE", ParamType::Float64)?;
629        self.params.total = base.create_param("ROISTAT_TOTAL", ParamType::Float64)?;
630        self.params.net = base.create_param("ROISTAT_NET", ParamType::Float64)?;
631
632        // Time series waveform arrays (per-ROI)
633        self.params.ts_total = base.create_param("ROISTAT_TS_TOTAL", ParamType::Float64Array)?;
634        self.params.ts_net = base.create_param("ROISTAT_TS_NET", ParamType::Float64Array)?;
635        self.params.ts_mean_value =
636            base.create_param("ROISTAT_TS_MEAN_VALUE", ParamType::Float64Array)?;
637        self.params.ts_min_value =
638            base.create_param("ROISTAT_TS_MIN_VALUE", ParamType::Float64Array)?;
639        self.params.ts_max_value =
640            base.create_param("ROISTAT_TS_MAX_VALUE", ParamType::Float64Array)?;
641        self.params.ts_timestamp =
642            base.create_param("ROISTAT_TS_TIMESTAMP", ParamType::Float64Array)?;
643
644        // Set initial per-ROI values
645        for (i, roi) in self.rois.iter().enumerate() {
646            let addr = i as i32;
647            base.set_int32_param(self.params.use_, addr, roi.enabled as i32)?;
648            base.set_int32_param(self.params.bgd_width, addr, roi.bgd_width as i32)?;
649            base.set_int32_param(self.params.dim0_min, addr, roi.offset[0] as i32)?;
650            base.set_int32_param(self.params.dim1_min, addr, roi.offset[1] as i32)?;
651            base.set_int32_param(self.params.dim0_size, addr, roi.size[0] as i32)?;
652            base.set_int32_param(self.params.dim1_size, addr, roi.size[1] as i32)?;
653        }
654
655        // Export params
656        *self.params_out.lock() = self.params;
657
658        Ok(())
659    }
660
661    fn on_param_change(
662        &mut self,
663        reason: usize,
664        snapshot: &PluginParamSnapshot,
665    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
666        let addr = snapshot.addr as usize;
667        let p = &self.params;
668
669        if reason == p.use_ && addr < self.rois.len() {
670            self.rois[addr].enabled = snapshot.value.as_i32() != 0;
671        } else if reason == p.dim0_min && addr < self.rois.len() {
672            self.rois[addr].offset[0] = snapshot.value.as_i32().max(0) as usize;
673        } else if reason == p.dim1_min && addr < self.rois.len() {
674            self.rois[addr].offset[1] = snapshot.value.as_i32().max(0) as usize;
675        } else if reason == p.dim0_size && addr < self.rois.len() {
676            self.rois[addr].size[0] = snapshot.value.as_i32().max(0) as usize;
677        } else if reason == p.dim1_size && addr < self.rois.len() {
678            self.rois[addr].size[1] = snapshot.value.as_i32().max(0) as usize;
679        } else if reason == p.bgd_width && addr < self.rois.len() {
680            self.rois[addr].bgd_width = snapshot.value.as_i32().max(0) as usize;
681        } else if reason == p.reset && addr < self.rois.len() {
682            self.results[addr] = ROIStatResult::default();
683        } else if reason == p.reset_all {
684            for r in &mut self.results {
685                *r = ROIStatResult::default();
686            }
687        } else if reason == p.ts_control {
688            // 0=EraseStart (clear+start), 1=Start (resume), 2=Stop, 3=Read, 4=Erase
689            match snapshot.value.as_i32() {
690                0 => {
691                    // EraseStart: clear buffers then start
692                    for roi_bufs in &mut self.ts_buffers {
693                        for stat_buf in roi_bufs.iter_mut() {
694                            stat_buf.clear();
695                        }
696                    }
697                    self.ts_current = 0;
698                    self.ts_mode = TSMode::Acquiring;
699                }
700                1 => {
701                    // Start: resume without clearing
702                    self.ts_mode = TSMode::Acquiring;
703                }
704                2 => {
705                    // Stop
706                    self.ts_mode = TSMode::Idle;
707                }
708                3 => {
709                    // Read: callback without stopping (no-op here, param update triggers read)
710                }
711                4 => {
712                    // Erase: clear buffers
713                    for roi_bufs in &mut self.ts_buffers {
714                        for stat_buf in roi_bufs.iter_mut() {
715                            stat_buf.clear();
716                        }
717                    }
718                    self.ts_current = 0;
719                }
720                _ => {}
721            }
722        } else if reason == p.ts_num_points {
723            self.ts_num_points = snapshot.value.as_i32().max(0) as usize;
724        }
725        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
726    }
727}
728
729/// Create a ROIStat plugin runtime. The TS receiver is stored in the registry
730/// for later pickup by `NDTimeSeriesConfigure`.
731pub fn create_roi_stat_runtime(
732    port_name: &str,
733    pool: Arc<NDArrayPool>,
734    queue_size: usize,
735    ndarray_port: &str,
736    wiring: Arc<WiringRegistry>,
737    num_rois: usize,
738    ts_registry: &crate::time_series::TsReceiverRegistry,
739) -> (
740    PluginRuntimeHandle,
741    ROIStatParams,
742    std::thread::JoinHandle<()>,
743) {
744    let (ts_tx, ts_rx) = tokio::sync::mpsc::channel(256);
745
746    let rois: Vec<ROIStatROI> = (0..num_rois).map(|_| ROIStatROI::default()).collect();
747    let mut processor = ROIStatProcessor::new(rois, 2048);
748    processor.set_ts_sender(ts_tx);
749    let params_handle = processor.params_handle();
750
751    let (handle, data_jh) = ad_core_rs::plugin::runtime::create_plugin_runtime_multi_addr(
752        port_name,
753        processor,
754        pool,
755        queue_size,
756        ndarray_port,
757        wiring,
758        num_rois,
759    );
760
761    let roi_stat_params = *params_handle.lock();
762
763    // Store the TS receiver for NDTimeSeriesConfigure to pick up
764    let channel_names = roi_stat_ts_channel_names(num_rois);
765    ts_registry.store(port_name, ts_rx, channel_names);
766
767    (handle, roi_stat_params, data_jh)
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use ad_core_rs::ndarray::{NDDataType, NDDimension};
774
775    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
776        let mut arr = NDArray::new(
777            vec![NDDimension::new(x), NDDimension::new(y)],
778            NDDataType::Float64,
779        );
780        if let NDDataBuffer::F64(ref mut v) = arr.data {
781            for iy in 0..y {
782                for ix in 0..x {
783                    v[iy * x + ix] = fill(ix, iy);
784                }
785            }
786        }
787        arr
788    }
789
790    #[test]
791    fn test_single_roi_full_image() {
792        let arr = make_2d_array(4, 4, |_x, _y| 10.0);
793        let rois = vec![ROIStatROI {
794            enabled: true,
795            offset: [0, 0],
796            size: [4, 4],
797            bgd_width: 0,
798        }];
799
800        let mut proc = ROIStatProcessor::new(rois, 0);
801        let pool = NDArrayPool::new(1_000_000);
802        proc.process_array(&arr, &pool);
803
804        let r = &proc.results()[0];
805        assert!((r.min - 10.0).abs() < 1e-10);
806        assert!((r.max - 10.0).abs() < 1e-10);
807        assert!((r.mean - 10.0).abs() < 1e-10);
808        assert!((r.total - 160.0).abs() < 1e-10);
809    }
810
811    #[test]
812    fn test_single_roi_subregion() {
813        // 8x8 image, values = x + y * 8
814        let arr = make_2d_array(8, 8, |x, y| (x + y * 8) as f64);
815
816        let rois = vec![ROIStatROI {
817            enabled: true,
818            offset: [2, 2],
819            size: [3, 3],
820            bgd_width: 0,
821        }];
822
823        let mut proc = ROIStatProcessor::new(rois, 0);
824        let pool = NDArrayPool::new(1_000_000);
825        proc.process_array(&arr, &pool);
826
827        let r = &proc.results()[0];
828        // 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
829        assert!((r.min - 18.0).abs() < 1e-10);
830        assert!((r.max - 36.0).abs() < 1e-10);
831        let expected_total = 18.0 + 19.0 + 20.0 + 26.0 + 27.0 + 28.0 + 34.0 + 35.0 + 36.0;
832        assert!((r.total - expected_total).abs() < 1e-10);
833        assert!((r.mean - expected_total / 9.0).abs() < 1e-10);
834    }
835
836    #[test]
837    fn test_multiple_rois() {
838        let arr = make_2d_array(8, 8, |x, _y| x as f64);
839
840        let rois = vec![
841            ROIStatROI {
842                enabled: true,
843                offset: [0, 0],
844                size: [4, 4],
845                bgd_width: 0,
846            },
847            ROIStatROI {
848                enabled: true,
849                offset: [4, 0],
850                size: [4, 4],
851                bgd_width: 0,
852            },
853        ];
854
855        let mut proc = ROIStatProcessor::new(rois, 0);
856        let pool = NDArrayPool::new(1_000_000);
857        proc.process_array(&arr, &pool);
858
859        let r0 = &proc.results()[0];
860        assert!((r0.min - 0.0).abs() < 1e-10);
861        assert!((r0.max - 3.0).abs() < 1e-10);
862
863        let r1 = &proc.results()[1];
864        assert!((r1.min - 4.0).abs() < 1e-10);
865        assert!((r1.max - 7.0).abs() < 1e-10);
866    }
867
868    #[test]
869    fn test_bgd_width() {
870        // 6x6 image, center 2x2 has value 100, border has value 10
871        let arr = make_2d_array(6, 6, |x, y| {
872            if x >= 2 && x < 4 && y >= 2 && y < 4 {
873                100.0
874            } else {
875                10.0
876            }
877        });
878
879        let rois = vec![ROIStatROI {
880            enabled: true,
881            offset: [1, 1],
882            size: [4, 4],
883            bgd_width: 1,
884        }];
885
886        let mut proc = ROIStatProcessor::new(rois, 0);
887        let pool = NDArrayPool::new(1_000_000);
888        proc.process_array(&arr, &pool);
889
890        let r = &proc.results()[0];
891        // ROI is 4x4 at (1,1): border pixels = 12 (all with value 10), center = 4 (value 100)
892        // bgd average = (12*10 + ... well, border includes some 100s)
893        // Actually border pixels at bgd_width=1: the outer ring of the 4x4 ROI
894        // That outer ring occupies 12 of 16 pixels
895        assert!(
896            r.net < r.total,
897            "net should be less than total with bgd subtraction"
898        );
899    }
900
901    #[test]
902    fn test_empty_roi() {
903        let arr = make_2d_array(4, 4, |_, _| 10.0);
904        let rois = vec![ROIStatROI {
905            enabled: true,
906            offset: [0, 0],
907            size: [0, 0],
908            bgd_width: 0,
909        }];
910
911        let mut proc = ROIStatProcessor::new(rois, 0);
912        let pool = NDArrayPool::new(1_000_000);
913        proc.process_array(&arr, &pool);
914
915        // C clamps a zero-size ROI to a single pixel (size >= 1) at the
916        // clamped offset (0,0), so stats reflect that one pixel, not zero.
917        let r = &proc.results()[0];
918        assert!((r.total - 10.0).abs() < 1e-10);
919        assert!((r.mean - 10.0).abs() < 1e-10);
920    }
921
922    #[test]
923    fn test_disabled_roi() {
924        let arr = make_2d_array(4, 4, |_, _| 10.0);
925        let rois = vec![ROIStatROI {
926            enabled: false,
927            offset: [0, 0],
928            size: [4, 4],
929            bgd_width: 0,
930        }];
931
932        let mut proc = ROIStatProcessor::new(rois, 0);
933        let pool = NDArrayPool::new(1_000_000);
934        proc.process_array(&arr, &pool);
935
936        let r = &proc.results()[0];
937        assert!(
938            (r.total - 0.0).abs() < 1e-10,
939            "disabled ROI should have zero stats"
940        );
941    }
942
943    #[test]
944    fn test_roi_out_of_bounds() {
945        let arr = make_2d_array(4, 4, |_, _| 10.0);
946        let rois = vec![ROIStatROI {
947            enabled: true,
948            offset: [10, 10],
949            size: [4, 4],
950            bgd_width: 0,
951        }];
952
953        let mut proc = ROIStatProcessor::new(rois, 0);
954        let pool = NDArrayPool::new(1_000_000);
955        proc.process_array(&arr, &pool);
956
957        // C clamps offset to dim-1 (3,3) and size to 1, so the ROI is the
958        // single corner pixel — stats reflect it, not zero.
959        let r = &proc.results()[0];
960        assert!(
961            (r.total - 10.0).abs() < 1e-10,
962            "out-of-bounds ROI clamps to one edge pixel, not zero"
963        );
964        assert!((r.mean - 10.0).abs() < 1e-10);
965    }
966
967    #[test]
968    fn test_roi_partially_out_of_bounds() {
969        let arr = make_2d_array(4, 4, |_, _| 5.0);
970        let rois = vec![ROIStatROI {
971            enabled: true,
972            offset: [2, 2],
973            size: [10, 10], // extends beyond image
974            bgd_width: 0,
975        }];
976
977        let mut proc = ROIStatProcessor::new(rois, 0);
978        let pool = NDArrayPool::new(1_000_000);
979        proc.process_array(&arr, &pool);
980
981        let r = &proc.results()[0];
982        // Should be clamped to 2x2 region
983        assert!((r.total - 20.0).abs() < 1e-10);
984        assert!((r.mean - 5.0).abs() < 1e-10);
985    }
986
987    #[test]
988    fn test_time_series() {
989        let rois = vec![ROIStatROI {
990            enabled: true,
991            offset: [0, 0],
992            size: [4, 4],
993            bgd_width: 0,
994        }];
995
996        let mut proc = ROIStatProcessor::new(rois, 100);
997        let pool = NDArrayPool::new(1_000_000);
998        proc.set_ts_mode(TSMode::Acquiring);
999
1000        for i in 0..5 {
1001            let arr = make_2d_array(4, 4, |_, _| (i + 1) as f64);
1002            proc.process_array(&arr, &pool);
1003        }
1004
1005        // Check mean time series (stat index 2)
1006        let ts = proc.ts_buffer(0, 2);
1007        assert_eq!(ts.len(), 5);
1008        assert!((ts[0] - 1.0).abs() < 1e-10);
1009        assert!((ts[4] - 5.0).abs() < 1e-10);
1010    }
1011
1012    #[test]
1013    fn test_u8_data() {
1014        let mut arr = NDArray::new(
1015            vec![NDDimension::new(4), NDDimension::new(4)],
1016            NDDataType::UInt8,
1017        );
1018        if let NDDataBuffer::U8(ref mut v) = arr.data {
1019            for (i, val) in v.iter_mut().enumerate() {
1020                *val = (i + 1) as u8;
1021            }
1022        }
1023
1024        let rois = vec![ROIStatROI {
1025            enabled: true,
1026            offset: [0, 0],
1027            size: [4, 4],
1028            bgd_width: 0,
1029        }];
1030
1031        let mut proc = ROIStatProcessor::new(rois, 0);
1032        let pool = NDArrayPool::new(1_000_000);
1033        proc.process_array(&arr, &pool);
1034
1035        let r = &proc.results()[0];
1036        assert!((r.min - 1.0).abs() < 1e-10);
1037        assert!((r.max - 16.0).abs() < 1e-10);
1038    }
1039
1040    #[test]
1041    fn test_ts_channel_names() {
1042        let names = roi_stat_ts_channel_names(2);
1043        assert_eq!(names.len(), 10); // 2 ROIs * 5 stats
1044        assert_eq!(names[0], "TS1:MinValue");
1045        assert_eq!(names[1], "TS1:MaxValue");
1046        assert_eq!(names[4], "TS1:Net");
1047        assert_eq!(names[5], "TS2:MinValue");
1048        assert_eq!(names[9], "TS2:Net");
1049    }
1050
1051    #[test]
1052    fn test_ts_sender_integration() {
1053        let (tx, mut rx) = tokio::sync::mpsc::channel::<TimeSeriesData>(16);
1054
1055        let rois = vec![
1056            ROIStatROI {
1057                enabled: true,
1058                offset: [0, 0],
1059                size: [4, 4],
1060                bgd_width: 0,
1061            },
1062            ROIStatROI {
1063                enabled: true,
1064                offset: [0, 0],
1065                size: [2, 2],
1066                bgd_width: 0,
1067            },
1068        ];
1069
1070        let mut proc = ROIStatProcessor::new(rois, 0);
1071        proc.set_ts_sender(tx);
1072
1073        let pool = NDArrayPool::new(1_000_000);
1074        let arr = make_2d_array(4, 4, |_, _| 7.0);
1075        proc.process_array(&arr, &pool);
1076
1077        let data = rx.try_recv().unwrap();
1078        // 2 ROIs * 5 stats = 10 values
1079        assert_eq!(data.values.len(), 10);
1080        // ROI1: min=7, max=7, mean=7, total=112 (4*4*7), net=112
1081        assert!((data.values[0] - 7.0).abs() < 1e-10); // min
1082        assert!((data.values[1] - 7.0).abs() < 1e-10); // max
1083        assert!((data.values[2] - 7.0).abs() < 1e-10); // mean
1084        assert!((data.values[3] - 112.0).abs() < 1e-10); // total
1085        // ROI2: 2x2 region, total=28 (2*2*7)
1086        assert!((data.values[8] - 28.0).abs() < 1e-10); // total
1087    }
1088
1089    fn make_1d_array(n: usize, fill: impl Fn(usize) -> f64) -> NDArray {
1090        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1091        if let NDDataBuffer::F64(ref mut v) = arr.data {
1092            for (i, slot) in v.iter_mut().enumerate() {
1093                *slot = fill(i);
1094            }
1095        }
1096        arr
1097    }
1098
1099    #[test]
1100    fn test_adp16_clamp_out_of_range_offset_to_one_pixel() {
1101        // 4x4 array. offset (10,1) is out of range in X; size (4,9) overflows
1102        // Y. C clamps offset to [0,dim-1] and size to [1,dim-offset].
1103        let roi = ROIStatROI {
1104            enabled: true,
1105            offset: [10, 1],
1106            size: [4, 9],
1107            bgd_width: 0,
1108        };
1109        let (offset, size) = ROIStatProcessor::clamp_roi_geometry(&roi, 2, [4, 4]).unwrap();
1110        assert_eq!(offset, [3, 1]); // X: min(10,3); Y: min(1,3)
1111        assert_eq!(size, [1, 3]); // X: min(4,4-3)=1; Y: min(9,4-1)=3
1112    }
1113
1114    #[test]
1115    fn test_adp16_clamp_zero_size_to_one_pixel() {
1116        let roi = ROIStatROI {
1117            enabled: true,
1118            offset: [0, 0],
1119            size: [0, 0],
1120            bgd_width: 0,
1121        };
1122        let (offset, size) = ROIStatProcessor::clamp_roi_geometry(&roi, 2, [4, 4]).unwrap();
1123        assert_eq!(offset, [0, 0]);
1124        assert_eq!(size, [1, 1]); // zero size clamps up to 1 in each dim
1125    }
1126
1127    #[test]
1128    fn test_adp16_geometry_writeback_uses_clamped_values() {
1129        use asyn_rs::port::{PortDriverBase, PortFlags};
1130
1131        let arr = make_2d_array(4, 4, |_, _| 10.0);
1132        let rois = vec![ROIStatROI {
1133            enabled: true,
1134            offset: [10, 1],
1135            size: [4, 9],
1136            bgd_width: 0,
1137        }];
1138        let mut proc = ROIStatProcessor::new(rois, 0);
1139        let mut base = PortDriverBase::new("roistat_adp16", 1, PortFlags::default());
1140        proc.register_params(&mut base).unwrap();
1141        let p = *proc.params_handle().lock();
1142
1143        let pool = NDArrayPool::new(1_000_000);
1144        let res = proc.process_array(&arr, &pool);
1145
1146        let find = |reason: usize, addr: i32| {
1147            res.param_updates.iter().find_map(|u| match u {
1148                ParamUpdate::Int32 {
1149                    reason: r,
1150                    addr: a,
1151                    value,
1152                } if *r == reason && *a == addr => Some(*value),
1153                _ => None,
1154            })
1155        };
1156        // offset (10,1) -> (3,1); size (4,9) -> (1,3); MaxSize = dims (4,4).
1157        assert_eq!(find(p.dim0_min, 0), Some(3));
1158        assert_eq!(find(p.dim0_size, 0), Some(1));
1159        assert_eq!(find(p.dim1_min, 0), Some(1));
1160        assert_eq!(find(p.dim1_size, 0), Some(3));
1161        assert_eq!(find(p.dim0_max_size, 0), Some(4));
1162        assert_eq!(find(p.dim1_max_size, 0), Some(4));
1163    }
1164
1165    #[test]
1166    fn test_adp17_1d_background_uses_x_strips_only() {
1167        // Genuine 1-D array [10,0,0,0,0,10]. With bgd_width=1 the C 1-D path
1168        // averages only the two X-end pixels (both 10): bgd = 10, scaled over
1169        // 6 elements = 60, so net = total - 60 = 20 - 60 = -40. A 2-D
1170        // border-ring would treat the single row as all-border (net = 0), and
1171        // the old early-return would zero every stat for a 1-D array.
1172        let arr = make_1d_array(6, |x| if x == 0 || x == 5 { 10.0 } else { 0.0 });
1173        let rois = vec![ROIStatROI {
1174            enabled: true,
1175            offset: [0, 0],
1176            size: [6, 0],
1177            bgd_width: 1,
1178        }];
1179        let mut proc = ROIStatProcessor::new(rois, 0);
1180        let pool = NDArrayPool::new(1_000_000);
1181        proc.process_array(&arr, &pool);
1182
1183        let r = &proc.results()[0];
1184        assert!((r.total - 20.0).abs() < 1e-10, "total={}", r.total);
1185        assert!((r.min - 0.0).abs() < 1e-10);
1186        assert!((r.max - 10.0).abs() < 1e-10);
1187        assert!((r.net + 40.0).abs() < 1e-10, "net={}", r.net);
1188    }
1189}