diff --git a/Cargo.lock b/Cargo.lock index a2e4290b2..4d2230d99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,6 +603,8 @@ name = "dicom-toimage" version = "0.2.1" dependencies = [ "clap", + "dicom-core", + "dicom-dictionary-std", "dicom-object", "dicom-pixeldata", "snafu", diff --git a/toimage/Cargo.toml b/toimage/Cargo.toml index a75139a94..2d8fc92d1 100644 --- a/toimage/Cargo.toml +++ b/toimage/Cargo.toml @@ -15,8 +15,10 @@ default = ['dicom-object/inventory-registry', 'dicom-object/backtraces'] [dependencies] clap = { version = "4.0.18", features = ["derive"] } +dicom-core = { version = "0.6.1", path = "../core" } +dicom-dictionary-std = { version = "0.6.0", path = "../dictionary-std" } dicom-object = { path = "../object/", version = "0.6.1" } dicom-pixeldata = { path = "../pixeldata/", version = "0.2.0", features = ["image"] } -snafu = "0.7.3" +snafu = { version = "0.7.3", features = ["rust_1_61"] } tracing = "0.1.34" tracing-subscriber = "0.3.11" diff --git a/toimage/src/main.rs b/toimage/src/main.rs index a6424a4fb..3b6a8ea61 100644 --- a/toimage/src/main.rs +++ b/toimage/src/main.rs @@ -1,11 +1,13 @@ //! A CLI tool for converting a DICOM image file //! into a general purpose image file (e.g. PNG). -use std::path::PathBuf; +use std::{borrow::Cow, path::PathBuf}; use clap::Parser; +use dicom_core::prelude::*; +use dicom_dictionary_std::{tags, uids}; use dicom_object::open_file; use dicom_pixeldata::{ConvertOptions, PixelDecoder}; -use snafu::{Report, ResultExt, Whatever}; +use snafu::{OptionExt, Report, ResultExt, Snafu, Whatever}; use tracing::{error, Level}; /// Convert a DICOM file into an image file @@ -32,24 +34,86 @@ struct App { #[arg(long = "16bit", conflicts_with = "force_8bit")] force_16bit: bool, + /// Output the raw pixel data instead of decoding it + #[arg( + long = "unwrap", + conflicts_with = "force_8bit", + conflicts_with = "force_16bit" + )] + unwrap: bool, + /// Print more information about the image and the output file #[arg(short = 'v', long = "verbose")] verbose: bool, } +#[derive(Debug, Snafu)] +enum Error { + #[snafu(display("could not read DICOM file {}", path.display()))] + ReadFile { + #[snafu(source(from(dicom_object::ReadError, Box::new)))] + source: Box, + path: PathBuf, + }, + /// failed to decode pixel data + DecodePixelData { + #[snafu(source(from(dicom_pixeldata::Error, Box::new)))] + source: Box, + }, + /// missing offset table entry for frame #{frame_number} + MissingOffsetEntry { frame_number: u32 }, + /// missing key property {name} + MissingProperty { name: &'static str }, + /// property {name} contains an invalid value + InvalidPropertyValue { + name: &'static str, + #[snafu(source(from(dicom_core::value::ConvertValueError, Box::new)))] + source: Box, + }, + /// pixel data of frame #{frame_number} is out of bounds + FrameOutOfBounds { frame_number: u32 }, + /// failed to convert pixel data to image + ConvertImage { + #[snafu(source(from(dicom_pixeldata::Error, Box::new)))] + source: Box, + }, + /// failed to save image to file + SaveImage { + #[snafu(source(from(dicom_pixeldata::image::ImageError, Box::new)))] + source: Box, + }, + /// failed to save pixel data to file + SaveData { source: std::io::Error }, + /// Unexpected DICOM pixel data as data set sequence + UnexpectedPixelData, +} + +impl Error { + fn to_exit_code(&self) -> i32 { + match self { + Error::ReadFile { .. } => -1, + Error::DecodePixelData { .. } + | Error::MissingOffsetEntry { .. } + | Error::MissingProperty { .. } + | Error::InvalidPropertyValue { .. } + | Error::FrameOutOfBounds { .. } => -2, + Error::ConvertImage { .. } => -3, + Error::SaveData { .. } | Error::SaveImage { .. } => -4, + Error::UnexpectedPixelData => -7, + } + } +} + fn main() { - let App { - file, - output, - frame_number, - verbose, - force_8bit, - force_16bit, - } = App::parse(); + let args = App::parse(); tracing::subscriber::set_global_default( tracing_subscriber::FmtSubscriber::builder() - .with_max_level(if verbose { Level::DEBUG } else { Level::INFO }) + .with_max_level(if args.verbose { + Level::DEBUG + } else { + Level::INFO + }) .finish(), ) .whatever_context("Could not set up global logging subscriber") @@ -57,55 +121,183 @@ fn main() { eprintln!("[ERROR] {}", Report::from_error(e)); }); - let output = output.unwrap_or_else(|| { - let mut path = file.clone(); - path.set_extension("png"); - path - }); - - let obj = open_file(&file).unwrap_or_else(|e| { + run(args).unwrap_or_else(|e| { + let code = e.to_exit_code(); error!("{}", Report::from_error(e)); - std::process::exit(-1); + std::process::exit(code); }); +} - let pixel = obj.decode_pixel_data().unwrap_or_else(|e| { - error!("{}", Report::from_error(e)); - std::process::exit(-2); - }); +fn run(args: App) -> Result<(), Error> { + let App { + file, + output, + frame_number, + force_8bit, + force_16bit, + unwrap, + verbose, + } = args; - if verbose { - println!( - "{}x{}x{} image, {}-bit", - pixel.columns(), - pixel.rows(), - pixel.samples_per_pixel(), - pixel.bits_stored() - ); - } + let obj = open_file(&file).with_context(|_| ReadFileSnafu { path: file.clone() })?; - let mut options = ConvertOptions::new(); + if unwrap { + let output = output.unwrap_or_else(|| { + let mut path = file.clone(); - if force_16bit { - options = options.force_16bit(); - } else if force_8bit { - options = options.force_8bit(); - } + // try to identify a better extension for this file + // based on transfer syntax + match obj.meta().transfer_syntax() { + uids::JPEG_BASELINE8_BIT + | uids::JPEG_EXTENDED12_BIT + | uids::JPEG_LOSSLESS + | uids::JPEG_LOSSLESS_SV1 => { + path.set_extension("jpg"); + } + uids::JPEG2000 + | uids::JPEG2000MC + | uids::JPEG2000MC_LOSSLESS + | uids::JPEG2000_LOSSLESS => { + path.set_extension("jp2"); + } + _ => { + path.set_extension("data"); + } + } + path + }); - let image = pixel - .to_dynamic_image_with_options(frame_number, &options) - .unwrap_or_else(|e| { - error!("{}", Report::from_error(e)); - std::process::exit(-3); + let pixeldata = obj.get(tags::PIXEL_DATA).unwrap_or_else(|| { + error!("DICOM file has no pixel data"); + std::process::exit(-2); }); - image.save(&output).unwrap_or_else(|e| { - error!("{}", Report::from_error(e)); - std::process::exit(-4); - }); + let out_data = match pixeldata.value() { + DicomValue::PixelSequence(seq) => { + let number_of_frames = match obj.get(tags::NUMBER_OF_FRAMES) { + Some(elem) => elem.to_int::().unwrap_or_else(|e| { + tracing::warn!("Invalid Number of Frames: {}", e); + 1 + }), + None => 1, + }; + + if number_of_frames as usize == seq.fragments().len() { + // frame-to-fragment mapping is 1:1 + + // get fragment containing our frame + let fragment = + seq.fragments() + .get(frame_number as usize) + .unwrap_or_else(|| { + error!("Frame number {} is out of range", frame_number); + std::process::exit(-2); + }); + + Cow::Borrowed(&fragment[..]) + } else { + // In this case we look up the basic offset table + // and gather all of the frame's fragments in a single vector. + // Note: not the most efficient way to do this, + // consider optimizing later with byte chunk readers + let offset_table = seq.offset_table(); + let base_offset = offset_table.get(frame_number as usize).copied(); + let base_offset = if frame_number == 0 { + base_offset.unwrap_or(0) as usize + } else { + base_offset.context(MissingOffsetEntrySnafu { frame_number })? as usize + }; + let next_offset = offset_table.get(frame_number as usize + 1); + + let mut offset = 0; + let mut frame_data = Vec::new(); + for fragment in seq.fragments() { + // include it + if offset >= base_offset { + frame_data.extend_from_slice(fragment); + } + offset += fragment.len() + 8; + if let Some(&next_offset) = next_offset { + if offset >= next_offset as usize { + // next fragment is for the next frame + break; + } + } + } + + Cow::Owned(frame_data) + } + } + DicomValue::Primitive(v) => { + // grab the intended slice based on image properties + + let get_int_property = |tag, name| { + obj.get(tag) + .context(MissingPropertySnafu { name })? + .to_int::() + .context(InvalidPropertyValueSnafu { name }) + }; - if verbose { - println!("Image saved to {}", output.display()); + let rows = get_int_property(tags::ROWS, "Rows")?; + let columns = get_int_property(tags::COLUMNS, "Columns")?; + let samples_per_pixel = + get_int_property(tags::SAMPLES_PER_PIXEL, "Samples Per Pixel")?; + let bits_allocated = get_int_property(tags::BITS_ALLOCATED, "Bits Allocated")?; + let frame_size = rows * columns * samples_per_pixel * ((bits_allocated + 7) / 8); + + let frame = frame_number as usize; + Cow::Owned( + v.to_bytes() + .get((frame_size * frame)..(frame_size * (frame + 1))) + .context(FrameOutOfBoundsSnafu { frame_number })? + .to_vec(), + ) + } + _ => { + return UnexpectedPixelDataSnafu.fail(); + } + }; + + std::fs::write(output, out_data).context(SaveDataSnafu)?; + } else { + let output = output.unwrap_or_else(|| { + let mut path = file.clone(); + path.set_extension("png"); + path + }); + + let pixel = obj.decode_pixel_data().context(DecodePixelDataSnafu)?; + + if verbose { + println!( + "{}x{}x{} image, {}-bit", + pixel.columns(), + pixel.rows(), + pixel.samples_per_pixel(), + pixel.bits_stored() + ); + } + + let mut options = ConvertOptions::new(); + + if force_16bit { + options = options.force_16bit(); + } else if force_8bit { + options = options.force_8bit(); + } + + let image = pixel + .to_dynamic_image_with_options(frame_number, &options) + .context(ConvertImageSnafu)?; + + image.save(&output).context(SaveImageSnafu)?; + + if verbose { + println!("Image saved to {}", output.display()); + } } + + Ok(()) } #[cfg(test)]