From 3ca7e307dc7bee6282e295861a96e40105d91f9f Mon Sep 17 00:00:00 2001 From: Eduardo Pinho Date: Sat, 19 Oct 2024 12:20:19 +0100 Subject: [PATCH 1/4] [ts-registry] Add JPEG-LS encoding - rename JpegLSAdapter to JpegLsAdapter - impl PixelDataWriter for JpegLsAdapter - uses charls-rs all the same, transforms quality and sample bit depth into the NEAR parameter - add JpegLsLosslessEncoder, which overrides quality to lossless - update entries accordingly - add tests for JPEG-LS encoding and decoding --- .../src/adapters/jpegls.rs | 170 ++++++++++- transfer-syntax-registry/src/entries.rs | 24 +- transfer-syntax-registry/tests/jpegls.rs | 273 ++++++++++++++++++ 3 files changed, 447 insertions(+), 20 deletions(-) create mode 100644 transfer-syntax-registry/tests/jpegls.rs diff --git a/transfer-syntax-registry/src/adapters/jpegls.rs b/transfer-syntax-registry/src/adapters/jpegls.rs index 2ab87483a..d039867c2 100644 --- a/transfer-syntax-registry/src/adapters/jpegls.rs +++ b/transfer-syntax-registry/src/adapters/jpegls.rs @@ -1,15 +1,24 @@ //! Support for JPEG-LS image decoding. -use charls::CharLS; -use dicom_encoding::adapters::{decode_error, DecodeResult, PixelDataObject, PixelDataReader}; +use charls::{CharLS, FrameInfo}; +use dicom_core::ops::{AttributeAction, AttributeOp}; +use dicom_core::Tag; +use dicom_encoding::adapters::{ + decode_error, encode_error, DecodeResult, EncodeResult, PixelDataObject, PixelDataReader, + PixelDataWriter, +}; use dicom_encoding::snafu::prelude::*; use std::borrow::Cow; -/// Pixel data adapter for JPEG-LS transfer syntax. +/// Pixel data reader and writer for JPEG-LS transfer syntaxes. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct JpegLSAdapter; +pub struct JpegLsAdapter; -impl PixelDataReader for JpegLSAdapter { +/// Pixel data writer specifically for JPEG-LS lossless. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct JpegLsLosslessWriter; + +impl PixelDataReader for JpegLsAdapter { /// Decode a single frame in JPEG-LS from a DICOM object. fn decode_frame( &self, @@ -93,3 +102,154 @@ impl PixelDataReader for JpegLSAdapter { Ok(()) } } + +impl PixelDataWriter for JpegLsAdapter { + fn encode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + options: dicom_encoding::adapters::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", + })?; + let bits_stored = src + .bits_stored() + .context(encode_error::MissingAttributeSnafu { name: "BitsStored" })?; + + ensure_whatever!( + bits_allocated == 8 || bits_allocated == 16, + "BitsAllocated other than 8 or 16 is not supported" + ); + + ensure_whatever!( + bits_stored != 1, + "BitsStored of 1 is not supported" + ); + + 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; + + // identify frame data using the frame index + let pixeldata_uncompressed = &src + .raw_pixel_data() + .context(encode_error::MissingAttributeSnafu { name: "Pixel Data" })? + .fragments[0]; + + let frame_data = pixeldata_uncompressed + .get(frame_size * frame as usize..frame_size * (frame as usize + 1)) + .whatever_context("Frame index out of bounds")?; + + // Encode the data + let mut encoder = CharLS::default(); + + let frame_info = FrameInfo { + width: cols as u32, + height: rows as u32, + bits_per_sample: bits_stored as i32, + component_count: samples_per_pixel as i32, + }; + + // prefer lossless encoding by default + let quality = options.quality.map(|q| q.clamp(0, 100)).unwrap_or(100); + + // calculate the maximum acceptable error range + // based on the requested quality and bit depth + let near = ((1 << (bits_stored - 4)) * (100 - quality as i32) / 100).min(4096); + + let compressed_data = encoder + .encode(frame_info, near, frame_data) + .whatever_context("JPEG-LS encoding failed")?; + + dst.extend_from_slice(&compressed_data); + + let mut changes = if near > 0 { + let compressed_frame_size = compressed_data.len(); + + let compression_ratio = frame_size as f64 / compressed_frame_size as f64; + let compression_ratio = format!("{:.6}", compression_ratio); + + // provide attribute changes + 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()), + ), + ] + } else { + vec![ + // lossless image compression + AttributeOp::new(Tag(0x0028, 0x2110), AttributeAction::SetIfMissing("00".into())), + ] + }; + + let pmi = src.photometric_interpretation(); + + if samples_per_pixel == 1 { + // set Photometric Interpretation to Monochrome2 + // if it was neither of the expected monochromes + if pmi != Some("MONOCHROME1") && pmi != Some("MONOCHROME2") { + changes.push(AttributeOp::new( + Tag(0x0028, 0x0004), + AttributeAction::SetStr("MONOCHROME2".into()), + )); + } + } else if samples_per_pixel == 3 { + // set Photometric Interpretation to RGB + // if it was not already set to RGB + if pmi != Some("RGB") { + changes.push(AttributeOp::new( + Tag(0x0028, 0x0004), + AttributeAction::SetStr("RGB".into()), + )); + } + } + + Ok(changes) + } +} + +impl PixelDataWriter for JpegLsLosslessWriter { + fn encode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + mut options: dicom_encoding::adapters::EncodeOptions, + dst: &mut Vec, + ) -> EncodeResult> { + // override quality and defer to the main adapter + options.quality = Some(100); + JpegLsAdapter.encode_frame(src, frame, options, dst) + } + + fn encode( + &self, + src: &dyn PixelDataObject, + options: dicom_encoding::adapters::EncodeOptions, + dst: &mut Vec>, + offset_table: &mut Vec, + ) -> EncodeResult> { + // override quality and defer to the main adapter + let mut options = options; + options.quality = Some(100); + JpegLsAdapter.encode(src, options, dst, offset_table) + } +} diff --git a/transfer-syntax-registry/src/entries.rs b/transfer-syntax-registry/src/entries.rs index 280a5c6a3..c158719d4 100644 --- a/transfer-syntax-registry/src/entries.rs +++ b/transfer-syntax-registry/src/entries.rs @@ -35,7 +35,7 @@ use crate::adapters::jpeg::JpegAdapter; #[cfg(any(feature = "openjp2", feature = "openjpeg-sys"))] use crate::adapters::jpeg2k::Jpeg2000Adapter; #[cfg(feature = "charls")] -use crate::adapters::jpegls::JpegLSAdapter; +use crate::adapters::jpegls::{JpegLsAdapter, JpegLsLosslessWriter}; #[cfg(feature = "rle")] use crate::adapters::rle_lossless::RleLosslessAdapter; @@ -264,25 +264,17 @@ pub const JPEG_2000_PART2_MULTI_COMPONENT_IMAGE_COMPRESSION: Ts = create_ts_stub // --- partially supported transfer syntaxes, pixel data encapsulation not supported --- -/// An alias for a transfer syntax specifier with [`JpegLSAdapter`] +/// An alias for a transfer syntax specifier with [`JpegLSAdapter`] as the decoder +/// and an arbitrary encoder (since two impls are available) #[cfg(feature = "charls")] -type JpegLSTs = TransferSyntax; - -/// Create JPEG-LS TransferSyntax -#[cfg(feature = "charls")] -const fn create_ts_jpegls(uid: &'static str, name: &'static str) -> JpegLSTs { - TransferSyntax::new_ele( - uid, - name, - Codec::EncapsulatedPixelData(Some(JpegLSAdapter), None), - ) -} +type JpegLSTs = TransferSyntax; /// **Decoder Implementation:** JPEG-LS Lossless Image Compression #[cfg(feature = "charls")] -pub const JPEG_LS_LOSSLESS_IMAGE_COMPRESSION: JpegLSTs = create_ts_jpegls( +pub const JPEG_LS_LOSSLESS_IMAGE_COMPRESSION: JpegLSTs = TransferSyntax::new_ele( "1.2.840.10008.1.2.4.80", "JPEG-LS Lossless Image Compression", + Codec::EncapsulatedPixelData(Some(JpegLsAdapter), Some(JpegLsLosslessWriter)), ); /// **Stub descriptor:** JPEG-LS Lossless Image Compression @@ -291,11 +283,13 @@ pub const JPEG_LS_LOSSLESS_IMAGE_COMPRESSION: Ts = create_ts_stub( "1.2.840.10008.1.2.4.80", "JPEG-LS Lossless Image Compression", ); + /// **Decoder Implementation:** JPEG-LS Lossy (Near-Lossless) Image Compression #[cfg(feature = "charls")] -pub const JPEG_LS_LOSSY_IMAGE_COMPRESSION: JpegLSTs = create_ts_jpegls( +pub const JPEG_LS_LOSSY_IMAGE_COMPRESSION: JpegLSTs = TransferSyntax::new_ele( "1.2.840.10008.1.2.4.81", "JPEG-LS Lossy (Near-Lossless) Image Compression", + Codec::EncapsulatedPixelData(Some(JpegLsAdapter), Some(JpegLsAdapter)), ); /// **Stub descriptor:** JPEG-LS Lossy (Near-Lossless) Image Compression diff --git a/transfer-syntax-registry/tests/jpegls.rs b/transfer-syntax-registry/tests/jpegls.rs new file mode 100644 index 000000000..f7199ad19 --- /dev/null +++ b/transfer-syntax-registry/tests/jpegls.rs @@ -0,0 +1,273 @@ +//! Test suite for JPEG-LS pixel data reading and writing +#![cfg(feature = "charls")] + +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_LS_LOSSLESS_IMAGE_COMPRESSION, JPEG_LS_LOSSY_IMAGE_COMPRESSION, +}; + +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_w_monochrome_pixel(pixels: &[u8], columns: u16, x: u16, y: u16, expected_pixel: u16) { + let i = (y as usize * columns as usize + x as usize) * 2; + if i + 1 >= pixels.len() { + panic!("pixel index {} at ({}, {}) is out of bounds", i, x, y); + } + let got = u16::from_le_bytes([pixels[i], pixels[i + 1]]); + assert_eq!( + got, expected_pixel, + "pixel mismatch at ({}, {}): {:?} vs {:?}", + x, y, got, expected_pixel + ); +} + +fn check_w_monochrome_pixel_approx(data: &[u8], columns: u16, x: u16, y: u16, pixel: u16, margin: u16) { + let i = (y as usize * columns as usize + x as usize) * 2; + let sample = u16::from_le_bytes([data[i], data[i + 1]]); + + assert!( + sample.abs_diff(pixel) <= margin, + "sample error at ({}, {}): {} vs {}", + x, + y, + sample, + pixel + ); +} + +#[test] +fn read_jpeg_ls_1() { + let test_file = dicom_test_files::path("WG04/JLSN/NM1_JLSN").unwrap(); + + // manually fetch the pixel data fragment from the file + + // single fragment found in file data offset 0x0bd4, 29194 bytes + let buf = read_data_piece(test_file, 0x0bd4, 29194); + + // create test object + let obj = TestDataObject { + // JPEG-LS Lossy (near-lossless) + ts_uid: "1.2.840.10008.1.2.4.81".to_string(), + rows: 1024, + columns: 256, + bits_allocated: 16, + bits_stored: 16, + samples_per_pixel: 1, + photometric_interpretation: "MONOCHROME2", + number_of_frames: 1, + flat_pixel_data: None, + pixel_data_sequence: Some(PixelFragmentSequence::new(vec![], vec![buf])), + }; + + // fetch decoder + + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_LS_LOSSY_IMAGE_COMPRESSION.codec() else { + panic!("JPEG-LS pixel data reader not found") + }; + + let mut dest = vec![]; + + adapter + .decode_frame(&obj, 0, &mut dest) + .expect("JPEG-LS frame decoding failed"); + + // inspect the result + + assert_eq!(dest.len(), 1024 * 256 * 2); + + let err_margin = 512; + + // check a few known pixels + + // 0, 0 + check_w_monochrome_pixel_approx(&dest, 256, 0, 0, 0, err_margin); + // 64, 154 + check_w_monochrome_pixel_approx(&dest, 256, 64, 154, 0, err_margin); + // 135, 145 + check_w_monochrome_pixel_approx(&dest, 256, 135, 145, 168, err_margin); + // 80, 188 + check_w_monochrome_pixel_approx(&dest, 256, 80, 188, 9, err_margin); + // 136, 416 + check_w_monochrome_pixel_approx(&dest, 256, 136, 416, 245, err_margin); +} + +#[test] +fn read_jpeg_ls_lossless_1() { + let test_file = dicom_test_files::path("pydicom/MR_small_jpeg_ls_lossless.dcm").unwrap(); + + // manually fetch the pixel data fragment from the file + + // single fragment found in file data offset 0x60c, 4430 bytes + let buf = read_data_piece(test_file, 0x060c, 4430); + + let cols = 64; + + // create test object + let obj = TestDataObject { + // JPEG-LS Lossless + ts_uid: "1.2.840.10008.1.2.4.80".to_string(), + rows: 64, + columns: cols, + bits_allocated: 16, + bits_stored: 16, + samples_per_pixel: 1, + photometric_interpretation: "MONOCHROME2", + number_of_frames: 1, + flat_pixel_data: None, + pixel_data_sequence: Some(PixelFragmentSequence::new(vec![], vec![buf])), + }; + + // fetch decoder + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_LS_LOSSLESS_IMAGE_COMPRESSION.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(), 64 * 64 * 2); + + // check a few known pixels + + // 0, 0 + check_w_monochrome_pixel(&dest, cols, 0, 0, 905); + // 50, 9 + check_w_monochrome_pixel(&dest, cols, 50, 9, 1162); + // 8, 22 + check_w_monochrome_pixel(&dest, cols, 8, 22, 227); + // 46, 41 + check_w_monochrome_pixel(&dest, cols, 46, 41, 1152); + // 34, 53 + check_w_monochrome_pixel(&dest, cols, 34, 53, 164); + // 38, 61 + check_w_monochrome_pixel(&dest, cols, 38, 61, 1857); +} + +/// writing to JPEG-LS and back should yield approximately the same pixel data +#[test] +fn write_and_read_jpeg_ls() { + 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, + photometric_interpretation: "RGB", + number_of_frames: 1, + flat_pixel_data: Some(samples.clone()), + pixel_data_sequence: None, + }; + + // fetch decoder and encoder + let Codec::EncapsulatedPixelData(Some(reader), Some(writer)) = JPEG_LS_LOSSY_IMAGE_COMPRESSION.codec() else { + panic!("JPEG-LS pixel data adapters not found") + }; + + // request enough quality to admit some loss, but not too much + let mut options = EncodeOptions::default(); + options.quality = Some(85); + + let mut encoded = vec![]; + + let _ops = writer + .encode_frame(&obj, 0, options, &mut encoded) + .expect("JPEG-LS frame encoding failed"); + + // instantiate new object representing the compressed version + + let obj = TestDataObject { + // JPEG-LS Lossy (near-lossless) + ts_uid: "1.2.840.10008.1.2.4.81".to_string(), + rows, + columns, + bits_allocated: 8, + bits_stored: 8, + samples_per_pixel: 3, + photometric_interpretation: "RGB", + 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-LS 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 = 4; + + 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 + ); + } +} From cb129d30b572f433672997c3c57dd69a39db39f8 Mon Sep 17 00:00:00 2001 From: Eduardo Pinho Date: Sat, 19 Oct 2024 12:22:27 +0100 Subject: [PATCH 2/4] [ts-registry] Update root crate documentation - JPEG-LS encoding is supported --- transfer-syntax-registry/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transfer-syntax-registry/src/lib.rs b/transfer-syntax-registry/src/lib.rs index 82a852308..5b7340f2f 100644 --- a/transfer-syntax-registry/src/lib.rs +++ b/transfer-syntax-registry/src/lib.rs @@ -56,8 +56,8 @@ //! | JPEG Extended (Process 2 & 4) | Cargo feature `jpeg` | x | //! | JPEG Lossless, Non-Hierarchical (Process 14) | Cargo feature `jpeg` | x | //! | JPEG Lossless, Non-Hierarchical, First-Order Prediction (Process 14 [Selection Value 1]) | Cargo feature `jpeg` | x | -//! | JPEG-LS Lossless | Cargo feature `charls` | x | -//! | JPEG-LS Lossy (Near-Lossless) | Cargo feature `charls` | x | +//! | JPEG-LS Lossless | Cargo feature `charls` | ✓ | +//! | JPEG-LS Lossy (Near-Lossless) | Cargo feature `charls` | ✓ | //! | JPEG 2000 (Lossless Only) | Cargo feature `openjp2` or `openjpeg-sys` | x | //! | JPEG 2000 | Cargo feature `openjp2` or `openjpeg-sys` | x | //! | JPEG 2000 Part 2 Multi-component Image Compression (Lossless Only) | Cargo feature `openjp2` or `openjpeg-sys` | x | From 69e399483efb8465cad956948533efc5430fd3b1 Mon Sep 17 00:00:00 2001 From: Eduardo Pinho Date: Sat, 19 Oct 2024 12:31:52 +0100 Subject: [PATCH 3/4] [toimage] Add extension inference to .jls when unwrapping JPEG-LS files --- toimage/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/toimage/src/main.rs b/toimage/src/main.rs index 96a26b3c1..5f17f9e83 100644 --- a/toimage/src/main.rs +++ b/toimage/src/main.rs @@ -340,6 +340,10 @@ fn convert_single_file( | uids::JPEG2000_LOSSLESS => { output.set_extension("jp2"); } + uids::JPEGLS_LOSSLESS + | uids::JPEGLS_NEAR_LOSSLESS => { + output.set_extension("jls"); + } _ => { output.set_extension("data"); } From fd44db901d8a65d7d3e899e0da149ec2f2bb20ed Mon Sep 17 00:00:00 2001 From: Eduardo Pinho Date: Wed, 23 Oct 2024 16:28:57 +0100 Subject: [PATCH 4/4] [ts-registry] tweak JPEG-LS encoding logic for palette color images - retain photometric interpretation in that case and force lossless encoding --- transfer-syntax-registry/src/adapters/jpegls.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/transfer-syntax-registry/src/adapters/jpegls.rs b/transfer-syntax-registry/src/adapters/jpegls.rs index d039867c2..9b2cb7d17 100644 --- a/transfer-syntax-registry/src/adapters/jpegls.rs +++ b/transfer-syntax-registry/src/adapters/jpegls.rs @@ -166,7 +166,14 @@ impl PixelDataWriter for JpegLsAdapter { }; // prefer lossless encoding by default - let quality = options.quality.map(|q| q.clamp(0, 100)).unwrap_or(100); + let mut quality = options.quality.map(|q| q.clamp(0, 100)).unwrap_or(100); + + let pmi = src.photometric_interpretation(); + + if pmi == Some("PALETTE COLOR") { + // force lossless encoding of palette color samples + quality = 100; + } // calculate the maximum acceptable error range // based on the requested quality and bit depth @@ -201,12 +208,10 @@ impl PixelDataWriter for JpegLsAdapter { ] }; - let pmi = src.photometric_interpretation(); - if samples_per_pixel == 1 { - // set Photometric Interpretation to Monochrome2 - // if it was neither of the expected monochromes - if pmi != Some("MONOCHROME1") && pmi != Some("MONOCHROME2") { + // set Photometric Interpretation to MONOCHROME2 + // if it was neither of the expected 1-channel formats + if pmi != Some("MONOCHROME1") && pmi != Some("MONOCHROME2") && pmi != Some("PALETTE COLOR") { changes.push(AttributeOp::new( Tag(0x0028, 0x0004), AttributeAction::SetStr("MONOCHROME2".into()),