diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 997ea7f4..750d576a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -24,7 +24,7 @@ jobs: components: clippy cache: true # test project with default + extra features - - run: cargo test --features image,ndarray,sop-class,rle,cli + - run: cargo test --features image,ndarray,sop-class,rle,cli,jpegxl # test dicom-pixeldata with openjp2 - run: cargo test -p dicom-pixeldata --features openjp2 # test dicom-pixeldata with openjpeg-sys and charls diff --git a/Cargo.lock b/Cargo.lock index 3f5259ae..92ffe680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -658,8 +658,11 @@ dependencies = [ "jpeg-decoder", "jpeg-encoder", "jpeg2k", + "jxl-oxide", "lazy_static", "tracing", + "zune-core", + "zune-jpegxl", ] [[package]] @@ -1218,6 +1221,147 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855ff16398ffbcf81fee52c41ca65326499c8764b21bb9952c367ace98995fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5b5093904e940bc11ef50e872c7bdf7b6e88653f012b925f8479daf212b5c9" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ec11695f2091e50531c970ad7eb4819989a20a2c89d68ae1b4f74f48454c10" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4719f285ebfff5e64f352d0ef149a5244aef4f8e6b5aa666ba6241e90b50632f" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a28ba2734da33624db4b426b44750a7b1238e6cba65d27b7d84bf3cba7f507" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3824c81613c05c19a9e4329d569145d3f460c0fcadb3965bd8418162d43f7f4" +dependencies = [ + "jxl-bitstream", + "jxl-color", + "jxl-grid", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e10bbc6041d9ea64bcfc6931ed89f0192954ac0a02bdbad624aa43b345e613" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71f3b9dbe4adefadac57b25a15bf7735202ba58c0e5500c6bfb2d63398bf21c2" +dependencies = [ + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-render" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4259446ca029587f2b7850d223d57b4f69665dd8628e83bcb0a6c2ab964f1ef" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2860c68899a3c6266044fc26c6a0041e9f27145f58cc69b6eedc1b77f5ee13" +dependencies = [ + "rayon", + "rayon-core", + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15da4b49b832b3d8a67329f47e2a1732e0847667938bb9b4a37d99a4668775c2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-threadpool", + "tracing", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2592,3 +2736,12 @@ checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768" dependencies = [ "zune-core", ] + +[[package]] +name = "zune-jpegxl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ffee484384b15f99ed4768bfdb3fa186d63e1f8c3aafba1d6d141a7b9e3674" +dependencies = [ + "zune-core", +] diff --git a/pixeldata/Cargo.toml b/pixeldata/Cargo.toml index 57eac303..caeda3dc 100644 --- a/pixeldata/Cargo.toml +++ b/pixeldata/Cargo.toml @@ -58,6 +58,8 @@ image = ["dep:image"] native = ["dicom-transfer-syntax-registry/native", "jpeg", "rle"] # native JPEG codec implementation jpeg = ["dicom-transfer-syntax-registry/jpeg"] +# native JPEG XL codec implementation +jpegxl = ["dicom-transfer-syntax-registry/jpegxl"] # native RLE lossless codec implementation rle = ["dicom-transfer-syntax-registry/rle"] # JPEG 2000 decoding via OpenJPEG static linking diff --git a/pixeldata/README.md b/pixeldata/README.md index 3c47e7d9..0db145a1 100644 --- a/pixeldata/README.md +++ b/pixeldata/README.md @@ -18,7 +18,9 @@ You can use it to transcode a DICOM file to another transfer syntax, transforming pixel data along the way. ```none -Usage: dicom-transcode [OPTIONS] <--ts |--expl-vr-le|--impl-vr-le|--jpeg-baseline> +Transcode a DICOM file + +Usage: dicom-transcode [OPTIONS] <--ts |--expl-vr-le|--impl-vr-le|--jpeg-baseline|--jpeg-ls-lossless|--jpeg-ls|--jpeg-xl-lossless|--jpeg-xl> Arguments: @@ -31,6 +33,10 @@ Options: --expl-vr-le Transcode to Explicit VR Little Endian --impl-vr-le Transcode to Implicit VR Little Endian --jpeg-baseline Transcode to JPEG baseline (8-bit) + --jpeg-ls-lossless Transcode to JPEG-LS lossless + --jpeg-ls Transcode to JPEG-LS near-lossless + --jpeg-xl-lossless Transcode to JPEG XL lossless + --jpeg-xl Transcode to JPEG XL --retain-implementation Retain the original implementation class UID and version name -v, --verbose Verbose mode -h, --help Print help diff --git a/pixeldata/src/bin/dicom-transcode.rs b/pixeldata/src/bin/dicom-transcode.rs index a82347bf..ffc9c4a4 100644 --- a/pixeldata/src/bin/dicom-transcode.rs +++ b/pixeldata/src/bin/dicom-transcode.rs @@ -51,7 +51,7 @@ struct App { /// Specifier for the target transfer syntax #[derive(Debug, Parser)] -#[group(required = true, multiple = false)] +#[group(required = true, multiple = false, id = "transfer_syntax")] struct TargetTransferSyntax { /// Transcode to the Transfer Syntax indicated by UID #[clap(long = "ts")] @@ -66,41 +66,105 @@ struct TargetTransferSyntax { implicit_vr_le: bool, /// Transcode to JPEG baseline (8-bit) + #[cfg(feature = "jpeg")] #[clap(long = "jpeg-baseline")] jpeg_baseline: bool, + + /// Transcode to JPEG-LS lossless + #[cfg(feature = "charls")] + #[clap(long = "jpeg-ls-lossless")] + jpeg_ls_lossless: bool, + + /// Transcode to JPEG-LS near-lossless + #[cfg(feature = "charls")] + #[clap(long = "jpeg-ls")] + jpeg_ls: bool, + + /// Transcode to JPEG XL lossless + #[cfg(feature = "jpegxl")] + #[clap(long = "jpeg-xl-lossless")] + jpeg_xl_lossless: bool, + + /// Transcode to JPEG XL + #[cfg(feature = "jpegxl")] + #[clap(long = "jpeg-xl")] + jpeg_xl: bool, } impl TargetTransferSyntax { fn resolve(&self) -> Result<&'static TransferSyntax, Whatever> { - // explicit VR little endian - if self.explicit_vr_le { - return Ok(TransferSyntaxRegistry + match self { + // none specified + TargetTransferSyntax { + ts: None, + explicit_vr_le: false, + implicit_vr_le: false, + #[cfg(feature = "jpeg")] + jpeg_baseline: false, + #[cfg(feature = "charls")] + jpeg_ls_lossless: false, + #[cfg(feature = "charls")] + jpeg_ls: false, + #[cfg(feature = "jpegxl")] + jpeg_xl_lossless: false, + #[cfg(feature = "jpegxl")] + jpeg_xl: false, + } => snafu::whatever!("No target transfer syntax specified"), + // explicit VR little endian + TargetTransferSyntax { + explicit_vr_le: true, + .. + } => Ok(TransferSyntaxRegistry .get(uids::EXPLICIT_VR_LITTLE_ENDIAN) - .expect("Explicit VR Little Endian is missing???")); - } - - // implicit VR little endian - if self.implicit_vr_le { - return Ok(TransferSyntaxRegistry + .expect("Explicit VR Little Endian is missing???")), + // implicit VR little endian + TargetTransferSyntax { + implicit_vr_le: true, + .. + } => Ok(TransferSyntaxRegistry .get(uids::IMPLICIT_VR_LITTLE_ENDIAN) - .expect("Implicit VR Little Endian is missing???")); - } - - // JPEG baseline - if self.jpeg_baseline { - return TransferSyntaxRegistry + .expect("Implicit VR Little Endian is missing???")), + // JPEG baseline + #[cfg(feature = "jpeg")] + TargetTransferSyntax { + jpeg_baseline: true, + .. + } => TransferSyntaxRegistry .get(uids::JPEG_BASELINE8_BIT) - .whatever_context("Missing specifier for JPEG Baseline (8-bit)"); + .whatever_context("Missing specifier for JPEG Baseline (8-bit)"), + // JPEG-LS lossless + #[cfg(feature = "charls")] + TargetTransferSyntax { + jpeg_ls_lossless: true, + .. + } => TransferSyntaxRegistry + .get(uids::JPEGLS_LOSSLESS) + .whatever_context("Missing specifier for JPEG-LS Lossless"), + // JPEG-LS near-lossless + #[cfg(feature = "charls")] + TargetTransferSyntax { + jpeg_ls: true, + .. + } => TransferSyntaxRegistry + .get(uids::JPEGLS_NEAR_LOSSLESS) + .whatever_context("Missing specifier for JPEG-LS Near-Lossless"), + // JPEG XL lossless + #[cfg(feature = "jpegxl")] + TargetTransferSyntax { + jpeg_xl_lossless: true, + .. + } => TransferSyntaxRegistry + .get(uids::JPEGXL_LOSSLESS) + .whatever_context("Missing specifier for JPEG XL Lossless"), + // JPEG XL + #[cfg(feature = "jpegxl")] + TargetTransferSyntax { jpeg_xl: true, .. } => TransferSyntaxRegistry + .get(uids::JPEGXL) + .whatever_context("Missing specifier for JPEG XL"), + TargetTransferSyntax { ts: Some(ts), .. } => TransferSyntaxRegistry + .get(ts) + .whatever_context("Unknown transfer syntax"), } - - // by TS UID - let Some(ts) = &self.ts else { - snafu::whatever!("No target transfer syntax specified"); - }; - - TransferSyntaxRegistry - .get(ts) - .whatever_context("Unknown transfer syntax") } } diff --git a/toimage/Cargo.toml b/toimage/Cargo.toml index 13cba4da..05b5c94e 100644 --- a/toimage/Cargo.toml +++ b/toimage/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["cli", "dicom", "image", "image-conversion"] readme = "README.md" [features] -default = ['dicom-object/inventory-registry', 'dicom-pixeldata/native'] +default = ['dicom-object/inventory-registry', 'dicom-pixeldata/native', 'dicom-pixeldata/jpegxl'] [dependencies] clap = { version = "4.0.18", features = ["derive"] } diff --git a/toimage/src/main.rs b/toimage/src/main.rs index 5f17f9e8..52da7728 100644 --- a/toimage/src/main.rs +++ b/toimage/src/main.rs @@ -344,6 +344,11 @@ fn convert_single_file( | uids::JPEGLS_NEAR_LOSSLESS => { output.set_extension("jls"); } + uids::JPEGXL + | uids::JPEGXLJPEG_RECOMPRESSION + | uids::JPEGXL_LOSSLESS => { + output.set_extension("jxl"); + } _ => { output.set_extension("data"); } diff --git a/transfer-syntax-registry/Cargo.toml b/transfer-syntax-registry/Cargo.toml index 85f07c97..0546d95b 100644 --- a/transfer-syntax-registry/Cargo.toml +++ b/transfer-syntax-registry/Cargo.toml @@ -21,13 +21,16 @@ native = ["jpeg", "rle"] native_windows = ["jpeg", "rle"] # native JPEG support jpeg = ["jpeg-decoder", "jpeg-encoder"] +# native JPEG XL support +jpegxl = ["dep:jxl-oxide", "dep:zune-jpegxl", "dep:zune-core"] + # JPEG 2000 support via the OpenJPEG Rust port, # works on Linux and a few other platforms openjp2 = ["dep:jpeg2k", "jpeg2k/openjp2"] # native RLE lossless support rle = [] # enable Rayon for JPEG decoding -rayon = ["jpeg-decoder?/rayon"] +rayon = ["jpeg-decoder?/rayon", "jxl-oxide?/rayon"] # enable SIMD operations for JPEG encoding simd = ["jpeg-encoder?/simd"] @@ -42,6 +45,9 @@ charls = ["dep:charls"] # implies "rayon" openjpeg-sys-threads = ["rayon", "jpeg2k?/threads"] +# multithreading for JPEG XL encoding +zune-jpegxl-threads = ["zune-jpegxl?/threads"] + [dependencies] dicom-core = { path = "../core", version = "0.7.1" } dicom-encoding = { path = "../encoding", version = "0.7.1" } @@ -67,6 +73,21 @@ version = "0.3" optional = true features = ["static"] +[dependencies.jxl-oxide] +version = "0.9.1" +optional = true + +[dependencies.zune-jpegxl] +version = "0.4.0" +optional = true +default-features = false +features = ["std"] + +[dependencies.zune-core] +version = "0.4.12" +optional = true +default-features = false + [package.metadata.docs.rs] features = ["native"] diff --git a/transfer-syntax-registry/src/adapters/jpegxl.rs b/transfer-syntax-registry/src/adapters/jpegxl.rs new file mode 100644 index 00000000..bd20ea81 --- /dev/null +++ b/transfer-syntax-registry/src/adapters/jpegxl.rs @@ -0,0 +1,282 @@ +//! Support for JPEG XL image decoding and encoding. + +use dicom_core::ops::{AttributeAction, AttributeOp}; +use dicom_core::Tag; +use dicom_encoding::adapters::{ + decode_error, encode_error, DecodeResult, PixelDataObject, PixelDataReader, PixelDataWriter, +}; +use dicom_encoding::snafu::prelude::*; +use jxl_oxide::JxlImage; +use zune_core::bit_depth::BitDepth; +use zune_core::colorspace::ColorSpace; +use zune_core::options::EncoderOptions; + +/// Base pixel data adapter (decoder and encoder) for transfer syntaxes based on JPEG XL. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct JpegXlAdapter; + +/// Pixel data encoder specifically for JPEG XL lossless compression. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct JpegXlLosslessEncoder; + +impl PixelDataReader for JpegXlAdapter { + /// Decode a single frame in JPEG XL from a DICOM object. + 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", + })?; + + 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 + ); + + let bytes_per_sample = bits_allocated / 8; + + // `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; + dst.reserve_exact(samples_per_pixel as usize * stride); + + let raw = src + .raw_pixel_data() + .whatever_context("Expected to have raw pixel data available")?; + + ensure_whatever!( + raw.fragments.len() == nr_frames, + "Unexpected number of fragments" + ); + let frame_data = + // assuming 1:1 frame-to-fragment mapping + raw.fragments + .get(frame as usize) + .with_whatever_context(|| { + format!("Missing fragment #{} for the frame requested", frame) + })?; + + let image = JxlImage::builder() + .read(&**frame_data) + .whatever_context("failed to read JPEG XL data")?; + let frame = image + .render_frame(0) + .whatever_context("failed to render JPEG XL frame")?; + + let mut stream = frame.stream(); + + // write all f32 samples to a buffer + + let mut buffer = + vec![ + 0_f32; + stream.channels() as usize * stream.width() as usize * stream.height() as usize + ]; + + let count = stream.write_to_buffer(&mut buffer); + buffer.truncate(count); + + // convert samples to the destination buffer depending on bit depth + match bits_allocated { + 1 => { + whatever!("Unsupported bit depth 1 by JPEG XL decoder"); + } + 8 => { + for &sample in &buffer { + dst.push((sample * 255.) as u8); + } + } + 16 => { + for &sample in &buffer { + dst.extend_from_slice(&((sample * 65535.) as u16).to_ne_bytes()); + } + } + 24 => { + for &sample in &buffer { + let bytes = &((sample * 16777215.) as u32).to_ne_bytes(); + dst.extend_from_slice(&bytes[..3]); + } + } + _ => unreachable!(), + } + + Ok(()) + } +} + +impl PixelDataWriter for JpegXlAdapter { + fn encode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + options: dicom_encoding::adapters::EncodeOptions, + dst: &mut Vec, + ) -> dicom_encoding::adapters::EncodeResult> { + use zune_jpegxl::JxlSimpleEncoder; + + 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 dicom_encoding::adapters::EncodeOptions { + quality, + effort, + .. + } = options; + + 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; + + // record dst length before encoding to know full jpeg size + let len_before = dst.len(); + + // 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")?; + + let bit_depth = match bits_allocated { + 8 => BitDepth::Eight, + 16 => BitDepth::Sixteen, + _ => unreachable!(), + }; + let color_space = match samples_per_pixel { + 1 => ColorSpace::Luma, + 3 => ColorSpace::RGB, + _ => ColorSpace::Unknown, + }; + let options = EncoderOptions::new(cols as usize, rows as usize, color_space, bit_depth); + let mut quality = quality.unwrap_or(85); + + let pmi = src.photometric_interpretation(); + + if pmi == Some("PALETTE COLOR") { + // force lossless compression for palette color + quality = 100; + } + + options.set_quality(quality); + options.set_effort(effort.map(|e| e + 27).unwrap_or(64)); + let encoder = JxlSimpleEncoder::new(frame_data, options); + + let jxl = encoder.encode() + .map_err(|e| format!("{e:?}")) + .whatever_context("Failed to encode JPEG XL data")?; + + dst.extend_from_slice(&jxl); + + // provide attribute changes + let mut changes = if quality != 100 { + 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); + 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![] + }; + + 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") && pmi != Some("PALETTE COLOR") { + 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 JpegXlLosslessEncoder { + fn encode_frame( + &self, + src: &dyn PixelDataObject, + frame: u32, + options: dicom_encoding::adapters::EncodeOptions, + dst: &mut Vec, + ) -> dicom_encoding::adapters::EncodeResult> { + // override quality option and defer to JpegXlAdapter + let mut options = options; + options.quality = Some(100); + JpegXlAdapter.encode_frame(src, frame, options, dst) + } + + fn encode( + &self, + src: &dyn PixelDataObject, + options: dicom_encoding::adapters::EncodeOptions, + dst: &mut Vec>, + offset_table: &mut Vec, + ) -> dicom_encoding::adapters::EncodeResult> { + // override quality option and defer to JpegXlAdapter + let mut options = options; + options.quality = Some(100); + JpegXlAdapter.encode(src, options, dst, offset_table) + } +} diff --git a/transfer-syntax-registry/src/adapters/mod.rs b/transfer-syntax-registry/src/adapters/mod.rs index 4ab7bf73..16027777 100644 --- a/transfer-syntax-registry/src/adapters/mod.rs +++ b/transfer-syntax-registry/src/adapters/mod.rs @@ -17,6 +17,8 @@ //! to statically link to the OpenJPEG reference implementation. //! `openjp2` is enabled by the feature `native`. //! To build on Windows, enable `native_windows` instead. +//! - [`jpegxl`](jpegxl) provides JPEG XL decoding and encoding, +//! through `jxl-oxide` and `zune-jpegxl`, respectively. //! - [`rle_lossless`](rle_lossless) provides native RLE lossless decoding. //! Requires the `rle` feature, //! enabled by default. @@ -29,6 +31,8 @@ pub mod jpeg; pub mod jpeg2k; #[cfg(feature = "charls")] pub mod jpegls; +#[cfg(feature = "jpegxl")] +pub mod jpegxl; #[cfg(feature = "rle")] pub mod rle_lossless; @@ -53,3 +57,8 @@ pub mod rle {} /// Enable the `charls` feature to use this module. #[cfg(not(feature = "charls"))] pub mod jpegls {} + +/// **Note:** This module is a stub. +/// Enable the `jpegxl` feature to use this module. +#[cfg(not(feature = "jpegxl"))] +pub mod jpegxl {} diff --git a/transfer-syntax-registry/src/entries.rs b/transfer-syntax-registry/src/entries.rs index c158719d..bc4510c6 100644 --- a/transfer-syntax-registry/src/entries.rs +++ b/transfer-syntax-registry/src/entries.rs @@ -36,6 +36,8 @@ use crate::adapters::jpeg::JpegAdapter; use crate::adapters::jpeg2k::Jpeg2000Adapter; #[cfg(feature = "charls")] use crate::adapters::jpegls::{JpegLsAdapter, JpegLsLosslessWriter}; +#[cfg(feature = "jpegxl")] +use crate::adapters::jpegxl::{JpegXlAdapter, JpegXlLosslessEncoder}; #[cfg(feature = "rle")] use crate::adapters::rle_lossless::RleLosslessAdapter; @@ -262,7 +264,7 @@ pub const JPEG_2000_PART2_MULTI_COMPONENT_IMAGE_COMPRESSION: Ts = create_ts_stub "JPEG 2000 Part 2 Multi-component Image Compression", ); -// --- partially supported transfer syntaxes, pixel data encapsulation not supported --- +// --- JPEG-LS --- /// An alias for a transfer syntax specifier with [`JpegLSAdapter`] as the decoder /// and an arbitrary encoder (since two impls are available) @@ -292,6 +294,57 @@ pub const JPEG_LS_LOSSY_IMAGE_COMPRESSION: JpegLSTs = TransferSyn Codec::EncapsulatedPixelData(Some(JpegLsAdapter), Some(JpegLsAdapter)), ); +// --- JPEG XL support --- + +/// An alias for a transfer syntax specifier with [`JpegXLAdapter`] +#[cfg(feature = "jpegxl")] +type JpegXlTs = TransferSyntax; + +/// **Implemented:** JPEG XL Lossless +#[cfg(feature = "jpegxl")] +pub const JPEG_XL_LOSSLESS: JpegXlTs = TransferSyntax::new_ele( + "1.2.840.10008.1.2.4.110", + "JPEG XL Lossless", + Codec::EncapsulatedPixelData(Some(JpegXlAdapter), Some(JpegXlLosslessEncoder)), +); + +/// **Stub descriptor:** JPEG XL Lossless +#[cfg(not(feature = "jpegxl"))] +pub const JPEG_XL_LOSSLESS: Ts = create_ts_stub( + "1.2.840.10008.1.2.4.110", + "JPEG XL Lossless" +); + +/// **Decoder Implementation:** JPEG XL Recompression +#[cfg(feature = "jpegxl")] +pub const JPEG_XL_RECOMPRESSION: JpegXlTs = TransferSyntax::new_ele( + "1.2.840.10008.1.2.4.111", + "JPEG XL Recompression", + Codec::EncapsulatedPixelData(Some(JpegXlAdapter), None), +); + +/// **Stub descriptor:** JPEG XL Recompression +#[cfg(not(feature = "jpegxl"))] +pub const JPEG_XL_RECOMPRESSION: Ts = create_ts_stub( + "1.2.840.10008.1.2.4.111", + "JPEG XL Recompression" +); + +/// **Implemented:** JPEG XL +#[cfg(feature = "jpegxl")] +pub const JPEG_XL: JpegXlTs = TransferSyntax::new_ele( + "1.2.840.10008.1.2.4.112", + "JPEG XL", + Codec::EncapsulatedPixelData(Some(JpegXlAdapter), Some(JpegXlAdapter)), +); + +/// **Stub descriptor:** JPEG XL +#[cfg(not(feature = "jpegxl"))] +pub const JPEG_XL: Ts = create_ts_stub( + "1.2.840.10008.1.2.4.112", + "JPEG XL" +); + /// **Stub descriptor:** JPEG-LS Lossy (Near-Lossless) Image Compression #[cfg(not(feature = "charls"))] pub const JPEG_LS_LOSSY_IMAGE_COMPRESSION: Ts = create_ts_stub( diff --git a/transfer-syntax-registry/src/lib.rs b/transfer-syntax-registry/src/lib.rs index 5b7340f2..ecd7eac1 100644 --- a/transfer-syntax-registry/src/lib.rs +++ b/transfer-syntax-registry/src/lib.rs @@ -62,13 +62,17 @@ //! | 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 | //! | JPEG 2000 Part 2 Multi-component Image Compression | Cargo feature `openjp2` or `openjpeg-sys` | x | +//! | JPEG XL Lossless | Cargo feature `jpegxl` | ✓ | +//! | JPEG XL Recompression | Cargo feature `jpegxl` | x | +//! | JPEG XL | Cargo feature `jpegxl` | ✓ | //! | RLE Lossless | Cargo feature `rle` | x | //! -//! Cargo features behind `native` (`jpeg`, `rle`) -//! provide implementations that are written in pure Rust -//! and are likely available in all supported platforms. -//! However, a native implementation might not always be available, -//! or alternative implementations may be preferred: +//! Cargo features behind `native` (`jpeg`, `rle`) are added by default. +//! They provide implementations that are written in pure Rust +//! and are likely available in all supported platforms without issues. +//! Additional codecs are opt-in by enabling Cargo features, +//! for scenarios where a native implementation is not available, +//! or alternative implementations are available. //! //! - `charls` provides support for JPEG-LS //! by linking to the CharLS reference implementation, @@ -81,7 +85,9 @@ //! Include `openjpeg-sys-threads` to build OpenJPEG with multithreading. //! - `openjp2` provides a binding to a computer-translated Rust port of OpenJPEG. //! Due to the nature of this crate, -//! it does not work on all supported platforms. +//! it might not work on all modern platforms. +//! - `jpegxl` adds JPEG XL support using `jxl-oxide` for decoding +//! and `zune-jpegxl` for encoding. //! //! Transfer syntaxes which are not supported, //! either due to being unable to read the data set @@ -92,7 +98,6 @@ //! if using the inventory-based registry. //! //! [inventory]: https://docs.rs/inventory/0.3.12/inventory - use dicom_encoding::transfer_syntax::{ AdapterFreeTransferSyntax as Ts, Codec, TransferSyntaxIndex, }; @@ -228,7 +233,7 @@ lazy_static! { }; use self::entries::*; - let built_in_ts: [TransferSyntax; 37] = [ + let built_in_ts: [TransferSyntax; 40] = [ IMPLICIT_VR_LITTLE_ENDIAN.erased(), EXPLICIT_VR_LITTLE_ENDIAN.erased(), EXPLICIT_VR_BIG_ENDIAN.erased(), @@ -247,6 +252,9 @@ lazy_static! { JPEG_2000_IMAGE_COMPRESSION.erased(), JPEG_2000_PART2_MULTI_COMPONENT_IMAGE_COMPRESSION_LOSSLESS_ONLY.erased(), JPEG_2000_PART2_MULTI_COMPONENT_IMAGE_COMPRESSION.erased(), + JPEG_XL_LOSSLESS.erased(), + JPEG_XL_RECOMPRESSION.erased(), + JPEG_XL.erased(), JPIP_REFERENCED.erased(), MPEG2_MAIN_PROFILE_MAIN_LEVEL.erased(), FRAGMENTABLE_MPEG2_MAIN_PROFILE_MAIN_LEVEL.erased(), diff --git a/transfer-syntax-registry/tests/jpegxl.rs b/transfer-syntax-registry/tests/jpegxl.rs new file mode 100644 index 00000000..d1af0d39 --- /dev/null +++ b/transfer-syntax-registry/tests/jpegxl.rs @@ -0,0 +1,258 @@ +//! Test suite for JPEG XL pixel data reading and writing +#![cfg(feature = "jpegxl")] + +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_XL, JPEG_XL_LOSSLESS}; + +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] +#[ignore] +fn read_jpeg_xl_1() { + let test_file = dicom_test_files::path("???.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 XL + ts_uid: "1.2.840.10008.1.2.4.112".to_string(), + rows: 100, + columns: 100, + 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![buf])), + }; + + // instantiate JpegAdapter and call decode_frame + + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_XL.codec() else { + panic!("JPEG XL pixel data reader not found") + }; + + let mut dest = vec![]; + + adapter + .decode_frame(&obj, 0, &mut dest) + .expect("JPEG XL 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] +#[ignore] +fn read_jpeg_xl_lossless_1() { + let test_file = dicom_test_files::path("???.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 XL lossless + ts_uid: "1.2.840.10008.1.2.4.110".to_string(), + rows: 100, + columns: 100, + 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![buf])), + }; + + // instantiate JpegAdapter and call decode_frame + + let Codec::EncapsulatedPixelData(Some(adapter), _) = JPEG_XL.codec() else { + panic!("JPEG XL pixel data reader not found") + }; + + let mut dest = vec![]; + + adapter + .decode_frame(&obj, 0, &mut dest) + .expect("JPEG XL 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 XL lossless and back should yield exactly the same pixel data +#[test] +fn write_and_read_jpeg_xl() { + let rows: u16 = 128; + let columns: u16 = 256; + + // 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 adapters for JPEG XL lossless + + let Codec::EncapsulatedPixelData(Some(reader), Some(writer)) = JPEG_XL_LOSSLESS.codec() else { + panic!("JPEG XL pixel data adapters not found") + }; + + let mut encoded = vec![]; + + let _ops = writer + .encode_frame(&obj, 0, EncodeOptions::default(), &mut encoded) + .expect("JPEG XL frame encoding failed"); + + // instantiate new object representing the compressed version + + let obj = TestDataObject { + // JPEG XL lossless + ts_uid: "1.2.840.10008.1.2.4.110".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 XL frame decoding failed"); + + // compare pixels, lossless encoding should yield exactly the same data + assert_eq!(samples, decoded , "pixel data mismatch"); +}