use crate::core::roi::Roi;
use crate::core::stats::ComCoord;
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct RoiStats {
pub count: usize,
pub min: Option<f64>,
pub max: Option<f64>,
pub mean: Option<f64>,
pub sum: f64,
pub integral: f64,
pub com: ComCoord,
pub coord_min: ComCoord,
pub coord_max: ComCoord,
}
#[derive(Clone, Copy, Debug, Default)]
struct Accumulator {
count: usize,
min: f64,
max: f64,
sum: f64,
com_x_num: f64,
com_y_num: f64,
nan_pos: Option<(f64, Option<f64>)>,
min_pos: (f64, Option<f64>),
max_pos: (f64, Option<f64>),
inited: bool,
two_d: bool,
}
impl Accumulator {
fn push(&mut self, v: f64, x: f64, y: Option<f64>) {
self.count += 1;
self.two_d |= y.is_some();
self.sum += v;
self.com_x_num += v * x;
if let Some(y) = y {
self.com_y_num += v * y;
}
if v.is_nan() {
if self.nan_pos.is_none() {
self.nan_pos = Some((x, y));
}
} else if !self.inited {
self.inited = true;
self.min = v;
self.max = v;
self.min_pos = (x, y);
self.max_pos = (x, y);
} else {
if v < self.min {
self.min = v;
self.min_pos = (x, y);
}
if v > self.max {
self.max = v;
self.max_pos = (x, y);
}
}
}
fn finish(self, area_per_sample: f64) -> RoiStats {
if self.count == 0 {
return RoiStats::default();
}
let mean = self.sum / self.count as f64;
let coord = |pos: (f64, Option<f64>)| ComCoord {
x: Some(pos.0),
y: pos.1,
};
let (min, max) = if self.inited {
(self.min, self.max)
} else {
(f64::NAN, f64::NAN)
};
let (coord_min, coord_max) = match self.nan_pos {
Some(pos) => (coord(pos), coord(pos)),
None => (coord(self.min_pos), coord(self.max_pos)),
};
let com = if self.sum == 0.0 {
ComCoord::NONE
} else {
ComCoord {
x: Some(self.com_x_num / self.sum),
y: self.two_d.then(|| self.com_y_num / self.sum),
}
};
RoiStats {
count: self.count,
min: Some(min),
max: Some(max),
mean: Some(mean),
sum: self.sum,
integral: self.sum * area_per_sample,
com,
coord_min,
coord_max,
}
}
}
pub fn image_roi_stats(
roi: &Roi,
data: &[f32],
width: usize,
height: usize,
origin: [f64; 2],
scale: [f64; 2],
) -> RoiStats {
let mut acc = Accumulator::default();
for row in 0..height {
let cy = origin[1] + (row as f64 + 0.5) * scale[1];
let base = row * width;
for col in 0..width {
let idx = base + col;
let Some(&value) = data.get(idx) else {
break;
};
let cx = origin[0] + (col as f64 + 0.5) * scale[0];
if roi.contains((cx, cy)) {
acc.push(value as f64, cx, Some(cy));
}
}
}
let pixel_area = (scale[0] * scale[1]).abs();
acc.finish(pixel_area)
}
pub fn curve_roi_stats(roi: &Roi, x: &[f64], y: &[f64]) -> RoiStats {
let mut acc = Accumulator::default();
if let Some((x0, x1)) = roi_x_span(roi) {
for (&xi, &yi) in x.iter().zip(y.iter()) {
if xi >= x0 && xi <= x1 {
acc.push(yi, xi, None);
}
}
}
acc.finish(1.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CurveRoiCounts {
pub raw_counts: f64,
pub net_counts: f64,
pub raw_area: f64,
pub net_area: f64,
}
pub fn curve_roi_counts(roi: &Roi, x: &[f64], y: &[f64]) -> Option<CurveRoiCounts> {
let (from, to) = roi_x_span(roi)?;
let mut xw = Vec::new();
let mut yw = Vec::new();
for (&xi, &yi) in x.iter().zip(y.iter()) {
if xi >= from && xi <= to {
xw.push(xi);
yw.push(yi);
}
}
if xw.is_empty() {
return Some(CurveRoiCounts {
raw_counts: 0.0,
net_counts: 0.0,
raw_area: 0.0,
net_area: 0.0,
});
}
let raw_counts: f64 = yw.iter().sum();
let x0 = xw[0];
let xn = xw[xw.len() - 1];
let y0 = yw[0];
let yn = yw[yw.len() - 1];
let delta_x = xn - x0;
let net_counts = if delta_x > 0.0 {
let slope = (yn - y0) / delta_x;
let background: f64 = xw.iter().map(|&xi| y0 + slope * (xi - x0)).sum();
raw_counts - background
} else {
0.0
};
let raw_area = trapezoid(&yw, &xw);
let left = argmin_abs_diff(&xw, from);
let right = argmin_abs_diff(&xw, to);
let background_area = (xn - x0) * (yw[left] + yw[right]) / 2.0;
let net_area = raw_area - background_area;
Some(CurveRoiCounts {
raw_counts,
net_counts,
raw_area,
net_area,
})
}
fn trapezoid(y: &[f64], x: &[f64]) -> f64 {
x.windows(2)
.zip(y.windows(2))
.map(|(xs, ys)| (xs[1] - xs[0]) * (ys[1] + ys[0]) / 2.0)
.sum()
}
fn argmin_abs_diff(values: &[f64], target: f64) -> usize {
let mut best = 0;
let mut best_diff = (values[0] - target).abs();
for (i, &v) in values.iter().enumerate().skip(1) {
let diff = (v - target).abs();
if diff < best_diff {
best = i;
best_diff = diff;
}
}
best
}
pub fn roi_x_span(roi: &Roi) -> Option<(f64, f64)> {
match roi {
Roi::VRange { x } => Some(norm(x.0, x.1)),
Roi::Rect { x, .. } => Some(norm(x.0, x.1)),
Roi::Line { start, end } => Some(norm(start.0, end.0)),
_ => None,
}
}
fn norm(a: f64, b: f64) -> (f64, f64) {
if a <= b { (a, b) } else { (b, a) }
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp_4x4() -> Vec<f32> {
(0..16).map(|v| v as f32).collect()
}
#[test]
fn image_empty_region_yields_no_stats() {
let roi = Roi::Rect {
x: (100.0, 200.0),
y: (100.0, 200.0),
};
let s = image_roi_stats(&roi, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 0);
assert_eq!(s.min, None);
assert_eq!(s.max, None);
assert_eq!(s.mean, None);
assert_eq!(s.sum, 0.0);
assert_eq!(s.integral, 0.0);
}
#[test]
fn image_single_pixel_region() {
let roi = Roi::Rect {
x: (1.4, 1.6),
y: (2.4, 2.6),
};
let s = image_roi_stats(&roi, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 1);
assert_eq!(s.min, Some(9.0));
assert_eq!(s.max, Some(9.0));
assert_eq!(s.mean, Some(9.0));
assert_eq!(s.sum, 9.0);
assert_eq!(s.integral, 9.0); }
#[test]
fn image_full_region_covers_every_pixel() {
let roi = Roi::Rect {
x: (0.0, 4.0),
y: (0.0, 4.0),
};
let s = image_roi_stats(&roi, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 16);
assert_eq!(s.min, Some(0.0));
assert_eq!(s.max, Some(15.0));
assert_eq!(s.sum, (0..16).sum::<i32>() as f64); assert_eq!(s.mean, Some(120.0 / 16.0));
assert_eq!(s.integral, 120.0);
}
#[test]
fn image_com_and_coords_are_value_weighted_in_data_space() {
let roi = Roi::Rect {
x: (0.0, 4.0),
y: (0.0, 4.0),
};
let s = image_roi_stats(&roi, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert!(
(s.com.x.unwrap() - 13.0 / 6.0).abs() < 1e-9,
"com.x = {:?}",
s.com.x
);
assert!(
(s.com.y.unwrap() - 8.0 / 3.0).abs() < 1e-9,
"com.y = {:?}",
s.com.y
);
assert_eq!(s.coord_min.x, Some(0.5));
assert_eq!(s.coord_min.y, Some(0.5));
assert_eq!(s.coord_max.x, Some(3.5));
assert_eq!(s.coord_max.y, Some(3.5));
}
#[test]
fn curve_com_and_coords_are_x_only() {
let roi = Roi::VRange { x: (0.0, 3.0) };
let x = [0.0, 1.0, 2.0, 3.0];
let y = [1.0, 2.0, 3.0, 4.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 4);
assert_eq!(s.com.x, Some(2.0));
assert_eq!(s.com.y, None);
assert_eq!(
s.coord_min,
ComCoord {
x: Some(0.0),
y: None
}
);
assert_eq!(
s.coord_max,
ComCoord {
x: Some(3.0),
y: None
}
);
}
#[test]
fn image_all_nan_pixels_surface_nan_not_empty() {
let data = vec![f32::NAN; 16];
let roi = Roi::Rect {
x: (0.0, 4.0),
y: (0.0, 4.0),
};
let s = image_roi_stats(&roi, &data, 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 16);
assert!(s.min.unwrap().is_nan());
assert!(s.sum.is_nan());
assert!(s.mean.unwrap().is_nan());
assert_eq!(s.coord_min.x, Some(0.5));
assert_eq!(s.coord_min.y, Some(0.5));
}
#[test]
fn image_nan_pixel_propagates_but_min_max_skip_it() {
let mut data = ramp_4x4();
data[2 * 4 + 1] = f32::NAN; let roi = Roi::Rect {
x: (0.0, 4.0),
y: (0.0, 4.0),
};
let s = image_roi_stats(&roi, &data, 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 16);
assert!(s.sum.is_nan());
assert!(s.mean.unwrap().is_nan());
assert_eq!(s.min, Some(0.0));
assert_eq!(s.max, Some(15.0));
assert_eq!(s.coord_min.x, Some(1.5), "first NaN wins the coords");
assert_eq!(s.coord_min.y, Some(2.5));
assert_eq!(s.coord_max, s.coord_min);
}
#[test]
fn image_integral_scales_with_pixel_area() {
let roi = Roi::Rect {
x: (-10.0, 10.0),
y: (-10.0, 10.0),
};
let s = image_roi_stats(&roi, &ramp_4x4(), 4, 4, [-10.0, -10.0], [2.0, 3.0]);
assert_eq!(s.count, 16);
assert_eq!(s.sum, 120.0);
assert_eq!(s.integral, 120.0 * 6.0);
}
#[test]
fn image_circle_selects_fewer_than_its_bounding_rect() {
let circle = Roi::Circle {
center: (1.5, 1.5),
radius: 1.1,
};
let s = image_roi_stats(&circle, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 5);
assert_eq!(s.sum, 25.0);
let rect = Roi::Rect {
x: (0.0, 3.0),
y: (0.0, 3.0),
};
let sr = image_roi_stats(&rect, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(sr.count, 9);
}
#[test]
fn image_polygon_triangle_containment() {
let tri = Roi::Polygon {
vertices: vec![(0.0, 0.0), (4.0, 0.0), (0.0, 4.0)],
};
let s = image_roi_stats(&tri, &ramp_4x4(), 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 6);
}
#[test]
fn image_ragged_buffer_stops_at_available_data() {
let data: Vec<f32> = (0..8).map(|v| v as f32).collect();
let roi = Roi::Rect {
x: (0.0, 4.0),
y: (0.0, 4.0),
};
let s = image_roi_stats(&roi, &data, 4, 4, [0.0, 0.0], [1.0, 1.0]);
assert_eq!(s.count, 8);
assert_eq!(s.sum, (0..8).sum::<i32>() as f64); }
#[test]
fn curve_vrange_selects_points_in_x_span() {
let roi = Roi::VRange { x: (2.0, 4.0) };
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 3);
assert_eq!(s.min, Some(20.0));
assert_eq!(s.max, Some(40.0));
assert_eq!(s.sum, 90.0);
assert_eq!(s.mean, Some(30.0));
assert_eq!(s.integral, 90.0); }
#[test]
fn curve_span_is_inclusive_at_both_edges() {
let roi = Roi::VRange { x: (2.0, 4.0) };
let x = vec![2.0, 4.0];
let y = vec![7.0, 8.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 2); assert_eq!(s.sum, 15.0);
}
#[test]
fn curve_empty_selection_when_no_point_in_span() {
let roi = Roi::VRange { x: (100.0, 200.0) };
let x = vec![1.0, 2.0, 3.0];
let y = vec![1.0, 2.0, 3.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 0);
assert_eq!(s.min, None);
assert_eq!(s.sum, 0.0);
}
#[test]
fn curve_nan_x_excluded_nan_y_propagates() {
let roi = Roi::VRange { x: (0.0, 10.0) };
let x = vec![1.0, f64::NAN, 3.0, 4.0];
let y = vec![1.0, 2.0, f64::NAN, 4.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 3);
assert!(s.sum.is_nan());
assert!(s.mean.unwrap().is_nan());
assert_eq!(s.min, Some(1.0));
assert_eq!(s.max, Some(4.0));
assert_eq!(s.coord_min.x, Some(3.0), "first NaN y at x=3");
assert_eq!(s.coord_max.x, Some(3.0));
}
#[test]
fn curve_rect_uses_its_x_extent() {
let roi = Roi::Rect {
x: (2.0, 4.0),
y: (-100.0, 100.0),
};
let x = vec![1.0, 3.0, 5.0];
let y = vec![1.0, 3.0, 5.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 1); assert_eq!(s.sum, 3.0);
}
#[test]
fn curve_line_uses_unordered_endpoint_x_extent() {
let roi = Roi::Line {
start: (6.0, 0.0),
end: (2.0, 0.0),
};
let x = vec![1.0, 2.0, 4.0, 6.0, 7.0];
let y = vec![1.0, 1.0, 1.0, 1.0, 1.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 3); }
#[test]
fn curve_hrange_has_no_x_span_and_selects_nothing() {
let roi = Roi::HRange { y: (0.0, 10.0) };
assert_eq!(roi_x_span(&roi), None);
let x = vec![1.0, 2.0, 3.0];
let y = vec![1.0, 2.0, 3.0];
let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 0);
}
#[test]
fn curve_paired_by_index_ignores_unpaired_tail() {
let roi = Roi::VRange { x: (0.0, 10.0) };
let x = vec![1.0, 2.0, 3.0];
let y = vec![1.0, 2.0]; let s = curve_roi_stats(&roi, &x, &y);
assert_eq!(s.count, 2); assert_eq!(s.sum, 3.0);
}
#[test]
fn curve_counts_linear_data_has_zero_net() {
let roi = Roi::VRange { x: (1.0, 3.0) };
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let y = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(c.raw_counts, 6.0); assert_eq!(c.net_counts, 0.0);
assert_eq!(c.raw_area, 4.0); assert_eq!(c.net_area, 0.0);
}
#[test]
fn curve_counts_peak_over_flat_background() {
let roi = Roi::VRange { x: (0.0, 4.0) };
let x = vec![0.0, 1.0, 2.0, 3.0, 4.0];
let y = vec![0.0, 0.0, 10.0, 0.0, 0.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(c.raw_counts, 10.0);
assert_eq!(c.net_counts, 10.0); assert_eq!(c.raw_area, 10.0); assert_eq!(c.net_area, 10.0); }
#[test]
fn curve_counts_sloped_background_subtracted() {
let roi = Roi::VRange { x: (0.0, 2.0) };
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 5.0, 2.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(c.raw_counts, 7.0);
assert_eq!(c.net_counts, 4.0);
assert_eq!(c.raw_area, 6.0);
assert_eq!(c.net_area, 4.0);
}
#[test]
fn curve_counts_empty_selection_is_all_zero() {
let roi = Roi::VRange { x: (100.0, 200.0) };
let x = vec![0.0, 1.0, 2.0];
let y = vec![5.0, 6.0, 7.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(
c,
CurveRoiCounts {
raw_counts: 0.0,
net_counts: 0.0,
raw_area: 0.0,
net_area: 0.0,
}
);
}
#[test]
fn curve_counts_single_point_has_zero_net_and_area() {
let roi = Roi::VRange { x: (1.9, 2.1) };
let x = vec![0.0, 1.0, 2.0, 3.0];
let y = vec![0.0, 0.0, 9.0, 0.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(c.raw_counts, 9.0);
assert_eq!(c.net_counts, 0.0);
assert_eq!(c.raw_area, 0.0);
assert_eq!(c.net_area, 0.0);
}
#[test]
fn curve_counts_preserve_array_order_like_numpy_trapezoid() {
let roi = Roi::VRange { x: (0.0, 2.0) };
let x = vec![0.0, 2.0, 1.0];
let y = vec![0.0, 10.0, 0.0];
let c = curve_roi_counts(&roi, &x, &y).expect("VRange has an x-span");
assert_eq!(c.raw_counts, 10.0);
assert_eq!(c.raw_area, 5.0); }
#[test]
fn curve_counts_none_without_x_span() {
let roi = Roi::HRange { y: (0.0, 10.0) };
let x = vec![0.0, 1.0, 2.0];
let y = vec![1.0, 2.0, 3.0];
assert_eq!(curve_roi_counts(&roi, &x, &y), None);
}
}