Skip to main content

ad_plugins_rs/
stats.rs

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