diff --git a/Cargo.lock b/Cargo.lock index 4733c034d..3e79f3656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -615,7 +615,9 @@ dependencies = [ "byteordered", "dicom-core", "dicom-encoding", + "dicom-test-files", "jpeg-decoder", + "jpeg-encoder", "lazy_static", "tracing", ] @@ -1096,6 +1098,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "jpeg-encoder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cf3affe27ffd9f1992690ec7575568b222abe9cb39738f6531968aca8e64906" + [[package]] name = "js-sys" version = "0.3.63" diff --git a/transfer-syntax-registry/Cargo.toml b/transfer-syntax-registry/Cargo.toml index 727c6ad62..091b783ea 100644 --- a/transfer-syntax-registry/Cargo.toml +++ b/transfer-syntax-registry/Cargo.toml @@ -18,7 +18,7 @@ inventory-registry = ['dicom-encoding/inventory-registry'] # natively implemented image encodings native = ["jpeg", "rle"] # native JPEG support -jpeg = ["jpeg-decoder"] +jpeg = ["jpeg-decoder", "jpeg-encoder"] # native RLE lossless support rle = [] rayon = ["jpeg-decoder?/rayon"] @@ -34,5 +34,12 @@ tracing = "0.1.34" version = "0.3.0" optional = true +[dependencies.jpeg-encoder] +version = "0.5.1" +optional = true + [package.metadata.docs.rs] features = ["native"] + +[dev-dependencies] +dicom-test-files = "0.2.1" diff --git a/transfer-syntax-registry/src/adapters/jpeg.rs b/transfer-syntax-registry/src/adapters/jpeg.rs index bafb4cac9..f19aff1b4 100644 --- a/transfer-syntax-registry/src/adapters/jpeg.rs +++ b/transfer-syntax-registry/src/adapters/jpeg.rs @@ -1,8 +1,14 @@ //! Support for JPG image decoding. +use dicom_core::ops::{AttributeAction, AttributeOp}; +use dicom_core::Tag; +use dicom_encoding::adapters::{ + decode_error, encode_error, DecodeResult, EncodeOptions, EncodeResult, PixelDataObject, + PixelDataReader, PixelDataWriter, +}; use dicom_encoding::snafu::prelude::*; -use dicom_encoding::adapters::{DecodeResult, PixelDataObject, decode_error, PixelDataReader}; use jpeg_decoder::Decoder; +use jpeg_encoder::ColorType; use std::io::Cursor; /// Pixel data adapter for JPEG-based transfer syntaxes. @@ -15,13 +21,19 @@ impl PixelDataReader for JpegAdapter { let cols = src .cols() .context(decode_error::MissingAttributeSnafu { name: "Columns" })?; - let rows = src.rows().context(decode_error::MissingAttributeSnafu { name: "Rows" })?; - let samples_per_pixel = src.samples_per_pixel().context(decode_error::MissingAttributeSnafu { - name: "SamplesPerPixel", - })?; - let bits_allocated = src.bits_allocated().context(decode_error::MissingAttributeSnafu { - name: "BitsAllocated", - })?; + let rows = src + .rows() + .context(decode_error::MissingAttributeSnafu { name: "Rows" })?; + let samples_per_pixel = + src.samples_per_pixel() + .context(decode_error::MissingAttributeSnafu { + name: "SamplesPerPixel", + })?; + let bits_allocated = src + .bits_allocated() + .context(decode_error::MissingAttributeSnafu { + name: "BitsAllocated", + })?; if bits_allocated != 8 && bits_allocated != 16 { whatever!("BitsAllocated other than 8 or 16 is not supported"); @@ -33,7 +45,10 @@ impl PixelDataReader for JpegAdapter { // `stride` it the total number of bytes for each sample plane let stride: usize = bytes_per_sample as usize * cols as usize * rows as usize; let base_offset = dst.len(); - dst.resize(base_offset + (samples_per_pixel as usize * stride) * nr_frames, 0); + dst.resize( + base_offset + (samples_per_pixel as usize * stride) * nr_frames, + 0, + ); // Embedded jpegs can span multiple fragments // Hence we collect all fragments into single vector @@ -76,25 +91,40 @@ impl PixelDataReader for JpegAdapter { } /// Decode DICOM image data with jpeg encoding. - fn decode_frame(&self, src: &dyn PixelDataObject, frame: u32, dst: &mut Vec) -> DecodeResult<()> { + fn decode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + dst: &mut Vec, + ) -> DecodeResult<()> { let cols = src .cols() .context(decode_error::MissingAttributeSnafu { name: "Columns" })?; - let rows = src.rows().context(decode_error::MissingAttributeSnafu { name: "Rows" })?; - let samples_per_pixel = src.samples_per_pixel().context(decode_error::MissingAttributeSnafu { - name: "SamplesPerPixel", - })?; - let bits_allocated = src.bits_allocated().context(decode_error::MissingAttributeSnafu { - name: "BitsAllocated", - })?; + let rows = src + .rows() + .context(decode_error::MissingAttributeSnafu { name: "Rows" })?; + let samples_per_pixel = + src.samples_per_pixel() + .context(decode_error::MissingAttributeSnafu { + name: "SamplesPerPixel", + })?; + let bits_allocated = src + .bits_allocated() + .context(decode_error::MissingAttributeSnafu { + name: "BitsAllocated", + })?; - if bits_allocated != 8 && bits_allocated != 16 { - whatever!("BitsAllocated other than 8 or 16 is not supported"); - } + ensure_whatever!( + bits_allocated == 8 || bits_allocated == 16, + "BitsAllocated other than 8 or 16 is not supported" + ); let nr_frames = src.number_of_frames().unwrap_or(1) as usize; - ensure!(nr_frames > frame as usize, decode_error::FrameRangeOutOfBoundsSnafu); + ensure!( + nr_frames > frame as usize, + decode_error::FrameRangeOutOfBoundsSnafu + ); let bytes_per_sample = bits_allocated / 8; @@ -106,7 +136,8 @@ impl PixelDataReader for JpegAdapter { // Embedded jpegs can span multiple fragments // Hence we collect all fragments into single vector // and then just iterate a cursor for each frame - let raw_pixeldata = src.raw_pixel_data() + let raw_pixeldata = src + .raw_pixel_data() .whatever_context("Expected to have raw pixel data available")?; let fragment = raw_pixeldata .fragments @@ -142,3 +173,87 @@ impl PixelDataReader for JpegAdapter { Ok(()) } } + +impl PixelDataWriter for JpegAdapter { + fn encode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + options: EncodeOptions, + dst: &mut Vec, + ) -> EncodeResult> { + let cols = src + .cols() + .context(encode_error::MissingAttributeSnafu { name: "Columns" })?; + let rows = src + .rows() + .context(encode_error::MissingAttributeSnafu { name: "Rows" })?; + let samples_per_pixel = + src.samples_per_pixel() + .context(encode_error::MissingAttributeSnafu { + name: "SamplesPerPixel", + })?; + let bits_allocated = src + .bits_allocated() + .context(encode_error::MissingAttributeSnafu { + name: "BitsAllocated", + })?; + + ensure_whatever!( + bits_allocated == 8 || bits_allocated == 16, + "BitsAllocated other than 8 or 16 is not supported" + ); + + let quality = options.quality.unwrap_or(85); + + let bytes_per_sample = (bits_allocated / 8) as usize; + let frame_size = + cols as usize * rows as usize * samples_per_pixel as usize * bytes_per_sample; + + let color_type = match samples_per_pixel { + 1 => ColorType::Luma, + 3 => ColorType::Rgb, + _ => whatever!("Unsupported samples per pixel: {}", samples_per_pixel), + }; + + let photometric_interpretation = match samples_per_pixel { + 1 => "MONOCHROME2", + 3 => "RGB", + _ => unreachable!(), + }; + + // record dst length before encoding to know full jpeg size + let len_before = dst.len(); + + // Encode the data + let frame_uncompressed = src + .fragment(frame as usize) + .context(encode_error::FrameRangeOutOfBoundsSnafu)?; + let mut encoder = jpeg_encoder::Encoder::new(&mut *dst, quality); + encoder.set_progressive(false); + encoder + .encode(&frame_uncompressed, cols, rows, color_type) + .whatever_context("JPEG encoding failed")?; + + let compressed_frame_size = dst.len() - len_before; + + let compression_ratio = frame_size as f64 / compressed_frame_size as f64; + let compression_ratio = format!("{:.6}", compression_ratio); + + // provide attribute changes + Ok(vec![ + // lossy image compression + AttributeOp::new(Tag(0x0028, 0x2110), AttributeAction::SetStr("01".into())), + // lossy image compression ratio + AttributeOp::new( + Tag(0x0028, 0x2112), + AttributeAction::PushStr(compression_ratio.into()), + ), + // Photometric interpretation + AttributeOp::new( + Tag(0x0028, 0x0004), + AttributeAction::SetStr(photometric_interpretation.into()), + ), + ]) + } +} diff --git a/transfer-syntax-registry/src/entries.rs b/transfer-syntax-registry/src/entries.rs index 01de93772..67cd529b8 100644 --- a/transfer-syntax-registry/src/entries.rs +++ b/transfer-syntax-registry/src/entries.rs @@ -81,25 +81,29 @@ pub const RLE_LOSSLESS: Ts = create_ts_stub("1.2.840.10008.1.2.5", "RLE Lossless // JPEG encoded pixel data -/// An alias for a transfer syntax specifier with `JpegPixelAdapter` -/// (note that only decoding is supported at the moment). +/// An alias for a transfer syntax specifier with [`JpegAdapter`] +/// (supports decoding and encoding to JPEG baseline, +/// support for JPEG extended and JPEG lossless may vary). #[cfg(feature = "jpeg")] -type JpegTs = TransferSyntax; +type JpegTs = TransferSyntax; /// Create a transfer syntax with JPEG encapsulated pixel data #[cfg(feature = "jpeg")] -const fn create_ts_jpeg(uid: &'static str, name: &'static str) -> JpegTs { +const fn create_ts_jpeg(uid: &'static str, name: &'static str, encoder: bool) -> JpegTs { TransferSyntax::new_ele( uid, name, - Codec::EncapsulatedPixelData(Some(JpegAdapter), None), + Codec::EncapsulatedPixelData( + Some(JpegAdapter), + if encoder { Some(JpegAdapter) } else { None }, + ), ) } /// **Implemented:** JPEG Baseline (Process 1): Default Transfer Syntax for Lossy JPEG 8 Bit Image Compression #[cfg(feature = "jpeg")] pub const JPEG_BASELINE: JpegTs = - create_ts_jpeg("1.2.840.10008.1.2.4.50", "JPEG Baseline (Process 1)"); + create_ts_jpeg("1.2.840.10008.1.2.4.50", "JPEG Baseline (Process 1)", true); /// **Implemented:** JPEG Baseline (Process 1): Default Transfer Syntax for Lossy JPEG 8 Bit Image Compression /// /// A native implementation is available @@ -109,8 +113,11 @@ pub const JPEG_BASELINE: Ts = create_ts_stub("1.2.840.10008.1.2.4.50", "JPEG Bas /// **Implemented:** JPEG Extended (Process 2 & 4): Default Transfer Syntax for Lossy JPEG 12 Bit Image Compression (Process 4 only) #[cfg(feature = "jpeg")] -pub const JPEG_EXTENDED: JpegTs = - create_ts_jpeg("1.2.840.10008.1.2.4.51", "JPEG Extended (Process 2 & 4)"); +pub const JPEG_EXTENDED: JpegTs = create_ts_jpeg( + "1.2.840.10008.1.2.4.51", + "JPEG Extended (Process 2 & 4)", + false, +); /// **Stub descriptor:** JPEG Extended (Process 2 & 4): Default Transfer Syntax for Lossy JPEG 12 Bit Image Compression (Process 4 only) /// /// A native implementation is available @@ -124,6 +131,7 @@ pub const JPEG_EXTENDED: Ts = pub const JPEG_LOSSLESS_NON_HIERARCHICAL: JpegTs = create_ts_jpeg( "1.2.840.10008.1.2.4.57", "JPEG Lossless, Non-Hierarchical (Process 14)", + false, ); /// **Stub descriptor:** JPEG Lossless, Non-Hierarchical (Process 14) /// @@ -142,6 +150,7 @@ pub const JPEG_LOSSLESS_NON_HIERARCHICAL: Ts = create_ts_stub( pub const JPEG_LOSSLESS_NON_HIERARCHICAL_FIRST_ORDER_PREDICTION: JpegTs = create_ts_jpeg( "1.2.840.10008.1.2.4.70", "JPEG Lossless, Non-Hierarchical, First-Order Prediction", + false, ); /// **Stub descriptor:** JPEG Lossless, Non-Hierarchical, First-Order Prediction /// (Process 14 [Selection Value 1]): diff --git a/transfer-syntax-registry/tests/adapters/mod.rs b/transfer-syntax-registry/tests/adapters/mod.rs new file mode 100644 index 000000000..3163b849a --- /dev/null +++ b/transfer-syntax-registry/tests/adapters/mod.rs @@ -0,0 +1,103 @@ +//! Utility module for testing pixel data adapters. +use std::borrow::Cow; + +use dicom_core::value::{InMemFragment, PixelFragmentSequence}; +use dicom_encoding::adapters::{PixelDataObject, RawPixelData}; + +/// A test data object. +/// +/// Can be used to test pixel data adapters +/// without having to open a real DICOM file using `dicom_object`. +#[derive(Debug)] +pub(crate) struct TestDataObject { + pub ts_uid: String, + pub rows: u16, + pub columns: u16, + pub bits_allocated: u16, + pub bits_stored: u16, + pub samples_per_pixel: u16, + pub number_of_frames: u32, + pub flat_pixel_data: Option>, + pub pixel_data_sequence: Option>, +} + +impl PixelDataObject for TestDataObject { + fn transfer_syntax_uid(&self) -> &str { + &self.ts_uid + } + + fn rows(&self) -> Option { + Some(self.rows) + } + + fn cols(&self) -> Option { + Some(self.columns) + } + + fn samples_per_pixel(&self) -> Option { + Some(self.samples_per_pixel) + } + + fn bits_allocated(&self) -> Option { + Some(self.bits_allocated) + } + + fn bits_stored(&self) -> Option { + Some(self.bits_stored) + } + + fn number_of_frames(&self) -> Option { + Some(self.number_of_frames) + } + + fn number_of_fragments(&self) -> Option { + match &self.pixel_data_sequence { + Some(v) => Some(v.fragments().len() as u32), + _ => None, + } + } + + fn fragment(&self, fragment: usize) -> Option> { + match (&self.flat_pixel_data, &self.pixel_data_sequence) { + (Some(_), Some(_)) => { + panic!("Invalid pixel data object (both flat and fragment sequence)") + } + (_, Some(v)) => v + .fragments() + .get(fragment) + .map(|f| Cow::Borrowed(f.as_slice())), + (Some(v), _) => { + if fragment == 0 { + Some(Cow::Borrowed(v)) + } else { + None + } + } + (None, None) => None, + } + } + + fn offset_table(&self) -> Option> { + match &self.pixel_data_sequence { + Some(v) => Some(Cow::Borrowed(v.offset_table())), + _ => None, + } + } + + fn raw_pixel_data(&self) -> Option { + match (&self.flat_pixel_data, &self.pixel_data_sequence) { + (Some(_), Some(_)) => { + panic!("Invalid pixel data object (both flat and fragment sequence)") + } + (Some(v), _) => Some(RawPixelData { + fragments: vec![v.clone()].into(), + offset_table: Default::default(), + }), + (_, Some(v)) => Some(RawPixelData { + fragments: v.fragments().into(), + offset_table: v.offset_table().into(), + }), + _ => None, + } + } +} diff --git a/transfer-syntax-registry/tests/jpeg.rs b/transfer-syntax-registry/tests/jpeg.rs new file mode 100644 index 000000000..e09311ae2 --- /dev/null +++ b/transfer-syntax-registry/tests/jpeg.rs @@ -0,0 +1,268 @@ +//! Test suite for JPEG pixel data reading and writing +#![cfg(feature = "jpeg")] + +mod adapters; + +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::Path, +}; + +use adapters::TestDataObject; +use dicom_core::value::PixelFragmentSequence; +use dicom_encoding::{ + adapters::{EncodeOptions, PixelDataReader, PixelDataWriter}, + Codec, +}; +use dicom_transfer_syntax_registry::entries::JPEG_BASELINE; + +fn read_data_piece(test_file: impl AsRef, offset: u64, length: usize) -> Vec { + let mut file = File::open(test_file).unwrap(); + // single fragment found in file data offset 0x6b6, 3314 bytes + let mut buf = vec![0; length]; + file.seek(SeekFrom::Start(offset)).unwrap(); + file.read_exact(&mut buf).unwrap(); + buf +} + +fn check_rgb_pixel(pixels: &[u8], columns: u16, x: u16, y: u16, expected_pixel: [u8; 3]) { + let i = (y as usize * columns as usize + x as usize) * 3; + let got = [pixels[i], pixels[i + 1], pixels[i + 2]]; + assert_eq!( + got, expected_pixel, + "pixel mismatch at ({}, {}): {:?} vs {:?}", + x, y, got, expected_pixel + ); +} + +fn check_rgb_pixel_approx(pixels: &[u8], columns: u16, x: u16, y: u16, pixel: [u8; 3], margin: u8) { + let i = (y as usize * columns as usize + x as usize) * 3; + + // check each component separately + assert!( + pixels[i].abs_diff(pixel[0]) <= margin, + "R channel error: {} vs {}", + pixels[i], + pixel[0] + ); + assert!( + pixels[i + 1].abs_diff(pixel[1]) <= margin, + "G channel error: {} vs {}", + pixels[i + 1], + pixel[1] + ); + assert!( + pixels[i + 2].abs_diff(pixel[2]) <= margin, + "B channel error: {} vs {}", + pixels[i + 2], + pixel[2] + ); +} + +#[test] +fn read_jpeg_baseline_1() { + let test_file = dicom_test_files::path("pydicom/SC_rgb_jpeg_lossy_gdcm.dcm").unwrap(); + + // manually fetch the pixel data fragment from the file + + // single fragment found in file data offset 0x6b8, 3314 bytes + let buf = read_data_piece(test_file, 0x6b8, 3314); + + // create test object + let obj = TestDataObject { + // JPEG baseline (Process 1) + ts_uid: "1.2.840.10008.1.2.4.50".to_string(), + rows: 100, + columns: 100, + bits_allocated: 8, + bits_stored: 8, + samples_per_pixel: 3, + number_of_frames: 1, + flat_pixel_data: None, + pixel_data_sequence: Some(PixelFragmentSequence::new(vec![], vec![buf])), + }; + + // instantiate JpegAdapter and call decode_frame + + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_BASELINE.codec() else { + panic!("JPEG pixel data reader not found") + }; + + let mut dest = vec![]; + + adapter + .decode_frame(&obj, 0, &mut dest) + .expect("JPEG frame decoding failed"); + + // inspect the result + + assert_eq!(dest.len(), 30_000); + + let err_margin = 7; + + // check a few known pixels + + // 0, 0 + check_rgb_pixel_approx(&dest, 100, 0, 0, [254, 0, 0], err_margin); + // 50, 50 + check_rgb_pixel_approx(&dest, 100, 50, 50, [124, 124, 255], err_margin); + // 75, 75 + check_rgb_pixel_approx(&dest, 100, 75, 75, [64, 64, 64], err_margin); + // 16, 49 + check_rgb_pixel_approx(&dest, 100, 16, 49, [4, 4, 226], err_margin); +} + +#[test] +fn read_jpeg_lossless_1() { + let test_file = dicom_test_files::path("pydicom/SC_rgb_jpeg_gdcm.dcm").unwrap(); + + // manually fetch the pixel data fragment from the file + + // single fragment found in file data offset 0x538, 3860 bytes + let buf = read_data_piece(test_file, 0x538, 3860); + + // create test object + let obj = TestDataObject { + // JPEG baseline (Process 1) + ts_uid: "1.2.840.10008.1.2.4.70".to_string(), + rows: 100, + columns: 100, + bits_allocated: 8, + bits_stored: 8, + samples_per_pixel: 3, + number_of_frames: 1, + flat_pixel_data: None, + pixel_data_sequence: Some(PixelFragmentSequence::new(vec![], vec![buf])), + }; + + // instantiate JpegAdapter and call decode_frame + + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_BASELINE.codec() else { + panic!("JPEG pixel data reader not found") + }; + + let mut dest = vec![]; + + adapter + .decode_frame(&obj, 0, &mut dest) + .expect("JPEG frame decoding failed"); + + // inspect the result + + assert_eq!(dest.len(), 30_000); + + // check a few known pixels + + // 0, 0 + check_rgb_pixel(&dest, 100, 0, 0, [255, 0, 0]); + // 50, 50 + check_rgb_pixel(&dest, 100, 50, 50, [128, 128, 255]); + // 75, 75 + check_rgb_pixel(&dest, 100, 75, 75, [64, 64, 64]); + // 16, 49 + check_rgb_pixel(&dest, 100, 16, 49, [0, 0, 255]); +} + +/// writing to JPEG and back should yield approximately the same pixel data +#[test] +fn write_and_read_jpeg_baseline() { + let rows: u16 = 256; + let columns: u16 = 512; + + // build some random RGB image + let mut samples = vec![0; rows as usize * columns as usize * 3]; + + // use linear congruence to make RGB noise + let mut seed = 0xcfcf_acab_u32; + let mut gen_sample = || { + let r = 4_294_967_291_u32; + let b = 67291_u32; + seed = seed.wrapping_mul(r).wrapping_add(b); + // grab a portion from the seed + (seed >> 7) as u8 + }; + + let slab = 8; + for y in (0..rows as usize).step_by(slab) { + let scan_r = gen_sample(); + let scan_g = gen_sample(); + let scan_b = gen_sample(); + + for x in 0..columns as usize { + for k in 0..slab { + let offset = ((y + k) * columns as usize + x) * 3; + samples[offset] = scan_r; + samples[offset + 1] = scan_g; + samples[offset + 2] = scan_b; + } + } + } + + // create test object of native encoding + let obj = TestDataObject { + // Explicit VR Little Endian + ts_uid: "1.2.840.10008.1.2.1".to_string(), + rows, + columns, + bits_allocated: 8, + bits_stored: 8, + samples_per_pixel: 3, + number_of_frames: 1, + flat_pixel_data: Some(samples.clone()), + pixel_data_sequence: None, + }; + + // instantiate JpegAdapter and call encode_frame + + let Codec::EncapsulatedPixelData(Some(reader), Some(writer)) = JPEG_BASELINE.codec() else { + panic!("JPEG pixel data adapters not found") + }; + + // request higher quality to reduce loss + let mut options = EncodeOptions::default(); + options.quality = Some(95); + + let mut encoded = vec![]; + + let _ops = writer + .encode_frame(&obj, 0, options, &mut encoded) + .expect("JPEG frame encoding failed"); + + // instantiate new object representing the compressed version + + let obj = TestDataObject { + // JPEG baseline (Process 1) + ts_uid: "1.2.840.10008.1.2.4.50".to_string(), + rows, + columns, + bits_allocated: 8, + bits_stored: 8, + samples_per_pixel: 3, + number_of_frames: 1, + flat_pixel_data: None, + pixel_data_sequence: Some(PixelFragmentSequence::new(vec![], vec![encoded])), + }; + + // decode frame + let mut decoded = vec![]; + + reader + .decode_frame(&obj, 0, &mut decoded) + .expect("JPEG frame decoding failed"); + + // inspect the result + assert_eq!(samples.len(), decoded.len(), "pixel data length mismatch"); + + // traverse all pixels, compare with error margin + let err_margin = 7; + + for (src_sample, decoded_sample) in samples.iter().copied().zip(decoded.iter().copied()) { + assert!( + src_sample.abs_diff(decoded_sample) <= err_margin, + "pixel sample mismatch: {} vs {}", + src_sample, + decoded_sample + ); + } +}