medh5 2.0.1

The MEDH5 format engine: a self-describing HDF5 container for one medical imaging subject and all of its ground truth
Documentation
//! §4 --- images and multiscale pyramids (E2xx).

use ndarray::Array2;

use super::{child, grid_shape, loc, str_attr, strs_attr, Context};
use crate::geometry::grid::read_grid;
use crate::geometry::multiscale::{check_pyramid, GEOMETRY_RTOL};
use crate::h5::attrs;
use crate::h5::data::{self, Kind};
use crate::h5::ops::{self, Node};
use crate::json::{repr_int_tuple, repr_list, repr_str};
use crate::sample::image::VALUE_TYPES;
use crate::validate::Diagnostic;
use crate::Result;

/// Structural: every image against §4.1--§4.2 --- its attributes, its grid
/// and shape, channel names (E101, E105, E201--E205) --- and float storage an
/// `int16` would hold losslessly (W907).
pub fn check_images(ctx: &mut Context) -> Result<Vec<Diagnostic>> {
    let mut out = Vec::new();
    let Some(node) = ops::child_group(&ctx.root, "images") else { return Ok(out) };
    let names = ops::members(&node)?;
    if names.is_empty() {
        out.push(ctx.err("E201", "/images", "a sample must contain at least one image"));
        return Ok(out);
    }
    let grids = ops::child_group(&ctx.root, "grids");
    for name in names {
        let Some(image) = child(&node, &name) else { continue };
        let a = loc(&image);
        let location = format!("/images/{name}");
        for key in ["grid", "modality", "value_type"] {
            if !attrs::has(a, key) {
                out.push(ctx.err("E205", location.clone(), format!("missing required attribute {}", repr_str(key))));
            }
        }
        if let Some(vt) = str_attr(a, "value_type")? {
            if !VALUE_TYPES.contains(&vt.as_str()) {
                out.push(ctx.unknown("E203", location.clone(), format!("unknown value_type {}", repr_str(&vt))));
            }
        }
        let Some(grid_id) = str_attr(a, "grid")? else { continue };
        let Some(grids) = grids.as_ref().filter(|g| ops::is_group(g, &grid_id)) else {
            out.push(ctx.err("E101", location, format!("names grid {}, which does not exist", repr_str(&grid_id))));
            continue;
        };
        let gshape = grid_shape(grids, &grid_id)?;
        let dataset = match &image {
            Node::Group(g) => match ops::child_dataset(g, "0") {
                Some(d) => d,
                None => {
                    out.push(ctx.err("E105", location, "multiscale image has no level-0 dataset `0` (§4.3)"));
                    continue;
                }
            },
            Node::Dataset(d) => d.clone(),
        };
        let shape: Vec<i64> = dataset.shape().iter().map(|v| *v as i64).collect();
        if shape != gshape {
            out.push(ctx.err(
                "E202",
                location.clone(),
                format!(
                    "shape {} != grid {} shape {}",
                    repr_int_tuple(&shape),
                    repr_str(&grid_id),
                    repr_int_tuple(&gshape)
                ),
            ));
        }
        if attrs::has(a, "channel_names") {
            let gg = grids.group(&grid_id)?;
            let kinds = strs_attr(&gg, "axis_kinds")?;
            match kinds.iter().position(|k| k == "channel") {
                None => out.push(ctx.err("E204", location.clone(), "`channel_names` on a grid with no channel axis")),
                Some(axis) => {
                    let extent = gshape.get(axis).copied().unwrap_or(0);
                    let n = strs_attr(a, "channel_names")?.len() as i64;
                    if n != extent {
                        out.push(ctx.err(
                            "E204",
                            location.clone(),
                            format!("`channel_names` has {n} entries for a channel axis of extent {extent}"),
                        ));
                    }
                }
            }
        }
        if !ctx.errors_only {
            if let Ok(Kind::Numeric(d)) = data::kind(&dataset) {
                if d.is_float() && int16_lossless(&dataset)? {
                    out.push(ctx.err(
                        "W907",
                        location,
                        format!(
                            "stored as {} but every value is an integer within int16 range; int16 + rescale is lossless and ~3x smaller",
                            d.name()
                        ),
                    ));
                }
            }
        }
    }
    Ok(out)
}

fn int16_lossless(ds: &hdf5::Dataset) -> Result<bool> {
    let size: usize = ds.shape().iter().product();
    if size == 0 || size > 4_000_000 {
        return Ok(false);
    }
    Ok(crate::sample::image::lossless_as_int16(&data::read(ds)?))
}

/// Semantic: every multiscale image against §4.3.
pub fn check_multiscale(ctx: &mut Context) -> Result<Vec<Diagnostic>> {
    let mut out = Vec::new();
    let (Some(node), Some(grids)) = (ops::child_group(&ctx.root, "images"), ops::child_group(&ctx.root, "grids"))
    else {
        return Ok(out);
    };
    for name in ops::members(&node)? {
        let Some(image) = ops::child_group(&node, &name) else { continue };
        let location = format!("/images/{name}");
        match check_levels(ctx, &image, &grids, &location) {
            Ok(found) => out.extend(found),
            Err(e) => out.push(ctx.failed(location, &e)),
        }
    }
    Ok(out)
}

/// One multiscale image (§4.3): its declaration, a dataset of its level's
/// grid's shape for every level, and every level's geometry derived from
/// level 0's.
fn check_levels(ctx: &Context, image: &hdf5::Group, grids: &hdf5::Group, location: &str) -> Result<Vec<Diagnostic>> {
    let mut out = Vec::new();
    let missing: Vec<&str> =
        ["levels", "grid_levels", "downsample_factors"].into_iter().filter(|k| !attrs::has(image, k)).collect();
    if !missing.is_empty() {
        out.push(ctx.err(
            "E105",
            location,
            format!(
                "multiscale image needs `levels`, `grid_levels` and `downsample_factors`; missing {}",
                repr_list(&missing)
            ),
        ));
        return Ok(out);
    }
    let level_ids = strs_attr(image, "grid_levels")?;
    if level_ids.is_empty() {
        out.push(ctx.err("E105", location, "`grid_levels` names no level, not even level 0"));
        return Ok(out);
    }
    let n = level_ids.len();
    match attrs::get_i64(image, "levels")? {
        Some(levels) if levels == n as i64 => {}
        Some(levels) => out.push(ctx.err(
            "E105",
            location,
            format!("`levels` is {levels}, but `grid_levels` names {n} level grids"),
        )),
        None => out.push(ctx.err("E105", location, "`levels` is not an integer")),
    }
    let absent: Vec<&String> = level_ids.iter().filter(|g| !ops::is_group(grids, g)).collect();
    if !absent.is_empty() {
        out.push(ctx.err("E101", location, format!("grid_levels reference missing grids {}", repr_list(&absent))));
        return Ok(out);
    }
    // Level 0 against the image's own `grid` is the image rule's.
    let image_grid = str_attr(image, "grid")?;
    for (i, grid_id) in level_ids.iter().enumerate() {
        let Some(dataset) = ops::child_dataset(image, &i.to_string()) else {
            if i > 0 {
                out.push(ctx.err("E105", location, format!("level {i} has no dataset `{i}`")));
            }
            continue;
        };
        if i == 0 && image_grid.as_deref() == Some(grid_id.as_str()) {
            continue;
        }
        let shape: Vec<i64> = dataset.shape().iter().map(|v| *v as i64).collect();
        let gshape = grid_shape(grids, grid_id)?;
        if shape != gshape {
            out.push(ctx.err(
                "E202",
                location,
                format!(
                    "level {i} has shape {} != grid {} shape {}",
                    repr_int_tuple(&shape),
                    repr_str(grid_id),
                    repr_int_tuple(&gshape)
                ),
            ));
        }
    }
    // A level grid a reader refuses is the grid rule's finding, under its own
    // code; the pyramid's geometry is checked between readable grids only.
    let mut levels = Vec::with_capacity(n);
    for id in &level_ids {
        match read_grid(&grids.group(id)?, Some(id)) {
            Ok(grid) => levels.push(grid),
            Err(_) => return Ok(out),
        }
    }
    let spatial = levels[0].n_spatial();
    let factors = match attrs::read(image, "downsample_factors")?.and_then(|v| v.as_matrix()) {
        Some((r, c, v)) if (r, c) == (n, spatial) => Array2::from_shape_vec((r, c), v)?,
        Some((r, c, _)) => {
            out.push(ctx.err(
                "E105",
                location,
                format!(
                    "`downsample_factors` is ({r}, {c}); {n} levels of {spatial} spatial axes need ({n}, {spatial})"
                ),
            ));
            return Ok(out);
        }
        None => {
            out.push(ctx.err("E105", location, "`downsample_factors` must be a 2-D array, one row per level"));
            return Ok(out);
        }
    };
    if factors.iter().any(|f| !(f.is_finite() && *f > 0.0)) {
        out.push(ctx.err("E105", location, "downsample factors must be finite and > 0"));
        return Ok(out);
    }
    let refs: Vec<&crate::geometry::grid::Grid> = levels.iter().collect();
    match check_pyramid(&levels[0], &refs, &factors, GEOMETRY_RTOL) {
        Ok(problems) => out.extend(problems.into_iter().map(|p| ctx.err("E105", location, p))),
        Err(e) => out.push(ctx.failed(location, &e)),
    }
    Ok(out)
}