Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -0,0 +1,541 @@
//! DICOM file loading and data extraction
//!
//! This module provides functionality for loading DICOM files and extracting their data.
//! It includes support for single files, series, and directories.
use super::parser::{DicomElement, DicomParser, DicomTag};
use crate::error::{MedicalError, MedicalResult};
use std::collections::HashMap;
use std::path::Path;
/// DICOM data structure containing parsed elements
#[derive(Debug)]
pub struct DicomData {
/// File path of the DICOM file
pub file_path: String,
/// Parsed DICOM elements indexed by tag
pub elements: HashMap<DicomTag, DicomElement>,
}
impl DicomData {
/// Get string value by tag
pub fn get_string(&self, group: u16, element: u16) -> Option<String> {
let tag = DicomTag { group, element };
match self.elements.get(&tag) {
Some(DicomElement::String(s)) => Some(s.clone()),
_ => None,
}
}
/// Get uint16 value by tag
pub fn get_uint16(&self, group: u16, element: u16) -> Option<u16> {
let tag = DicomTag { group, element };
match self.elements.get(&tag) {
Some(DicomElement::UInt16(v)) => Some(*v),
_ => None,
}
}
/// Get uint32 value by tag
pub fn get_uint32(&self, group: u16, element: u16) -> Option<u32> {
let tag = DicomTag { group, element };
match self.elements.get(&tag) {
Some(DicomElement::UInt32(v)) => Some(*v),
_ => None,
}
}
/// Get float64 value by tag
pub fn get_float64(&self, group: u16, element: u16) -> Option<f64> {
let tag = DicomTag { group, element };
match self.elements.get(&tag) {
Some(DicomElement::Float64(v)) => Some(*v),
_ => None,
}
}
/// Get bytes by tag
pub fn get_bytes(&self, group: u16, element: u16) -> Option<&[u8]> {
let tag = DicomTag { group, element };
match self.elements.get(&tag) {
Some(DicomElement::Bytes(bytes)) => Some(bytes),
_ => None,
}
}
}
/// DICOM loader for medical images
pub struct DicomLoader;
impl DicomLoader {
/// Load a single DICOM file
pub fn load_single(dicom_path: &str) -> MedicalResult<DicomData> {
if !Path::new(dicom_path).exists() {
return Err(MedicalError::Dicom(format!(
"DICOM file not found: {}",
dicom_path
)));
}
// Parse DICOM file
let dicom_data = Self::parse_dicom_file(dicom_path)?;
Ok(dicom_data)
}
/// Load a DICOM series (multiple related slices)
pub fn load_series(dicom_paths: &[String]) -> MedicalResult<Vec<DicomData>> {
if dicom_paths.is_empty() {
return Err(MedicalError::Dicom("No DICOM files provided".to_string()));
}
// Load and parse all DICOM files
let mut dicom_files = Vec::new();
for path in dicom_paths {
let dicom_data = Self::parse_dicom_file(path)?;
dicom_files.push(dicom_data);
}
// Sort by slice location or instance number
Self::sort_dicom_series(&mut dicom_files)?;
Ok(dicom_files)
}
/// Load DICOM directory (automatically find and group series)
pub fn load_directory(dicom_dir: &str) -> MedicalResult<HashMap<String, Vec<DicomData>>> {
let dicom_files = Self::find_dicom_files(dicom_dir)?;
let series_groups = Self::group_by_series(&dicom_files)?;
let mut series_map = HashMap::new();
for (series_uid, file_paths) in series_groups {
match Self::load_series(&file_paths) {
Ok(series_data) => {
series_map.insert(series_uid, series_data);
}
Err(_) => { /* Skip failed series */ }
}
}
Ok(series_map)
}
/// Find all DICOM files in a directory
fn find_dicom_files(dir_path: &str) -> MedicalResult<Vec<String>> {
let mut dicom_files = Vec::new();
let entries = std::fs::read_dir(dir_path).map_err(|e| MedicalError::Io(e))?;
for entry in entries {
let entry = entry.map_err(|e| MedicalError::Io(e))?;
let path = entry.path();
if path.is_file() {
if let Some(path_str) = path.to_str() {
// Check if file is DICOM (basic heuristic)
if Self::is_dicom_file(path_str)? {
dicom_files.push(path_str.to_string());
}
}
}
}
Ok(dicom_files)
}
/// Check if file is a DICOM file
fn is_dicom_file(file_path: &str) -> MedicalResult<bool> {
let file = std::fs::File::open(file_path).map_err(|e| MedicalError::Io(e))?;
let mut buffer = [0u8; 132];
use std::io::Read;
let mut file = file;
// Read first 132 bytes
match file.read(&mut buffer) {
Ok(bytes_read) if bytes_read >= 132 => {
// Check for DICOM prefix at offset 128
let dicm = &buffer[128..132];
Ok(dicm == b"DICM")
}
_ => {
// If can't read header, try alternative methods
// Check file extension or other heuristics
let path = Path::new(file_path);
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
Ok(matches!(ext_str.as_str(), "dcm" | "dicom" | "ima" | ""))
} else {
// No extension, assume it might be DICOM
Ok(true)
}
}
}
}
/// Parse DICOM file and extract data elements
fn parse_dicom_file(file_path: &str) -> MedicalResult<DicomData> {
// This is a simplified DICOM parser
// In production, you'd use a proper DICOM library like dicom-rs
let file_data = std::fs::read(file_path).map_err(|e| MedicalError::Io(e))?;
// Skip DICOM preamble (128 bytes) and DICM prefix (4 bytes)
if file_data.len() < 132 || &file_data[128..132] != b"DICM" {
return Err(MedicalError::Dicom("Invalid DICOM file format".to_string()));
}
let data_start = 132;
let mut parser = DicomParser::new(&file_data[data_start..]);
let elements = parser.parse_elements()?;
Ok(DicomData {
file_path: file_path.to_string(),
elements,
})
}
/// Extract pixel data dimensions from DICOM
pub fn get_pixel_dimensions(dicom_data: &DicomData) -> MedicalResult<(usize, usize)> {
let rows = dicom_data
.get_uint16(0x0028, 0x0010)
.ok_or_else(|| MedicalError::Dicom("Missing Rows (0028,0010)".to_string()))?
as usize;
let columns = dicom_data
.get_uint16(0x0028, 0x0011)
.ok_or_else(|| MedicalError::Dicom("Missing Columns (0028,0011)".to_string()))?
as usize;
Ok((rows, columns))
}
/// Extract raw pixel data bytes from DICOM
pub fn get_pixel_data_bytes(dicom_data: &DicomData) -> MedicalResult<&[u8]> {
dicom_data
.get_bytes(0x7FE0, 0x0010)
.ok_or_else(|| MedicalError::Dicom("Missing Pixel Data (7FE0,0010)".to_string()))
}
/// Get bits allocated per pixel
pub fn get_bits_allocated(dicom_data: &DicomData) -> u16 {
dicom_data.get_uint16(0x0028, 0x0100).unwrap_or(16)
}
/// Get rescale slope and intercept
pub fn get_rescale_params(dicom_data: &DicomData) -> (f32, f32) {
let slope = dicom_data.get_float64(0x0028, 0x1053).unwrap_or(1.0) as f32;
let intercept = dicom_data.get_float64(0x0028, 0x1052).unwrap_or(0.0) as f32;
(slope, intercept)
}
/// Parse pixel spacing from DICOM string
pub fn parse_pixel_spacing(spacing_str: &str) -> Option<(f32, f32)> {
let parts: Vec<&str> = spacing_str.split('\\').collect();
if parts.len() >= 2 {
let row_spacing = parts[0].parse::<f32>().ok()?;
let col_spacing = parts[1].parse::<f32>().ok()?;
Some((col_spacing, row_spacing)) // Note: DICOM uses row\column order
} else {
None
}
}
/// Sort DICOM files in a series by slice location or instance number
fn sort_dicom_series(dicom_files: &mut [DicomData]) -> MedicalResult<()> {
// Try to sort by slice location first
let has_slice_location = dicom_files
.iter()
.all(|d| d.get_float64(0x0020, 0x1041).is_some());
if has_slice_location {
dicom_files.sort_by(|a, b| {
let loc_a = a.get_float64(0x0020, 0x1041).unwrap_or(0.0);
let loc_b = b.get_float64(0x0020, 0x1041).unwrap_or(0.0);
loc_a.total_cmp(&loc_b)
});
} else {
// Fall back to instance number
dicom_files.sort_by(|a, b| {
let inst_a = a.get_uint32(0x0020, 0x0013).unwrap_or(0);
let inst_b = b.get_uint32(0x0020, 0x0013).unwrap_or(0);
inst_a.cmp(&inst_b)
});
}
Ok(())
}
/// Group DICOM files by series UID
fn group_by_series(dicom_files: &[String]) -> MedicalResult<HashMap<String, Vec<String>>> {
let mut series_groups: HashMap<String, Vec<String>> = HashMap::new();
for file_path in dicom_files {
let dicom_data = Self::parse_dicom_file(file_path)?;
let series_uid = dicom_data
.get_string(0x0020, 0x000E)
.unwrap_or_else(|| "UNKNOWN".to_string());
series_groups
.entry(series_uid)
.or_insert_with(Vec::new)
.push(file_path.clone());
}
Ok(series_groups)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dicom_data_get_string() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0010,
element: 0x0020,
},
DicomElement::String("PT12345".to_string()),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
assert_eq!(data.get_string(0x0010, 0x0020), Some("PT12345".to_string()));
assert_eq!(data.get_string(0x0010, 0x0021), None);
}
#[test]
fn test_dicom_data_get_uint16() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x0010,
},
DicomElement::UInt16(512),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
assert_eq!(data.get_uint16(0x0028, 0x0010), Some(512));
assert_eq!(data.get_uint16(0x0028, 0x0011), None);
}
#[test]
fn test_dicom_data_get_uint32() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0020,
element: 0x0013,
},
DicomElement::UInt32(123456),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
assert_eq!(data.get_uint32(0x0020, 0x0013), Some(123456));
assert_eq!(data.get_uint32(0x0020, 0x0014), None);
}
#[test]
fn test_dicom_data_get_float64() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x1053,
},
DicomElement::Float64(1.5),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
assert_eq!(data.get_float64(0x0028, 0x1053), Some(1.5));
assert_eq!(data.get_float64(0x0028, 0x1054), None);
}
#[test]
fn test_dicom_data_get_bytes() {
let mut elements = HashMap::new();
let pixel_data = vec![1u8, 2, 3, 4, 5];
elements.insert(
DicomTag {
group: 0x7FE0,
element: 0x0010,
},
DicomElement::Bytes(pixel_data.clone()),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
assert_eq!(data.get_bytes(0x7FE0, 0x0010), Some(pixel_data.as_slice()));
assert_eq!(data.get_bytes(0x7FE0, 0x0011), None);
}
#[test]
fn test_parse_pixel_spacing_valid() {
let spacing = DicomLoader::parse_pixel_spacing("0.5\\0.5");
assert_eq!(spacing, Some((0.5, 0.5)));
}
#[test]
fn test_parse_pixel_spacing_different_values() {
let spacing = DicomLoader::parse_pixel_spacing("0.5\\1.0");
assert_eq!(spacing, Some((1.0, 0.5)));
}
#[test]
fn test_parse_pixel_spacing_invalid() {
let spacing = DicomLoader::parse_pixel_spacing("invalid");
assert_eq!(spacing, None);
}
#[test]
fn test_parse_pixel_spacing_single_value() {
let spacing = DicomLoader::parse_pixel_spacing("0.5");
assert_eq!(spacing, None);
}
#[test]
fn test_get_pixel_dimensions() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x0010,
},
DicomElement::UInt16(512),
);
elements.insert(
DicomTag {
group: 0x0028,
element: 0x0011,
},
DicomElement::UInt16(512),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
let dims = DicomLoader::get_pixel_dimensions(&data).unwrap();
assert_eq!(dims, (512, 512));
}
#[test]
fn test_get_pixel_dimensions_missing_rows() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x0011,
},
DicomElement::UInt16(512),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
let result = DicomLoader::get_pixel_dimensions(&data);
assert!(result.is_err());
}
#[test]
fn test_get_bits_allocated_default() {
let data = DicomData {
file_path: "test.dcm".to_string(),
elements: HashMap::new(),
};
let bits = DicomLoader::get_bits_allocated(&data);
assert_eq!(bits, 16);
}
#[test]
fn test_get_bits_allocated_custom() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x0100,
},
DicomElement::UInt16(8),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
let bits = DicomLoader::get_bits_allocated(&data);
assert_eq!(bits, 8);
}
#[test]
fn test_get_rescale_params_default() {
let data = DicomData {
file_path: "test.dcm".to_string(),
elements: HashMap::new(),
};
let (slope, intercept) = DicomLoader::get_rescale_params(&data);
assert_eq!(slope, 1.0);
assert_eq!(intercept, 0.0);
}
#[test]
fn test_get_rescale_params_custom() {
let mut elements = HashMap::new();
elements.insert(
DicomTag {
group: 0x0028,
element: 0x1053,
},
DicomElement::Float64(1.5),
);
elements.insert(
DicomTag {
group: 0x0028,
element: 0x1052,
},
DicomElement::Float64(-1024.0),
);
let data = DicomData {
file_path: "test.dcm".to_string(),
elements,
};
let (slope, intercept) = DicomLoader::get_rescale_params(&data);
assert_eq!(slope, 1.5);
assert_eq!(intercept, -1024.0);
}
#[test]
fn test_load_series_empty_returns_error() {
let result = DicomLoader::load_series(&[]);
assert!(result.is_err());
}
}
@@ -0,0 +1,476 @@
//! DICOM file format support
//!
//! This module provides DICOM file loading, parsing, and validation functionality.
//! It includes support for single files, series, and directories.
//!
//! ## Features
//!
//! - Load single DICOM files or entire series
//! - Parse DICOM data elements and extract metadata
//! - Validate DICOM files and series for consistency
//! - Extract pixel data and associated parameters
//!
//! ## Example
//!
//! ```rust,ignore
//! use rtx_medical_core::dicom::{DicomLoader, DicomValidator};
//!
//! // Load a single DICOM file
//! let dicom_data = DicomLoader::load_single("path/to/file.dcm")?;
//!
//! // Validate the file
//! let report = DicomValidator::validate_file(&dicom_data)?;
//! if !report.is_valid {
//! eprintln!("Errors: {:?}", report.errors);
//! }
//!
//! // Load a DICOM series
//! let series = DicomLoader::load_series(&vec![
//! "slice1.dcm".to_string(),
//! "slice2.dcm".to_string(),
//! "slice3.dcm".to_string(),
//! ])?;
//! ```
use crate::error::{MedicalError, MedicalResult};
// Submodules
mod loader;
mod parser;
mod validator;
// Re-export public types from loader
pub use loader::{DicomData, DicomLoader};
// Re-export public types from parser
pub use parser::{DicomElement, DicomTag};
// Re-export public types from validator
pub use validator::{DicomValidator, FileValidationReport, SeriesValidationReport};
// Keep the basic types from the original scaffold for compatibility
/// DICOM Transfer Syntax UIDs (subset)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferSyntax {
/// Implicit VR Little Endian
ImplicitVRLittleEndian,
/// Explicit VR Little Endian
ExplicitVRLittleEndian,
/// Explicit VR Big Endian
ExplicitVRBigEndian,
}
impl TransferSyntax {
/// Get UID string for transfer syntax
pub fn uid(&self) -> &'static str {
match self {
Self::ImplicitVRLittleEndian => "1.2.840.10008.1.2",
Self::ExplicitVRLittleEndian => "1.2.840.10008.1.2.1",
Self::ExplicitVRBigEndian => "1.2.840.10008.1.2.2",
}
}
/// Create from UID string
pub fn from_uid(uid: &str) -> MedicalResult<Self> {
match uid {
"1.2.840.10008.1.2" => Ok(Self::ImplicitVRLittleEndian),
"1.2.840.10008.1.2.1" => Ok(Self::ExplicitVRLittleEndian),
"1.2.840.10008.1.2.2" => Ok(Self::ExplicitVRBigEndian),
_ => Err(MedicalError::Dicom(format!(
"Unknown transfer syntax UID: {}",
uid
))),
}
}
/// Check if little endian
pub fn is_little_endian(&self) -> bool {
matches!(
self,
Self::ImplicitVRLittleEndian | Self::ExplicitVRLittleEndian
)
}
/// Check if explicit VR
pub fn is_explicit_vr(&self) -> bool {
matches!(
self,
Self::ExplicitVRLittleEndian | Self::ExplicitVRBigEndian
)
}
}
/// DICOM modality codes
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Modality {
/// Computed Tomography
CT,
/// Magnetic Resonance
MR,
/// Positron Emission Tomography
PT,
/// Single Photon Emission Computed Tomography
SPECT,
/// X-Ray Angiography
XA,
/// X-Ray (general)
CR,
/// Digital Radiography
DX,
/// Ultrasound
US,
/// Mammography
MG,
/// Slide Microscopy
SM,
/// Ophthalmic Photography
OP,
/// Other/Unknown
Other,
}
impl Modality {
/// Get standard DICOM code string
pub fn code(&self) -> &'static str {
match self {
Self::CT => "CT",
Self::MR => "MR",
Self::PT => "PT",
Self::SPECT => "ST",
Self::XA => "XA",
Self::CR => "CR",
Self::DX => "DX",
Self::US => "US",
Self::MG => "MG",
Self::SM => "SM",
Self::OP => "OP",
Self::Other => "OT",
}
}
/// Create from code string
pub fn from_code(code: &str) -> Self {
match code {
"CT" => Self::CT,
"MR" => Self::MR,
"PT" => Self::PT,
"ST" => Self::SPECT,
"XA" => Self::XA,
"CR" => Self::CR,
"DX" => Self::DX,
"US" => Self::US,
"MG" => Self::MG,
"SM" => Self::SM,
"OP" => Self::OP,
_ => Self::Other,
}
}
}
/// Basic DICOM metadata
#[derive(Debug, Clone)]
pub struct DicomMetadata {
/// Patient ID
pub patient_id: Option<String>,
/// Study Instance UID
pub study_uid: Option<String>,
/// Series Instance UID
pub series_uid: Option<String>,
/// Modality
pub modality: Modality,
/// Transfer syntax
pub transfer_syntax: TransferSyntax,
}
impl DicomMetadata {
/// Create new DICOM metadata with required fields
pub fn new(modality: Modality, transfer_syntax: TransferSyntax) -> Self {
Self {
patient_id: None,
study_uid: None,
series_uid: None,
modality,
transfer_syntax,
}
}
/// Set patient ID
pub fn with_patient_id(mut self, patient_id: String) -> Self {
self.patient_id = Some(patient_id);
self
}
/// Set study UID
pub fn with_study_uid(mut self, study_uid: String) -> Self {
self.study_uid = Some(study_uid);
self
}
/// Set series UID
pub fn with_series_uid(mut self, series_uid: String) -> Self {
self.series_uid = Some(series_uid);
self
}
/// Extract metadata from DicomData
pub fn from_dicom_data(dicom_data: &DicomData) -> Self {
let modality_str = dicom_data
.get_string(0x0008, 0x0060)
.unwrap_or_else(|| "OT".to_string());
let modality = Modality::from_code(&modality_str);
Self {
patient_id: dicom_data.get_string(0x0010, 0x0020),
study_uid: dicom_data.get_string(0x0020, 0x000D),
series_uid: dicom_data.get_string(0x0020, 0x000E),
modality,
transfer_syntax: TransferSyntax::ExplicitVRLittleEndian, // Default
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transfer_syntax_uid_implicit_vr() {
assert_eq!(
TransferSyntax::ImplicitVRLittleEndian.uid(),
"1.2.840.10008.1.2"
);
}
#[test]
fn test_transfer_syntax_uid_explicit_vr_little() {
assert_eq!(
TransferSyntax::ExplicitVRLittleEndian.uid(),
"1.2.840.10008.1.2.1"
);
}
#[test]
fn test_transfer_syntax_uid_explicit_vr_big() {
assert_eq!(
TransferSyntax::ExplicitVRBigEndian.uid(),
"1.2.840.10008.1.2.2"
);
}
#[test]
fn test_transfer_syntax_from_uid_implicit() {
let ts = TransferSyntax::from_uid("1.2.840.10008.1.2").unwrap();
assert_eq!(ts, TransferSyntax::ImplicitVRLittleEndian);
}
#[test]
fn test_transfer_syntax_from_uid_explicit_little() {
let ts = TransferSyntax::from_uid("1.2.840.10008.1.2.1").unwrap();
assert_eq!(ts, TransferSyntax::ExplicitVRLittleEndian);
}
#[test]
fn test_transfer_syntax_from_uid_explicit_big() {
let ts = TransferSyntax::from_uid("1.2.840.10008.1.2.2").unwrap();
assert_eq!(ts, TransferSyntax::ExplicitVRBigEndian);
}
#[test]
fn test_transfer_syntax_from_uid_unknown() {
let result = TransferSyntax::from_uid("1.2.840.10008.1.2.99");
assert!(result.is_err());
match result {
Err(MedicalError::Dicom(msg)) => {
assert!(msg.contains("Unknown transfer syntax UID"));
}
_ => panic!("Expected Dicom error"),
}
}
#[test]
fn test_transfer_syntax_round_trip() {
let original = TransferSyntax::ExplicitVRLittleEndian;
let uid = original.uid();
let restored = TransferSyntax::from_uid(uid).unwrap();
assert_eq!(original, restored);
}
#[test]
fn test_transfer_syntax_is_little_endian_true() {
assert!(TransferSyntax::ImplicitVRLittleEndian.is_little_endian());
assert!(TransferSyntax::ExplicitVRLittleEndian.is_little_endian());
}
#[test]
fn test_transfer_syntax_is_little_endian_false() {
assert!(!TransferSyntax::ExplicitVRBigEndian.is_little_endian());
}
#[test]
fn test_transfer_syntax_is_explicit_vr_true() {
assert!(TransferSyntax::ExplicitVRLittleEndian.is_explicit_vr());
assert!(TransferSyntax::ExplicitVRBigEndian.is_explicit_vr());
}
#[test]
fn test_transfer_syntax_is_explicit_vr_false() {
assert!(!TransferSyntax::ImplicitVRLittleEndian.is_explicit_vr());
}
#[test]
fn test_modality_code_ct() {
assert_eq!(Modality::CT.code(), "CT");
}
#[test]
fn test_modality_code_mr() {
assert_eq!(Modality::MR.code(), "MR");
}
#[test]
fn test_modality_code_pt() {
assert_eq!(Modality::PT.code(), "PT");
}
#[test]
fn test_modality_code_spect() {
assert_eq!(Modality::SPECT.code(), "ST");
}
#[test]
fn test_modality_code_xa() {
assert_eq!(Modality::XA.code(), "XA");
}
#[test]
fn test_modality_code_cr() {
assert_eq!(Modality::CR.code(), "CR");
}
#[test]
fn test_modality_code_dx() {
assert_eq!(Modality::DX.code(), "DX");
}
#[test]
fn test_modality_code_us() {
assert_eq!(Modality::US.code(), "US");
}
#[test]
fn test_modality_code_mg() {
assert_eq!(Modality::MG.code(), "MG");
}
#[test]
fn test_modality_code_other() {
assert_eq!(Modality::Other.code(), "OT");
}
#[test]
fn test_modality_from_code_ct() {
assert_eq!(Modality::from_code("CT"), Modality::CT);
}
#[test]
fn test_modality_from_code_mr() {
assert_eq!(Modality::from_code("MR"), Modality::MR);
}
#[test]
fn test_modality_from_code_pt() {
assert_eq!(Modality::from_code("PT"), Modality::PT);
}
#[test]
fn test_modality_from_code_spect() {
assert_eq!(Modality::from_code("ST"), Modality::SPECT);
}
#[test]
fn test_modality_from_code_unknown() {
assert_eq!(Modality::from_code("UNKNOWN"), Modality::Other);
}
#[test]
fn test_modality_round_trip() {
let original = Modality::MR;
let code = original.code();
let restored = Modality::from_code(code);
assert_eq!(original, restored);
}
#[test]
fn test_dicom_metadata_new() {
let metadata = DicomMetadata::new(Modality::CT, TransferSyntax::ExplicitVRLittleEndian);
assert_eq!(metadata.modality, Modality::CT);
assert_eq!(
metadata.transfer_syntax,
TransferSyntax::ExplicitVRLittleEndian
);
assert!(metadata.patient_id.is_none());
assert!(metadata.study_uid.is_none());
assert!(metadata.series_uid.is_none());
}
#[test]
fn test_dicom_metadata_with_patient_id() {
let metadata = DicomMetadata::new(Modality::MR, TransferSyntax::ImplicitVRLittleEndian)
.with_patient_id("PT12345".to_string());
assert_eq!(metadata.patient_id, Some("PT12345".to_string()));
}
#[test]
fn test_dicom_metadata_with_study_uid() {
let metadata = DicomMetadata::new(Modality::MR, TransferSyntax::ImplicitVRLittleEndian)
.with_study_uid("1.2.3.4.5".to_string());
assert_eq!(metadata.study_uid, Some("1.2.3.4.5".to_string()));
}
#[test]
fn test_dicom_metadata_with_series_uid() {
let metadata = DicomMetadata::new(Modality::MR, TransferSyntax::ImplicitVRLittleEndian)
.with_series_uid("1.2.3.4.5.6".to_string());
assert_eq!(metadata.series_uid, Some("1.2.3.4.5.6".to_string()));
}
#[test]
fn test_dicom_metadata_builder_chain() {
let metadata = DicomMetadata::new(Modality::CT, TransferSyntax::ExplicitVRLittleEndian)
.with_patient_id("PT001".to_string())
.with_study_uid("1.2.3".to_string())
.with_series_uid("1.2.3.4".to_string());
assert_eq!(metadata.patient_id, Some("PT001".to_string()));
assert_eq!(metadata.study_uid, Some("1.2.3".to_string()));
assert_eq!(metadata.series_uid, Some("1.2.3.4".to_string()));
}
#[test]
fn test_dicom_metadata_clone() {
let metadata1 = DicomMetadata::new(Modality::MR, TransferSyntax::ExplicitVRLittleEndian)
.with_patient_id("PT001".to_string());
let metadata2 = metadata1.clone();
assert_eq!(metadata1.patient_id, metadata2.patient_id);
assert_eq!(metadata1.modality, metadata2.modality);
}
#[test]
fn test_transfer_syntax_equality() {
assert_eq!(
TransferSyntax::ImplicitVRLittleEndian,
TransferSyntax::ImplicitVRLittleEndian
);
assert_ne!(
TransferSyntax::ImplicitVRLittleEndian,
TransferSyntax::ExplicitVRLittleEndian
);
}
#[test]
fn test_modality_equality() {
assert_eq!(Modality::CT, Modality::CT);
assert_ne!(Modality::CT, Modality::MR);
}
}
@@ -0,0 +1,262 @@
//! DICOM parser for reading and parsing DICOM data elements
//!
//! This module provides simplified DICOM parsing functionality.
//! In production, consider using a full-featured DICOM library like dicom-rs.
use crate::error::{MedicalError, MedicalResult};
use std::collections::HashMap;
/// DICOM tag (group, element)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DicomTag {
/// Group identifier (16-bit)
pub group: u16,
/// Element identifier (16-bit)
pub element: u16,
}
impl DicomTag {
/// Create a new DICOM tag
pub fn new(group: u16, element: u16) -> Self {
Self { group, element }
}
}
/// DICOM element value
#[derive(Debug, Clone)]
pub enum DicomElement {
/// String value
String(String),
/// Unsigned 16-bit integer
UInt16(u16),
/// Unsigned 32-bit integer
UInt32(u32),
/// 64-bit floating point
Float64(f64),
/// Raw bytes
Bytes(Vec<u8>),
}
/// Simplified DICOM parser
pub(crate) struct DicomParser<'a> {
data: &'a [u8],
position: usize,
}
impl<'a> DicomParser<'a> {
/// Create a new DICOM parser
pub(crate) fn new(data: &'a [u8]) -> Self {
Self { data, position: 0 }
}
/// Parse DICOM data elements
pub(crate) fn parse_elements(&mut self) -> MedicalResult<HashMap<DicomTag, DicomElement>> {
let mut elements = HashMap::new();
// This is a simplified parser - in production use dicom-rs or similar
while self.position + 8 < self.data.len() {
match self.parse_element() {
Ok((tag, element)) => {
elements.insert(tag, element);
}
Err(_) => {
// Skip parsing errors and continue
break;
}
}
}
Ok(elements)
}
/// Parse a single DICOM element
fn parse_element(&mut self) -> MedicalResult<(DicomTag, DicomElement)> {
if self.position + 8 > self.data.len() {
return Err(MedicalError::Dicom(
"Unexpected end of DICOM data".to_string(),
));
}
// Read tag (4 bytes)
let group = u16::from_le_bytes([self.data[self.position], self.data[self.position + 1]]);
let element =
u16::from_le_bytes([self.data[self.position + 2], self.data[self.position + 3]]);
let tag = DicomTag { group, element };
self.position += 4;
// Read VR (2 bytes) - Value Representation
let vr = [self.data[self.position], self.data[self.position + 1]];
self.position += 2;
// Read length (2 or 4 bytes depending on VR)
let length = if Self::is_explicit_vr(&vr) {
// Reserved 2 bytes + 4-byte length
self.position += 2; // Skip reserved bytes
u32::from_le_bytes([
self.data[self.position],
self.data[self.position + 1],
self.data[self.position + 2],
self.data[self.position + 3],
]) as usize
} else {
// 2-byte length
u16::from_le_bytes([self.data[self.position], self.data[self.position + 1]]) as usize
};
self.position += if Self::is_explicit_vr(&vr) { 4 } else { 2 };
// Read value
if self.position + length > self.data.len() {
return Err(MedicalError::Dicom("Invalid element length".to_string()));
}
let value_data = &self.data[self.position..self.position + length];
let element = self.parse_value(&vr, value_data)?;
self.position += length;
Ok((tag, element))
}
/// Parse element value based on VR
fn parse_value(&self, vr: &[u8; 2], data: &[u8]) -> MedicalResult<DicomElement> {
match vr {
b"CS" | b"SH" | b"LO" | b"ST" | b"LT" | b"PN" | b"UI" | b"DA" | b"TM" | b"DT" => {
let string = String::from_utf8_lossy(data)
.trim_end_matches('\0')
.to_string();
Ok(DicomElement::String(string))
}
b"US" => {
if data.len() >= 2 {
let value = u16::from_le_bytes([data[0], data[1]]);
Ok(DicomElement::UInt16(value))
} else {
Err(MedicalError::Dicom("Invalid US value length".to_string()))
}
}
b"UL" => {
if data.len() >= 4 {
let value = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
Ok(DicomElement::UInt32(value))
} else {
Err(MedicalError::Dicom("Invalid UL value length".to_string()))
}
}
b"DS" | b"IS" => {
let string = String::from_utf8_lossy(data)
.trim_end_matches('\0')
.to_string();
if let Ok(value) = string.parse::<f64>() {
Ok(DicomElement::Float64(value))
} else {
Ok(DicomElement::String(string))
}
}
_ => {
// Default to bytes for unknown or binary VRs
Ok(DicomElement::Bytes(data.to_vec()))
}
}
}
/// Check if VR uses explicit length encoding
fn is_explicit_vr(vr: &[u8; 2]) -> bool {
matches!(vr, b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dicom_tag_creation() {
let tag = DicomTag::new(0x0008, 0x0060);
assert_eq!(tag.group, 0x0008);
assert_eq!(tag.element, 0x0060);
}
#[test]
fn test_dicom_tag_equality() {
let tag1 = DicomTag::new(0x0008, 0x0060);
let tag2 = DicomTag::new(0x0008, 0x0060);
let tag3 = DicomTag::new(0x0008, 0x0061);
assert_eq!(tag1, tag2);
assert_ne!(tag1, tag3);
}
#[test]
fn test_dicom_tag_hash() {
let tag = DicomTag::new(0x0008, 0x0060);
let mut map = HashMap::new();
map.insert(tag, DicomElement::String("CT".to_string()));
assert!(map.contains_key(&tag));
}
#[test]
fn test_dicom_element_string() {
let elem = DicomElement::String("CT".to_string());
match elem {
DicomElement::String(s) => assert_eq!(s, "CT"),
_ => panic!("Expected String variant"),
}
}
#[test]
fn test_dicom_element_uint16() {
let elem = DicomElement::UInt16(512);
match elem {
DicomElement::UInt16(v) => assert_eq!(v, 512),
_ => panic!("Expected UInt16 variant"),
}
}
#[test]
fn test_dicom_element_uint32() {
let elem = DicomElement::UInt32(123456);
match elem {
DicomElement::UInt32(v) => assert_eq!(v, 123456),
_ => panic!("Expected UInt32 variant"),
}
}
#[test]
fn test_dicom_element_float64() {
let elem = DicomElement::Float64(3.14159);
match elem {
DicomElement::Float64(v) => assert!((v - 3.14159).abs() < f64::EPSILON),
_ => panic!("Expected Float64 variant"),
}
}
#[test]
fn test_dicom_element_bytes() {
let data = vec![1, 2, 3, 4, 5];
let elem = DicomElement::Bytes(data.clone());
match elem {
DicomElement::Bytes(b) => assert_eq!(b, data),
_ => panic!("Expected Bytes variant"),
}
}
#[test]
fn test_parser_is_explicit_vr_true() {
assert!(DicomParser::is_explicit_vr(b"OB"));
assert!(DicomParser::is_explicit_vr(b"OW"));
assert!(DicomParser::is_explicit_vr(b"OF"));
assert!(DicomParser::is_explicit_vr(b"SQ"));
assert!(DicomParser::is_explicit_vr(b"UT"));
assert!(DicomParser::is_explicit_vr(b"UN"));
}
#[test]
fn test_parser_is_explicit_vr_false() {
assert!(!DicomParser::is_explicit_vr(b"CS"));
assert!(!DicomParser::is_explicit_vr(b"US"));
assert!(!DicomParser::is_explicit_vr(b"UL"));
assert!(!DicomParser::is_explicit_vr(b"DS"));
}
}
@@ -0,0 +1,342 @@
//! DICOM validation utilities for ensuring data integrity and consistency
use super::loader::DicomData;
use crate::error::{MedicalError, MedicalResult};
/// Series validation report
#[derive(Debug)]
pub struct SeriesValidationReport {
/// Whether the series is valid
pub is_valid: bool,
/// Warning messages (non-critical issues)
pub warnings: Vec<String>,
/// Error messages (critical issues)
pub errors: Vec<String>,
}
/// File validation report
#[derive(Debug)]
pub struct FileValidationReport {
/// Whether the file is valid
pub is_valid: bool,
/// Warning messages (non-critical issues)
pub warnings: Vec<String>,
/// Error messages (critical issues)
pub errors: Vec<String>,
}
/// DICOM validation utilities
pub struct DicomValidator;
impl DicomValidator {
/// Validate DICOM series consistency
pub fn validate_series(dicom_files: &[DicomData]) -> MedicalResult<SeriesValidationReport> {
if dicom_files.is_empty() {
return Err(MedicalError::Dicom(
"No DICOM files to validate".to_string(),
));
}
let mut report = SeriesValidationReport {
is_valid: true,
warnings: Vec::new(),
errors: Vec::new(),
};
// Check series UID consistency
let first_series_uid = dicom_files[0].get_string(0x0020, 0x000E);
for (i, file) in dicom_files.iter().enumerate().skip(1) {
if file.get_string(0x0020, 0x000E) != first_series_uid {
report
.errors
.push(format!("File {} has different Series UID", i));
report.is_valid = false;
}
}
// Check image dimensions consistency
let first_rows = dicom_files[0].get_uint16(0x0028, 0x0010);
let first_cols = dicom_files[0].get_uint16(0x0028, 0x0011);
for (i, file) in dicom_files.iter().enumerate().skip(1) {
if file.get_uint16(0x0028, 0x0010) != first_rows
|| file.get_uint16(0x0028, 0x0011) != first_cols
{
report
.errors
.push(format!("File {} has different image dimensions", i));
report.is_valid = false;
}
}
// Check for missing slice locations
let slice_locations: Vec<Option<f64>> = dicom_files
.iter()
.map(|f| f.get_float64(0x0020, 0x1041))
.collect();
if slice_locations.iter().any(|loc| loc.is_none()) {
report
.warnings
.push("Some slices missing slice location".to_string());
}
// Check for gaps in slice locations
let mut actual_locations: Vec<f64> =
slice_locations.iter().filter_map(|&loc| loc).collect();
actual_locations.sort_by(|a, b| a.total_cmp(b));
if actual_locations.len() > 1 {
let spacing_variations: Vec<f64> = actual_locations
.windows(2)
.map(|pair| pair[1] - pair[0])
.collect();
let mean_spacing =
spacing_variations.iter().sum::<f64>() / spacing_variations.len() as f64;
let max_variation = spacing_variations
.iter()
.map(|&s| (s - mean_spacing).abs())
.fold(0.0, f64::max);
if max_variation > 0.1 {
// 0.1mm tolerance
report.warnings.push(format!(
"Irregular slice spacing (max variation: {:.2}mm)",
max_variation
));
}
}
Ok(report)
}
/// Validate individual DICOM file
pub fn validate_file(dicom_data: &DicomData) -> MedicalResult<FileValidationReport> {
let mut report = FileValidationReport {
is_valid: true,
warnings: Vec::new(),
errors: Vec::new(),
};
// Check required string tags
let required_string_tags = [
(0x0008, 0x0016, "SOP Class UID"),
(0x0008, 0x0018, "SOP Instance UID"),
(0x0020, 0x000D, "Study Instance UID"),
(0x0020, 0x000E, "Series Instance UID"),
];
for &(group, element, name) in &required_string_tags {
if dicom_data.get_string(group, element).is_none() {
report
.errors
.push(format!("Missing required tag: {}", name));
report.is_valid = false;
}
}
// Check required numeric tags (Rows and Columns are UInt16)
if dicom_data.get_uint16(0x0028, 0x0010).is_none() {
report.errors.push("Missing required tag: Rows".to_string());
report.is_valid = false;
}
if dicom_data.get_uint16(0x0028, 0x0011).is_none() {
report
.errors
.push("Missing required tag: Columns".to_string());
report.is_valid = false;
}
// Check pixel data
if dicom_data.get_bytes(0x7FE0, 0x0010).is_none() {
report.errors.push("Missing pixel data".to_string());
report.is_valid = false;
}
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dicom::parser::{DicomElement, DicomTag};
use std::collections::HashMap;
fn create_test_dicom_data(
series_uid: &str,
rows: u16,
cols: u16,
slice_location: Option<f64>,
has_pixel_data: bool,
) -> DicomData {
let mut elements = HashMap::new();
// Required tags
elements.insert(
DicomTag::new(0x0008, 0x0016),
DicomElement::String("1.2.840.10008.5.1.4.1.1.2".to_string()), // SOP Class UID
);
elements.insert(
DicomTag::new(0x0008, 0x0018),
DicomElement::String("1.2.3.4.5.6.7".to_string()), // SOP Instance UID
);
elements.insert(
DicomTag::new(0x0020, 0x000D),
DicomElement::String("1.2.3.4.5".to_string()), // Study UID
);
elements.insert(
DicomTag::new(0x0020, 0x000E),
DicomElement::String(series_uid.to_string()), // Series UID
);
elements.insert(
DicomTag::new(0x0028, 0x0010),
DicomElement::UInt16(rows), // Rows
);
elements.insert(
DicomTag::new(0x0028, 0x0011),
DicomElement::UInt16(cols), // Columns
);
// Optional slice location
if let Some(loc) = slice_location {
elements.insert(DicomTag::new(0x0020, 0x1041), DicomElement::Float64(loc));
}
// Pixel data
if has_pixel_data {
elements.insert(
DicomTag::new(0x7FE0, 0x0010),
DicomElement::Bytes(vec![0u8; 100]),
);
}
DicomData {
file_path: "test.dcm".to_string(),
elements,
}
}
#[test]
fn test_validate_series_empty_returns_error() {
let result = DicomValidator::validate_series(&[]);
assert!(result.is_err());
}
#[test]
fn test_validate_series_consistent() {
let files = vec![
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true),
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(11.0), true),
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(12.0), true),
];
let report = DicomValidator::validate_series(&files).unwrap();
assert!(report.is_valid);
assert!(report.errors.is_empty());
}
#[test]
fn test_validate_series_different_series_uid() {
let files = vec![
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true),
create_test_dicom_data("1.2.3.4.5.7", 512, 512, Some(11.0), true),
];
let report = DicomValidator::validate_series(&files).unwrap();
assert!(!report.is_valid);
assert!(!report.errors.is_empty());
assert!(report.errors[0].contains("different Series UID"));
}
#[test]
fn test_validate_series_different_dimensions() {
let files = vec![
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true),
create_test_dicom_data("1.2.3.4.5.6", 256, 256, Some(11.0), true),
];
let report = DicomValidator::validate_series(&files).unwrap();
assert!(!report.is_valid);
assert!(!report.errors.is_empty());
assert!(report.errors[0].contains("different image dimensions"));
}
#[test]
fn test_validate_series_missing_slice_location_warns() {
let files = vec![
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true),
create_test_dicom_data("1.2.3.4.5.6", 512, 512, None, true),
];
let report = DicomValidator::validate_series(&files).unwrap();
assert!(report.is_valid); // Still valid, but with warnings
assert!(!report.warnings.is_empty());
assert!(report.warnings[0].contains("missing slice location"));
}
#[test]
fn test_validate_series_irregular_spacing() {
let files = vec![
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true),
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(11.0), true),
create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(12.5), true), // Irregular spacing
];
let report = DicomValidator::validate_series(&files).unwrap();
assert!(report.is_valid);
assert!(!report.warnings.is_empty());
assert!(
report
.warnings
.iter()
.any(|w| w.contains("Irregular slice spacing"))
);
}
#[test]
fn test_validate_file_valid() {
let data = create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), true);
let report = DicomValidator::validate_file(&data).unwrap();
assert!(report.is_valid);
assert!(report.errors.is_empty());
}
#[test]
fn test_validate_file_missing_pixel_data() {
let data = create_test_dicom_data("1.2.3.4.5.6", 512, 512, Some(10.0), false);
let report = DicomValidator::validate_file(&data).unwrap();
assert!(!report.is_valid);
assert!(
report
.errors
.iter()
.any(|e| e.contains("Missing pixel data"))
);
}
#[test]
fn test_series_validation_report_structure() {
let report = SeriesValidationReport {
is_valid: true,
warnings: vec!["test warning".to_string()],
errors: vec![],
};
assert!(report.is_valid);
assert_eq!(report.warnings.len(), 1);
assert_eq!(report.errors.len(), 0);
}
#[test]
fn test_file_validation_report_structure() {
let report = FileValidationReport {
is_valid: false,
warnings: vec![],
errors: vec!["test error".to_string()],
};
assert!(!report.is_valid);
assert_eq!(report.warnings.len(), 0);
assert_eq!(report.errors.len(), 1);
}
}