wsi-annotation-tests 0.1.3

DICOM Annotation Test Suite for WSI ANN, SEG, SR, and Parametric Map
Documentation
use std::cmp::Ordering;

use serde_json::{Map, Value};

pub(super) struct CoordinateErrors {
    pub(super) canonical: Vec<f64>,
    pub(super) z: Vec<f64>,
    pub(super) native: Vec<f64>,
}

pub(super) fn errors(
    expected: &Map<String, Value>,
    actual: &Map<String, Value>,
    graphic_type: &str,
) -> Result<CoordinateErrors, String> {
    let canonical = primitive_errors(expected, actual, graphic_type, CoordinateSpec::CANONICAL)?;
    let native = primitive_errors(expected, actual, graphic_type, CoordinateSpec::NATIVE)?;
    Ok(CoordinateErrors {
        canonical: canonical.distances,
        z: canonical.z,
        native: native.distances,
    })
}

#[derive(Clone, Copy)]
struct CoordinateSpec {
    coordinate_key: &'static str,
    dimensions_key: &'static str,
    index_dimensions_key: Option<&'static str>,
    label: &'static str,
    xy_with_separate_z: bool,
    optional: bool,
}

impl CoordinateSpec {
    const CANONICAL: Self = Self {
        coordinate_key: "canonical_level0_coordinates",
        dimensions_key: "canonical_dimensions",
        index_dimensions_key: Some("native_dimensions"),
        label: "canonical",
        xy_with_separate_z: true,
        optional: false,
    };

    const NATIVE: Self = Self {
        coordinate_key: "native_coordinates",
        dimensions_key: "native_dimensions",
        index_dimensions_key: None,
        label: "native",
        xy_with_separate_z: false,
        optional: true,
    };
}

struct PrimitiveErrors {
    distances: Vec<f64>,
    z: Vec<f64>,
}

fn primitive_errors(
    expected: &Map<String, Value>,
    actual: &Map<String, Value>,
    graphic_type: &str,
    spec: CoordinateSpec,
) -> Result<PrimitiveErrors, String> {
    if spec.optional
        && !expected.contains_key(spec.coordinate_key)
        && !actual.contains_key(spec.coordinate_key)
    {
        return Ok(PrimitiveErrors {
            distances: Vec::new(),
            z: Vec::new(),
        });
    }
    let coordinate_dimensions = dimensions(expected, spec.dimensions_key)?;
    if dimensions(actual, spec.dimensions_key)? != coordinate_dimensions {
        return Err(format!("{} dimensions differ or are invalid", spec.label));
    }
    if expected.get("primitive_point_indices") != actual.get("primitive_point_indices") {
        return Err("primitive point associations differ".to_owned());
    }
    let index_dimensions = match spec.index_dimensions_key {
        Some(key) => expected
            .get(key)
            .map_or(Ok(coordinate_dimensions), json_dimensions)?,
        None => coordinate_dimensions,
    };
    let actual_index_dimensions = match spec.index_dimensions_key {
        Some(key) => actual
            .get(key)
            .map_or(Ok(coordinate_dimensions), json_dimensions)?,
        None => coordinate_dimensions,
    };
    if index_dimensions != actual_index_dimensions {
        return Err("native dimensions differ or are invalid".to_owned());
    }
    let expected_primitives = primitives(
        expected,
        coordinate_dimensions,
        index_dimensions,
        spec.coordinate_key,
    )?;
    let actual_primitives = primitives(
        actual,
        coordinate_dimensions,
        actual_index_dimensions,
        spec.coordinate_key,
    )?;
    if expected_primitives.len() != actual_primitives.len() {
        return Err(format!("{} primitive counts differ", spec.label));
    }
    compare_primitives(
        expected_primitives,
        actual_primitives,
        coordinate_dimensions,
        graphic_type,
        spec,
    )
}

fn compare_primitives(
    expected: Vec<Vec<Vec<f64>>>,
    actual: Vec<Vec<Vec<f64>>>,
    dimensions: usize,
    graphic_type: &str,
    spec: CoordinateSpec,
) -> Result<PrimitiveErrors, String> {
    let mut distances = Vec::new();
    let mut z = Vec::new();
    for (mut expected_points, actual_points) in expected.into_iter().zip(actual) {
        if expected_points.len() != actual_points.len() {
            return Err(format!("{} primitive point counts differ", spec.label));
        }
        if graphic_type == "ELLIPSE" && expected_points.len() == 4 {
            expected_points = canonical_ellipse(&expected_points);
        }
        let actual_points = align_points(&expected_points, &actual_points, graphic_type);
        for (left, right) in expected_points.iter().zip(&actual_points) {
            if spec.xy_with_separate_z {
                distances.push(distance(&left[..2], &right[..2]));
                if dimensions == 3 {
                    z.push((left[2] - right[2]).abs());
                }
            } else {
                distances.push(distance(left, right));
            }
        }
    }
    Ok(PrimitiveErrors { distances, z })
}

fn primitives(
    geometry: &Map<String, Value>,
    dimensions: usize,
    index_dimensions: usize,
    coordinate_key: &str,
) -> Result<Vec<Vec<Vec<f64>>>, String> {
    let raw = geometry
        .get(coordinate_key)
        .and_then(Value::as_array)
        .ok_or_else(|| format!("{coordinate_key} must be an array"))?;
    let coordinates: Vec<_> = raw
        .iter()
        .map(|value| {
            value
                .as_f64()
                .filter(|value| value.is_finite())
                .ok_or_else(|| format!("{coordinate_key} must be finite numeric data"))
        })
        .collect::<Result<_, _>>()?;
    if coordinates.len() % dimensions != 0 {
        return Err(format!(
            "{coordinate_key} count is not divisible by its dimensions"
        ));
    }
    let points: Vec<_> = coordinates
        .chunks_exact(dimensions)
        .map(<[f64]>::to_vec)
        .collect();
    let raw_starts = geometry
        .get("primitive_point_indices")
        .and_then(Value::as_array);
    let scalar_offsets = match raw_starts {
        None if points.is_empty() => Vec::new(),
        Some(raw_starts) if raw_starts.is_empty() && points.is_empty() => Vec::new(),
        None => vec![0],
        Some(raw_starts) if raw_starts.is_empty() => vec![0],
        Some(raw_starts) => raw_starts
            .iter()
            .map(|value| {
                value
                    .as_u64()
                    .and_then(|value| value.checked_sub(1))
                    .and_then(|value| usize::try_from(value).ok())
                    .ok_or_else(|| "primitive point indices must be positive integers".to_owned())
            })
            .collect::<Result<Vec<_>, _>>()?,
    };
    if scalar_offsets
        .iter()
        .any(|offset| offset % index_dimensions != 0)
    {
        return Err("primitive point index is not aligned to a coordinate tuple".to_owned());
    }
    let starts: Vec<_> = scalar_offsets
        .iter()
        .map(|offset| offset / index_dimensions)
        .collect();
    if !points.is_empty() && starts.first() != Some(&0) {
        return Err("primitive point indices must start at 1".to_owned());
    }
    if starts.iter().any(|start| *start >= points.len()) {
        return Err("primitive point index is outside the coordinate payload".to_owned());
    }
    if starts.windows(2).any(|window| window[0] >= window[1]) {
        return Err("primitive point indices must be strictly increasing".to_owned());
    }
    Ok(starts
        .iter()
        .enumerate()
        .map(|(index, start)| {
            let end = starts.get(index + 1).copied().unwrap_or(points.len());
            points[*start..end].to_vec()
        })
        .collect())
}

fn align_points(expected: &[Vec<f64>], actual: &[Vec<f64>], graphic_type: &str) -> Vec<Vec<f64>> {
    if expected.is_empty() {
        return actual.to_vec();
    }
    if matches!(graphic_type, "POLYGON" | "RECTANGLE") {
        let mut best = actual.to_vec();
        let mut best_error = squared_error(expected, &best);
        for reversed in [false, true] {
            let mut points = actual.to_vec();
            if reversed {
                points.reverse();
            }
            for offset in 0..points.len() {
                let mut candidate = points[offset..].to_vec();
                candidate.extend_from_slice(&points[..offset]);
                let error = squared_error(expected, &candidate);
                if error < best_error {
                    best = candidate;
                    best_error = error;
                }
            }
        }
        best
    } else if graphic_type == "ELLIPSE" && expected.len() == 4 && actual.len() == 4 {
        canonical_ellipse(actual)
    } else {
        actual.to_vec()
    }
}

fn canonical_ellipse(points: &[Vec<f64>]) -> Vec<Vec<f64>> {
    let mut pairs = [
        sorted_pair(&points[0], &points[1]),
        sorted_pair(&points[2], &points[3]),
    ];
    pairs.sort_by(|left, right| {
        distance(&right[0][..2], &right[1][..2])
            .total_cmp(&distance(&left[0][..2], &left[1][..2]))
            .then_with(|| compare_points(&left[0], &right[0]))
            .then_with(|| compare_points(&left[1], &right[1]))
    });
    pairs.into_iter().flatten().collect()
}

fn sorted_pair(left: &[f64], right: &[f64]) -> [Vec<f64>; 2] {
    if compare_points(left, right) == Ordering::Greater {
        [right.to_vec(), left.to_vec()]
    } else {
        [left.to_vec(), right.to_vec()]
    }
}

fn compare_points(left: &[f64], right: &[f64]) -> Ordering {
    left.iter()
        .zip(right)
        .map(|(left, right)| left.total_cmp(right))
        .find(|ordering| *ordering != Ordering::Equal)
        .unwrap_or_else(|| left.len().cmp(&right.len()))
}

fn squared_error(expected: &[Vec<f64>], actual: &[Vec<f64>]) -> f64 {
    expected
        .iter()
        .zip(actual)
        .flat_map(|(left, right)| left.iter().zip(right))
        .map(|(left, right)| (left - right).powi(2))
        .sum()
}

fn distance(left: &[f64], right: &[f64]) -> f64 {
    left.iter()
        .zip(right)
        .map(|(left, right)| (left - right).powi(2))
        .sum::<f64>()
        .sqrt()
}

fn dimensions(geometry: &Map<String, Value>, key: &str) -> Result<usize, String> {
    geometry
        .get(key)
        .ok_or_else(|| format!("{key} is missing"))
        .and_then(json_dimensions)
}

fn json_dimensions(value: &Value) -> Result<usize, String> {
    match value.as_u64() {
        Some(2) => Ok(2),
        Some(3) => Ok(3),
        _ => Err("coordinate dimensions must be 2 or 3".to_owned()),
    }
}