-
Notifications
You must be signed in to change notification settings - Fork 89
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[ts-registry] Add JPEG encoding/decoding tests
- add adapter utils and TestPixelData - fixes overflow on large resolutions - add dicom-test-files as a dev dependency - add tests/jpeg.rs test suite - reading jpeg baseline and jpeg lossless - write & read cycle
- Loading branch information
Showing
3 changed files
with
370 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,3 +40,6 @@ optional = true | |
|
||
[package.metadata.docs.rs] | ||
features = ["native"] | ||
|
||
[dev-dependencies] | ||
dicom-test-files = "0.2.1" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
//! 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<Vec<u8>>, | ||
pub pixel_data_sequence: Option<PixelFragmentSequence<InMemFragment>>, | ||
} | ||
|
||
impl PixelDataObject for TestDataObject { | ||
fn transfer_syntax_uid(&self) -> &str { | ||
&self.ts_uid | ||
} | ||
|
||
fn rows(&self) -> Option<u16> { | ||
Some(self.rows) | ||
} | ||
|
||
fn cols(&self) -> Option<u16> { | ||
Some(self.columns) | ||
} | ||
|
||
fn samples_per_pixel(&self) -> Option<u16> { | ||
Some(self.samples_per_pixel) | ||
} | ||
|
||
fn bits_allocated(&self) -> Option<u16> { | ||
Some(self.bits_allocated) | ||
} | ||
|
||
fn number_of_frames(&self) -> Option<u32> { | ||
Some(self.number_of_frames) | ||
} | ||
|
||
fn number_of_fragments(&self) -> Option<u32> { | ||
match &self.pixel_data_sequence { | ||
Some(v) => Some(v.fragments().len() as u32), | ||
_ => None, | ||
} | ||
} | ||
|
||
fn fragment(&self, fragment: usize) -> Option<Cow<[u8]>> { | ||
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<Cow<[u32]>> { | ||
match &self.pixel_data_sequence { | ||
Some(v) => Some(Cow::Borrowed(v.offset_table())), | ||
_ => None, | ||
} | ||
} | ||
|
||
fn raw_pixel_data(&self) -> Option<RawPixelData> { | ||
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, | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Path>, offset: u64, length: usize) -> Vec<u8> { | ||
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 | ||
); | ||
} | ||
} |