From fc95dc89fb71025cd635b793b252caf111f14d02 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 12:11:33 -0500 Subject: [PATCH 01/11] store reference_black_white --- src/decoder.rs | 9 +++++++++ src/ifd.rs | 27 +++++++++++++++++++++++++++ src/tags.rs | 5 +++++ src/tile.rs | 4 ++++ 4 files changed, 45 insertions(+) diff --git a/src/decoder.rs b/src/decoder.rs index 0266d5d8..deed124b 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -78,6 +78,7 @@ pub trait Decoder: Debug + Send + Sync { samples_per_pixel: u16, bits_per_sample: u16, lerc_parameters: Option<&[u32]>, + reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult>; } @@ -94,6 +95,7 @@ impl Decoder for DeflateDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { let mut decoder = ZlibDecoder::new(Cursor::new(buffer)); let mut buf = Vec::new(); @@ -156,6 +158,7 @@ impl Decoder for LercDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { // LercParameters[1] is the inner compression type: // 0 = none, 1 = deflate, 2 = zstd @@ -213,6 +216,7 @@ impl Decoder for LZMADecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { use bytes::Buf; use lzma_rust2::XzReader; @@ -237,6 +241,7 @@ impl Decoder for LZWDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { // https://github.com/image-rs/image-tiff/blob/90ae5b8e54356a35e266fb24e969aafbcb26e990/src/decoder/stream.rs#L147 let mut decoder = weezl::decode::Decoder::with_tiff_size_switch(weezl::BitOrder::Msb, 8); @@ -260,6 +265,7 @@ impl Decoder for JPEG2kDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { let decoder = jpeg2k::DecodeParameters::new(); @@ -294,6 +300,7 @@ impl Decoder for WebPDecoder { samples_per_pixel: u16, bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { let decoded = webp::Decoder::new(&buffer) .decode() @@ -330,6 +337,7 @@ impl Decoder for UncompressedDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { Ok(buffer.to_vec()) } @@ -348,6 +356,7 @@ impl Decoder for ZstdDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { let mut decoder = zstd::Decoder::new(Cursor::new(buffer))?; let mut buf = Vec::new(); diff --git a/src/ifd.rs b/src/ifd.rs index 43032488..87c645d4 100644 --- a/src/ifd.rs +++ b/src/ifd.rs @@ -133,6 +133,8 @@ pub struct ImageFileDirectory { pub(crate) jpeg_tables: Option, + pub(crate) reference_black_white: Option<[f64; 6]>, + pub(crate) copyright: Option, // Geospatial tags @@ -188,6 +190,7 @@ impl ImageFileDirectory { let mut extra_samples = None; let mut sample_format = None; let mut jpeg_tables = None; + let mut reference_black_white = None; let mut copyright = None; let mut geo_key_directory_data = None; let mut model_pixel_scale = None; @@ -268,6 +271,20 @@ impl ImageFileDirectory { ); } Tag::JPEGTables => jpeg_tables = Some(value.into_u8_vec()?.into()), + Tag::ReferenceBlackWhite => { + let vals = value.into_f64_vec()?; + // into_f64_vec returns alternating numerator/denominator for RATIONAL values + if vals.len() == 12 { + reference_black_white = Some([ + vals[0] / vals[1], + vals[2] / vals[3], + vals[4] / vals[5], + vals[6] / vals[7], + vals[8] / vals[9], + vals[10] / vals[11], + ]); + } + } Tag::Copyright => copyright = Some(value.into_string()?), // Geospatial tags @@ -423,6 +440,7 @@ impl ImageFileDirectory { .unwrap_or(vec![SampleFormat::Uint; samples_per_pixel as _]), copyright, jpeg_tables, + reference_black_white, geo_key_directory, model_pixel_scale, model_tiepoint, @@ -642,6 +660,14 @@ impl ImageFileDirectory { self.jpeg_tables.as_deref() } + /// Headroom and footroom codes for each pixel component used for YCbCr and RGB images. + /// + /// Six values as `[Y_black, Y_white, Cb_black, Cb_white, Cr_black, Cr_white]`. + /// + pub fn reference_black_white(&self) -> Option<&[f64; 6]> { + self.reference_black_white.as_ref() + } + /// Copyright notice. /// pub fn copyright(&self) -> Option<&str> { @@ -924,6 +950,7 @@ impl CompressedBytes { photometric_interpretation: ifd.photometric_interpretation, jpeg_tables: ifd.jpeg_tables.clone(), lerc_parameters: ifd.lerc_parameters.clone(), + reference_black_white: ifd.reference_black_white, } } } diff --git a/src/tags.rs b/src/tags.rs index 24f7f0d2..083e7de4 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -130,6 +130,11 @@ pub enum Tag(u16) unknown("A private or extension tag") { SMaxSampleValue = 341, // JPEG JPEGTables = 347, + // YCbCr + YCbCrCoefficients = 529, + YCbCrSubSampling = 530, + YCbCrPositioning = 531, + ReferenceBlackWhite = 532, // GeoTIFF ModelPixelScale = 33550, // (SoftDesk) ModelTransformation = 34264, // (JPL Carto Group) diff --git a/src/tile.rs b/src/tile.rs index 5b586b2f..25b39718 100644 --- a/src/tile.rs +++ b/src/tile.rs @@ -36,6 +36,8 @@ pub struct Tile { /// LERC parameters from the LercParameters tag: [version, compression_type, ...] /// compression_type: 0 = none, 1 = deflate, 2 = zstd pub(crate) lerc_parameters: Option>, + /// ReferenceBlackWhite tag values: [Y_black, Y_white, Cb_black, Cb_white, Cr_black, Cr_white] + pub(crate) reference_black_white: Option<[f64; 6]>, } impl Tile { @@ -98,6 +100,7 @@ impl Tile { self.samples_per_pixel, bits_per_sample, self.lerc_parameters.as_deref(), + self.reference_black_white.as_ref(), )?, CompressedBytes::Planar(band_bytes) => { let bytes_per_sample = (bits_per_sample as usize).div_ceil(8); @@ -113,6 +116,7 @@ impl Tile { 1, bits_per_sample, self.lerc_parameters.as_deref(), + self.reference_black_white.as_ref(), )?; result.extend_from_slice(&decoded_band); } From 5e2cc9272ef8f7dab45b2fb4773b7ecaf3566bfb Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 12:20:14 -0500 Subject: [PATCH 02/11] support parsing rational into f64 --- src/tag_value.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tag_value.rs b/src/tag_value.rs index 4b4d4e99..5be073f5 100644 --- a/src/tag_value.rs +++ b/src/tag_value.rs @@ -167,6 +167,10 @@ impl TagValue { pub fn into_f64(self) -> TiffResult { match self { Double(val) => Ok(val), + Rational(numerator, denominator) => Ok(numerator as f64 / denominator as f64), + RationalBig(numerator, denominator) => Ok(numerator as f64 / denominator as f64), + SRational(numerator, denominator) => Ok(numerator as f64 / denominator as f64), + SRationalBig(numerator, denominator) => Ok(numerator as f64 / denominator as f64), val => Err(TiffError::FormatError( TiffFormatError::SignedIntegerExpected(val), )), From 54ffba2c38eca69fcb0abcdf7a42ca059420b7f2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 12:20:50 -0500 Subject: [PATCH 03/11] fix parsing of reference black white --- src/ifd.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/ifd.rs b/src/ifd.rs index 87c645d4..236e9aac 100644 --- a/src/ifd.rs +++ b/src/ifd.rs @@ -272,18 +272,12 @@ impl ImageFileDirectory { } Tag::JPEGTables => jpeg_tables = Some(value.into_u8_vec()?.into()), Tag::ReferenceBlackWhite => { + // alternating numerator/denominator for RATIONAL values let vals = value.into_f64_vec()?; - // into_f64_vec returns alternating numerator/denominator for RATIONAL values - if vals.len() == 12 { - reference_black_white = Some([ - vals[0] / vals[1], - vals[2] / vals[3], - vals[4] / vals[5], - vals[6] / vals[7], - vals[8] / vals[9], - vals[10] / vals[11], - ]); + if vals.len() != 6 { + return Err(TiffError::FormatError(TiffFormatError::InvalidTag)); } + reference_black_white = Some(vals.try_into().expect("Convert Vec to [f64; 6]")); } Tag::Copyright => copyright = Some(value.into_string()?), From 4182a607bc04d9b1f624904b44fda16d253181a9 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 12:21:28 -0500 Subject: [PATCH 04/11] fix python compile --- python/src/decoder.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/python/src/decoder.rs b/python/src/decoder.rs index da167ba6..2aeb9900 100644 --- a/python/src/decoder.rs +++ b/python/src/decoder.rs @@ -82,6 +82,7 @@ impl Decoder for PyDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + _reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { Python::attach(|py| self.call(py, buffer)) .map_err(|err| AsyncTiffError::General(err.to_string())) From f346de5d0fd4f45f461a50a3794adccfc93f54c4 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 12:51:15 -0500 Subject: [PATCH 05/11] wip --- Cargo.toml | 3 +- python/Cargo.lock | 9 +--- src/decoder.rs | 128 ++++++++++++++++++++++++++++++++++------------ src/error.rs | 7 +-- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cdd0f5b..ce3037e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ byteorder = "1" bytes = "1.7.0" flate2 = "1.0.20" futures = "0.3.31" -jpeg = { package = "jpeg-decoder", version = "0.3.0", default-features = false } jpeg2k = { version = "0.10.1", optional = true } lerc = { version = "0.2.1", optional = true } lzma-rust2 = { version = "0.15.7", optional = true, features = ["xz"] } @@ -28,6 +27,8 @@ tokio = { version = "1.43.0", default-features = false, features = ["sync"] } webp = { version = "0.3", optional = true } weezl = "0.1.0" zstd = "0.13" +zune-core = "0.5.1" +zune-jpeg = "0.5.12" [dev-dependencies] criterion = { package = "codspeed-criterion-compat", version = "4.1.0" } diff --git a/python/Cargo.lock b/python/Cargo.lock index 71fcd297..a254d90f 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -42,7 +42,6 @@ dependencies = [ "bytes", "flate2", "futures", - "jpeg-decoder", "jpeg2k", "lerc", "lzma-rust2", @@ -54,6 +53,8 @@ dependencies = [ "webp", "weezl", "zstd", + "zune-core", + "zune-jpeg", ] [[package]] @@ -850,12 +851,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - [[package]] name = "jpeg2k" version = "0.10.1" diff --git a/src/decoder.rs b/src/decoder.rs index deed124b..c4bb6720 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -6,6 +6,10 @@ use std::io::{Cursor, Read}; use bytes::Bytes; use flate2::bufread::ZlibDecoder; +use zune_core::bytestream::ZCursor; +use zune_core::colorspace::ColorSpace; +use zune_core::options::DecoderOptions; +use zune_jpeg::JpegDecoder as ZuneJpegDecoder; use crate::error::{AsyncTiffError, AsyncTiffResult, TiffError, TiffUnsupportedError}; use crate::tags::{Compression, PhotometricInterpretation}; @@ -117,8 +121,9 @@ impl Decoder for JPEGDecoder { _samples_per_pixel: u16, _bits_per_sample: u16, _lerc_parameters: Option<&[u32]>, + reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { - decode_modern_jpeg(buffer, photometric_interpretation, jpeg_tables) + decode_modern_jpeg(buffer, photometric_interpretation, jpeg_tables, reference_black_white) } } @@ -365,56 +370,113 @@ impl Decoder for ZstdDecoder { } } -// https://github.com/image-rs/image-tiff/blob/3bfb43e83e31b0da476832067ada68a82b378b7b/src/decoder/image.rs#L389-L450 fn decode_modern_jpeg( buf: Bytes, photometric_interpretation: PhotometricInterpretation, jpeg_tables: Option<&[u8]>, + reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { - // Construct new jpeg_reader wrapping a SmartReader. - // // JPEG compression in TIFF allows saving quantization and/or huffman tables in one central - // location. These `jpeg_tables` are simply prepended to the remaining jpeg image data. Because - // these `jpeg_tables` start with a `SOI` (HEX: `0xFFD8`) or __start of image__ marker which is - // also at the beginning of the remaining JPEG image data and would confuse the JPEG renderer, - // one of these has to be taken off. In this case the first two bytes of the remaining JPEG - // data is removed because it follows `jpeg_tables`. Similary, `jpeg_tables` ends with a `EOI` - // (HEX: `0xFFD9`) or __end of image__ marker, this has to be removed as well (last two bytes - // of `jpeg_tables`). - let reader = Cursor::new(buf); - - let jpeg_reader = match jpeg_tables { - Some(jpeg_tables) => { - let mut reader = reader; - reader.read_exact(&mut [0; 2])?; - - Box::new(Cursor::new(&jpeg_tables[..jpeg_tables.len() - 2]).chain(reader)) - as Box + // location. These `jpeg_tables` are simply prepended to the remaining jpeg image data. + // `jpeg_tables` starts with SOI (0xFFD8) and ends with EOI (0xFFD9); strip them off before + // concatenating with the tile data (which starts with its own SOI). + let jpeg_data: Vec = match jpeg_tables { + Some(tables) => { + // Strip SOI from tile data (first 2 bytes) and EOI from tables (last 2 bytes), + // then prepend the stripped tables to the tile data. + let stripped_tables = &tables[..tables.len() - 2]; + let stripped_tile = &buf[2..]; + let mut data = Vec::with_capacity(stripped_tables.len() + stripped_tile.len()); + data.extend_from_slice(stripped_tables); + data.extend_from_slice(stripped_tile); + data } - None => Box::new(reader), + None => buf.to_vec(), }; - let mut decoder = jpeg::Decoder::new(jpeg_reader); + // Decode headers first so we can read the input colorspace. + let mut decoder = ZuneJpegDecoder::new(ZCursor::new(&jpeg_data)); + decoder + .decode_headers() + .map_err(|e| AsyncTiffError::General(format!("JPEG decode headers error: {e:?}")))?; + + // For YCbCr photometric interpretation, disable zune-jpeg's internal color conversion + // by setting output colorspace == input colorspace (raw YCbCr after upsampling), then + // apply the TIFF-correct YCbCr->RGB formula using the ReferenceBlackWhite tag. + // This matches image-tiff / libtiff / GDAL behavior. + if photometric_interpretation == PhotometricInterpretation::YCbCr { + let input_cs = decoder.input_colorspace().unwrap_or(ColorSpace::YCbCr); + let options = DecoderOptions::default().jpeg_set_out_colorspace(input_cs); + decoder.set_options(options); + let ycbcr = decoder + .decode() + .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e:?}")))?; + return Ok(ycbcr_to_rgb(&ycbcr, reference_black_white)); + } - match photometric_interpretation { - PhotometricInterpretation::RGB => decoder.set_color_transform(jpeg::ColorTransform::RGB), + let out_colorspace = match photometric_interpretation { + PhotometricInterpretation::RGB => ColorSpace::RGB, PhotometricInterpretation::WhiteIsZero | PhotometricInterpretation::BlackIsZero - | PhotometricInterpretation::TransparencyMask => { - decoder.set_color_transform(jpeg::ColorTransform::None) - } - PhotometricInterpretation::CMYK => decoder.set_color_transform(jpeg::ColorTransform::CMYK), - PhotometricInterpretation::YCbCr => { - decoder.set_color_transform(jpeg::ColorTransform::YCbCr) - } + | PhotometricInterpretation::TransparencyMask => ColorSpace::Luma, + PhotometricInterpretation::CMYK => ColorSpace::CMYK, photometric_interpretation => { return Err(TiffError::UnsupportedError( TiffUnsupportedError::UnsupportedInterpretation(photometric_interpretation), ) .into()); } - } + }; - let data = decoder.decode()?; + let options = DecoderOptions::default().jpeg_set_out_colorspace(out_colorspace); + decoder.set_options(options); + let data = decoder + .decode() + .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e:?}")))?; Ok(data) } + +/// Convert upsampled YCbCr pixels to RGB using the TIFF ReferenceBlackWhite formula. +/// +/// TIFF uses: `(component - black) * 255.0 / (white - black)` for each component, +/// then applies the standard CCIR 601 YCbCr→RGB matrix. This matches libtiff/GDAL behavior. +/// +/// The JPEG standard just uses `Cb - 128`, which gives slightly different results when +/// ReferenceBlackWhite is the TIFF default `[0, 255, 128, 255, 128, 255]`. +fn ycbcr_to_rgb(ycbcr: &[u8], reference_black_white: Option<&[f64; 6]>) -> Vec { + // Default TIFF ReferenceBlackWhite for YCbCr: Y in [0,255], Cb in [128,255], Cr in [128,255] + let rbw = reference_black_white.copied().unwrap_or([0.0, 255.0, 128.0, 255.0, 128.0, 255.0]); + let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = rbw; + + // Precompute scale factors: component_scaled = (raw - black) * 255 / (white - black) + // For Y (luma): scale into [0, 255] + // For Cb/Cr (chroma): center around 0 with range [-127.5, +127.5] + let y_scale = 255.0 / (y_white - y_black); + let cb_scale = 127.5 / (cb_white - cb_black); + let cr_scale = 127.5 / (cr_white - cr_black); + + let n_pixels = ycbcr.len() / 3; + let mut rgb = vec![0u8; n_pixels * 3]; + + for i in 0..n_pixels { + let y = ycbcr[i * 3] as f64; + let cb = ycbcr[i * 3 + 1] as f64; + let cr = ycbcr[i * 3 + 2] as f64; + + // Apply ReferenceBlackWhite scaling + let y_f = (y - y_black) * y_scale; + let cb_f = (cb - cb_black) * cb_scale; // centered around 0 + let cr_f = (cr - cr_black) * cr_scale; // centered around 0 + + // CCIR 601 YCbCr -> RGB matrix (same as libtiff TIFFYCbCrToRGBInit) + let r = y_f + 1.402 * cr_f; + let g = y_f - 0.344136 * cb_f - 0.714136 * cr_f; + let b = y_f + 1.772 * cb_f; + + rgb[i * 3] = r.round().clamp(0.0, 255.0) as u8; + rgb[i * 3 + 1] = g.round().clamp(0.0, 255.0) as u8; + rgb[i * 3 + 2] = b.round().clamp(0.0, 255.0) as u8; + } + + rgb +} diff --git a/src/error.rs b/src/error.rs index ac8ec0c3..215df6c4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,6 @@ use std::error::Error; use std::fmt::Debug; use std::{fmt, io, str, string}; -use jpeg::UnsupportedFeature; use thiserror::Error; use crate::tag_value::TagValue; @@ -32,10 +31,6 @@ pub enum AsyncTiffError { #[error(transparent)] IOError(#[from] std::io::Error), - /// Error while decoding JPEG data. - #[error(transparent)] - JPEGDecodingError(#[from] jpeg::Error), - /// Error while decoding JPEG2000 data. #[cfg(feature = "jpeg2k")] #[error(transparent)] @@ -210,7 +205,7 @@ pub enum TiffUnsupportedError { UnsupportedPlanarConfig(Option), UnsupportedDataType, UnsupportedInterpretation(PhotometricInterpretation), - UnsupportedJpegFeature(UnsupportedFeature), + UnsupportedJpegFeature(String), MisalignedTileBoundaries, } From 8bf5d8ba58715a00386361bd35fcd6575777b390 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:06:09 -0500 Subject: [PATCH 06/11] update decoder --- Cargo.toml | 3 +- python/Cargo.lock | 48 ++++++- python/tests/test_integration_rasterio.py | 24 +++- src/decoder.rs | 148 +++++++++++++--------- 4 files changed, 153 insertions(+), 70 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ce3037e8..b65ecc15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,8 @@ thiserror = "2" tokio = { version = "1.43.0", default-features = false, features = ["sync"] } webp = { version = "0.3", optional = true } weezl = "0.1.0" +mozjpeg = { version = "0.10", default-features = false, features = ["with_simd"] } zstd = "0.13" -zune-core = "0.5.1" -zune-jpeg = "0.5.12" [dev-dependencies] criterion = { package = "codspeed-criterion-compat", version = "4.1.0" } diff --git a/python/Cargo.lock b/python/Cargo.lock index a254d90f..23c03f44 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -32,6 +32,12 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-tiff" version = "0.2.0" @@ -45,6 +51,7 @@ dependencies = [ "jpeg2k", "lerc", "lzma-rust2", + "mozjpeg", "num_enum", "object_store", "reqwest 0.13.2", @@ -53,8 +60,6 @@ dependencies = [ "webp", "weezl", "zstd", - "zune-core", - "zune-jpeg", ] [[package]] @@ -320,6 +325,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -1020,6 +1031,30 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mozjpeg" +version = "0.10.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7891b80aaa86097d38d276eb98b3805d6280708c4e0a1e6f6aed9380c51fec9" +dependencies = [ + "arrayvec", + "bytemuck", + "libc", + "mozjpeg-sys", + "rgb", +] + +[[package]] +name = "mozjpeg-sys" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0dc668bf9bf888c88e2fb1ab16a406d2c380f1d082b20d51dd540ab2aa70c1" +dependencies = [ + "cc", + "dunce", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1622,6 +1657,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/python/tests/test_integration_rasterio.py b/python/tests/test_integration_rasterio.py index 9282e556..901439c4 100644 --- a/python/tests/test_integration_rasterio.py +++ b/python/tests/test_integration_rasterio.py @@ -41,11 +41,9 @@ ("rasterio", "uint8_rgb_deflate_block64_cog"), ("rasterio", "uint8_rgb_webp_block64_cog"), ("rasterio", "uint8_rgba_webp_block64_cog"), - # Ycbcr subsampling not implemented - # ("rio-tiler", "cog_rgb_with_stats"), + ("rio-tiler", "cog_rgb_with_stats"), ("umbra", "sydney_airport_GEC"), - # Ycbcr subsampling not implemented - # ("vantor", "maxar_opendata_yellowstone_visual"), + ("vantor", "maxar_opendata_yellowstone_visual"), ], ) async def test_read( @@ -68,6 +66,9 @@ async def test_read( x_count, y_count = tile_count with load_rasterio(file_name, variant=variant) as rasterio_ds: + img_width = rasterio_ds.width + img_height = rasterio_ds.height + for x in range(x_count): for y in range(y_count): tile = await ifd.fetch_tile(x, y) @@ -78,10 +79,21 @@ async def test_read( rasterio_data = rasterio_ds.read(window=rasterio_window, boundless=True) + # Clip to valid image region — JPEG edge tiles contain padded data beyond + # image boundaries that rasterio zeroes out with boundless=True. + valid_cols = min(tile_width, img_width - x * tile_width) + valid_rows = min(tile_height, img_height - y * tile_height) + if ifd.planar_configuration == PlanarConfiguration.Chunky: - np.testing.assert_array_equal(data, reshape_as_image(rasterio_data)) + np.testing.assert_array_equal( + data[:valid_rows, :valid_cols], + reshape_as_image(rasterio_data)[:valid_rows, :valid_cols], + ) else: - np.testing.assert_array_equal(data, rasterio_data) + np.testing.assert_array_equal( + data[..., :valid_rows, :valid_cols], + rasterio_data[..., :valid_rows, :valid_cols], + ) def create_window(tile_width: int, tile_height: int, x: int, y: int) -> Window: diff --git a/src/decoder.rs b/src/decoder.rs index c4bb6720..4234f64c 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -6,10 +6,7 @@ use std::io::{Cursor, Read}; use bytes::Bytes; use flate2::bufread::ZlibDecoder; -use zune_core::bytestream::ZCursor; -use zune_core::colorspace::ColorSpace; -use zune_core::options::DecoderOptions; -use zune_jpeg::JpegDecoder as ZuneJpegDecoder; +use mozjpeg::{ColorSpace as MozColorSpace, Decompress}; use crate::error::{AsyncTiffError, AsyncTiffResult, TiffError, TiffUnsupportedError}; use crate::tags::{Compression, PhotometricInterpretation}; @@ -394,32 +391,30 @@ fn decode_modern_jpeg( None => buf.to_vec(), }; - // Decode headers first so we can read the input colorspace. - let mut decoder = ZuneJpegDecoder::new(ZCursor::new(&jpeg_data)); - decoder - .decode_headers() - .map_err(|e| AsyncTiffError::General(format!("JPEG decode headers error: {e:?}")))?; + // Use mozjpeg (libjpeg-turbo) for decoding to match libtiff/GDAL/rasterio output exactly. + // For YCbCr, decode to raw YCbCr (no internal color conversion) then apply the + // TIFF-correct color conversion using the ReferenceBlackWhite tag. + let decompress = Decompress::new_mem(&jpeg_data) + .map_err(|e| AsyncTiffError::General(format!("JPEG init error: {e}")))?; - // For YCbCr photometric interpretation, disable zune-jpeg's internal color conversion - // by setting output colorspace == input colorspace (raw YCbCr after upsampling), then - // apply the TIFF-correct YCbCr->RGB formula using the ReferenceBlackWhite tag. - // This matches image-tiff / libtiff / GDAL behavior. if photometric_interpretation == PhotometricInterpretation::YCbCr { - let input_cs = decoder.input_colorspace().unwrap_or(ColorSpace::YCbCr); - let options = DecoderOptions::default().jpeg_set_out_colorspace(input_cs); - decoder.set_options(options); - let ycbcr = decoder - .decode() - .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e:?}")))?; + // Decode to upsampled interleaved YCbCr (no internal color conversion by libjpeg), + // then apply the TIFF-correct color conversion using the ReferenceBlackWhite tag. + let mut decompress = decompress + .to_colorspace(MozColorSpace::JCS_YCbCr) + .map_err(|e| AsyncTiffError::General(format!("JPEG colorspace error: {e}")))?; + let ycbcr = decompress + .read_scanlines::() + .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e}")))?; return Ok(ycbcr_to_rgb(&ycbcr, reference_black_white)); } - let out_colorspace = match photometric_interpretation { - PhotometricInterpretation::RGB => ColorSpace::RGB, + let moz_cs = match photometric_interpretation { + PhotometricInterpretation::RGB => MozColorSpace::JCS_RGB, PhotometricInterpretation::WhiteIsZero | PhotometricInterpretation::BlackIsZero - | PhotometricInterpretation::TransparencyMask => ColorSpace::Luma, - PhotometricInterpretation::CMYK => ColorSpace::CMYK, + | PhotometricInterpretation::TransparencyMask => MozColorSpace::JCS_GRAYSCALE, + PhotometricInterpretation::CMYK => MozColorSpace::JCS_CMYK, photometric_interpretation => { return Err(TiffError::UnsupportedError( TiffUnsupportedError::UnsupportedInterpretation(photometric_interpretation), @@ -428,54 +423,87 @@ fn decode_modern_jpeg( } }; - let options = DecoderOptions::default().jpeg_set_out_colorspace(out_colorspace); - decoder.set_options(options); - let data = decoder - .decode() - .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e:?}")))?; + let mut decompress = decompress + .to_colorspace(moz_cs) + .map_err(|e| AsyncTiffError::General(format!("JPEG colorspace error: {e}")))?; + + let data = decompress + .read_scanlines::() + .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e}")))?; Ok(data) } -/// Convert upsampled YCbCr pixels to RGB using the TIFF ReferenceBlackWhite formula. -/// -/// TIFF uses: `(component - black) * 255.0 / (white - black)` for each component, -/// then applies the standard CCIR 601 YCbCr→RGB matrix. This matches libtiff/GDAL behavior. +/// Convert upsampled YCbCr pixels to RGB replicating libtiff's exact fixed-point arithmetic. /// -/// The JPEG standard just uses `Cb - 128`, which gives slightly different results when -/// ReferenceBlackWhite is the TIFF default `[0, 255, 128, 255, 128, 255]`. +/// Replicates `TIFFYCbCrToRGBInit` + `TIFFYCbCrtoRGB` from libtiff/tif_color.c, including +/// the `Code2V` ReferenceBlackWhite scaling and the 16-bit fixed-point lookup tables. fn ycbcr_to_rgb(ycbcr: &[u8], reference_black_white: Option<&[f64; 6]>) -> Vec { - // Default TIFF ReferenceBlackWhite for YCbCr: Y in [0,255], Cb in [128,255], Cr in [128,255] - let rbw = reference_black_white.copied().unwrap_or([0.0, 255.0, 128.0, 255.0, 128.0, 255.0]); - let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = rbw; + // Default TIFF ReferenceBlackWhite for YCbCr + let rbw = + reference_black_white.copied().unwrap_or([0.0, 255.0, 128.0, 255.0, 128.0, 255.0]); + let [rb0, rw0, rb2, rw2, rb4, rw4] = rbw; + + // libtiff uses SHIFT=16, ONE_HALF=1<<15, FIX(x) = (x*(1<<16)+0.5) as i32 + const SHIFT: u32 = 16; + const ONE_HALF: i64 = 1 << 15; + + // CCIR 601 luma coefficients (ITU-R BT.601) + const LUMA_RED: f64 = 0.299; + const LUMA_GREEN: f64 = 0.587; + const LUMA_BLUE: f64 = 0.114; + + let fix = |x: f64| -> i64 { (x * (1i64 << SHIFT) as f64 + 0.5) as i64 }; + + // libtiff D1..D4 fixed-point matrix coefficients + let d1 = fix(2.0 - 2.0 * LUMA_RED); // Cr -> R + let d2 = fix(-(LUMA_RED * (2.0 - 2.0 * LUMA_RED) / LUMA_GREEN)); // Cr -> G + let d3 = fix(2.0 - 2.0 * LUMA_BLUE); // Cb -> B + let d4 = fix(-(LUMA_BLUE * (2.0 - 2.0 * LUMA_BLUE) / LUMA_GREEN)); // Cb -> G + + // Code2V: maps raw code to scaled value using ReferenceBlackWhite + let code2v = |c: i32, rb: f64, rw: f64, cr: f64| -> i32 { + let denom = if (rw - rb).abs() > f64::EPSILON { rw - rb } else { 1.0 }; + // CLAMPw to [-128*32, 128*32] + ((c as f64 - rb) * cr / denom).clamp(-128.0 * 32.0, 128.0 * 32.0) as i32 + }; - // Precompute scale factors: component_scaled = (raw - black) * 255 / (white - black) - // For Y (luma): scale into [0, 255] - // For Cb/Cr (chroma): center around 0 with range [-127.5, +127.5] - let y_scale = 255.0 / (y_white - y_black); - let cb_scale = 127.5 / (cb_white - cb_black); - let cr_scale = 127.5 / (cr_white - cr_black); + // Build lookup tables indexed by raw 0..=255 pixel values. + // libtiff loop: for (i=0, x=-128; i<256; i++, x++) + let mut y_tab = [0i32; 256]; + let mut cr_r_tab = [0i32; 256]; + let mut cb_b_tab = [0i32; 256]; + let mut cr_g_tab = [0i64; 256]; // stored unshifted + let mut cb_g_tab = [0i64; 256]; // stored unshifted + ONE_HALF + + for i in 0usize..256 { + let x = i as i32 - 128; + y_tab[i] = code2v(x + 128, rb0, rw0, 255.0); + + let cr = code2v(x, rb4 - 128.0, rw4 - 128.0, 127.0) as i64; + cr_r_tab[i] = ((d1 * cr + ONE_HALF) >> SHIFT) as i32; + cr_g_tab[i] = d2 * cr; + + let cb = code2v(x, rb2 - 128.0, rw2 - 128.0, 127.0) as i64; + cb_b_tab[i] = ((d3 * cb + ONE_HALF) >> SHIFT) as i32; + cb_g_tab[i] = d4 * cb + ONE_HALF; + } let n_pixels = ycbcr.len() / 3; let mut rgb = vec![0u8; n_pixels * 3]; for i in 0..n_pixels { - let y = ycbcr[i * 3] as f64; - let cb = ycbcr[i * 3 + 1] as f64; - let cr = ycbcr[i * 3 + 2] as f64; - - // Apply ReferenceBlackWhite scaling - let y_f = (y - y_black) * y_scale; - let cb_f = (cb - cb_black) * cb_scale; // centered around 0 - let cr_f = (cr - cr_black) * cr_scale; // centered around 0 - - // CCIR 601 YCbCr -> RGB matrix (same as libtiff TIFFYCbCrToRGBInit) - let r = y_f + 1.402 * cr_f; - let g = y_f - 0.344136 * cb_f - 0.714136 * cr_f; - let b = y_f + 1.772 * cb_f; - - rgb[i * 3] = r.round().clamp(0.0, 255.0) as u8; - rgb[i * 3 + 1] = g.round().clamp(0.0, 255.0) as u8; - rgb[i * 3 + 2] = b.round().clamp(0.0, 255.0) as u8; + let y = ycbcr[i * 3] as usize; + let cb = ycbcr[i * 3 + 1] as usize; + let cr = ycbcr[i * 3 + 2] as usize; + + let yv = y_tab[y]; + let r = yv + cr_r_tab[cr]; + let g = yv + ((cb_g_tab[cb] + cr_g_tab[cr]) >> SHIFT) as i32; + let b = yv + cb_b_tab[cb]; + + rgb[i * 3] = r.clamp(0, 255) as u8; + rgb[i * 3 + 1] = g.clamp(0, 255) as u8; + rgb[i * 3 + 2] = b.clamp(0, 255) as u8; } rgb From c2f49754cc9eff86c842438f383b2f0b8f5712c2 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:09:10 -0500 Subject: [PATCH 07/11] wrap in catch_unwind --- src/decoder.rs | 87 +++++++++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 37 deletions(-) diff --git a/src/decoder.rs b/src/decoder.rs index 4234f64c..dcec03f9 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::fmt::Debug; use std::io::{Cursor, Read}; +use std::panic; use bytes::Bytes; use flate2::bufread::ZlibDecoder; @@ -391,46 +392,58 @@ fn decode_modern_jpeg( None => buf.to_vec(), }; - // Use mozjpeg (libjpeg-turbo) for decoding to match libtiff/GDAL/rasterio output exactly. - // For YCbCr, decode to raw YCbCr (no internal color conversion) then apply the - // TIFF-correct color conversion using the ReferenceBlackWhite tag. - let decompress = Decompress::new_mem(&jpeg_data) - .map_err(|e| AsyncTiffError::General(format!("JPEG init error: {e}")))?; - - if photometric_interpretation == PhotometricInterpretation::YCbCr { - // Decode to upsampled interleaved YCbCr (no internal color conversion by libjpeg), - // then apply the TIFF-correct color conversion using the ReferenceBlackWhite tag. - let mut decompress = decompress - .to_colorspace(MozColorSpace::JCS_YCbCr) - .map_err(|e| AsyncTiffError::General(format!("JPEG colorspace error: {e}")))?; - let ycbcr = decompress - .read_scanlines::() - .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e}")))?; - return Ok(ycbcr_to_rgb(&ycbcr, reference_black_white)); - } - - let moz_cs = match photometric_interpretation { - PhotometricInterpretation::RGB => MozColorSpace::JCS_RGB, - PhotometricInterpretation::WhiteIsZero - | PhotometricInterpretation::BlackIsZero - | PhotometricInterpretation::TransparencyMask => MozColorSpace::JCS_GRAYSCALE, - PhotometricInterpretation::CMYK => MozColorSpace::JCS_CMYK, - photometric_interpretation => { - return Err(TiffError::UnsupportedError( - TiffUnsupportedError::UnsupportedInterpretation(photometric_interpretation), - ) - .into()); + // mozjpeg (libjpeg-turbo) uses panic-based error handling internally. + // All mozjpeg calls must be wrapped in catch_unwind per the library's requirements. + // See: https://docs.rs/mozjpeg/latest/mozjpeg/ + let result = panic::catch_unwind(|| -> std::io::Result> { + // Use mozjpeg (libjpeg-turbo) for decoding to match libtiff/GDAL/rasterio output exactly. + // For YCbCr, decode to raw YCbCr (no internal color conversion) then apply the + // TIFF-correct color conversion using the ReferenceBlackWhite tag. + if photometric_interpretation == PhotometricInterpretation::YCbCr { + // Decode to upsampled interleaved YCbCr (no internal color conversion by libjpeg), + // then apply the TIFF-correct color conversion using the ReferenceBlackWhite tag. + let mut decompress = Decompress::new_mem(&jpeg_data)? + .to_colorspace(MozColorSpace::JCS_YCbCr)?; + let ycbcr = decompress.read_scanlines::()?; + return Ok(ycbcr_to_rgb(&ycbcr, reference_black_white)); } - }; - let mut decompress = decompress - .to_colorspace(moz_cs) - .map_err(|e| AsyncTiffError::General(format!("JPEG colorspace error: {e}")))?; + let moz_cs = match photometric_interpretation { + PhotometricInterpretation::RGB => MozColorSpace::JCS_RGB, + PhotometricInterpretation::WhiteIsZero + | PhotometricInterpretation::BlackIsZero + | PhotometricInterpretation::TransparencyMask => MozColorSpace::JCS_GRAYSCALE, + PhotometricInterpretation::CMYK => MozColorSpace::JCS_CMYK, + _ => { + // Unsupported colorspaces are handled outside catch_unwind via the outer match. + // Return an empty vec as sentinel; the outer code handles the error. + return Ok(vec![]); + } + }; + + let mut decompress = Decompress::new_mem(&jpeg_data)?.to_colorspace(moz_cs)?; + decompress.read_scanlines::() + }); + + // Handle unsupported photometric interpretation (avoids passing non-UnwindSafe types into closure) + if !matches!( + photometric_interpretation, + PhotometricInterpretation::RGB + | PhotometricInterpretation::YCbCr + | PhotometricInterpretation::WhiteIsZero + | PhotometricInterpretation::BlackIsZero + | PhotometricInterpretation::TransparencyMask + | PhotometricInterpretation::CMYK + ) { + return Err(TiffError::UnsupportedError( + TiffUnsupportedError::UnsupportedInterpretation(photometric_interpretation), + ) + .into()); + } - let data = decompress - .read_scanlines::() - .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e}")))?; - Ok(data) + result + .map_err(|_| AsyncTiffError::General("JPEG decode panicked".to_string()))? + .map_err(|e| AsyncTiffError::General(format!("JPEG decode error: {e}"))) } /// Convert upsampled YCbCr pixels to RGB replicating libtiff's exact fixed-point arithmetic. From ce3fadb0f238905c213c70cb02c832c3da76b781 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:10:50 -0500 Subject: [PATCH 08/11] docstring --- src/ifd.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ifd.rs b/src/ifd.rs index 236e9aac..9765d273 100644 --- a/src/ifd.rs +++ b/src/ifd.rs @@ -133,6 +133,16 @@ pub struct ImageFileDirectory { pub(crate) jpeg_tables: Option, + /// Specifies a pair of headroom and footroom image data values (codes) for each pixel + /// component. + /// + /// The first component code within a pair is associated with ReferenceBlack, and the second is + /// associated with ReferenceWhite. The ordering of pairs is the same as those for pixel + /// components of the PhotometricInterpretation type. ReferenceBlackWhite can be applied to + /// images with a PhotometricInterpretation value of RGB or YCbCr. ReferenceBlackWhite is not + /// used with other PhotometricInterpretation values. + /// + /// pub(crate) reference_black_white: Option<[f64; 6]>, pub(crate) copyright: Option, From b90451370a046a9bcd91a5c4a18e2a5efaa20e7d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:12:02 -0500 Subject: [PATCH 09/11] sort deps --- Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b65ecc15..5d9007c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ futures = "0.3.31" jpeg2k = { version = "0.10.1", optional = true } lerc = { version = "0.2.1", optional = true } lzma-rust2 = { version = "0.15.7", optional = true, features = ["xz"] } +mozjpeg = { version = "0.10", default-features = false, features = [ + "with_simd", +] } ndarray = { version = "0.17", optional = true } num_enum = "0.7.3" object_store = { version = "0.13", optional = true } @@ -26,7 +29,6 @@ thiserror = "2" tokio = { version = "1.43.0", default-features = false, features = ["sync"] } webp = { version = "0.3", optional = true } weezl = "0.1.0" -mozjpeg = { version = "0.10", default-features = false, features = ["with_simd"] } zstd = "0.13" [dev-dependencies] From c8daa45cdfd018c4410fed45dff8dea6365746b8 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:12:40 -0500 Subject: [PATCH 10/11] fmt --- src/decoder.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/decoder.rs b/src/decoder.rs index dcec03f9..c95a8f16 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -72,6 +72,7 @@ impl Default for DecoderRegistry { /// A trait to decode a TIFF tile. pub trait Decoder: Debug + Send + Sync { /// Decode a TIFF tile. + #[allow(clippy::too_many_arguments)] fn decode_tile( &self, buffer: Bytes, @@ -121,7 +122,12 @@ impl Decoder for JPEGDecoder { _lerc_parameters: Option<&[u32]>, reference_black_white: Option<&[f64; 6]>, ) -> AsyncTiffResult> { - decode_modern_jpeg(buffer, photometric_interpretation, jpeg_tables, reference_black_white) + decode_modern_jpeg( + buffer, + photometric_interpretation, + jpeg_tables, + reference_black_white, + ) } } @@ -402,8 +408,8 @@ fn decode_modern_jpeg( if photometric_interpretation == PhotometricInterpretation::YCbCr { // Decode to upsampled interleaved YCbCr (no internal color conversion by libjpeg), // then apply the TIFF-correct color conversion using the ReferenceBlackWhite tag. - let mut decompress = Decompress::new_mem(&jpeg_data)? - .to_colorspace(MozColorSpace::JCS_YCbCr)?; + let mut decompress = + Decompress::new_mem(&jpeg_data)?.to_colorspace(MozColorSpace::JCS_YCbCr)?; let ycbcr = decompress.read_scanlines::()?; return Ok(ycbcr_to_rgb(&ycbcr, reference_black_white)); } @@ -435,10 +441,12 @@ fn decode_modern_jpeg( | PhotometricInterpretation::TransparencyMask | PhotometricInterpretation::CMYK ) { - return Err(TiffError::UnsupportedError( - TiffUnsupportedError::UnsupportedInterpretation(photometric_interpretation), - ) - .into()); + return Err( + TiffError::UnsupportedError(TiffUnsupportedError::UnsupportedInterpretation( + photometric_interpretation, + )) + .into(), + ); } result @@ -452,8 +460,9 @@ fn decode_modern_jpeg( /// the `Code2V` ReferenceBlackWhite scaling and the 16-bit fixed-point lookup tables. fn ycbcr_to_rgb(ycbcr: &[u8], reference_black_white: Option<&[f64; 6]>) -> Vec { // Default TIFF ReferenceBlackWhite for YCbCr - let rbw = - reference_black_white.copied().unwrap_or([0.0, 255.0, 128.0, 255.0, 128.0, 255.0]); + let rbw = reference_black_white + .copied() + .unwrap_or([0.0, 255.0, 128.0, 255.0, 128.0, 255.0]); let [rb0, rw0, rb2, rw2, rb4, rw4] = rbw; // libtiff uses SHIFT=16, ONE_HALF=1<<15, FIX(x) = (x*(1<<16)+0.5) as i32 @@ -475,7 +484,11 @@ fn ycbcr_to_rgb(ycbcr: &[u8], reference_black_white: Option<&[f64; 6]>) -> Vec i32 { - let denom = if (rw - rb).abs() > f64::EPSILON { rw - rb } else { 1.0 }; + let denom = if (rw - rb).abs() > f64::EPSILON { + rw - rb + } else { + 1.0 + }; // CLAMPw to [-128*32, 128*32] ((c as f64 - rb) * cr / denom).clamp(-128.0 * 32.0, 128.0 * 32.0) as i32 }; From ec08fb438577edf9092b3c305749e786cd475349 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 13:21:25 -0500 Subject: [PATCH 11/11] try without simd --- Cargo.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5d9007c4..5b84039f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,7 @@ futures = "0.3.31" jpeg2k = { version = "0.10.1", optional = true } lerc = { version = "0.2.1", optional = true } lzma-rust2 = { version = "0.15.7", optional = true, features = ["xz"] } -mozjpeg = { version = "0.10", default-features = false, features = [ - "with_simd", -] } +mozjpeg = { version = "0.10", default-features = false } ndarray = { version = "0.17", optional = true } num_enum = "0.7.3" object_store = { version = "0.13", optional = true }