From 8517bb81dbabbc86c8bc4bb7949d80af3f85da03 Mon Sep 17 00:00:00 2001 From: "A. Molzer" <5550310+197g@users.noreply.github.com> Date: Sun, 9 Nov 2025 06:25:53 +0100 Subject: [PATCH] Decode with ExtraSamples These samples are normal bits in the encoded (e.g. compressed) stream of each tile or row of the image. However, when considering the photometric interpretation of an image these samples should be discarded. They are only relevant for multiband. For the most part we previously rejected these images due to a mismatch between sample count and the color as indicated by the interpretation. Only, for alpha we never really verified if the extra channel is indeed alpha or something unrelated. We don't have a perfect solution as GeoTiff motivates us having a MultiBand pseudo-color output configuration that has no related photometric interpretation and borrows the usual gray tone instead. We can only really distinguish this from a gray-alpha image through looking at the values of the ExtraSamples vector. Then again we do not yet support GrayAlpha anyways. As a simplification for the discarding process we only support full-byte extra samples and require all samples to be the same bit-depth, not only those which we return. This is blessed by Tiff's recommendations for baseline decoding as well as libtiff. --- CHANGES.md | 9 ++ src/decoder/image.rs | 167 +++++++++++++++++++++++++++++++---- src/decoder/mod.rs | 16 +++- src/encoder/mod.rs | 25 +++++- src/error.rs | 3 + src/lib.rs | 14 +++ src/tags.rs | 11 +++ tests/decode_images.rs | 18 ++++ tests/encode_images.rs | 1 + tests/images/cmyk-4c-8b.tiff | Bin 105016 -> 130178 bytes 10 files changed, 243 insertions(+), 21 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f816018..f305a02 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,15 @@ - `Directory` now implements `FromIterator<(Tag, Value)>`. +Changes: +- The decoder now interprets the `ExtraSamples` tag. The sample count must now + more strict match the expected value with alpha channels only allowing for + explicitly denoted unassociated or associated alpha. This effects the + indicated color type when decoding images with additional samples indicated + as unspecified relation. Previously, these may have been interpreted as + alpha by the total sample count (e.g. RgbA if 4 samples under a photometric + interpretation of RGB). + # Version 0.10.3 New features: diff --git a/src/decoder/image.rs b/src/decoder/image.rs index f13a28b..05e82e1 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -4,7 +4,8 @@ use super::tag_reader::TagReader; use super::ChunkType; use super::{predict_f16, predict_f32, predict_f64, ValueReader}; use crate::tags::{ - CompressionMethod, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat, Tag, + CompressionMethod, ExtraSamples, PhotometricInterpretation, PlanarConfiguration, Predictor, + SampleFormat, Tag, }; use crate::{ ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError, @@ -69,6 +70,11 @@ pub(crate) struct Image { pub height: u32, pub bits_per_sample: u8, pub samples: u16, + /// The `ExtraSamples`, defaulting to empty if not given. + pub extra_samples: Vec, + /// Number of samples that belong to the photometric interpretation, samples except + /// `ExtraSamples` (338, 0x0152) tag. + pub photometric_samples: u16, pub sample_format: SampleFormat, pub photometric_interpretation: PhotometricInterpretation, pub compression_method: CompressionMethod, @@ -134,10 +140,30 @@ impl Image { .map(Value::into_u16) .transpose()? .unwrap_or(1); + if samples == 0 { return Err(TiffFormatError::SamplesPerPixelIsZero.into()); } + let extra_samples = match tag_reader.find_tag(Tag::ExtraSamples)? { + Some(n) => n.into_u16_vec()?, + None => vec![], + }; + + let extra_samples = extra_samples + .into_iter() + .map(|x| ExtraSamples::from_u16(x).unwrap_or(ExtraSamples::Unspecified)) + .collect::>(); + + let photometric_samples = match usize::from(samples).checked_sub(extra_samples.len()) { + None => { + return Err(TiffError::FormatError( + TiffFormatError::InconsistentSizesEncountered, + )); + } + Some(n) => n as u16, + }; + let sample_format = match tag_reader.find_tag_uint_vec(Tag::SampleFormat)? { Some(vals) => { let sample_format: Vec<_> = vals @@ -297,6 +323,8 @@ impl Image { height, bits_per_sample: bits_per_sample[0], samples, + extra_samples, + photometric_samples, sample_format, photometric_interpretation, compression_method, @@ -312,15 +340,19 @@ impl Image { } pub(crate) fn colortype(&self) -> TiffResult { + let is_alpha_extra_samples = matches!( + self.extra_samples.as_slice(), + [ExtraSamples::AssociatedAlpha] | [ExtraSamples::UnassociatedAlpha] + ); + match self.photometric_interpretation { - PhotometricInterpretation::RGB => match self.samples { - 3 => Ok(ColorType::RGB(self.bits_per_sample)), + PhotometricInterpretation::RGB => match self.photometric_samples { + 3 => Ok(if is_alpha_extra_samples { + ColorType::RGBA(self.bits_per_sample) + } else { + ColorType::RGB(self.bits_per_sample) + }), 4 => Ok(ColorType::RGBA(self.bits_per_sample)), - // FIXME: We should _ignore_ other components. In particular: - // > Beware of extra components. Some TIFF files may have more components per pixel - // than you think. A Baseline TIFF reader must skip over them gracefully,using the - // values of the SamplesPerPixel and BitsPerSample fields. - // > -- TIFF 6.0 Specification, Section 7, Additional Baseline requirements. _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( self.photometric_interpretation, @@ -328,8 +360,12 @@ impl Image { ), )), }, - PhotometricInterpretation::CMYK => match self.samples { - 4 => Ok(ColorType::CMYK(self.bits_per_sample)), + PhotometricInterpretation::CMYK => match self.photometric_samples { + 4 => Ok(if is_alpha_extra_samples { + ColorType::CMYKA(self.bits_per_sample) + } else { + ColorType::CMYK(self.bits_per_sample) + }), 5 => Ok(ColorType::CMYKA(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( @@ -338,7 +374,7 @@ impl Image { ), )), }, - PhotometricInterpretation::YCbCr => match self.samples { + PhotometricInterpretation::YCbCr => match self.photometric_samples { 3 => Ok(ColorType::YCbCr(self.bits_per_sample)), _ => Err(TiffError::UnsupportedError( TiffUnsupportedError::InterpretationWithBits( @@ -351,6 +387,9 @@ impl Image { // later called when that interpretation is read. That function does not support // Multiband as a color type and will error. It's unclear how to resolve that exactly. PhotometricInterpretation::BlackIsZero | PhotometricInterpretation::WhiteIsZero => { + // Note: compatibility with previous implementation requires us to return extra + // samples as `Multiband`. For gray images however the better choice would be + // returning a `Gray` color, i.e. matching on `photometric_samples` instead. match self.samples { 1 => Ok(ColorType::Gray(self.bits_per_sample)), _ => Ok(ColorType::Multiband { @@ -371,12 +410,28 @@ impl Image { } } + /// Get the multiband color describing this with its extra samples. + pub(crate) fn color_multiband_with_extras(&self) -> ColorType { + ColorType::Multiband { + bit_depth: self.bits_per_sample, + num_samples: self.samples, + } + } + + /// Describe this with an accurate color or a multiband. + pub(crate) fn color_or_fallback(&self) -> ColorType { + self.colortype() + .unwrap_or_else(|_| self.color_multiband_with_extras()) + } + pub(crate) fn minimum_row_stride(&self, dims: (u32, u32)) -> Option { let (width, height) = dims; + let color = self.color_or_fallback(); + let row_stride = u64::from(width) - .saturating_mul(self.samples_per_pixel() as u64) - .saturating_mul(self.bits_per_sample as u64) + .saturating_mul(u64::from(self.samples_per_out_texel(color))) + .saturating_mul(u64::from(self.bits_per_sample)) .div_ceil(8); // Note: row stride should be smaller than the len if we have an actual buffer. If there @@ -490,6 +545,13 @@ impl Image { } } + pub(crate) fn samples_per_out_texel(&self, color: ColorType) -> u16 { + match self.planar_config { + PlanarConfiguration::Chunky => color.num_samples(), + PlanarConfiguration::Planar => 1, + } + } + /// Number of strips per pixel. pub(crate) fn strips_per_pixel(&self) -> usize { match self.planar_config { @@ -643,6 +705,7 @@ impl Image { .ok_or(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, ))?; + if *compressed_bytes > limits.intermediate_buffer_size as u64 { return Err(TiffError::LimitsExceeded); } @@ -650,8 +713,16 @@ impl Image { let compression_method = self.compression_method; let photometric_interpretation = self.photometric_interpretation; let predictor = self.predictor; - let samples = self.samples_per_pixel(); + let samples = self.samples_per_pixel(); + let data_samples = self.samples_per_out_texel(color_type); + + // We have two dimensions: the 2d rectangle of encoded data and the 2d rectangle this + // takes up in the output. Each has an associated count of bits per pixel. The first + // dimension, i.e. a ''row'', is the number of pixels that are encoded with bit packing + // while the second is the byte-padded array of each so encoded slices. + // + // During decoding we map the relevant bits from one to the other. let chunk_dims = self.chunk_dimensions()?; let data_dims = self.chunk_data_dimensions(chunk_index)?; @@ -661,7 +732,7 @@ impl Image { let chunk_row_bytes: usize = chunk_row_bits.div_ceil(8).try_into()?; let data_row_bits = (u64::from(data_dims.0) * u64::from(self.bits_per_sample)) - .checked_mul(samples as u64) + .checked_mul(data_samples as u64) .ok_or(TiffError::LimitsExceeded)?; let data_row_bytes: usize = data_row_bits.div_ceil(8).try_into()?; @@ -677,7 +748,11 @@ impl Image { chunk_dims, )?; - if output_row_stride == chunk_row_bytes { + let is_all_bits = samples == usize::from(data_samples); + let is_output_chunk_rows = output_row_stride == chunk_row_bytes; + + if is_output_chunk_rows && is_all_bits { + // Here we can read directly into the output buffer itself. let tile = &mut buf[..chunk_row_bytes * data_dims.1 as usize]; reader.read_exact(tile)?; @@ -690,6 +765,7 @@ impl Image { predictor, ); } + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(tile, color_type, self.sample_format)?; } @@ -711,7 +787,8 @@ impl Image { super::invert_colors(row, color_type, self.sample_format)?; } } - } else { + } else if is_all_bits { + // We read row-by-row but each row fits in its output buffer. for row in buf.chunks_mut(output_row_stride).take(data_dims.1 as usize) { let row = &mut row[..data_row_bytes]; reader.read_exact(row)?; @@ -729,6 +806,48 @@ impl Image { byte_order, predictor, ); + + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { + super::invert_colors(row, color_type, self.sample_format)?; + } + } + } else { + // The encoded data potentially takes up more space than the output data so we must be + // prepared to discard some of it. That decision is bit-by-bit. + let bits_per_pixel = u32::from(self.bits_per_sample) * u32::from(self.samples); + // Assumes the photometric samples are always the start.. This is slightly problematic. + // To expand spport we should instead have different methods of transforming the read + // buffer data, not only the `compact_photometric_bytes` method below and then choose + // from the right one with supplied parameters. Then we can also bit-for-bit copy with + // a selection for better performance. + let photometric_bit_end = u32::from(self.bits_per_sample) * data_samples as u32; + + debug_assert!(bits_per_pixel >= photometric_bit_end); + + if bits_per_pixel % 8 != 0 || photometric_bit_end % 8 != 0 { + return Err(TiffError::UnsupportedError( + TiffUnsupportedError::InterpretationWithBits( + self.photometric_interpretation, + vec![self.bits_per_sample; self.samples as usize], + ), + )); + } + + let photo_range = photometric_bit_end / 8..bits_per_pixel / 8; + let mut encoded = vec![0u8; chunk_row_bytes]; + for row in buf.chunks_mut(output_row_stride).take(data_dims.1 as usize) { + reader.read_exact(&mut encoded)?; + + Self::compact_photometric_bytes(&mut encoded, row, &photo_range); + + super::fix_endianness_and_predict( + row, + color_type.bit_depth(), + samples, + byte_order, + predictor, + ); + if photometric_interpretation == PhotometricInterpretation::WhiteIsZero { super::invert_colors(row, color_type, self.sample_format)?; } @@ -737,4 +856,18 @@ impl Image { Ok(()) } + + /// Turn a contiguous buffer of a whole number of raw sample arrays into a whole number of + /// photometric sample arrays by removing the extra samples in-between. + fn compact_photometric_bytes( + raw: &mut [u8], + row: &mut [u8], + photo_range: &std::ops::Range, + ) { + raw.chunks_exact_mut(photo_range.end as usize) + .zip(row.chunks_exact_mut(photo_range.start as usize)) + .for_each(|(src, dst)| { + dst.copy_from_slice(&src[..photo_range.start as usize]); + }); + } } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index ffa3c32..7b42575 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -667,6 +667,8 @@ impl Decoder { height: 0, bits_per_sample: 1, samples: 1, + extra_samples: vec![], + photometric_samples: 1, sample_format: SampleFormat::Uint, photometric_interpretation: PhotometricInterpretation::BlackIsZero, compression_method: CompressionMethod::None, @@ -981,8 +983,10 @@ impl Decoder { chunk_index: u32, output_width: usize, ) -> TiffResult<()> { + let color = self.image.color_or_fallback(); + let output_row_stride = (output_width as u64) - .saturating_mul(self.image.samples_per_pixel() as u64) + .saturating_mul(self.image.samples_per_out_texel(color) as u64) .saturating_mul(self.image.bits_per_sample as u64) .div_ceil(8); @@ -1032,9 +1036,12 @@ impl Decoder { .map_err(|_| TiffError::LimitsExceeded)? }; + let color = self.image.color_or_fallback(); + let samples = self.image.samples_per_out_texel(color); + let buffer_size = row_samples .checked_mul(height) - .and_then(|x| x.checked_mul(self.image.samples_per_pixel())) + .and_then(|x| x.checked_mul(samples.into())) .ok_or(TiffError::LimitsExceeded)?; Ok(match self.image().sample_format { @@ -1232,13 +1239,16 @@ impl Decoder { chunk_dimensions.0.min(width), chunk_dimensions.1.min(height), ); + if chunk_dimensions.0 == 0 || chunk_dimensions.1 == 0 { return Err(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, )); } - let samples = self.image().samples_per_pixel(); + let color = self.image().colortype()?; + let samples = self.image().samples_per_out_texel(color); + if samples == 0 { return Err(TiffError::FormatError( TiffFormatError::InconsistentSizesEncountered, diff --git a/src/encoder/mod.rs b/src/encoder/mod.rs index ffea51b..7ba03cf 100644 --- a/src/encoder/mod.rs +++ b/src/encoder/mod.rs @@ -11,7 +11,7 @@ use std::{ use crate::{ decoder::ifd::Entry, error::{TiffResult, UsageError}, - tags::{CompressionMethod, IfdPointer, ResolutionUnit, SampleFormat, Tag, Type}, + tags::{CompressionMethod, ExtraSamples, IfdPointer, ResolutionUnit, SampleFormat, Tag, Type}, Directory, TiffError, TiffFormatError, }; @@ -506,6 +506,7 @@ pub struct ImageEncoder<'a, W: 'a + Write + Seek, C: ColorType, K: TiffKind> { row_samples: u64, width: u32, height: u32, + extra_samples: Vec, rows_per_strip: u64, strip_offsets: Vec, strip_byte_count: Vec, @@ -583,6 +584,7 @@ impl<'a, W: 'a + Write + Seek, T: ColorType, K: TiffKind> ImageEncoder<'a, W, T, strip_idx: 0, row_samples, rows_per_strip, + extra_samples: vec![], width, height, strip_offsets: Vec::new(), @@ -594,6 +596,27 @@ impl<'a, W: 'a + Write + Seek, T: ColorType, K: TiffKind> ImageEncoder<'a, W, T, }) } + pub fn extra_samples(&mut self, extra: &[ExtraSamples]) -> Result<(), TiffError> { + if self.strip_idx != 0 { + return Err(TiffError::UsageError( + UsageError::ReconfiguredAfterImageWrite, + )); + } + + let samples = self.extra_samples.len() + extra.len() + ::BITS_PER_SAMPLE.len(); + let row_samples = u64::from(self.width) * u64::try_from(samples)?; + + self.extra_samples + .extend(extra.iter().map(ExtraSamples::to_u16)); + + self.encoder + .write_tag(Tag::ExtraSamples, &self.extra_samples[..])?; + + self.row_samples = row_samples; + + Ok(()) + } + /// Number of samples the next strip should have. pub fn next_strip_sample_count(&self) -> u64 { if self.strip_idx >= self.strip_count { diff --git a/src/error.rs b/src/error.rs index 4fe7d3a..e8977e2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -190,6 +190,9 @@ quick_error! { ZeroIfdPointer { display("the offset 0 can not point to a valid IFD") } + ReconfiguredAfterImageWrite { + display("attempted to reconfigure the encoder after image writing has started") + } } } diff --git a/src/lib.rs b/src/lib.rs index 0e0c5bc..39b9ddb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,4 +61,18 @@ impl ColorType { | ColorType::Multiband { bit_depth: b, .. } => b, } } + + fn num_samples(&self) -> u16 { + match *self { + ColorType::Gray(_) => 1, + ColorType::RGB(_) => 3, + ColorType::Palette(_) => 1, + ColorType::GrayA(_) => 2, + ColorType::RGBA(_) => 4, + ColorType::CMYK(_) => 4, + ColorType::CMYKA(_) => 5, + ColorType::YCbCr(_) => 3, + ColorType::Multiband { num_samples, .. } => num_samples, + } + } } diff --git a/src/tags.rs b/src/tags.rs index 2cc1344..7e4aa31 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -311,6 +311,17 @@ pub enum SampleFormat(u16) unknown( } } +tags! { +pub enum ExtraSamples(u16) { + /// There is no specified association between the sample and the image. + Unspecified = 0, + /// The sample is associated alpha, i.e. pre-multiplied color. + AssociatedAlpha = 1, + /// The sample is unassociated alpha such as a mask. There might be more than one such sample. + UnassociatedAlpha = 2, +} +} + /// Byte order of the TIFF file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ByteOrder { diff --git a/tests/decode_images.rs b/tests/decode_images.rs index f6d7aa6..e1e55f5 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -684,3 +684,21 @@ fn test_fax4() { fn test_fax4_white_is_min() { test_image_sum_u8("imagemagick_group4.tiff", ColorType::Gray(1), 3742820); } + +#[test] +fn extra_bits_image() { + use tiff::{encoder, tags::ExtraSamples}; + + let path = PathBuf::from(TEST_IMAGE_DIR).join("extra_bits_gray_4b.tiff"); + let img_file = File::create(path).expect("Cannot find test image!"); + + let mut encoder = encoder::TiffEncoder::new(img_file).expect("Cannot create encoder"); + let mut img = encoder + .new_image::(8, 8) + .unwrap(); + + img.extra_samples(&[ExtraSamples::Unspecified]).unwrap(); + + let data: [u8; 128] = core::array::from_fn(|i| i as u8 % 2); + img.write_data(&data).expect("correct amount of data to write"); +} diff --git a/tests/encode_images.rs b/tests/encode_images.rs index ea83b25..d16bf69 100644 --- a/tests/encode_images.rs +++ b/tests/encode_images.rs @@ -235,6 +235,7 @@ const TEST_IMAGE_DIR: &str = "./tests/images/"; macro_rules! test_roundtrip { ($name:ident, $buffer:ident, $buffer_ty:ty) => { + #[track_caller] fn $name>( file: &str, expected_type: ColorType, diff --git a/tests/images/cmyk-4c-8b.tiff b/tests/images/cmyk-4c-8b.tiff index 43918437f62aec96f93dfc0785fb35e83d556ea5..4c4cb95d5aab0b917aa4959f288eb7f0706a3253 100644 GIT binary patch delta 166 zcmdn7g{|o)JG-Z+7K6ns#zyw7?2Lx9CUWR8hs|c3Xc^1zHk*+_nvs=(k%5814=A)$ zF)INmWW>nCAOaMc17zDWGJ~a=fczjN_8ulihJSVp3>$!IJlhxd?klD@) fR?jmFWGDlJ2M~i;Fq(q_1}7(SSZ~f|e4YRR%^)1- delta 21 ccmZqr$-ZL?8@s2c76S(ZLnHfEc1FWl07|t5&;S4c