feat: add YCbCr chroma subsampling support for non-JPEG compression

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.
This commit is contained in:
Lilith River committed 2026-03-10 22:46:59 -06:00
1 parent 26301fc3f1
commit 83409cbdf3
3 files changed
+107 -14

No files matched your search

+19 -10
View File
@@ -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 <fa225e820b96bef35f01bf4685654beeb4a8df0c> 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<dyn Read> = 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