From 23d1867f75c4172176437a46ce9ee8b075e09c00 Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:38:48 +0100 Subject: [PATCH 1/4] Recognize YCbCr/Chomra subsampling tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In particular we avoid mistaking the absence of this tag for an absence of subsampling. The specification instead requires it to mean half the samples in both directions—a mode that we do not support yet. Instead of returning erroneous samples or an error in the data stream (which would appear too short) we return an error when asked to layout the read buffer for these images. --- src/decoder/image.rs | 28 ++++++++++++++++++++++++++++ src/decoder/mod.rs | 1 + src/error.rs | 6 ++++++ src/tags.rs | 5 +++++ 4 files changed, 40 insertions(+) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index eef5d0a..70573d8 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -85,6 +85,7 @@ pub(crate) struct Image { pub tile_attributes: Option, pub chunk_offsets: Vec, pub chunk_bytes: Vec, + pub chroma_subsampling: (u16, u16), } /// Describes how to read a tile-aligned portion of the image. @@ -275,6 +276,25 @@ impl Image { .transpose()? .unwrap_or(PlanarConfiguration::Chunky); + let ycbcr_subsampling = tag_reader.find_tag_uint_vec::(Tag::ChromaSubsampling)?; + + let chroma_subsampling = if let Some(subsamples) = &ycbcr_subsampling { + let [a, b] = subsamples.as_slice() else { + return Err(TiffError::FormatError(TiffFormatError::InvalidCountForTag( + Tag::ChromaSubsampling, + subsamples.len(), + ))); + }; + + // ImageWidth and ImageLength are constrained to be integer multiples of + // YCbCrSubsampleHoriz and YCbCrSubsampleVert respectively. TileWidth and TileLength + // have the same constraints. RowsPerStrip must be an integer multiple of + // YCbCrSubsampleVert. + (*a, *b) + } else { + (2, 2) + }; + let planes = match planar_config { PlanarConfiguration::Chunky => 1, PlanarConfiguration::Planar => samples, @@ -386,6 +406,7 @@ impl Image { tile_attributes, chunk_offsets, chunk_bytes, + chroma_subsampling, }) } @@ -715,6 +736,13 @@ 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) { + return Err(TiffError::UnsupportedError( + TiffUnsupportedError::ChromaSubsampling, + )); + } + Ok(ReadoutLayout { planar_config: self.planar_config, color, diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index cc31572..40523b4 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -860,6 +860,7 @@ impl Decoder { tile_attributes: None, chunk_offsets: Vec::new(), chunk_bytes: Vec::new(), + chroma_subsampling: (2, 2), }, }; decoder.next_image()?; diff --git a/src/error.rs b/src/error.rs index e0c06cb..eb4fc86 100644 --- a/src/error.rs +++ b/src/error.rs @@ -90,6 +90,9 @@ quick_error! { InvalidTypeForTag { display("tag has invalid type") } + InvalidCountForTag(tag: Tag, len: usize) { + display("tag `{tag:?}` with incorrect number of elements ({len}) encountered") + } StripTileTagConflict { display("file should contain either (StripByteCounts and StripOffsets) or (TileByteCounts and TileOffsets), other combination was found") } @@ -156,6 +159,9 @@ quick_error! { UnsupportedInterpretation(interpretation: PhotometricInterpretation) { display("unsupported photometric interpretation \"{interpretation:?}\"") } + ChromaSubsampling { + display("chroma subsampling of YCbCr color is unsupported") + } MisalignedTileBoundaries { display("tile rows are not aligned to byte boundaries") } diff --git a/src/tags.rs b/src/tags.rs index f456f65..91f18f4 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -122,6 +122,11 @@ pub enum Tag(u16) unknown( SMaxSampleValue = 341, // TODO add support // JPEG JPEGTables = 347, + // Subsampling + #[doc(alias = "YCbCrSubsampling")] + ChromaSubsampling = 530, // TODO add support + #[doc(alias = "YCbCrPositioning")] + ChromaPositioning = 531, // TODO add support // GeoTIFF ModelPixelScaleTag = 33550, // (SoftDesk) ModelTransformationTag = 34264, // (JPL Carto Group) From ea373bae0046910d3f01e0200a0dc38e32fba59e Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:13:59 +0100 Subject: [PATCH 2/4] Fix YCbCr encoding not having subsampling --- src/encoder/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index 6c1a965..45024e3 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -11,7 +11,10 @@ use std::{ use crate::{ decoder::ifd::Entry, error::{TiffResult, UsageError}, - tags::{CompressionMethod, ExtraSamples, IfdPointer, ResolutionUnit, SampleFormat, Tag, Type}, + tags::{ + CompressionMethod, ExtraSamples, IfdPointer, PhotometricInterpretation, ResolutionUnit, + SampleFormat, Tag, Type, + }, Directory, TiffError, TiffFormatError, }; @@ -565,6 +568,12 @@ impl<'a, W: 'a + Write + Seek, T: ColorType, K: TiffKind> ImageEncoder<'a, W, T, encoder.write_tag(Tag::PhotometricInterpretation, ::TIFF_VALUE.to_u16())?; + if matches!(::TIFF_VALUE, PhotometricInterpretation::YCbCr) { + // The default for this tag is 2,2 for subsampling but we do not support such a + // transformation. Instead all samples must be provided. + encoder.write_tag(Tag::ChromaSubsampling, &[1u16, 1u16][..])?; + } + encoder.write_tag(Tag::RowsPerStrip, u32::try_from(rows_per_strip)?)?; encoder.write_tag( From fa225e820b96bef35f01bf4685654beeb4a8df0c Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:38:32 +0100 Subject: [PATCH 3/4] Exempt JPEG compression from upsampling restriction I didn't realize we support this in a slightly weird manner where the tag is completely ignored and zune-jpeg performs the upsampling as indicated in the JPEG stream. This is a recurring problem with the compression scheme and tags in TIFF. In the end, decoder can only handle the data as indicated by the JPEG steam and while the specification requires the tags to be consistent with the underlying data, sometimes they are not. The useful fallback is of course to ignore the tag. Ignoring the tag of course buts us in a weird spot for zero-copy decoding since the buffer indication to the caller will be incorrect. For now, maybe the best way of treating subsampling is to upsample all the cases when we add support. --- src/decoder/image.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 70573d8..0150e4d 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -738,9 +738,13 @@ 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) { - return Err(TiffError::UnsupportedError( - TiffUnsupportedError::ChromaSubsampling, - )); + // The JPEG library does upsampling for us and defines its buffers correctly + // (presumably). All other compression schemes are not supported.. + if !matches!(self.compression_method, CompressionMethod::ModernJPEG) { + return Err(TiffError::UnsupportedError( + TiffUnsupportedError::ChromaSubsampling, + )); + } } Ok(ReadoutLayout { From bd64e438a423589c37902681e9a6023fef8036de Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:46:59 +0100 Subject: [PATCH 4/4] Add note about subsampling support to decoder --- src/decoder/image.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 0150e4d..a1bda19 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -740,6 +740,12 @@ impl Image { 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) { return Err(TiffError::UnsupportedError( TiffUnsupportedError::ChromaSubsampling,