use crate::engine::stats::compute_stats;
use crate::engine::endian::FileEndian;
use crate::{Error, HeaderValidationError, Mode, Reader};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct ValidationIssue {
pub severity: Severity,
pub category: &'static str,
pub message: String,
}
impl ValidationIssue {
fn error(category: &'static str, message: String) -> Self {
Self { severity: Severity::Error, category, message }
}
fn warning(category: &'static str, message: String) -> Self {
Self { severity: Severity::Warning, category, message }
}
fn info(category: &'static str, message: String) -> Self {
Self { severity: Severity::Info, category, message }
}
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub path: String,
pub compression: String,
pub nx: i32,
pub ny: i32,
pub nz: i32,
pub mode: i32,
pub issues: Vec<ValidationIssue>,
}
impl ValidationReport {
pub fn is_valid(&self) -> bool {
!self.issues.iter().any(|i| i.severity == Severity::Error)
}
pub fn by_severity(&self, severity: Severity) -> impl Iterator<Item = &ValidationIssue> {
self.issues.iter().filter(move |i| i.severity == severity)
}
}
pub fn validate_reader(
reader: &Reader,
path: &str,
compression: &str,
warnings: &[String],
) -> Result<ValidationReport, Error> {
let mut issues: Vec<ValidationIssue> = Vec::new();
let header = reader.header();
let mode_val = header.mode;
let endian = reader.endian();
for w in warnings {
issues.push(ValidationIssue::warning("Open", w.clone()));
}
match header.validate_detailed() {
Ok(()) => {
issues.push(ValidationIssue::info("Header", "Structure is valid".into()));
}
Err(e) => {
let desc = match &e {
HeaderValidationError::InvalidDimensions { nx, ny, nz } =>
format!("Dimensions ({nx}×{ny}×{nz}) must all be positive"),
HeaderValidationError::UnsupportedMode(m) =>
format!("Unsupported mode value: {m}"),
HeaderValidationError::InvalidMap(m) =>
format!("MAP field is {:?}, expected b\"MAP \"", std::str::from_utf8(m).unwrap_or("?")),
HeaderValidationError::InvalidIspg(s) =>
format!("ISPG {s} is outside valid ranges (0, 1–230, 400–630)"),
HeaderValidationError::InvalidAxisMapping { mapc, mapr, maps } =>
format!("Axis mapping ({mapc}, {mapr}, {maps}) is not a permutation of 1,2,3"),
HeaderValidationError::InvalidNsymbt(s) =>
format!("NSYMBT is negative ({s})"),
HeaderValidationError::InvalidNlabl(n) =>
format!("NLABL is {n}, must be 0–10"),
HeaderValidationError::InvalidNversion(n) =>
format!("NVERSION is {n}, expected 20140 or 20141"),
HeaderValidationError::InvalidSampling { mx, my, mz } =>
format!("Sampling ({mx}×{my}×{mz}) must all be positive"),
HeaderValidationError::InvalidVolumeStack { nz, mz, ispg } =>
format!("Volume stack: nz={nz} not divisible by mz={mz} for ispg={ispg}"),
HeaderValidationError::LabelCountMismatch { nlabl, actual } =>
format!("nlabl={nlabl} but {actual} non-empty labels found"),
HeaderValidationError::EmptyLabelBeforeFilled { index } =>
format!("Empty label at index {index} before all filled labels"),
};
issues.push(ValidationIssue::error("Header", desc));
}
}
if let Some(data_size) = header.data_size() {
let expected_total = 1024 + header.nsymbt.max(0) as usize + data_size;
issues.push(ValidationIssue::info("File size",
format!("Expected {} bytes (header + ext + data)", expected_total)));
}
let machst_info = FileEndian::from_machst_with_info(&header.machst);
if !machst_info.is_standard {
issues.push(ValidationIssue::warning("Endianness",
format!("Non-standard MACHST stamp: {}", machst_info.description)));
}
let host = FileEndian::native();
if endian != host {
issues.push(ValidationIssue::info("Endianness",
format!("Non-native byte order ({:?}), host is {:?}", endian, host)));
} else {
issues.push(ValidationIssue::info("Endianness",
"Native byte order, fast-path available".into()));
}
let data_bytes = reader.data_bytes();
match compute_stats(data_bytes, reader.mode(), endian) {
Ok((actual_dmin, actual_dmax, actual_dmean, actual_rms)) => {
let complex = matches!(reader.mode(),
Mode::Float32Complex | Mode::Int16Complex);
let stats_unset = header.dmin > header.dmax;
let rms_unset = header.rms < 0.0;
let rtol = 0.01f32;
let min_ok = complex || stats_unset
|| crate::engine::stats::is_close(header.dmin, actual_dmin, rtol);
let max_ok = complex || stats_unset
|| crate::engine::stats::is_close(header.dmax, actual_dmax, rtol);
let mean_ok = complex || stats_unset
|| crate::engine::stats::is_close(header.dmean, actual_dmean, rtol);
let rms_ok = rms_unset
|| crate::engine::stats::is_close(header.rms, actual_rms, rtol);
if !stats_unset || !rms_unset {
let mut mismatch_parts = Vec::new();
if !min_ok { mismatch_parts.push("dmin".to_string()); }
if !max_ok { mismatch_parts.push("dmax".to_string()); }
if !mean_ok { mismatch_parts.push("dmean".to_string()); }
if !rms_ok { mismatch_parts.push("rms".to_string()); }
if mismatch_parts.is_empty() {
if stats_unset {
issues.push(ValidationIssue::info("Statistics",
"Statistics not written in header (sentinel values)".into()));
} else {
issues.push(ValidationIssue::info("Statistics",
"All statistics match actual data (within 1 % tolerance)".into()));
}
} else {
let mut detail = String::new();
if !min_ok {
detail.push_str(&format!(
" dmin claimed={} actual={}", header.dmin, actual_dmin));
}
if !max_ok {
detail.push_str(&format!(
" dmax claimed={} actual={}", header.dmax, actual_dmax));
}
if !mean_ok {
detail.push_str(&format!(
" dmean claimed={} actual={}", header.dmean, actual_dmean));
}
if !rms_ok {
detail.push_str(&format!(
" rms claimed={} actual={}", header.rms, actual_rms));
}
issues.push(ValidationIssue::error("Statistics",
format!("Mismatch:{}", detail)));
}
}
}
Err(e) => {
issues.push(ValidationIssue::error("Statistics",
format!("Cannot compute statistics: {e}")));
}
}
if reader.mode().is_float() && !reader.mode().is_complex() {
match float_mode_issues(data_bytes, reader.mode(), endian) {
Ok(has_issues) => {
if has_issues.is_empty() {
issues.push(ValidationIssue::info("Data integrity",
"All voxel values are finite numbers".into()));
} else {
for issue in has_issues {
issues.push(ValidationIssue::warning("Data integrity", issue));
}
}
}
Err(e) => {
issues.push(ValidationIssue::warning("Data integrity",
format!("Could not scan data: {e}")));
}
}
}
let vol_type = if header.is_single_image() {
"single 2D image"
} else if header.is_image_stack() {
"image stack"
} else if header.is_volume_stack() {
let nvol = if header.mz > 0 { header.nz / header.mz } else { 0 };
issues.push(ValidationIssue::info("Volume",
format!("Volume stack: {nvol} sub-volumes × {} slices", header.mz)));
"volume stack"
} else {
"3D volume"
};
issues.push(ValidationIssue::info("Volume",
format!("{} × {} × {} voxels, {}",
header.nx, header.ny, header.nz, vol_type)));
Ok(ValidationReport {
path: path.to_owned(),
compression: compression.to_owned(),
nx: header.nx,
ny: header.ny,
nz: header.nz,
mode: mode_val,
issues,
})
}
pub fn validate_full<P: AsRef<Path>>(
path: P,
permissive: bool,
) -> Result<ValidationReport, Error> {
let path_str = path.as_ref().to_string_lossy().into_owned();
let compression = match crate::io::reader::detect_compression(&path)? {
crate::io::reader::CompressionType::Plain => "plain".to_string(),
#[cfg(feature = "gzip")]
crate::io::reader::CompressionType::Gzip => "gzip".to_string(),
#[cfg(feature = "bzip2")]
crate::io::reader::CompressionType::Bzip2 => "bzip2".to_string(),
};
let (reader, warnings) = if permissive {
Reader::open_permissive(&path)?
} else {
(Reader::open(&path)?, Vec::new())
};
validate_reader(&reader, &path_str, &compression, &warnings)
}
fn float_mode_issues(
data_bytes: &[u8],
mode: Mode,
endian: FileEndian,
) -> Result<Vec<String>, Error> {
use crate::engine::codec::decode_slice;
let data: Vec<f32> = match mode {
Mode::Float32 => decode_slice::<f32>(data_bytes, endian)?,
Mode::Float16 => {
#[cfg(feature = "f16")]
{
let f16_data = decode_slice::<crate::f16>(data_bytes, endian)?;
f16_data.iter().map(|&v| f32::from(v)).collect()
}
#[cfg(not(feature = "f16"))]
return Ok(vec!["Float16 scanning unavailable (requires `f16` feature)".into()]);
}
_ => return Ok(Vec::new()),
};
let mut issues = Vec::new();
let mut nan_count = 0usize;
let mut inf_count = 0usize;
let mut neg_inf_count = 0usize;
for &v in &data {
if v.is_nan() {
nan_count += 1;
} else if v.is_infinite() {
if v.is_sign_negative() {
neg_inf_count += 1;
} else {
inf_count += 1;
}
}
}
if nan_count > 0 {
issues.push(format!("{nan_count} NaN values found ({:.2}%)",
nan_count as f64 / data.len() as f64 * 100.0));
}
if inf_count > 0 {
issues.push(format!("{inf_count} +Inf values found ({:.2}%)",
inf_count as f64 / data.len() as f64 * 100.0));
}
if neg_inf_count > 0 {
issues.push(format!("{neg_inf_count} -Inf values found ({:.2}%)",
neg_inf_count as f64 / data.len() as f64 * 100.0));
}
Ok(issues)
}