use std::path::Path;
use hdf5::types::VarLenAscii;
use ndarray::Array1;
use crate::h5::file::{open_read, AtomicFile};
use crate::h5::{data, ops};
use crate::json::repr_str;
use crate::{Error, Result};
const GROUP: &str = "TransformGroup";
const TYPE: &str = "TransformType";
const PARAMETERS: &str = "TransformParameters";
const FIXED: &str = "TransformFixedParameters";
const LEGACY_PARAMETERS: &str = "TranformParameters";
const LEGACY_FIXED: &str = "TranformFixedParameters";
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct ItkEntry {
pub name: String,
pub parameters: Vec<f64>,
pub fixed_parameters: Vec<f64>,
}
impl ItkEntry {
pub fn new(name: impl Into<String>, parameters: Vec<f64>, fixed_parameters: Vec<f64>) -> ItkEntry {
ItkEntry { name: name.into(), parameters, fixed_parameters }
}
fn is_composite(&self) -> bool {
self.name.starts_with("CompositeTransform")
}
}
pub fn read_itk_h5(path: &Path) -> Result<Vec<ItkEntry>> {
let shown = repr_str(&path.to_string_lossy());
let refused = |why: String| Error::invalid(format!("{shown} is not an ITK transform file: {why}"));
let file = open_read(path)?;
let group = file.group(GROUP).map_err(|_| refused(format!("it has no /{GROUP}")))?;
let mut numbered = Vec::new();
for name in group.member_names()? {
match name.parse::<usize>() {
Ok(n) if n.to_string() == name => numbered.push(n),
_ => return Err(refused(format!("/{GROUP} holds {}, which is not a transform's number", repr_str(&name)))),
}
}
numbered.sort_unstable();
if numbered.is_empty() {
return Err(refused(format!("/{GROUP} holds no transform")));
}
if numbered.iter().enumerate().any(|(i, n)| i != *n) {
return Err(refused(format!(
"/{GROUP}'s transforms are numbered {numbered:?}, where ITK numbers them from 0 with none left out"
)));
}
let mut entries = Vec::with_capacity(numbered.len());
for n in numbered {
let at = format!("/{GROUP}/{n}");
let member = group.group(&n.to_string()).map_err(|_| refused(format!("{at} is not a group")))?;
let kind = member.dataset(TYPE).map_err(|_| refused(format!("{at} has no {TYPE}")))?;
if !data::is_strings(&kind) {
return Err(refused(format!("{at}/{TYPE} is not a string")));
}
let mut names = data::read_strings(&kind)?;
if names.len() != 1 {
return Err(refused(format!("{at}/{TYPE} holds {} strings, not one", names.len())));
}
let numbers = |dataset: &str, legacy: &str| -> Result<Option<Vec<f64>>> {
let (dataset, ds) = match member.dataset(dataset) {
Ok(ds) => (dataset, ds),
Err(_) => match member.dataset(legacy) {
Ok(ds) => (legacy, ds),
Err(_) => return Ok(None),
},
};
if data::is_strings(&ds) || ds.ndim() > 1 {
return Err(refused(format!("{at}/{dataset} is not a one-dimensional array of numbers")));
}
Ok(Some(data::read(&ds)?.to_f64().iter().copied().collect()))
};
let entry =
ItkEntry::new(names.swap_remove(0).trim(), numbers(PARAMETERS, LEGACY_PARAMETERS)?.unwrap_or_default(), {
numbers(FIXED, LEGACY_FIXED)?.unwrap_or_default()
});
if entry.is_composite() && !(entry.parameters.is_empty() && entry.fixed_parameters.is_empty()) {
return Err(refused(format!("{at} is a composite with parameters of its own, which ITK does not write")));
}
entries.push(entry);
}
Ok(entries)
}
pub fn write_itk_h5(path: &Path, entries: &[ItkEntry]) -> Result<()> {
if entries.is_empty() {
return Err(Error::invalid("an ITK transform file holds at least one transform"));
}
if let Some(bad) = entries.iter().find(|e| !e.name.is_ascii() || e.name.is_empty()) {
return Err(Error::invalid(format!("{} is not an ITK transform type", repr_str(&bad.name))));
}
let staged = AtomicFile::create(path)?;
let group = ops::new_group(staged.handle(), GROUP)?;
for (n, entry) in entries.iter().enumerate() {
let member = ops::new_group(&group, &n.to_string())?;
let name = VarLenAscii::from_ascii(&entry.name)
.map_err(|e| Error::invalid(format!("{}: {e}", repr_str(&entry.name))))?;
member.new_dataset_builder().obj_track_times(false).with_data(&[name]).create(TYPE)?;
if entry.is_composite() {
continue;
}
for (dataset, values) in [(FIXED, &entry.fixed_parameters), (PARAMETERS, &entry.parameters)] {
let array = Array1::from(values.clone());
member.new_dataset_builder().obj_track_times(false).with_data(array.view()).create(dataset)?;
}
}
staged.commit()
}
#[cfg(test)]
mod tests {
use super::*;
fn affine() -> ItkEntry {
ItkEntry::new(
"AffineTransform_double_3_3",
vec![1.0, 0.1, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.2, 1.0, 2.0, 3.0],
vec![5.0, 6.0, 7.0],
)
}
#[test]
fn itk_h5_entries_round_trip_with_a_composite_bare() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.h5");
let composite = ItkEntry::new("CompositeTransform_double_3_3", vec![], vec![]);
let entries = vec![composite, affine(), ItkEntry::new("TranslationTransform_double_3_3", vec![1.0; 3], vec![])];
write_itk_h5(&path, &entries).unwrap();
assert_eq!(read_itk_h5(&path).unwrap(), entries);
let file = hdf5::File::open(&path).unwrap();
let bare = file.group("TransformGroup/0").unwrap();
assert_eq!(bare.member_names().unwrap(), ["TransformType"]);
let kind = bare.dataset("TransformType").unwrap();
assert_eq!(kind.shape(), [1]);
assert!(matches!(kind.dtype().unwrap().to_descriptor().unwrap(), hdf5::types::TypeDescriptor::VarLenAscii));
}
#[test]
fn itk_h5_the_legacy_misspelled_parameters_are_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.h5");
{
let file = hdf5::File::create(&path).unwrap();
let zero = file.create_group("TransformGroup").unwrap().create_group("0").unwrap();
data::create_strings(&zero, "TransformType", &["AffineTransform_double_3_3".into()]).unwrap();
let entry = affine();
zero.new_dataset_builder()
.with_data(Array1::from(entry.parameters).view())
.create("TranformParameters")
.unwrap();
let fixed = Array1::from(entry.fixed_parameters);
zero.new_dataset_builder().with_data(fixed.view()).create("TranformFixedParameters").unwrap();
}
assert_eq!(read_itk_h5(&path).unwrap(), [affine()]);
}
#[test]
fn itk_h5_a_file_numbered_with_a_gap_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.h5");
{
let file = hdf5::File::create(&path).unwrap();
let one = file.create_group("TransformGroup").unwrap().create_group("1").unwrap();
data::create_strings(&one, "TransformType", &["IdentityTransform_double_3_3".into()]).unwrap();
}
let err = read_itk_h5(&path).unwrap_err().to_string();
assert!(err.contains("numbered [1]"), "{err}");
let other = dir.path().join("o.h5");
hdf5::File::create(&other).unwrap().create_group("images").unwrap();
let err = read_itk_h5(&other).unwrap_err().to_string();
assert!(err.contains("has no /TransformGroup"), "{err}");
}
}