use std::collections::{BTreeMap, BTreeSet, HashMap};
use indexmap::IndexMap;
use ndarray::Array2;
use serde_json::{json, Map, Value};
use crate::annotations::{Annotation, Instance};
use crate::array::Slice;
use crate::geometry::affine::{box_corners, box_to_slices, voxel_volume};
use crate::geometry::grid::{mm_per_unit, Grid};
use crate::json::{format_g, num, repr_str};
use crate::labels::ClassKey;
use crate::sample::Sample;
use crate::transforms::{stored_inverse, Body, Transform};
use crate::{Error, Result};
pub const PRESENT: &str = "present";
pub const RESOLVED: &str = "resolved";
pub const UNEXAMINED: &str = "unexamined";
pub const STATES: [&str; 3] = [PRESENT, RESOLVED, UNEXAMINED];
#[derive(Debug, Clone, PartialEq)]
pub struct Observation {
pub timepoint: String,
pub annotation: String,
pub kind: String,
pub index: usize,
pub instance_id: u64,
pub class_id: i64,
pub bbox: Array2<f32>,
pub voxel_count: Option<u64>,
pub volume: Option<f64>,
pub units: Option<String>,
pub score: Option<f64>,
pub grid: Option<String>,
}
impl Observation {
pub fn centroid(&self) -> Vec<f64> {
self.bbox.outer_iter().map(|r| (f64::from(r[0]) + f64::from(r[1])) / 2.0).collect()
}
pub fn extent(&self) -> Vec<f64> {
self.bbox.outer_iter().map(|r| f64::from(r[1]) - f64::from(r[0])).collect()
}
pub fn volume_mm(&self) -> Option<f64> {
let scale = mm_per_unit(self.units.as_deref()?)?;
Some(self.volume? * scale.powi(i32::try_from(self.bbox.nrows()).ok()?))
}
pub fn to_json(&self) -> Value {
let rows: Vec<Value> =
self.bbox.outer_iter().map(|r| json!(r.iter().map(|v| num(f64::from(*v))).collect::<Vec<_>>())).collect();
json!({
"timepoint": self.timepoint,
"annotation": self.annotation,
"kind": self.kind,
"index": self.index,
"class_id": self.class_id,
"box": rows,
"voxel_count": self.voxel_count,
"volume": self.volume.map(num),
"units": self.units,
"volume_mm": self.volume_mm().map(num),
"score": self.score.map(num),
})
}
pub fn repr(&self) -> String {
let volume = self.volume.map(|v| format_g(v, 4)).unwrap_or_else(|| "-".into());
format!(
"Observation({}[{}] @{}, class={}, volume={volume})",
self.annotation, self.index, self.timepoint, self.class_id
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Track {
pub instance_id: u64,
pub class_ids: Vec<i64>,
pub observations: Vec<Observation>,
pub class_key: Option<String>,
}
impl Track {
pub fn class_id(&self) -> i64 {
self.class_ids[0]
}
pub fn has_class_conflict(&self) -> bool {
self.class_ids.len() > 1
}
pub fn timepoints(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for o in &self.observations {
if !out.contains(&o.timepoint) {
out.push(o.timepoint.clone());
}
}
out
}
pub fn observations_at(&self, timepoint: &str) -> Vec<&Observation> {
self.observations.iter().filter(|o| o.timepoint == timepoint).collect()
}
pub fn measurements_at(&self, timepoint: &str) -> Vec<&Observation> {
let seen = self.observations_at(timepoint);
for kinds in [&["instances"][..], &["boxes", "obb"]] {
let found: Vec<&Observation> = seen.iter().copied().filter(|o| kinds.contains(&o.kind.as_str())).collect();
if !found.is_empty() {
return found;
}
}
seen
}
pub fn at(&self, timepoint: &str) -> Result<Option<&Observation>> {
let found = self.measurements_at(timepoint);
if found.len() > 1 {
let names: Vec<String> = found.iter().map(|o| o.annotation.clone()).collect();
return Err(Error::invalid(format!(
"instance {} is measured at {} by {} annotations ({}); which is meant is the caller's to choose --- \
observations_at() lists them",
self.instance_id,
repr_str(timepoint),
found.len(),
crate::json::repr_list(&names)
)));
}
Ok(found.first().copied())
}
pub fn volume(&self, timepoint: &str) -> Result<Option<f64>> {
Ok(self.at(timepoint)?.and_then(|o| o.volume))
}
pub fn volumes(&self) -> Result<IndexMap<String, Option<f64>>> {
self.timepoints().into_iter().map(|t| Ok((t.clone(), self.volume(&t)?))).collect()
}
pub fn relative_change(&self, first: &str, second: &str) -> Result<Option<f64>> {
let (Some(before), Some(after)) = (self.at(first)?, self.at(second)?) else {
return Ok(None);
};
let Some((before, after)) = comparable(before, after) else { return Ok(None) };
if before <= 0.0 {
return Ok(None);
}
Ok(Some((after - before) / before))
}
pub fn to_json(&self) -> Value {
json!({
"instance_id": self.instance_id,
"class_ids": self.class_ids,
"class_key": self.class_key,
"timepoints": self.timepoints(),
"observations": self.observations.iter().map(Observation::to_json).collect::<Vec<_>>(),
})
}
pub fn repr(&self) -> String {
let class = self.class_key.clone().unwrap_or_else(|| self.class_id().to_string());
format!("Track({}, class={class}, seen at {})", self.instance_id, crate::json::repr_list(&self.timepoints()))
}
}
fn comparable(before: &Observation, after: &Observation) -> Option<(f64, f64)> {
if before.bbox.nrows() != after.bbox.nrows() {
return None;
}
match (before.volume_mm(), after.volume_mm()) {
(Some(b), Some(a)) => Some((b, a)),
(None, None) if before.units == after.units => Some((before.volume?, after.volume?)),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Tracking {
pub tracks: BTreeMap<u64, Track>,
pub timepoints: Vec<String>,
pub coverage: IndexMap<String, BTreeSet<i64>>,
pub skipped: IndexMap<String, String>,
}
#[allow(deprecated)]
impl Tracking {
#[deprecated(
since = "2.1.0",
note = "medh5 3.0 removes the class-level states, under which an object outside the visit's field of view \
reads as resolved: use locate_tracks"
)]
pub fn state_at(&self, instance_id: u64, timepoint: &str) -> Option<&'static str> {
let track = self.tracks.get(&instance_id)?;
if !track.observations_at(timepoint).is_empty() {
return Some(PRESENT);
}
let examined = self.coverage.get(timepoint);
let looked = examined.is_some_and(|e| track.class_ids.iter().any(|c| e.contains(c)));
Some(if looked { RESOLVED } else { UNEXAMINED })
}
#[deprecated(
since = "2.1.0",
note = "medh5 3.0 removes the class-level states, under which an object outside the visit's field of view \
reads as resolved: use locate_tracks"
)]
pub fn states(&self, instance_id: u64) -> IndexMap<String, &'static str> {
self.timepoints.iter().map(|tp| (tp.clone(), self.state_at(instance_id, tp).unwrap_or(UNEXAMINED))).collect()
}
#[deprecated(
since = "2.1.0",
note = "medh5 3.0 removes the class-level states, under which an object outside the visit's field of view \
reads as resolved: use locate_tracks"
)]
pub fn is_new(&self, instance_id: u64) -> bool {
let states = self.states(instance_id);
match self.timepoints.first() {
None => false,
Some(first) => states[first] == RESOLVED && states.values().any(|s| *s == PRESENT),
}
}
#[deprecated(
since = "2.1.0",
note = "medh5 3.0 removes the class-level states, under which an object outside the visit's field of view \
reads as resolved: use locate_tracks"
)]
pub fn is_resolved(&self, instance_id: u64) -> bool {
if self.timepoints.len() < 2 {
return false;
}
let states = self.states(instance_id);
states[&self.timepoints[0]] == PRESENT && states[&self.timepoints[self.timepoints.len() - 1]] == RESOLVED
}
pub fn is_persistent(&self, instance_id: u64) -> bool {
self.states(instance_id).values().all(|s| *s == PRESENT)
}
pub fn class_conflicts(&self) -> BTreeMap<u64, Vec<i64>> {
self.tracks.iter().filter(|(_, t)| t.has_class_conflict()).map(|(i, t)| (*i, t.class_ids.clone())).collect()
}
pub fn unexamined(&self) -> IndexMap<String, Vec<u64>> {
let mut out = IndexMap::new();
for tp in &self.timepoints {
let ids: Vec<u64> =
self.tracks.keys().copied().filter(|i| self.state_at(*i, tp) == Some(UNEXAMINED)).collect();
if !ids.is_empty() {
out.insert(tp.clone(), ids);
}
}
out
}
pub fn repr(&self) -> String {
format!("Tracking({} tracks over {} timepoints)", self.tracks.len(), self.timepoints.len())
}
pub fn to_json(&self) -> Value {
self.json_with(&|instance_id, entry| {
entry.insert(
"states".into(),
Value::Object(self.states(instance_id).into_iter().map(|(k, v)| (k, json!(v))).collect()),
);
})
}
fn json_with(&self, per_track: &dyn Fn(u64, &mut Map<String, Value>)) -> Value {
let mut coverage: Vec<(&String, &BTreeSet<i64>)> = self.coverage.iter().collect();
coverage.sort_by(|a, b| a.0.cmp(b.0));
let coverage: Map<String, Value> = coverage.into_iter().map(|(k, v)| (k.clone(), json!(v))).collect();
let tracks: Vec<Value> = self
.tracks
.iter()
.map(|(i, t)| {
let mut v = t.to_json();
if let Value::Object(m) = &mut v {
per_track(*i, m);
}
v
})
.collect();
let conflicts: Map<String, Value> =
self.class_conflicts().into_iter().map(|(k, v)| (k.to_string(), json!(v))).collect();
json!({
"timepoints": self.timepoints,
"coverage": coverage,
"tracks": tracks,
"class_conflicts": conflicts,
"skipped": self.skipped,
})
}
pub fn summary(&self) -> Value {
self.summary_with(&|i| self.is_new(i), &|i| self.is_resolved(i), &|i| self.is_persistent(i))
}
fn summary_with(
&self,
new: &dyn Fn(u64) -> bool,
resolved: &dyn Fn(u64) -> bool,
persistent: &dyn Fn(u64) -> bool,
) -> Value {
let pick = |f: &dyn Fn(u64) -> bool| -> Vec<u64> { self.tracks.keys().copied().filter(|i| f(*i)).collect() };
json!({
"tracks": self.tracks.len(),
"timepoints": self.timepoints,
"new": pick(new),
"resolved": pick(resolved),
"persistent": pick(persistent),
"class_conflicts": self.class_conflicts().keys().copied().collect::<Vec<_>>(),
"skipped": self.skipped.keys().collect::<Vec<_>>(),
})
}
}
pub const TRACKED_KINDS: [&str; 4] = ["instances", "boxes", "obb", "points"];
pub fn carries_instance_ids(ann: &Annotation) -> Result<bool> {
Ok(ann.kind() == "instances" || ann.optional_dataset("instance_ids")?.is_some())
}
fn unjoinable(ann: &Annotation) -> Option<String> {
let kind = ann.kind();
if !TRACKED_KINDS.contains(&kind) {
return Some(format!(
"a {} annotation is neither measured nor marked by a track (they join {})",
repr_str(kind),
crate::json::repr_list(&TRACKED_KINDS)
));
}
if kind == "points" && matches!(ann.optional_dataset("class_ids"), Ok(None)) {
return Some("its points carry no `class_ids`, so no track could say what they follow".into());
}
None
}
fn ignores_part(ann: &Annotation) -> bool {
match ann.has_ignore_region() {
Ok(false) => false,
Ok(true) => !matches!(ann.ignore_region(), Ok(None)),
Err(_) => true,
}
}
fn objects(ann: &Annotation) -> Result<Vec<Instance>> {
let bboxes: Vec<Array2<f32>> = match ann.kind() {
"obb" => ann.obb_as_aabb()?.outer_iter().map(|b| b.mapv(|v| v as f32)).collect(),
"points" => {
let points = ann.read_as::<f32>("points")?.into_dimensionality::<ndarray::Ix2>()?;
points.outer_iter().map(|p| Array2::from_shape_fn((p.len(), 2), |(k, _)| p[k])).collect()
}
_ => return ann.instances(),
};
let n = bboxes.len();
let classes = ann.object_class_ids()?;
let ids = ann.instance_ids()?.unwrap_or_default();
let scores = ann.scores()?;
if classes.len() != n || ids.len() != n || scores.as_ref().is_some_and(|s| s.len() != n) {
return Err(Error::coded(
"E405",
format!(
"annotation {}: {n} object(s), but {} class id(s), {} instance id(s) and {} score(s)",
repr_str(&ann.ann_id),
classes.len(),
ids.len(),
scores.as_ref().map_or(n, Vec::len)
),
));
}
Ok(bboxes
.into_iter()
.enumerate()
.map(|(i, bbox)| Instance {
index: i,
instance_id: ids[i],
class_id: i64::from(classes[i]),
bbox,
mask: None,
score: scores.as_ref().map(|s| f64::from(s[i])).filter(|v| v.is_finite()),
})
.collect())
}
fn measure(
obj: &Instance,
grid: Option<&Grid>,
ann: &Annotation,
oriented: Option<f64>,
) -> Result<(Option<f64>, Option<u64>)> {
let space = ann.header.space.clone().unwrap_or_else(|| "index".into());
if ann.kind() == "points" {
return Ok((None, None));
}
if let Some(product) = oriented {
if space == "world" {
return Ok((Some(product), None));
}
return Ok((grid.map(|g| product * voxel_volume(&g.spacing)), None));
}
if let Some(mask) = &obj.mask {
let count = mask.iter().filter(|v| **v).count() as u64;
return Ok(match grid {
None => (None, Some(count)),
Some(g) => (Some(count as f64 * voxel_volume(&g.spacing)), Some(count)),
});
}
if obj.bbox.ncols() != 2 {
return Ok((None, None));
}
let extent: Vec<f64> = obj.bbox.outer_iter().map(|r| f64::from(r[1]) - f64::from(r[0])).collect();
if space == "world" {
return Ok((Some(extent.iter().product()), None));
}
let Some(g) = grid else { return Ok((None, None)) };
if ann.kind() == "instances" {
let flat: Vec<f64> = obj.bbox.iter().map(|v| f64::from(*v)).collect();
let counted: i64 = box_to_slices(&flat, None)?.iter().map(|(a, b)| b - a).product();
return Ok((Some(counted as f64 * voxel_volume(&g.spacing)), None));
}
Ok((Some(extent.iter().zip(&g.spacing).map(|(e, s)| e * s).product()), None))
}
pub fn build_tracks(sample: &Sample, class_key: Option<&ClassKey>, do_measure: bool) -> Result<Tracking> {
let declared = sample.timepoints()?.ids();
let mut coverage: IndexMap<String, BTreeSet<i64>> = declared.iter().map(|t| (t.clone(), BTreeSet::new())).collect();
let mut tracks: BTreeMap<u64, Vec<Observation>> = BTreeMap::new();
let implicit: Vec<String> = if declared.len() == 1 { declared.clone() } else { vec![String::new()] };
let mut skipped = IndexMap::new();
for ann in sample.annotations()?.values() {
if !carries_instance_ids(ann)? {
continue;
}
if let Some(why) = unjoinable(ann) {
skipped.insert(ann.ann_id.clone(), why);
continue;
}
let wanted = match class_key {
Some(k) => Some(ann.resolve_class(k)?),
None => None,
};
let own = ann.timepoints();
let timepoints = if own.is_empty() { implicit.clone() } else { own };
let examined: &[i64] = if ignores_part(ann) { &[] } else { ann.annotated_class_ids() };
for tp in &timepoints {
coverage.entry(tp.clone()).or_default().extend(examined.iter().copied());
}
let grid = ann.grid_id().and_then(|g| sample.grids().ok().and_then(|gs| gs.get(g).cloned()));
let oriented = if do_measure && ann.kind() == "obb" { Some(ann.obb_volumes()?) } else { None };
for obj in objects(ann)? {
if wanted.is_some_and(|w| obj.class_id != w) {
continue;
}
let (volume, count) = if do_measure {
measure(&obj, grid.as_ref(), ann, oriented.as_ref().and_then(|v| v.get(obj.index).copied()))?
} else {
(None, None)
};
for tp in &timepoints {
tracks.entry(obj.instance_id).or_default().push(Observation {
timepoint: tp.clone(),
annotation: ann.ann_id.clone(),
kind: ann.kind().to_string(),
index: obj.index,
instance_id: obj.instance_id,
class_id: obj.class_id,
bbox: obj.bbox.clone(),
voxel_count: count,
volume,
units: grid.as_ref().map(|g| g.units.clone()),
score: obj.score,
grid: ann.grid_id().map(str::to_string),
});
}
}
}
let order: IndexMap<&String, usize> = declared.iter().enumerate().map(|(i, t)| (t, i)).collect();
let label_set = sample.label_set()?;
let mut built = BTreeMap::new();
for (instance_id, mut observations) in tracks {
observations.sort_by(|a, b| {
let ka = (order.get(&a.timepoint).copied().unwrap_or(1 << 30), a.annotation.clone());
let kb = (order.get(&b.timepoint).copied().unwrap_or(1 << 30), b.annotation.clone());
ka.cmp(&kb)
});
let class_ids: Vec<i64> =
observations.iter().map(|o| o.class_id).collect::<BTreeSet<_>>().into_iter().collect();
let class_key = label_set.and_then(|ls| ls.by_id(class_ids[0])).map(|c| c.key.clone());
built.insert(instance_id, Track { instance_id, class_ids, observations, class_key });
}
Ok(Tracking { tracks: built, timepoints: declared, coverage, skipped })
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Absence {
Located,
ClassExamined,
OutsideFov,
Ignored,
Unrelated,
NoIdentity,
NotExamined,
}
pub const REASONS: &[&str] =
&["located", "class_examined", "outside_fov", "ignored", "unrelated", "no_identity", "not_examined"];
const ABSENCES: [Absence; 7] = [
Absence::Located,
Absence::ClassExamined,
Absence::OutsideFov,
Absence::Ignored,
Absence::Unrelated,
Absence::NoIdentity,
Absence::NotExamined,
];
impl Absence {
pub fn as_str(self) -> &'static str {
REASONS[self as usize]
}
pub fn parse(reason: &str) -> Option<Absence> {
REASONS.iter().position(|r| *r == reason).map(|i| ABSENCES[i])
}
pub fn is_finding(self) -> bool {
matches!(self, Absence::Located | Absence::ClassExamined)
}
pub fn state(self) -> &'static str {
if self.is_finding() {
RESOLVED
} else {
UNEXAMINED
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct LocatedTracking {
pub tracking: Tracking,
pub absences: BTreeMap<(u64, String), Absence>,
}
impl LocatedTracking {
pub fn new(tracking: Tracking, absences: BTreeMap<(u64, String), Absence>) -> LocatedTracking {
LocatedTracking { tracking, absences }
}
pub fn state_at(&self, instance_id: u64, timepoint: &str) -> Option<&'static str> {
let track = self.tracking.tracks.get(&instance_id)?;
if !track.observations_at(timepoint).is_empty() {
return Some(PRESENT);
}
Some(self.reason_at(instance_id, timepoint).map_or(UNEXAMINED, Absence::state))
}
pub fn reason_at(&self, instance_id: u64, timepoint: &str) -> Option<Absence> {
self.absences.get(&(instance_id, timepoint.to_string())).copied()
}
pub fn states(&self, instance_id: u64) -> IndexMap<String, &'static str> {
self.tracking
.timepoints
.iter()
.map(|tp| (tp.clone(), self.state_at(instance_id, tp).unwrap_or(UNEXAMINED)))
.collect()
}
pub fn reasons(&self, instance_id: u64) -> IndexMap<String, Option<Absence>> {
self.tracking.timepoints.iter().map(|tp| (tp.clone(), self.reason_at(instance_id, tp))).collect()
}
fn looked(&self, instance_id: u64, timepoint: &str, located: bool) -> bool {
match self.reason_at(instance_id, timepoint) {
Some(reason) if located => reason == Absence::Located,
Some(reason) => reason.is_finding(),
None => false,
}
}
pub fn is_new(&self, instance_id: u64, located: bool) -> bool {
let Some(first) = self.tracking.timepoints.first() else { return false };
self.looked(instance_id, first, located) && self.states(instance_id).values().any(|s| *s == PRESENT)
}
pub fn is_resolved(&self, instance_id: u64, located: bool) -> bool {
let timepoints = &self.tracking.timepoints;
if timepoints.len() < 2 {
return false;
}
self.state_at(instance_id, &timepoints[0]) == Some(PRESENT)
&& self.looked(instance_id, &timepoints[timepoints.len() - 1], located)
}
pub fn is_persistent(&self, instance_id: u64) -> bool {
self.states(instance_id).values().all(|s| *s == PRESENT)
}
pub fn unexamined(&self) -> IndexMap<String, Vec<u64>> {
let mut out = IndexMap::new();
for tp in &self.tracking.timepoints {
let ids: Vec<u64> =
self.tracking.tracks.keys().copied().filter(|i| self.state_at(*i, tp) == Some(UNEXAMINED)).collect();
if !ids.is_empty() {
out.insert(tp.clone(), ids);
}
}
out
}
pub fn to_json(&self) -> Value {
self.tracking.json_with(&|instance_id, entry| {
entry.insert(
"states".into(),
Value::Object(self.states(instance_id).into_iter().map(|(k, v)| (k, json!(v))).collect()),
);
entry.insert(
"reasons".into(),
Value::Object(
self.reasons(instance_id).into_iter().map(|(k, v)| (k, json!(v.map(Absence::as_str)))).collect(),
),
);
})
}
pub fn summary(&self) -> Value {
self.tracking
.summary_with(&|i| self.is_new(i, false), &|i| self.is_resolved(i, false), &|i| self.is_persistent(i))
}
pub fn repr(&self) -> String {
self.tracking.repr()
}
}
pub fn locate_tracks(sample: &Sample, class_key: Option<&ClassKey>, do_measure: bool) -> Result<LocatedTracking> {
let tracking = build_tracks(sample, class_key, do_measure)?;
let implicit: Vec<String> =
if tracking.timepoints.len() == 1 { tracking.timepoints.clone() } else { vec![String::new()] };
let mut at: IndexMap<String, Vec<&Annotation>> = IndexMap::new();
for ann in sample.annotations()?.values() {
let own = ann.timepoints();
for tp in if own.is_empty() { implicit.clone() } else { own } {
at.entry(tp).or_default().push(ann);
}
}
let mut locator = Locator { sample, valid_masks: HashMap::new() };
let mut absences = BTreeMap::new();
for (instance_id, track) in &tracking.tracks {
for tp in &tracking.timepoints {
if !track.observations_at(tp).is_empty() {
continue;
}
let mut reason = Absence::NotExamined;
for ann in at.get(tp).map(Vec::as_slice).unwrap_or(&[]) {
reason = reason.min(locator.reason(track, ann)?);
if reason == Absence::Located {
break;
}
}
absences.insert((*instance_id, tp.clone()), reason);
}
}
Ok(LocatedTracking { tracking, absences })
}
type Extent = Vec<(i64, i64)>;
struct Locator<'a> {
sample: &'a Sample,
valid_masks: HashMap<String, Vec<String>>,
}
impl Locator<'_> {
fn reason(&mut self, track: &Track, ann: &Annotation) -> Result<Absence> {
if !track.class_ids.iter().any(|c| ann.annotated_class_ids().contains(c)) {
return Ok(Absence::NotExamined);
}
if !carries_instance_ids(ann)? || unjoinable(ann).is_some() {
return Ok(Absence::NoIdentity);
}
let Some((grid, extent)) = self.extent(track, ann)? else {
return Ok(if ignores_part(ann) { Absence::Unrelated } else { Absence::ClassExamined });
};
let shape = grid.spatial_shape();
if extent.iter().zip(&shape).any(|((a, b), n)| *a < 0 || *b > *n as i64) || !self.acquired(&grid, &extent) {
return Ok(Absence::OutsideFov);
}
if ignores_within(self.sample, ann, &extent) {
return Ok(Absence::Ignored);
}
Ok(Absence::Located)
}
fn extent(&self, track: &Track, ann: &Annotation) -> Result<Option<(Grid, Extent)>> {
let grids = self.sample.grids()?;
let Some(target) = ann.grid_id().and_then(|g| grids.get(g)) else { return Ok(None) };
let s = target.n_spatial();
if track.observations.is_empty() || s == 0 {
return Ok(None);
}
let (mut lo, mut hi) = (vec![f64::INFINITY; s], vec![f64::NEG_INFINITY; s]);
for observation in &track.observations {
let Some(corners) = self.corners_in(observation, target)? else { return Ok(None) };
for corner in corners.chunks(s) {
for k in 0..s {
lo[k] = lo[k].min(corner[k]);
hi[k] = hi[k].max(corner[k]);
}
}
}
if lo.iter().chain(&hi).any(|v| !v.is_finite()) {
return Ok(None);
}
Ok(Some((target.clone(), rounded_out(&lo, &hi))))
}
fn corners_in(&self, observation: &Observation, target: &Grid) -> Result<Option<Vec<f64>>> {
let grids = self.sample.grids()?;
let Some(source) = observation.grid.as_deref().and_then(|g| grids.get(g)) else { return Ok(None) };
let s = target.n_spatial();
let bbox = &observation.bbox;
if bbox.ncols() != 2 || bbox.nrows() != s || source.n_spatial() != s {
return Ok(None);
}
let flat: Vec<f64> = bbox.iter().map(|v| f64::from(*v)).collect();
let corners = box_corners(&flat)?.concat();
let space = self.sample.annotation(&observation.annotation)?.header.space.clone();
let world = if space.as_deref() == Some("world") { corners } else { source.index_to_world(&corners)? };
let Some(world) = self.world_between(source, target, &world) else { return Ok(None) };
Ok(Some(target.world_to_index(&world)?))
}
fn world_between(&self, source: &Grid, target: &Grid, points: &[f64]) -> Option<Vec<f64>> {
if source.grid_id == target.grid_id || source.comparable_with(target) {
return source.world_into(target, points).ok();
}
let frame = |g: &Grid| g.frame_uid.clone().filter(|f| !f.is_empty());
let (from, to) = (frame(source)?, frame(target)?);
let transform = self.sample.resolve_frames(&from, &to).ok()??;
if !is_linear(&transform) {
return None;
}
let into = unit_scale(&source.units, transform.units())?;
let out = unit_scale(transform.units(), &target.units)?;
let s = source.n_spatial();
let scaled: Vec<f64> = points.iter().map(|v| v * into).collect();
let array = ndarray::ArrayD::from_shape_vec(ndarray::IxDyn(&[points.len() / s, s]), scaled).ok()?;
let mapped = transform.transform_points(&array).ok()?;
Some(mapped.iter().map(|v| v * out).collect())
}
fn acquired(&mut self, grid: &Grid, extent: &Extent) -> bool {
let sample = self.sample;
let images = self.valid_masks.entry(grid.grid_id.clone()).or_insert_with(|| match sample.images() {
Ok(images) => images
.iter()
.filter(|(_, image)| {
image.grid_id().ok().as_deref() == Some(grid.grid_id.as_str())
&& image.valid_mask().ok().flatten().is_some()
})
.map(|(id, _)| id.clone())
.collect(),
Err(_) => Vec::new(),
});
let roi = slices_of(extent);
images.iter().all(|id| sample.valid_region(id, Some(&roi)).is_ok_and(|region| region.iter().all(|v| *v)))
}
}
fn ignores_within(sample: &Sample, ann: &Annotation, extent: &Extent) -> bool {
if !ignores_part(ann) {
return false;
}
let roi = slices_of(extent);
if ann.is_voxel() {
return sample.ignore_region(&ann.ann_id, Some(&roi)).map_or(true, |region| region.iter().any(|v| *v));
}
match ann.ignore_region() {
Ok(Some(region)) => {
let index: Vec<ndarray::SliceInfoElem> =
extent.iter().map(|(a, b)| ndarray::SliceInfoElem::from(*a as usize..*b as usize)).collect();
region.ndim() != extent.len() || region.slice(index.as_slice()).iter().any(|v| *v)
}
Ok(None) => false,
Err(_) => true,
}
}
fn slices_of(extent: &Extent) -> Vec<Slice> {
extent.iter().map(|(a, b)| Slice::new(*a, *b)).collect()
}
fn rounded_out(lo: &[f64], hi: &[f64]) -> Extent {
let snap = |v: f64| if (v - v.round()).abs() < 1e-6 { v.round() } else { v };
lo.iter()
.zip(hi)
.map(|(l, h)| {
let start = snap(l + 0.5).floor() as i64;
let stop = snap(h + 0.5).ceil() as i64;
(start, stop.max(start + 1))
})
.collect()
}
fn unit_scale(from: &str, to: &str) -> Option<f64> {
if from == to {
return Some(1.0);
}
Some(mm_per_unit(from)? / mm_per_unit(to)?)
}
fn is_linear(transform: &Transform) -> bool {
match &transform.body {
Body::Chain(steps) => steps.iter().all(is_linear),
Body::Inverse(inner) => {
is_linear(inner)
&& match stored_inverse(inner) {
Ok(Some(stored)) => is_linear(&stored),
Ok(None) => true,
Err(_) => false,
}
}
Body::Stored => match transform.kind() {
"identity" | "affine" => true,
"displacement" | "bspline" => false,
_ => transform.components().is_ok_and(|components| components.iter().all(is_linear)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn s7_4_reason_precedence_prefers_a_finding() {
let reasons: Vec<Absence> = REASONS.iter().map(|r| Absence::parse(r).expect("a reason")).collect();
assert_eq!(reasons, ABSENCES);
assert!(reasons.windows(2).all(|w| w[0] < w[1]));
let disagreeing = [Absence::Ignored, Absence::ClassExamined, Absence::OutsideFov];
assert_eq!(disagreeing.into_iter().min(), Some(Absence::ClassExamined));
assert_eq!(reasons.iter().filter(|r| r.is_finding()).count(), 2);
assert!(reasons.iter().all(|r| r.as_str() == REASONS[*r as usize]));
assert_eq!(Absence::parse("resolved"), None);
}
#[test]
fn s7_4_an_extent_rounds_out_to_whole_voxels() {
assert_eq!(rounded_out(&[0.5], &[4.5]), vec![(1, 5)]);
assert_eq!(rounded_out(&[0.6], &[4.4]), vec![(1, 5)]);
assert_eq!(rounded_out(&[0.4], &[4.6]), vec![(0, 6)]);
assert_eq!(rounded_out(&[0.5 + 1e-9], &[4.5 - 1e-9]), vec![(1, 5)]);
assert_eq!(rounded_out(&[3.0], &[3.0]), vec![(3, 4)]);
assert_eq!(rounded_out(&[2.5], &[2.5]), vec![(3, 4)]);
}
#[test]
fn s7_4_units_scale_only_where_calibrated() {
assert_eq!(unit_scale("mm", "mm"), Some(1.0));
assert_eq!(unit_scale("m", "mm"), Some(1000.0));
assert_eq!(unit_scale("px", "px"), Some(1.0));
assert_eq!(unit_scale("px", "mm"), None);
}
}