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