Skip to main content

ad_plugins/
stats.rs

1use std::sync::Arc;
2
3#[cfg(feature = "parallel")]
4use rayon::prelude::*;
5#[cfg(feature = "parallel")]
6use crate::par_util;
7
8use ad_core::ndarray::{NDArray, NDDataBuffer};
9use ad_core::ndarray_pool::NDArrayPool;
10use ad_core::plugin::registry::{build_plugin_base_registry, ParamInfo, ParamRegistry};
11use ad_core::plugin::runtime::{NDPluginProcess, ParamUpdate, PluginParamSnapshot, PluginRuntimeHandle, ProcessResult};
12use ad_core::plugin::wiring::WiringRegistry;
13use asyn_rs::param::ParamType;
14use asyn_rs::port::PortDriverBase;
15use parking_lot::Mutex;
16
17/// Parameter indices for NDStats plugin-specific params.
18#[derive(Clone, Copy, Default)]
19pub struct NDStatsParams {
20    pub compute_statistics: usize,
21    pub bgd_width: usize,
22    pub min_value: usize,
23    pub max_value: usize,
24    pub mean_value: usize,
25    pub sigma_value: usize,
26    pub total: usize,
27    pub net: usize,
28    pub min_x: usize,
29    pub min_y: usize,
30    pub max_x: usize,
31    pub max_y: usize,
32    pub compute_centroid: usize,
33    pub centroid_threshold: usize,
34    pub centroid_total: usize,
35    pub centroid_x: usize,
36    pub centroid_y: usize,
37    pub sigma_x: usize,
38    pub sigma_y: usize,
39    pub sigma_xy: usize,
40    pub skewness_x: usize,
41    pub skewness_y: usize,
42    pub kurtosis_x: usize,
43    pub kurtosis_y: usize,
44    pub eccentricity: usize,
45    pub orientation: usize,
46    pub compute_histogram: usize,
47    pub hist_size: usize,
48    pub hist_min: usize,
49    pub hist_max: usize,
50    pub hist_below: usize,
51    pub hist_above: usize,
52    pub hist_entropy: usize,
53    pub compute_profiles: usize,
54    pub cursor_x: usize,
55    pub cursor_y: usize,
56}
57
58/// Statistics computed from an NDArray.
59#[derive(Debug, Clone, Default)]
60pub struct StatsResult {
61    pub min: f64,
62    pub max: f64,
63    pub mean: f64,
64    pub sigma: f64,
65    pub total: f64,
66    pub net: f64,
67    pub num_elements: usize,
68    pub min_x: usize,
69    pub min_y: usize,
70    pub max_x: usize,
71    pub max_y: usize,
72    pub histogram: Vec<f64>,
73    pub hist_below: f64,
74    pub hist_above: f64,
75    pub hist_entropy: f64,
76    pub profile_avg_x: Vec<f64>,
77    pub profile_avg_y: Vec<f64>,
78    pub profile_threshold_x: Vec<f64>,
79    pub profile_threshold_y: Vec<f64>,
80    pub profile_centroid_x: Vec<f64>,
81    pub profile_centroid_y: Vec<f64>,
82    pub profile_cursor_x: Vec<f64>,
83    pub profile_cursor_y: Vec<f64>,
84}
85
86/// Centroid and higher-order moment results.
87#[derive(Debug, Clone, Default)]
88pub struct CentroidResult {
89    pub centroid_x: f64,
90    pub centroid_y: f64,
91    pub sigma_x: f64,
92    pub sigma_y: f64,
93    pub sigma_xy: f64,
94    pub centroid_total: f64,
95    pub skewness_x: f64,
96    pub skewness_y: f64,
97    pub kurtosis_x: f64,
98    pub kurtosis_y: f64,
99    pub eccentricity: f64,
100    pub orientation: f64,
101}
102
103/// Profile computation results.
104#[derive(Debug, Clone, Default)]
105pub struct ProfileResult {
106    pub avg_x: Vec<f64>,
107    pub avg_y: Vec<f64>,
108    pub threshold_x: Vec<f64>,
109    pub threshold_y: Vec<f64>,
110    pub centroid_x: Vec<f64>,
111    pub centroid_y: Vec<f64>,
112    pub cursor_x: Vec<f64>,
113    pub cursor_y: Vec<f64>,
114}
115
116/// Compute min/max/mean/sigma/total from an NDDataBuffer, with min/max positions
117/// and optional background subtraction.
118///
119/// When `bgd_width > 0`, the average of edge pixels (bgd_width pixels from each
120/// edge of a 2D image) is subtracted: `net = total - bgd_avg * num_elements`.
121/// When `bgd_width == 0`, `net = total`.
122pub fn compute_stats(
123    data: &NDDataBuffer,
124    dims: &[ad_core::ndarray::NDDimension],
125    bgd_width: usize,
126) -> StatsResult {
127    macro_rules! stats_for {
128        ($vec:expr) => {{
129            let v = $vec;
130            if v.is_empty() {
131                return StatsResult::default();
132            }
133
134            let (min, max, min_idx, max_idx, total, variance);
135
136            #[cfg(feature = "parallel")]
137            {
138                if par_util::should_parallelize(v.len()) {
139                    // Parallel: fold+reduce for min/max/total
140                    let (pmin, pmax, pmin_idx, pmax_idx, ptotal) = par_util::thread_pool().install(|| {
141                        v.par_iter().enumerate()
142                            .fold(
143                                || (f64::MAX, f64::MIN, 0usize, 0usize, 0.0f64),
144                                |(mn, mx, mn_i, mx_i, s), (i, &elem)| {
145                                    let f = elem as f64;
146                                    let (new_mn, new_mn_i) = if f < mn { (f, i) } else { (mn, mn_i) };
147                                    let (new_mx, new_mx_i) = if f > mx { (f, i) } else { (mx, mx_i) };
148                                    (new_mn, new_mx, new_mn_i, new_mx_i, s + f)
149                                },
150                            )
151                            .reduce(
152                                || (f64::MAX, f64::MIN, 0, 0, 0.0),
153                                |(mn1, mx1, mn_i1, mx_i1, s1), (mn2, mx2, mn_i2, mx_i2, s2)| {
154                                    let (rmn, rmn_i) = if mn1 <= mn2 { (mn1, mn_i1) } else { (mn2, mn_i2) };
155                                    let (rmx, rmx_i) = if mx1 >= mx2 { (mx1, mx_i1) } else { (mx2, mx_i2) };
156                                    (rmn, rmx, rmn_i, rmx_i, s1 + s2)
157                                },
158                            )
159                    });
160                    min = pmin; max = pmax; min_idx = pmin_idx; max_idx = pmax_idx; total = ptotal;
161                    let mean_tmp = total / v.len() as f64;
162                    variance = par_util::thread_pool().install(|| {
163                        v.par_iter()
164                            .map(|&elem| { let d = elem as f64 - mean_tmp; d * d })
165                            .sum::<f64>()
166                    });
167                } else {
168                    let mut lmin = v[0] as f64;
169                    let mut lmax = v[0] as f64;
170                    let mut lmin_idx: usize = 0;
171                    let mut lmax_idx: usize = 0;
172                    let mut ltotal = 0.0f64;
173                    for (i, &elem) in v.iter().enumerate() {
174                        let f = elem as f64;
175                        if f < lmin { lmin = f; lmin_idx = i; }
176                        if f > lmax { lmax = f; lmax_idx = i; }
177                        ltotal += f;
178                    }
179                    min = lmin; max = lmax; min_idx = lmin_idx; max_idx = lmax_idx; total = ltotal;
180                    let mean_tmp = total / v.len() as f64;
181                    let mut lvar = 0.0f64;
182                    for &elem in v.iter() {
183                        let d = elem as f64 - mean_tmp;
184                        lvar += d * d;
185                    }
186                    variance = lvar;
187                }
188            }
189
190            #[cfg(not(feature = "parallel"))]
191            {
192                let mut lmin = v[0] as f64;
193                let mut lmax = v[0] as f64;
194                let mut lmin_idx: usize = 0;
195                let mut lmax_idx: usize = 0;
196                let mut ltotal = 0.0f64;
197                for (i, &elem) in v.iter().enumerate() {
198                    let f = elem as f64;
199                    if f < lmin { lmin = f; lmin_idx = i; }
200                    if f > lmax { lmax = f; lmax_idx = i; }
201                    ltotal += f;
202                }
203                min = lmin; max = lmax; min_idx = lmin_idx; max_idx = lmax_idx; total = ltotal;
204                let mean_tmp = total / v.len() as f64;
205                let mut lvar = 0.0f64;
206                for &elem in v.iter() {
207                    let d = elem as f64 - mean_tmp;
208                    lvar += d * d;
209                }
210                variance = lvar;
211            }
212
213            let mean = total / v.len() as f64;
214            let sigma = (variance / v.len() as f64).sqrt();
215            let x_size = dims.first().map_or(v.len(), |d| d.size);
216
217            // Background subtraction
218            let net = if bgd_width > 0 && dims.len() >= 2 {
219                let y_size = dims[1].size;
220                let mut bgd_sum = 0.0f64;
221                let mut bgd_count = 0usize;
222                for iy in 0..y_size {
223                    for ix in 0..x_size {
224                        let is_edge = ix < bgd_width
225                            || ix >= x_size.saturating_sub(bgd_width)
226                            || iy < bgd_width
227                            || iy >= y_size.saturating_sub(bgd_width);
228                        if is_edge {
229                            let idx = iy * x_size + ix;
230                            if idx < v.len() {
231                                bgd_sum += v[idx] as f64;
232                                bgd_count += 1;
233                            }
234                        }
235                    }
236                }
237                let bgd_avg = if bgd_count > 0 { bgd_sum / bgd_count as f64 } else { 0.0 };
238                total - bgd_avg * v.len() as f64
239            } else {
240                total
241            };
242
243            StatsResult {
244                min,
245                max,
246                mean,
247                sigma,
248                total,
249                net,
250                num_elements: v.len(),
251                min_x: if x_size > 0 { min_idx % x_size } else { 0 },
252                min_y: if x_size > 0 { min_idx / x_size } else { 0 },
253                max_x: if x_size > 0 { max_idx % x_size } else { 0 },
254                max_y: if x_size > 0 { max_idx / x_size } else { 0 },
255                ..StatsResult::default()
256            }
257        }};
258    }
259
260    match data {
261        NDDataBuffer::I8(v) => stats_for!(v),
262        NDDataBuffer::U8(v) => stats_for!(v),
263        NDDataBuffer::I16(v) => stats_for!(v),
264        NDDataBuffer::U16(v) => stats_for!(v),
265        NDDataBuffer::I32(v) => stats_for!(v),
266        NDDataBuffer::U32(v) => stats_for!(v),
267        NDDataBuffer::I64(v) => stats_for!(v),
268        NDDataBuffer::U64(v) => stats_for!(v),
269        NDDataBuffer::F32(v) => stats_for!(v),
270        NDDataBuffer::F64(v) => stats_for!(v),
271    }
272}
273
274/// Compute centroid, sigma, and higher-order moments for a 2D array.
275///
276/// Pixels with value < `threshold` are excluded from all moment accumulation.
277pub fn compute_centroid(
278    data: &NDDataBuffer,
279    x_size: usize,
280    y_size: usize,
281    threshold: f64,
282) -> CentroidResult {
283    let n = x_size * y_size;
284    if n == 0 || data.len() < n {
285        return CentroidResult::default();
286    }
287
288    // Collect values into a flat f64 vec for potential parallel access
289    let vals: Vec<f64> = (0..n).map(|i| data.get_as_f64(i).unwrap_or(0.0)).collect();
290
291    // Pass 1: compute M00 (total), M10, M01 for centroid
292    let (m00, m10, m01);
293
294    #[cfg(feature = "parallel")]
295    {
296        if par_util::should_parallelize(n) {
297            let xs = x_size;
298            let thr = threshold;
299            let (pm00, pm10, pm01) = par_util::thread_pool().install(|| {
300                vals.par_iter().enumerate()
301                    .fold(
302                        || (0.0f64, 0.0f64, 0.0f64),
303                        |(s00, s10, s01), (i, &val)| {
304                            if val < thr { return (s00, s10, s01); }
305                            let ix = i % xs;
306                            let iy = i / xs;
307                            (s00 + val, s10 + val * ix as f64, s01 + val * iy as f64)
308                        },
309                    )
310                    .reduce(
311                        || (0.0, 0.0, 0.0),
312                        |(a0, a1, a2), (b0, b1, b2)| (a0 + b0, a1 + b1, a2 + b2),
313                    )
314            });
315            m00 = pm00; m10 = pm10; m01 = pm01;
316        } else {
317            let mut lm00 = 0.0f64;
318            let mut lm10 = 0.0f64;
319            let mut lm01 = 0.0f64;
320            for iy in 0..y_size {
321                for ix in 0..x_size {
322                    let val = vals[iy * x_size + ix];
323                    if val < threshold { continue; }
324                    lm00 += val;
325                    lm10 += val * ix as f64;
326                    lm01 += val * iy as f64;
327                }
328            }
329            m00 = lm00; m10 = lm10; m01 = lm01;
330        }
331    }
332
333    #[cfg(not(feature = "parallel"))]
334    {
335        let mut lm00 = 0.0f64;
336        let mut lm10 = 0.0f64;
337        let mut lm01 = 0.0f64;
338        for iy in 0..y_size {
339            for ix in 0..x_size {
340                let val = vals[iy * x_size + ix];
341                if val < threshold { continue; }
342                lm00 += val;
343                lm10 += val * ix as f64;
344                lm01 += val * iy as f64;
345            }
346        }
347        m00 = lm00; m10 = lm10; m01 = lm01;
348    }
349
350    if m00 == 0.0 {
351        return CentroidResult::default();
352    }
353
354    let cx = m10 / m00;
355    let cy = m01 / m00;
356
357    // Pass 2: compute central moments up to 4th order
358    let (mu20, mu02, mu11, m30_central, m03_central, m40_central, m04_central);
359
360    #[cfg(feature = "parallel")]
361    {
362        if par_util::should_parallelize(n) {
363            let xs = x_size;
364            let thr = threshold;
365            let (p20, p02, p11, p30, p03, p40, p04) = par_util::thread_pool().install(|| {
366                vals.par_iter().enumerate()
367                    .fold(
368                        || (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64),
369                        |(s20, s02, s11, s30, s03, s40, s04), (i, &val)| {
370                            if val < thr { return (s20, s02, s11, s30, s03, s40, s04); }
371                            let ix = i % xs;
372                            let iy = i / xs;
373                            let dx = ix as f64 - cx;
374                            let dy = iy as f64 - cy;
375                            let dx2 = dx * dx;
376                            let dy2 = dy * dy;
377                            (
378                                s20 + val * dx2,
379                                s02 + val * dy2,
380                                s11 + val * dx * dy,
381                                s30 + val * dx2 * dx,
382                                s03 + val * dy2 * dy,
383                                s40 + val * dx2 * dx2,
384                                s04 + val * dy2 * dy2,
385                            )
386                        },
387                    )
388                    .reduce(
389                        || (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
390                        |(a0,a1,a2,a3,a4,a5,a6),(b0,b1,b2,b3,b4,b5,b6)|
391                            (a0+b0,a1+b1,a2+b2,a3+b3,a4+b4,a5+b5,a6+b6),
392                    )
393            });
394            mu20 = p20; mu02 = p02; mu11 = p11;
395            m30_central = p30; m03_central = p03; m40_central = p40; m04_central = p04;
396        } else {
397            let mut l20 = 0.0f64; let mut l02 = 0.0f64; let mut l11 = 0.0f64;
398            let mut l30 = 0.0f64; let mut l03 = 0.0f64; let mut l40 = 0.0f64; let mut l04 = 0.0f64;
399            for iy in 0..y_size {
400                for ix in 0..x_size {
401                    let val = vals[iy * x_size + ix];
402                    if val < threshold { continue; }
403                    let dx = ix as f64 - cx; let dy = iy as f64 - cy;
404                    let dx2 = dx * dx; let dy2 = dy * dy;
405                    l20 += val * dx2; l02 += val * dy2; l11 += val * dx * dy;
406                    l30 += val * dx2 * dx; l03 += val * dy2 * dy;
407                    l40 += val * dx2 * dx2; l04 += val * dy2 * dy2;
408                }
409            }
410            mu20 = l20; mu02 = l02; mu11 = l11;
411            m30_central = l30; m03_central = l03; m40_central = l40; m04_central = l04;
412        }
413    }
414
415    #[cfg(not(feature = "parallel"))]
416    {
417        let mut l20 = 0.0f64; let mut l02 = 0.0f64; let mut l11 = 0.0f64;
418        let mut l30 = 0.0f64; let mut l03 = 0.0f64; let mut l40 = 0.0f64; let mut l04 = 0.0f64;
419        for iy in 0..y_size {
420            for ix in 0..x_size {
421                let val = vals[iy * x_size + ix];
422                if val < threshold { continue; }
423                let dx = ix as f64 - cx; let dy = iy as f64 - cy;
424                let dx2 = dx * dx; let dy2 = dy * dy;
425                l20 += val * dx2; l02 += val * dy2; l11 += val * dx * dy;
426                l30 += val * dx2 * dx; l03 += val * dy2 * dy;
427                l40 += val * dx2 * dx2; l04 += val * dy2 * dy2;
428            }
429        }
430        mu20 = l20; mu02 = l02; mu11 = l11;
431        m30_central = l30; m03_central = l03; m40_central = l40; m04_central = l04;
432    }
433
434    let sigma_x = (mu20 / m00).sqrt();
435    let sigma_y = (mu02 / m00).sqrt();
436    let sigma_xy = mu11 / m00;
437
438    // Skewness: M30_central / (M00 * sigma_x^3)
439    let skewness_x = if sigma_x > 0.0 {
440        m30_central / (m00 * sigma_x.powi(3))
441    } else {
442        0.0
443    };
444    let skewness_y = if sigma_y > 0.0 {
445        m03_central / (m00 * sigma_y.powi(3))
446    } else {
447        0.0
448    };
449
450    // Excess kurtosis: M40_central / (M00 * sigma_x^4) - 3
451    let kurtosis_x = if sigma_x > 0.0 {
452        m40_central / (m00 * sigma_x.powi(4)) - 3.0
453    } else {
454        0.0
455    };
456    let kurtosis_y = if sigma_y > 0.0 {
457        m04_central / (m00 * sigma_y.powi(4)) - 3.0
458    } else {
459        0.0
460    };
461
462    // Eccentricity: ((mu20 - mu02)^2 + 4*mu11^2) / (mu20 + mu02)^2
463    let mu20_norm = mu20 / m00;
464    let mu02_norm = mu02 / m00;
465    let mu11_norm = mu11 / m00;
466    let denom = mu20_norm + mu02_norm;
467    let eccentricity = if denom > 0.0 {
468        ((mu20_norm - mu02_norm).powi(2) + 4.0 * mu11_norm.powi(2)) / denom.powi(2)
469    } else {
470        0.0
471    };
472
473    // Orientation: 0.5 * atan2(2*mu11, mu20 - mu02) in degrees
474    let orientation =
475        0.5 * (2.0 * mu11_norm).atan2(mu20_norm - mu02_norm) * 180.0 / std::f64::consts::PI;
476
477    CentroidResult {
478        centroid_x: cx,
479        centroid_y: cy,
480        sigma_x,
481        sigma_y,
482        sigma_xy,
483        centroid_total: m00,
484        skewness_x,
485        skewness_y,
486        kurtosis_x,
487        kurtosis_y,
488        eccentricity,
489        orientation,
490    }
491}
492
493/// Compute histogram of pixel values.
494///
495/// Returns (histogram, below_count, above_count, entropy).
496/// - `hist_size`: number of bins
497/// - `hist_min` / `hist_max`: value range for binning
498/// - bin index = `((val - hist_min) * (hist_size - 1) / (hist_max - hist_min) + 0.5) as usize`
499/// - Values below `hist_min` increment `below_count`; above `hist_max` increment `above_count`
500/// - Entropy = `-sum(p * ln(p))` for non-zero bins where `p = count / total_count`
501pub fn compute_histogram(
502    data: &NDDataBuffer,
503    hist_size: usize,
504    hist_min: f64,
505    hist_max: f64,
506) -> (Vec<f64>, f64, f64, f64) {
507    if hist_size == 0 || hist_max <= hist_min {
508        return (vec![], 0.0, 0.0, 0.0);
509    }
510
511    let mut histogram = vec![0.0f64; hist_size];
512    let mut below = 0.0f64;
513    let mut above = 0.0f64;
514    let range = hist_max - hist_min;
515    let n = data.len();
516
517    #[cfg(feature = "parallel")]
518    let use_parallel = par_util::should_parallelize(n);
519    #[cfg(not(feature = "parallel"))]
520    let use_parallel = false;
521
522    if use_parallel {
523        #[cfg(feature = "parallel")]
524        {
525            let vals: Vec<f64> = (0..n).map(|i| data.get_as_f64(i).unwrap_or(0.0)).collect();
526            let chunk_size = (n / rayon::current_num_threads().max(1)).max(1024);
527            let hs = hist_size;
528            let hmin = hist_min;
529            let hmax = hist_max;
530            let rng = range;
531            let chunk_results: Vec<(Vec<f64>, f64, f64)> = par_util::thread_pool().install(|| {
532                vals.par_chunks(chunk_size)
533                    .map(|chunk| {
534                        let mut local_hist = vec![0.0f64; hs];
535                        let mut local_below = 0.0f64;
536                        let mut local_above = 0.0f64;
537                        for &val in chunk {
538                            if val < hmin {
539                                local_below += 1.0;
540                            } else if val > hmax {
541                                local_above += 1.0;
542                            } else {
543                                let bin = ((val - hmin) * (hs - 1) as f64 / rng + 0.5) as usize;
544                                let bin = bin.min(hs - 1);
545                                local_hist[bin] += 1.0;
546                            }
547                        }
548                        (local_hist, local_below, local_above)
549                    })
550                    .collect()
551            });
552            for (local_hist, local_below, local_above) in chunk_results {
553                below += local_below;
554                above += local_above;
555                for (i, &count) in local_hist.iter().enumerate() {
556                    histogram[i] += count;
557                }
558            }
559        }
560    } else {
561        for i in 0..n {
562            let val = data.get_as_f64(i).unwrap_or(0.0);
563            if val < hist_min {
564                below += 1.0;
565            } else if val > hist_max {
566                above += 1.0;
567            } else {
568                let bin = ((val - hist_min) * (hist_size - 1) as f64 / range + 0.5) as usize;
569                let bin = bin.min(hist_size - 1);
570                histogram[bin] += 1.0;
571            }
572        }
573    }
574
575    // Compute entropy: -sum(p * ln(p)) for non-zero bins
576    let total_in_bins: f64 = histogram.iter().sum();
577    let entropy = if total_in_bins > 0.0 {
578        let mut ent = 0.0f64;
579        for &count in &histogram {
580            if count > 0.0 {
581                let p = count / total_in_bins;
582                ent -= p * p.ln();
583            }
584        }
585        ent
586    } else {
587        0.0
588    };
589
590    (histogram, below, above, entropy)
591}
592
593/// Compute profile projections for a 2D image.
594///
595/// - Average X/Y: column/row averages over the full image
596/// - Threshold X/Y: column/row averages using only pixels >= threshold
597/// - Centroid X/Y: single row/column at the centroid position (rounded)
598/// - Cursor X/Y: single row/column at cursor position
599pub fn compute_profiles(
600    data: &NDDataBuffer,
601    x_size: usize,
602    y_size: usize,
603    threshold: f64,
604    centroid_x: f64,
605    centroid_y: f64,
606    cursor_x: usize,
607    cursor_y: usize,
608) -> ProfileResult {
609    if x_size == 0 || y_size == 0 || data.len() < x_size * y_size {
610        return ProfileResult::default();
611    }
612
613    let mut avg_x = vec![0.0f64; x_size];
614    let mut avg_y = vec![0.0f64; y_size];
615    let mut thresh_x_sum = vec![0.0f64; x_size];
616    let mut thresh_x_cnt = vec![0usize; x_size];
617    let mut thresh_y_sum = vec![0.0f64; y_size];
618    let mut thresh_y_cnt = vec![0usize; y_size];
619
620    // Accumulate sums for average and threshold profiles
621    for iy in 0..y_size {
622        for ix in 0..x_size {
623            let val = data.get_as_f64(iy * x_size + ix).unwrap_or(0.0);
624            avg_x[ix] += val;
625            avg_y[iy] += val;
626            if val >= threshold {
627                thresh_x_sum[ix] += val;
628                thresh_x_cnt[ix] += 1;
629                thresh_y_sum[iy] += val;
630                thresh_y_cnt[iy] += 1;
631            }
632        }
633    }
634
635    // Average profiles: divide column sums by y_size, row sums by x_size
636    for ix in 0..x_size {
637        avg_x[ix] /= y_size as f64;
638    }
639    for iy in 0..y_size {
640        avg_y[iy] /= x_size as f64;
641    }
642
643    // Threshold profiles: divide by count of pixels above threshold
644    let threshold_x: Vec<f64> = thresh_x_sum
645        .iter()
646        .zip(thresh_x_cnt.iter())
647        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
648        .collect();
649    let threshold_y: Vec<f64> = thresh_y_sum
650        .iter()
651        .zip(thresh_y_cnt.iter())
652        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
653        .collect();
654
655    // Centroid profiles: extract single row/column at centroid position
656    let cy_row = (centroid_y + 0.5) as usize;
657    let cx_col = (centroid_x + 0.5) as usize;
658
659    let centroid_x_profile = if cy_row < y_size {
660        (0..x_size)
661            .map(|ix| data.get_as_f64(cy_row * x_size + ix).unwrap_or(0.0))
662            .collect()
663    } else {
664        vec![0.0; x_size]
665    };
666
667    let centroid_y_profile = if cx_col < x_size {
668        (0..y_size)
669            .map(|iy| data.get_as_f64(iy * x_size + cx_col).unwrap_or(0.0))
670            .collect()
671    } else {
672        vec![0.0; y_size]
673    };
674
675    // Cursor profiles: extract single row/column at cursor position
676    let cursor_x_profile = if cursor_y < y_size {
677        (0..x_size)
678            .map(|ix| data.get_as_f64(cursor_y * x_size + ix).unwrap_or(0.0))
679            .collect()
680    } else {
681        vec![0.0; x_size]
682    };
683
684    let cursor_y_profile = if cursor_x < x_size {
685        (0..y_size)
686            .map(|iy| data.get_as_f64(iy * x_size + cursor_x).unwrap_or(0.0))
687            .collect()
688    } else {
689        vec![0.0; y_size]
690    };
691
692    ProfileResult {
693        avg_x,
694        avg_y,
695        threshold_x,
696        threshold_y,
697        centroid_x: centroid_x_profile,
698        centroid_y: centroid_y_profile,
699        cursor_x: cursor_x_profile,
700        cursor_y: cursor_y_profile,
701    }
702}
703
704/// Pure processing logic for statistics computation.
705pub struct StatsProcessor {
706    latest_stats: Arc<Mutex<StatsResult>>,
707    do_compute_centroid: bool,
708    do_compute_histogram: bool,
709    do_compute_profiles: bool,
710    bgd_width: usize,
711    centroid_threshold: f64,
712    cursor_x: usize,
713    cursor_y: usize,
714    hist_size: usize,
715    hist_min: f64,
716    hist_max: f64,
717    params: NDStatsParams,
718    /// Shared cell to export params after register_params is called.
719    params_out: Arc<Mutex<NDStatsParams>>,
720    /// Optional sender to push time series data to the TS port driver.
721    ts_sender: Option<crate::time_series::TimeSeriesSender>,
722}
723
724impl StatsProcessor {
725    pub fn new() -> Self {
726        Self {
727            latest_stats: Arc::new(Mutex::new(StatsResult::default())),
728            do_compute_centroid: true,
729            do_compute_histogram: false,
730            do_compute_profiles: false,
731            bgd_width: 0,
732            centroid_threshold: 0.0,
733            cursor_x: 0,
734            cursor_y: 0,
735            hist_size: 256,
736            hist_min: 0.0,
737            hist_max: 255.0,
738            params: NDStatsParams::default(),
739            params_out: Arc::new(Mutex::new(NDStatsParams::default())),
740            ts_sender: None,
741        }
742    }
743
744    /// Get a cloneable handle to the latest stats.
745    pub fn stats_handle(&self) -> Arc<Mutex<StatsResult>> {
746        self.latest_stats.clone()
747    }
748
749    /// Get a shared handle to the params (populated after register_params is called).
750    pub fn params_handle(&self) -> Arc<Mutex<NDStatsParams>> {
751        self.params_out.clone()
752    }
753
754    /// Set the time series sender for pushing data to the TS port driver.
755    pub fn set_ts_sender(&mut self, sender: crate::time_series::TimeSeriesSender) {
756        self.ts_sender = Some(sender);
757    }
758}
759
760impl Default for StatsProcessor {
761    fn default() -> Self {
762        Self::new()
763    }
764}
765
766impl NDPluginProcess for StatsProcessor {
767    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
768        let p = &self.params;
769        let info = array.info();
770
771        let mut result = compute_stats(&array.data, &array.dims, self.bgd_width);
772
773        // Centroid computation
774        let mut centroid = CentroidResult::default();
775        if self.do_compute_centroid {
776            if info.color_size == 1 && array.dims.len() >= 2 {
777                centroid = compute_centroid(
778                    &array.data, info.x_size, info.y_size, self.centroid_threshold,
779                );
780            }
781        }
782
783        // Histogram computation
784        if self.do_compute_histogram {
785            let (histogram, below, above, entropy) =
786                compute_histogram(&array.data, self.hist_size, self.hist_min, self.hist_max);
787            result.histogram = histogram;
788            result.hist_below = below;
789            result.hist_above = above;
790            result.hist_entropy = entropy;
791        }
792
793        // Profile computation
794        if self.do_compute_profiles && info.color_size == 1 && array.dims.len() >= 2 {
795            let profiles = compute_profiles(
796                &array.data,
797                info.x_size,
798                info.y_size,
799                self.centroid_threshold,
800                centroid.centroid_x,
801                centroid.centroid_y,
802                self.cursor_x,
803                self.cursor_y,
804            );
805            result.profile_avg_x = profiles.avg_x;
806            result.profile_avg_y = profiles.avg_y;
807            result.profile_threshold_x = profiles.threshold_x;
808            result.profile_threshold_y = profiles.threshold_y;
809            result.profile_centroid_x = profiles.centroid_x;
810            result.profile_centroid_y = profiles.centroid_y;
811            result.profile_cursor_x = profiles.cursor_x;
812            result.profile_cursor_y = profiles.cursor_y;
813        }
814
815        let updates = vec![
816            ParamUpdate::float64(p.min_value, result.min),
817            ParamUpdate::float64(p.max_value, result.max),
818            ParamUpdate::float64(p.mean_value, result.mean),
819            ParamUpdate::float64(p.sigma_value, result.sigma),
820            ParamUpdate::float64(p.total, result.total),
821            ParamUpdate::float64(p.net, result.net),
822            ParamUpdate::float64(p.min_x, result.min_x as f64),
823            ParamUpdate::float64(p.min_y, result.min_y as f64),
824            ParamUpdate::float64(p.max_x, result.max_x as f64),
825            ParamUpdate::float64(p.max_y, result.max_y as f64),
826            ParamUpdate::float64(p.centroid_x, centroid.centroid_x),
827            ParamUpdate::float64(p.centroid_y, centroid.centroid_y),
828            ParamUpdate::float64(p.sigma_x, centroid.sigma_x),
829            ParamUpdate::float64(p.sigma_y, centroid.sigma_y),
830            ParamUpdate::float64(p.sigma_xy, centroid.sigma_xy),
831            ParamUpdate::float64(p.centroid_total, centroid.centroid_total),
832            ParamUpdate::float64(p.skewness_x, centroid.skewness_x),
833            ParamUpdate::float64(p.skewness_y, centroid.skewness_y),
834            ParamUpdate::float64(p.kurtosis_x, centroid.kurtosis_x),
835            ParamUpdate::float64(p.kurtosis_y, centroid.kurtosis_y),
836            ParamUpdate::float64(p.eccentricity, centroid.eccentricity),
837            ParamUpdate::float64(p.orientation, centroid.orientation),
838            ParamUpdate::float64(p.hist_below, result.hist_below),
839            ParamUpdate::float64(p.hist_above, result.hist_above),
840            ParamUpdate::float64(p.hist_entropy, result.hist_entropy),
841        ];
842
843        // Send time series data to TS port driver (if configured)
844        if let Some(ref sender) = self.ts_sender {
845            let ts_data = crate::time_series::TimeSeriesData {
846                values: vec![
847                    result.min,
848                    result.min_x as f64,
849                    result.min_y as f64,
850                    result.max,
851                    result.max_x as f64,
852                    result.max_y as f64,
853                    result.mean,
854                    result.sigma,
855                    result.total,
856                    result.net,
857                    centroid.centroid_total,
858                    centroid.centroid_x,
859                    centroid.centroid_y,
860                    centroid.sigma_x,
861                    centroid.sigma_y,
862                    centroid.sigma_xy,
863                    centroid.skewness_x,
864                    centroid.skewness_y,
865                    centroid.kurtosis_x,
866                    centroid.kurtosis_y,
867                    centroid.eccentricity,
868                    centroid.orientation,
869                    array.timestamp.as_f64(),
870                ],
871            };
872            let _ = sender.try_send(ts_data);
873        }
874
875        *self.latest_stats.lock() = result;
876        ProcessResult::sink(updates)
877    }
878
879    fn plugin_type(&self) -> &str {
880        "NDPluginStats"
881    }
882
883    fn register_params(&mut self, base: &mut PortDriverBase) -> Result<(), asyn_rs::error::AsynError> {
884        self.params.compute_statistics = base.create_param("COMPUTE_STATISTICS", ParamType::Int32)?;
885        base.set_int32_param(self.params.compute_statistics, 0, 1)?;
886
887        self.params.bgd_width = base.create_param("BGD_WIDTH", ParamType::Int32)?;
888        self.params.min_value = base.create_param("MIN_VALUE", ParamType::Float64)?;
889        self.params.max_value = base.create_param("MAX_VALUE", ParamType::Float64)?;
890        self.params.mean_value = base.create_param("MEAN_VALUE", ParamType::Float64)?;
891        self.params.sigma_value = base.create_param("SIGMA_VALUE", ParamType::Float64)?;
892        self.params.total = base.create_param("TOTAL", ParamType::Float64)?;
893        self.params.net = base.create_param("NET", ParamType::Float64)?;
894        self.params.min_x = base.create_param("MIN_X", ParamType::Float64)?;
895        self.params.min_y = base.create_param("MIN_Y", ParamType::Float64)?;
896        self.params.max_x = base.create_param("MAX_X", ParamType::Float64)?;
897        self.params.max_y = base.create_param("MAX_Y", ParamType::Float64)?;
898
899        self.params.compute_centroid = base.create_param("COMPUTE_CENTROID", ParamType::Int32)?;
900        base.set_int32_param(self.params.compute_centroid, 0, 1)?;
901
902        self.params.centroid_threshold = base.create_param("CENTROID_THRESHOLD", ParamType::Float64)?;
903        self.params.centroid_total = base.create_param("CENTROID_TOTAL", ParamType::Float64)?;
904        self.params.centroid_x = base.create_param("CENTROIDX_VALUE", ParamType::Float64)?;
905        self.params.centroid_y = base.create_param("CENTROIDY_VALUE", ParamType::Float64)?;
906        self.params.sigma_x = base.create_param("SIGMAX_VALUE", ParamType::Float64)?;
907        self.params.sigma_y = base.create_param("SIGMAY_VALUE", ParamType::Float64)?;
908        self.params.sigma_xy = base.create_param("SIGMAXY_VALUE", ParamType::Float64)?;
909        self.params.skewness_x = base.create_param("SKEWNESSX_VALUE", ParamType::Float64)?;
910        self.params.skewness_y = base.create_param("SKEWNESSY_VALUE", ParamType::Float64)?;
911        self.params.kurtosis_x = base.create_param("KURTOSISX_VALUE", ParamType::Float64)?;
912        self.params.kurtosis_y = base.create_param("KURTOSISY_VALUE", ParamType::Float64)?;
913        self.params.eccentricity = base.create_param("ECCENTRICITY_VALUE", ParamType::Float64)?;
914        self.params.orientation = base.create_param("ORIENTATION_VALUE", ParamType::Float64)?;
915
916        self.params.compute_histogram = base.create_param("COMPUTE_HISTOGRAM", ParamType::Int32)?;
917        self.params.hist_size = base.create_param("HIST_SIZE", ParamType::Int32)?;
918        base.set_int32_param(self.params.hist_size, 0, 256)?;
919        self.params.hist_min = base.create_param("HIST_MIN", ParamType::Float64)?;
920        self.params.hist_max = base.create_param("HIST_MAX", ParamType::Float64)?;
921        base.set_float64_param(self.params.hist_max, 0, 255.0)?;
922        self.params.hist_below = base.create_param("HIST_BELOW", ParamType::Float64)?;
923        self.params.hist_above = base.create_param("HIST_ABOVE", ParamType::Float64)?;
924        self.params.hist_entropy = base.create_param("HIST_ENTROPY", ParamType::Float64)?;
925
926        self.params.compute_profiles = base.create_param("COMPUTE_PROFILES", ParamType::Int32)?;
927        self.params.cursor_x = base.create_param("CURSOR_X", ParamType::Int32)?;
928        base.set_int32_param(self.params.cursor_x, 0, 0)?;
929        self.params.cursor_y = base.create_param("CURSOR_Y", ParamType::Int32)?;
930        base.set_int32_param(self.params.cursor_y, 0, 0)?;
931
932        // Export params so create_stats_runtime can retrieve them after the move
933        *self.params_out.lock() = self.params;
934
935        Ok(())
936    }
937
938    fn on_param_change(&mut self, reason: usize, snapshot: &PluginParamSnapshot) {
939        let p = &self.params;
940        if reason == p.compute_statistics {
941            // toggle handled by do_compute_centroid etc. if needed
942        } else if reason == p.compute_centroid {
943            self.do_compute_centroid = snapshot.value.as_i32() != 0;
944        } else if reason == p.compute_histogram {
945            self.do_compute_histogram = snapshot.value.as_i32() != 0;
946        } else if reason == p.compute_profiles {
947            self.do_compute_profiles = snapshot.value.as_i32() != 0;
948        } else if reason == p.bgd_width {
949            self.bgd_width = snapshot.value.as_i32().max(0) as usize;
950        } else if reason == p.centroid_threshold {
951            self.centroid_threshold = snapshot.value.as_f64();
952        } else if reason == p.cursor_x {
953            self.cursor_x = snapshot.value.as_i32().max(0) as usize;
954        } else if reason == p.cursor_y {
955            self.cursor_y = snapshot.value.as_i32().max(0) as usize;
956        } else if reason == p.hist_size {
957            self.hist_size = (snapshot.value.as_i32().max(1)) as usize;
958        } else if reason == p.hist_min {
959            self.hist_min = snapshot.value.as_f64();
960        } else if reason == p.hist_max {
961            self.hist_max = snapshot.value.as_f64();
962        }
963    }
964}
965
966/// Build a parameter registry for NDStats plugins, extending the base with stats-specific params.
967pub fn build_stats_registry(h: &PluginRuntimeHandle, sp: &NDStatsParams) -> ParamRegistry {
968    let mut map = build_plugin_base_registry(h);
969
970    // Control params (read/write)
971    map.insert("ComputeStatistics".into(), ParamInfo::int32(sp.compute_statistics, "COMPUTE_STATISTICS"));
972    map.insert("ComputeStatistics_RBV".into(), ParamInfo::int32(sp.compute_statistics, "COMPUTE_STATISTICS"));
973    map.insert("BgdWidth".into(), ParamInfo::int32(sp.bgd_width, "BGD_WIDTH"));
974    map.insert("BgdWidth_RBV".into(), ParamInfo::int32(sp.bgd_width, "BGD_WIDTH"));
975    map.insert("ComputeCentroid".into(), ParamInfo::int32(sp.compute_centroid, "COMPUTE_CENTROID"));
976    map.insert("ComputeCentroid_RBV".into(), ParamInfo::int32(sp.compute_centroid, "COMPUTE_CENTROID"));
977    map.insert("CentroidThreshold".into(), ParamInfo::float64(sp.centroid_threshold, "CENTROID_THRESHOLD"));
978    map.insert("CentroidThreshold_RBV".into(), ParamInfo::float64(sp.centroid_threshold, "CENTROID_THRESHOLD"));
979
980    // Statistics readback params
981    map.insert("MinValue_RBV".into(), ParamInfo::float64(sp.min_value, "MIN_VALUE"));
982    map.insert("MaxValue_RBV".into(), ParamInfo::float64(sp.max_value, "MAX_VALUE"));
983    map.insert("MeanValue_RBV".into(), ParamInfo::float64(sp.mean_value, "MEAN_VALUE"));
984    map.insert("Sigma_RBV".into(), ParamInfo::float64(sp.sigma_value, "SIGMA_VALUE"));
985    map.insert("Total_RBV".into(), ParamInfo::float64(sp.total, "TOTAL"));
986    map.insert("Net_RBV".into(), ParamInfo::float64(sp.net, "NET"));
987
988    // Min/Max position readbacks
989    map.insert("MinX_RBV".into(), ParamInfo::float64(sp.min_x, "MIN_X"));
990    map.insert("MinY_RBV".into(), ParamInfo::float64(sp.min_y, "MIN_Y"));
991    map.insert("MaxX_RBV".into(), ParamInfo::float64(sp.max_x, "MAX_X"));
992    map.insert("MaxY_RBV".into(), ParamInfo::float64(sp.max_y, "MAX_Y"));
993
994    // Centroid readbacks
995    map.insert("CentroidTotal_RBV".into(), ParamInfo::float64(sp.centroid_total, "CENTROID_TOTAL"));
996    map.insert("CentroidX_RBV".into(), ParamInfo::float64(sp.centroid_x, "CENTROIDX_VALUE"));
997    map.insert("CentroidY_RBV".into(), ParamInfo::float64(sp.centroid_y, "CENTROIDY_VALUE"));
998    map.insert("SigmaX_RBV".into(), ParamInfo::float64(sp.sigma_x, "SIGMAX_VALUE"));
999    map.insert("SigmaY_RBV".into(), ParamInfo::float64(sp.sigma_y, "SIGMAY_VALUE"));
1000    map.insert("SigmaXY_RBV".into(), ParamInfo::float64(sp.sigma_xy, "SIGMAXY_VALUE"));
1001
1002    // Higher-order moment readbacks
1003    map.insert("SkewnessX_RBV".into(), ParamInfo::float64(sp.skewness_x, "SKEWNESSX_VALUE"));
1004    map.insert("SkewnessY_RBV".into(), ParamInfo::float64(sp.skewness_y, "SKEWNESSY_VALUE"));
1005    map.insert("KurtosisX_RBV".into(), ParamInfo::float64(sp.kurtosis_x, "KURTOSISX_VALUE"));
1006    map.insert("KurtosisY_RBV".into(), ParamInfo::float64(sp.kurtosis_y, "KURTOSISY_VALUE"));
1007    map.insert("Eccentricity_RBV".into(), ParamInfo::float64(sp.eccentricity, "ECCENTRICITY_VALUE"));
1008    map.insert("Orientation_RBV".into(), ParamInfo::float64(sp.orientation, "ORIENTATION_VALUE"));
1009
1010    // Histogram params
1011    map.insert("ComputeHistogram".into(), ParamInfo::int32(sp.compute_histogram, "COMPUTE_HISTOGRAM"));
1012    map.insert("ComputeHistogram_RBV".into(), ParamInfo::int32(sp.compute_histogram, "COMPUTE_HISTOGRAM"));
1013    map.insert("HistSize".into(), ParamInfo::int32(sp.hist_size, "HIST_SIZE"));
1014    map.insert("HistSize_RBV".into(), ParamInfo::int32(sp.hist_size, "HIST_SIZE"));
1015    map.insert("HistMin".into(), ParamInfo::float64(sp.hist_min, "HIST_MIN"));
1016    map.insert("HistMin_RBV".into(), ParamInfo::float64(sp.hist_min, "HIST_MIN"));
1017    map.insert("HistMax".into(), ParamInfo::float64(sp.hist_max, "HIST_MAX"));
1018    map.insert("HistMax_RBV".into(), ParamInfo::float64(sp.hist_max, "HIST_MAX"));
1019    map.insert("HistBelow_RBV".into(), ParamInfo::float64(sp.hist_below, "HIST_BELOW"));
1020    map.insert("HistAbove_RBV".into(), ParamInfo::float64(sp.hist_above, "HIST_ABOVE"));
1021    map.insert("HistEntropy_RBV".into(), ParamInfo::float64(sp.hist_entropy, "HIST_ENTROPY"));
1022
1023    // Profile params
1024    map.insert("ComputeProfiles".into(), ParamInfo::int32(sp.compute_profiles, "COMPUTE_PROFILES"));
1025    map.insert("ComputeProfiles_RBV".into(), ParamInfo::int32(sp.compute_profiles, "COMPUTE_PROFILES"));
1026    map.insert("CursorX".into(), ParamInfo::int32(sp.cursor_x, "CURSOR_X"));
1027    map.insert("CursorX_RBV".into(), ParamInfo::int32(sp.cursor_x, "CURSOR_X"));
1028    map.insert("CursorY".into(), ParamInfo::int32(sp.cursor_y, "CURSOR_Y"));
1029    map.insert("CursorY_RBV".into(), ParamInfo::int32(sp.cursor_y, "CURSOR_Y"));
1030
1031    map
1032}
1033
1034/// Create a stats plugin runtime with an integrated time series port.
1035///
1036/// Returns:
1037/// - Plugin runtime handle (for the stats plugin)
1038/// - Stats result accessor
1039/// - Stats params (for building stats registry)
1040/// - TS port runtime handle (for registering as a separate port)
1041/// - TS params (for building TS registry)
1042/// - Thread join handles (stats data, TS actor, TS data)
1043pub fn create_stats_runtime(
1044    port_name: &str,
1045    pool: Arc<NDArrayPool>,
1046    queue_size: usize,
1047    ndarray_port: &str,
1048    wiring: Arc<WiringRegistry>,
1049) -> (
1050    PluginRuntimeHandle,
1051    Arc<Mutex<StatsResult>>,
1052    NDStatsParams,
1053    asyn_rs::runtime::port::PortRuntimeHandle,
1054    crate::time_series::TSParams,
1055    std::thread::JoinHandle<()>,
1056    std::thread::JoinHandle<()>,
1057    std::thread::JoinHandle<()>,
1058) {
1059    // Create TS channel
1060    let (ts_tx, ts_rx) = tokio::sync::mpsc::channel(256);
1061
1062    let mut processor = StatsProcessor::new();
1063    processor.set_ts_sender(ts_tx);
1064    let stats_handle = processor.stats_handle();
1065    let params_handle = processor.params_handle();
1066
1067    let (plugin_handle, data_jh) = ad_core::plugin::runtime::create_plugin_runtime(
1068        port_name,
1069        processor,
1070        pool,
1071        queue_size,
1072        ndarray_port,
1073        wiring,
1074    );
1075
1076    // Params were populated by register_params (called during create_plugin_runtime)
1077    // and exported via the shared params_out handle.
1078    let stats_params = *params_handle.lock();
1079
1080    // Create TS port with stats-specific channel names
1081    let ts_port_name = format!("{port_name}_TS");
1082    let (ts_runtime, ts_params, ts_actor_jh, ts_data_jh) =
1083        crate::time_series::create_ts_port_runtime(
1084            &ts_port_name,
1085            &crate::time_series::STATS_TS_CHANNEL_NAMES,
1086            2048,
1087            ts_rx,
1088        );
1089
1090    (plugin_handle, stats_handle, stats_params, ts_runtime, ts_params, data_jh, ts_actor_jh, ts_data_jh)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use ad_core::ndarray::{NDDataType, NDDimension};
1097
1098    #[test]
1099    fn test_compute_stats_u8() {
1100        let dims = vec![NDDimension::new(5)];
1101        let data = NDDataBuffer::U8(vec![10, 20, 30, 40, 50]);
1102        let stats = compute_stats(&data, &dims, 0);
1103        assert_eq!(stats.min, 10.0);
1104        assert_eq!(stats.max, 50.0);
1105        assert_eq!(stats.mean, 30.0);
1106        assert_eq!(stats.total, 150.0);
1107        assert_eq!(stats.num_elements, 5);
1108    }
1109
1110    #[test]
1111    fn test_compute_stats_sigma() {
1112        let dims = vec![NDDimension::new(8)];
1113        let data = NDDataBuffer::F64(vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]);
1114        let stats = compute_stats(&data, &dims, 0);
1115        assert!((stats.mean - 5.0).abs() < 1e-10);
1116        assert!((stats.sigma - 2.0).abs() < 1e-10);
1117    }
1118
1119    #[test]
1120    fn test_compute_stats_u16() {
1121        let dims = vec![NDDimension::new(3)];
1122        let data = NDDataBuffer::U16(vec![100, 200, 300]);
1123        let stats = compute_stats(&data, &dims, 0);
1124        assert_eq!(stats.min, 100.0);
1125        assert_eq!(stats.max, 300.0);
1126        assert_eq!(stats.mean, 200.0);
1127    }
1128
1129    #[test]
1130    fn test_compute_stats_f64() {
1131        let dims = vec![NDDimension::new(3)];
1132        let data = NDDataBuffer::F64(vec![1.5, 2.5, 3.5]);
1133        let stats = compute_stats(&data, &dims, 0);
1134        assert!((stats.min - 1.5).abs() < 1e-10);
1135        assert!((stats.max - 3.5).abs() < 1e-10);
1136        assert!((stats.mean - 2.5).abs() < 1e-10);
1137    }
1138
1139    #[test]
1140    fn test_compute_stats_single_element() {
1141        let dims = vec![NDDimension::new(1)];
1142        let data = NDDataBuffer::I32(vec![42]);
1143        let stats = compute_stats(&data, &dims, 0);
1144        assert_eq!(stats.min, 42.0);
1145        assert_eq!(stats.max, 42.0);
1146        assert_eq!(stats.mean, 42.0);
1147        assert_eq!(stats.sigma, 0.0);
1148        assert_eq!(stats.num_elements, 1);
1149    }
1150
1151    #[test]
1152    fn test_compute_stats_empty() {
1153        let data = NDDataBuffer::U8(vec![]);
1154        let stats = compute_stats(&data, &[], 0);
1155        assert_eq!(stats.num_elements, 0);
1156    }
1157
1158    #[test]
1159    fn test_compute_stats_min_max_position() {
1160        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1161        // 4x4 array: min at [0], max at [15]
1162        let data = NDDataBuffer::U8((1..=16).collect());
1163        let stats = compute_stats(&data, &dims, 0);
1164        assert_eq!(stats.min_x, 0); // index 0 -> x=0, y=0
1165        assert_eq!(stats.min_y, 0);
1166        assert_eq!(stats.max_x, 3); // index 15 -> x=3, y=3
1167        assert_eq!(stats.max_y, 3);
1168    }
1169
1170    #[test]
1171    fn test_compute_stats_net_no_bgd() {
1172        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1173        let data = NDDataBuffer::U8((1..=16).collect());
1174        let stats = compute_stats(&data, &dims, 0);
1175        // With bgd_width=0, net should equal total
1176        assert_eq!(stats.net, stats.total);
1177    }
1178
1179    #[test]
1180    fn test_compute_stats_bgd_subtraction() {
1181        // 4x4 image with uniform value 10, plus a bright center pixel
1182        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1183        let mut pixels = vec![10u16; 16];
1184        // Put a bright spot at (2,2) = index 10
1185        pixels[2 * 4 + 2] = 110;
1186        let data = NDDataBuffer::U16(pixels);
1187        let stats = compute_stats(&data, &dims, 1);
1188
1189        // With bgd_width=1, all edge pixels (1 pixel from each edge) are used for background.
1190        // In a 4x4 image with bgd_width=1, only pixels at (1,1), (2,1), (1,2), (2,2) are interior.
1191        // Edge pixels are the 12 remaining pixels. 11 of them are 10, one at (2,2) might be edge or not.
1192        // Actually (2,2) is interior (ix=2 is not <1 and not >=3, iy=2 is not <1 and not >=3).
1193        // So edge pixels: 12 pixels all with value 10. bgd_avg = 10.0
1194        // net = total - bgd_avg * num_elements
1195        // total = 15*10 + 110 = 260
1196        // net = 260 - 10.0 * 16 = 260 - 160 = 100
1197        assert!((stats.net - 100.0).abs() < 1e-10);
1198    }
1199
1200    #[test]
1201    fn test_centroid_uniform() {
1202        let data = NDDataBuffer::U8(vec![1; 16]);
1203        let c = compute_centroid(&data, 4, 4, 0.0);
1204        assert!((c.centroid_x - 1.5).abs() < 1e-10);
1205        assert!((c.centroid_y - 1.5).abs() < 1e-10);
1206    }
1207
1208    #[test]
1209    fn test_centroid_corner() {
1210        let mut d = vec![0u8; 16];
1211        d[0] = 255;
1212        let data = NDDataBuffer::U8(d);
1213        let c = compute_centroid(&data, 4, 4, 0.0);
1214        assert!((c.centroid_x - 0.0).abs() < 1e-10);
1215        assert!((c.centroid_y - 0.0).abs() < 1e-10);
1216    }
1217
1218    #[test]
1219    fn test_centroid_threshold() {
1220        // 4x4 image: background of 5, bright spot of 100 at (2,2)
1221        let mut pixels = vec![5u8; 16];
1222        pixels[2 * 4 + 2] = 100;
1223        let data = NDDataBuffer::U8(pixels);
1224
1225        // With threshold=50, only the bright pixel should be counted
1226        let c = compute_centroid(&data, 4, 4, 50.0);
1227        assert!((c.centroid_x - 2.0).abs() < 1e-10);
1228        assert!((c.centroid_y - 2.0).abs() < 1e-10);
1229        assert!((c.centroid_total - 100.0).abs() < 1e-10);
1230    }
1231
1232    #[test]
1233    fn test_centroid_higher_moments_symmetric() {
1234        // Symmetric distribution: skewness should be ~0, eccentricity ~0 for uniform
1235        let data = NDDataBuffer::U8(vec![1; 16]);
1236        let c = compute_centroid(&data, 4, 4, 0.0);
1237        // Symmetric -> skewness ~0
1238        assert!(c.skewness_x.abs() < 1e-10);
1239        assert!(c.skewness_y.abs() < 1e-10);
1240        // Uniform 4x4 -> sigma_x == sigma_y -> eccentricity ~0
1241        assert!(c.eccentricity.abs() < 1e-10);
1242    }
1243
1244    #[test]
1245    fn test_histogram_basic() {
1246        // 10 values: 0..9, hist range [0, 9], 10 bins
1247        let data = NDDataBuffer::F64((0..10).map(|x| x as f64).collect());
1248        let (hist, below, above, entropy) = compute_histogram(&data, 10, 0.0, 9.0);
1249        assert_eq!(hist.len(), 10);
1250        assert_eq!(below, 0.0);
1251        assert_eq!(above, 0.0);
1252        // Each bin should have ~1 count (uniform distribution)
1253        let total: f64 = hist.iter().sum();
1254        assert!((total - 10.0).abs() < 1e-10);
1255        // Entropy of uniform distribution over 10 bins = ln(10)
1256        assert!((entropy - 10.0f64.ln()).abs() < 0.1);
1257    }
1258
1259    #[test]
1260    fn test_histogram_below_above() {
1261        let data = NDDataBuffer::F64(vec![-1.0, 0.5, 1.5, 3.0]);
1262        let (hist, below, above, _entropy) = compute_histogram(&data, 2, 0.0, 2.0);
1263        assert_eq!(below, 1.0);  // -1.0 is below
1264        assert_eq!(above, 1.0);  // 3.0 is above
1265        let total_in_bins: f64 = hist.iter().sum();
1266        assert!((total_in_bins - 2.0).abs() < 1e-10);  // 0.5 and 1.5
1267    }
1268
1269    #[test]
1270    fn test_histogram_single_value() {
1271        let data = NDDataBuffer::F64(vec![5.0; 100]);
1272        let (hist, below, above, entropy) = compute_histogram(&data, 10, 0.0, 10.0);
1273        assert_eq!(below, 0.0);
1274        assert_eq!(above, 0.0);
1275        // All values go to one bin -> entropy = 0
1276        assert!((entropy - 0.0).abs() < 1e-10);
1277        let total: f64 = hist.iter().sum();
1278        assert!((total - 100.0).abs() < 1e-10);
1279    }
1280
1281    #[test]
1282    fn test_profiles_8x8() {
1283        // 8x8 image with value = row index (0..7 repeated across columns)
1284        let mut pixels = vec![0.0f64; 64];
1285        for iy in 0..8 {
1286            for ix in 0..8 {
1287                pixels[iy * 8 + ix] = iy as f64;
1288            }
1289        }
1290        let data = NDDataBuffer::F64(pixels);
1291
1292        let profiles = compute_profiles(
1293            &data, 8, 8,
1294            0.0,   // threshold
1295            3.5,   // centroid_x (center)
1296            3.5,   // centroid_y (center)
1297            0,     // cursor_x
1298            7,     // cursor_y
1299        );
1300
1301        // Average X profile: each column has the same values (0..7), avg = 3.5
1302        assert_eq!(profiles.avg_x.len(), 8);
1303        for &v in &profiles.avg_x {
1304            assert!((v - 3.5).abs() < 1e-10, "avg_x should be 3.5, got {v}");
1305        }
1306
1307        // Average Y profile: each row has uniform value = row index, avg = row index
1308        assert_eq!(profiles.avg_y.len(), 8);
1309        for (iy, &v) in profiles.avg_y.iter().enumerate() {
1310            assert!((v - iy as f64).abs() < 1e-10, "avg_y[{iy}] should be {iy}, got {v}");
1311        }
1312
1313        // Cursor X profile: row at cursor_y=7 -> all pixels are 7.0
1314        assert_eq!(profiles.cursor_x.len(), 8);
1315        for &v in &profiles.cursor_x {
1316            assert!((v - 7.0).abs() < 1e-10);
1317        }
1318
1319        // Cursor Y profile: column at cursor_x=0 -> values are 0,1,2,...,7
1320        assert_eq!(profiles.cursor_y.len(), 8);
1321        for (iy, &v) in profiles.cursor_y.iter().enumerate() {
1322            assert!((v - iy as f64).abs() < 1e-10);
1323        }
1324
1325        // Centroid X profile: row at round(centroid_y=3.5+0.5)=4 -> all pixels are 4.0
1326        assert_eq!(profiles.centroid_x.len(), 8);
1327        for &v in &profiles.centroid_x {
1328            assert!((v - 4.0).abs() < 1e-10);
1329        }
1330
1331        // Centroid Y profile: column at round(centroid_x=3.5+0.5)=4 -> values are 0,1,...,7
1332        assert_eq!(profiles.centroid_y.len(), 8);
1333        for (iy, &v) in profiles.centroid_y.iter().enumerate() {
1334            assert!((v - iy as f64).abs() < 1e-10);
1335        }
1336    }
1337
1338    #[test]
1339    fn test_profiles_threshold() {
1340        // 4x4 image: all 1.0 except one bright pixel at (2,1) = 10.0
1341        let mut pixels = vec![1.0f64; 16];
1342        pixels[1 * 4 + 2] = 10.0;
1343        let data = NDDataBuffer::F64(pixels);
1344
1345        let profiles = compute_profiles(
1346            &data, 4, 4,
1347            5.0,   // threshold
1348            2.0, 1.0,
1349            0, 0,
1350        );
1351
1352        // Threshold X profile: only column 2 has a pixel >= 5.0 (at row 1)
1353        assert_eq!(profiles.threshold_x.len(), 4);
1354        assert!((profiles.threshold_x[2] - 10.0).abs() < 1e-10);
1355        // Other columns: no pixels above threshold
1356        assert!((profiles.threshold_x[0] - 0.0).abs() < 1e-10);
1357        assert!((profiles.threshold_x[1] - 0.0).abs() < 1e-10);
1358        assert!((profiles.threshold_x[3] - 0.0).abs() < 1e-10);
1359
1360        // Threshold Y profile: only row 1 has a pixel >= 5.0
1361        assert_eq!(profiles.threshold_y.len(), 4);
1362        assert!((profiles.threshold_y[1] - 10.0).abs() < 1e-10);
1363        assert!((profiles.threshold_y[0] - 0.0).abs() < 1e-10);
1364    }
1365
1366    #[test]
1367    fn test_stats_processor_direct() {
1368        let mut proc = StatsProcessor::new();
1369        let pool = NDArrayPool::new(1_000_000);
1370
1371        let mut arr = NDArray::new(vec![NDDimension::new(5)], NDDataType::UInt8);
1372        if let NDDataBuffer::U8(ref mut v) = arr.data {
1373            v[0] = 10; v[1] = 20; v[2] = 30; v[3] = 40; v[4] = 50;
1374        }
1375
1376        let result = proc.process_array(&arr, &pool);
1377        assert!(result.output_arrays.is_empty(), "stats is a sink");
1378
1379        let stats = proc.stats_handle().lock().clone();
1380        assert_eq!(stats.min, 10.0);
1381        assert_eq!(stats.max, 50.0);
1382        assert_eq!(stats.mean, 30.0);
1383    }
1384
1385    #[test]
1386    fn test_stats_runtime_end_to_end() {
1387        let pool = Arc::new(NDArrayPool::new(1_000_000));
1388        let wiring = Arc::new(WiringRegistry::new());
1389        let (handle, stats, _params, _ts_runtime, _ts_params, _jh, _ts_actor_jh, _ts_data_jh) =
1390            create_stats_runtime("STATS_RT", pool, 10, "", wiring);
1391
1392        // Plugins default to disabled — enable for test
1393        handle.port_runtime().port_handle()
1394            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1).unwrap();
1395        std::thread::sleep(std::time::Duration::from_millis(10));
1396
1397        let mut arr = NDArray::new(
1398            vec![NDDimension::new(4), NDDimension::new(4)],
1399            NDDataType::UInt8,
1400        );
1401        if let NDDataBuffer::U8(ref mut v) = arr.data {
1402            for (i, val) in v.iter_mut().enumerate() {
1403                *val = (i + 1) as u8;
1404            }
1405        }
1406
1407        handle.array_sender().send(Arc::new(arr));
1408        std::thread::sleep(std::time::Duration::from_millis(100));
1409
1410        let result = stats.lock().clone();
1411        assert_eq!(result.min, 1.0);
1412        assert_eq!(result.max, 16.0);
1413        assert_eq!(result.num_elements, 16);
1414    }
1415}