From 83409cbdf3c1543be1da53df4e4cdb942203a4f7 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Tue, 10 Mar 2026 22:46:59 -0600 Subject: [PATCH] feat: add YCbCr chroma subsampling support for non-JPEG compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add YCbCrUpsamplingReader that converts chroma-subsampled YCbCr block data to full-resolution 3-bytes-per-pixel output. Each block of h×v pixels stores h*v Y samples plus one shared Cb and Cr sample; the reader expands these into per-pixel (Y, Cb, Cr) triples. This enables decoding YCbCr TIFFs with any supported compression method (uncompressed, LZW, Deflate, etc.), not just JPEG. Two more libtiffpic test images now decode with verified hashes: - dscf0013.tif (uncompressed YCbCr 2,1 subsampling) - ycbcr-cat.tif (LZW-compressed YCbCr 2,2 subsampling) The remaining two YCbCr test images (smallliz.tif, zackthecat.tif) use Old JPEG compression (type 6) which is a separate unsupported feature. --- src/decoder/image.rs | 29 ++++++++----- src/decoder/stream.rs | 84 ++++++++++++++++++++++++++++++++++++++ tests/decode_libtiffpic.rs | 8 ++-- 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index e3da8f3..ad859f3 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -948,15 +948,10 @@ impl Image { // Only this color type interprets the tag, which is defined with a default of (2, 2) if matches!(color, ColorType::YCbCr(_)) && self.chroma_subsampling != (1, 1) { - // The JPEG library does upsampling for us and defines its buffers correctly - // (presumably). All other compression schemes are not supported.. - // - // NOTE: as explained in we may be better - // off supporting this tag by consistently upsampling, not by adjusting the buffer - // size. At least as a default this makes more sense and is much more permissive in - // case the compression stream disagrees with the tags (we would not have enough / or - // the wrong buffer layout if we only asked for subsampled planes in a planar layout). - if !matches!(self.compression_method, CompressionMethod::ModernJPEG) { + // The JPEG library does upsampling for us. For other compression methods, + // the expand_chunk pipeline wraps the decompressor in a YCbCrUpsamplingReader + // that converts the subsampled block layout to full-resolution pixel rows. + if matches!(self.planar_config, PlanarConfiguration::Planar) { return Err(TiffError::UnsupportedError( TiffUnsupportedError::ChromaSubsampling, )); @@ -1156,7 +1151,7 @@ impl Image { let is_all_bits = samples == data_samples; let is_output_chunk_rows = layout.row_stride == chunk_row_bytes; - let mut reader = Self::create_reader( + let mut reader: Box = Self::create_reader( reader.inner(), compression_method, *compressed_bytes, @@ -1166,6 +1161,20 @@ impl Image { self.fill_order, )?; + // For non-JPEG YCbCr with chroma subsampling, wrap the decompressor in an + // upsampling reader that converts the subsampled block layout to full-resolution + // pixel rows. JPEG handles upsampling internally. + if matches!(color_type, ColorType::YCbCr(_)) + && self.chroma_subsampling != (1, 1) + && !matches!(compression_method, CompressionMethod::ModernJPEG) + { + reader = Box::new(super::stream::YCbCrUpsamplingReader::new( + reader, + data_dims, + self.chroma_subsampling, + )); + } + // Extended bit depths (9-15, 17-31): packed N-bit samples must be unpacked to // standard-width output samples before endianness fix and predictor. let needs_unpacking = layout.tiff_bits_per_sample != layout.output_bits_per_sample diff --git a/src/decoder/stream.rs b/src/decoder/stream.rs index 9fb5e4e..205e365 100644 --- a/src/decoder/stream.rs +++ b/src/decoder/stream.rs @@ -507,6 +507,90 @@ impl Read for Group3Reader { } } +/// Converts YCbCr data from chroma-subsampled block layout to full-resolution +/// 3-bytes-per-pixel YCbCr output. Each block of h×v pixels stores h*v Y samples +/// plus one shared Cb and one shared Cr sample. This reader expands those into +/// per-pixel (Y, Cb, Cr) triples. +pub struct YCbCrUpsamplingReader { + reader: R, + width: u32, + horiz_sub: u32, + vert_sub: u32, + height: u32, + current_row: u32, + output_buf: io::Cursor>, +} + +impl YCbCrUpsamplingReader { + pub fn new(reader: R, dimensions: (u32, u32), chroma: (u16, u16)) -> Self { + Self { + reader, + width: dimensions.0, + horiz_sub: u32::from(chroma.0), + vert_sub: u32::from(chroma.1), + height: dimensions.1, + current_row: 0, + output_buf: io::Cursor::new(Vec::new()), + } + } +} + +impl Read for YCbCrUpsamplingReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + // If output buffer is exhausted, decode next block row + if self.output_buf.position() as usize >= self.output_buf.get_ref().len() { + if self.current_row >= self.height { + return Ok(0); + } + + let h = self.horiz_sub; + let v = self.vert_sub; + let blocks_across = self.width.div_ceil(h); + let block_size = (h * v + 2) as usize; + let block_row_bytes = blocks_across as usize * block_size; + + let rows_in_block = v.min(self.height - self.current_row) as usize; + + let mut subsampled = vec![0u8; block_row_bytes]; + self.reader.read_exact(&mut subsampled)?; + + let output_row_bytes = self.width as usize * 3; + let output = self.output_buf.get_mut(); + output.clear(); + output.resize(rows_in_block * output_row_bytes, 0); + + let mut src_offset = 0; + for bx in 0..blocks_across { + let x0 = (bx * h) as usize; + + let y_samples = &subsampled[src_offset..src_offset + (h * v) as usize]; + let cb = subsampled[src_offset + (h * v) as usize]; + let cr = subsampled[src_offset + (h * v) as usize + 1]; + src_offset += block_size; + + for dy in 0..rows_in_block { + for dx in 0..h as usize { + let x = x0 + dx; + if x >= self.width as usize { + break; + } + let y_val = y_samples[dy * h as usize + dx]; + let pixel_offset = dy * output_row_bytes + x * 3; + output[pixel_offset] = y_val; + output[pixel_offset + 1] = cb; + output[pixel_offset + 2] = cr; + } + } + } + + self.current_row += v; + self.output_buf.set_position(0); + } + + self.output_buf.read(buf) + } +} + #[cfg(feature = "webp")] pub struct WebPReader { inner: Cursor>, diff --git a/tests/decode_libtiffpic.rs b/tests/decode_libtiffpic.rs index 288e0ee..9213623 100644 --- a/tests/decode_libtiffpic.rs +++ b/tests/decode_libtiffpic.rs @@ -173,7 +173,7 @@ const FILES: &[(&str, Option)] = &[ ("tests/libtiffpic/depth/genimages", None), ("tests/libtiffpic/depth/README.txt", None), ("tests/libtiffpic/depth/summary.txt", None), - ("tests/libtiffpic/dscf0013.tif", Some(0)), // unsupported YCbCr chroma subsampling + ("tests/libtiffpic/dscf0013.tif", Some(0x6896e83a)), ("tests/libtiffpic/fax2d.tif", Some(0xc2f30c6f)), // No support for direct CCITT Group 3 1D format ("tests/libtiffpic/g3test.g3", None), @@ -193,11 +193,11 @@ const FILES: &[(&str, Option)] = &[ ("tests/libtiffpic/quad-lzw.tif", Some(0)), // invalid LZW stream ("tests/libtiffpic/quad-tile.tif", Some(0x6055f9ee)), ("tests/libtiffpic/README", None), - ("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported YCbCr chroma subsampling + ("tests/libtiffpic/smallliz.tif", Some(0)), // unsupported Old JPEG compression (type 6) ("tests/libtiffpic/strike.tif", Some(0x016b0f6a)), ("tests/libtiffpic/text.tif", Some(0)), // unsupported compression - ("tests/libtiffpic/ycbcr-cat.tif", Some(0)), // unsupported YCbCr chroma subsampling - ("tests/libtiffpic/zackthecat.tif", Some(0)), // unsupported YCbCr chroma subsampling + ("tests/libtiffpic/ycbcr-cat.tif", Some(0x388a9d8f)), + ("tests/libtiffpic/zackthecat.tif", Some(0)), // unsupported Old JPEG compression (type 6) ]; fn main() {