feat: support packed extended bit depths (9-15, 17-31 bits per sample)

TIFF files can store samples at non-standard bit depths (e.g. 10, 12,
14, 24 bits) packed tightly in a continuous bit stream. Previously
these were rejected as unsupported color types.

This adds:
- MSB-first bit extraction from packed byte streams (unpack_bits,
  extract_bits_msb) to expand N-bit samples to standard-width output
  (9-16 bit → u16, 17-32 bit → u32)
- Output buffer sizing using the promoted sample width instead of the
  packed bit depth (output_bits_per_sample field in ReadoutLayout)
- A new code path in expand_chunk that reads packed rows, unpacks to
  standard-width samples, then applies predictor and color inversion
- Horizontal predictor support for extended depths

Tested with 12-bit grayscale and RGB images (existing test suite),
plus all libtiffpic depth test images (10/12/14/24-bit in gray, RGB
contig, and RGB planar configurations).
This commit is contained in:
Lilith River committed 2026-03-10 06:01:48 -06:00
1 parent 8a5c2da511
commit 3d3c16e26f
2 files changed
+152 -8

No files matched your search

+149 -6
View File
@@ -135,6 +135,10 @@ pub(crate) struct ReadoutLayout {
/// Bits per sample in the encoded data.
pub tiff_bits_per_sample: u8,
/// Bits per sample in the output buffer, promoted to a standard width.
/// For sub-byte depths (1-8): same as tiff_bits_per_sample.
/// For 9-16: 16. For 17-32: 32. For 33-64: 64.
pub output_bits_per_sample: u8,
/// Number of samples in the encoded data.
pub tiff_samples: u16,
/// Dimensions of the underlying rectangular chunks (tile or strips).
@@ -872,17 +876,29 @@ impl Image {
let data_dimensions = (width, height);
// Output bits per sample: sub-byte depths stay packed, supra-byte non-standard
// depths are promoted to the next standard width (matching sample_type() mapping).
let output_bits_per_sample: u8 = match tiff_bits_per_sample {
0..=8 => tiff_bits_per_sample,
9..=16 => 16,
17..=32 => 32,
_ => 64,
};
// Packed row size for reading from the file (uses tiff_bits_per_sample).
let tiff_row_bits = (u64::from(tiff_chunk_dimensions.0) * u64::from(tiff_bits_per_sample))
.checked_mul(u64::from(tiff_samples))
.ok_or(TiffError::LimitsExceeded)?;
let tiff_row_bytes: usize = tiff_row_bits.div_ceil(8).try_into()?;
let chunk_row_bits = (u64::from(tiff_chunk_dimensions.0) * u64::from(tiff_bits_per_sample))
.checked_mul(u64::from(data_samples))
.ok_or(TiffError::LimitsExceeded)?;
// Output buffer sizes use output_bits_per_sample (the promoted width).
let chunk_row_bits = (u64::from(tiff_chunk_dimensions.0)
* u64::from(output_bits_per_sample))
.checked_mul(u64::from(data_samples))
.ok_or(TiffError::LimitsExceeded)?;
let chunk_row_bytes: usize = chunk_row_bits.div_ceil(8).try_into()?;
let data_row_bits = (u64::from(data_dimensions.0) * u64::from(tiff_bits_per_sample))
let data_row_bits = (u64::from(data_dimensions.0) * u64::from(output_bits_per_sample))
.checked_mul(u64::from(data_samples))
.ok_or(TiffError::LimitsExceeded)?;
let data_row_bytes: usize = data_row_bits.div_ceil(8).try_into()?;
@@ -938,6 +954,7 @@ impl Image {
chunk_col_stride,
plane_stride,
tiff_bits_per_sample,
output_bits_per_sample,
tiff_samples,
tiff_chunk_dimensions,
tiff_row_bytes,
@@ -1031,6 +1048,26 @@ impl Image {
));
}
},
// Extended bit depths (9-15, 17-31): packed N-bit samples unpacked to standard width.
// Horizontal predictor is supported (operates on unpacked standard-width samples).
// FloatingPoint predictor is not meaningful for non-standard integer depths.
ColorType::RGB(n)
| ColorType::RGBA(n)
| ColorType::CMYK(n)
| ColorType::CMYKA(n)
| ColorType::YCbCr(n)
| ColorType::Gray(n)
| ColorType::Multiband {
bit_depth: n,
num_samples: _,
} if n <= 32 => match self.predictor {
Predictor::None | Predictor::Horizontal => {}
Predictor::FloatingPoint => {
return Err(TiffError::UnsupportedError(
TiffUnsupportedError::FloatingPointPredictor(color_type),
));
}
},
type_ => {
return Err(TiffError::UnsupportedError(
TiffUnsupportedError::UnsupportedColorType(type_),
@@ -1104,7 +1141,49 @@ impl Image {
self.samples,
)?;
if is_output_chunk_rows && is_all_bits {
// 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
&& layout.tiff_bits_per_sample > 8;
if needs_unpacking {
if !is_all_bits {
// Packed extended depths with extra samples to discard is not yet supported.
return Err(TiffError::UnsupportedError(
TiffUnsupportedError::InterpretationWithBits(
self.photometric_interpretation,
vec![self.bits_per_sample; self.samples as usize],
),
));
}
let tiff_bps = layout.tiff_bits_per_sample;
let out_bps = layout.output_bits_per_sample;
let samples_per_data_row = data_dims.0 as usize * samples as usize;
let mut packed = vec![0u8; chunk_row_bytes];
for row in buf.chunks_mut(layout.row_stride).take(data_dims.1 as usize) {
reader.read_exact(&mut packed)?;
let out_row = &mut row[..data_row_bytes];
unpack_bits(&packed, out_row, tiff_bps, samples_per_data_row, out_bps);
// Unpacked values are in native byte order, so pass native to skip
// endianness conversion while still applying the predictor.
super::fix_endianness_and_predict(
out_row,
color_type.bit_depth(),
samples,
ByteOrder::native(),
predictor,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
super::invert_colors(out_row, color_type, self.sample_format)?;
}
}
} else 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)?;
@@ -1229,6 +1308,70 @@ impl Image {
}
}
/// Unpack N-bit packed samples (MSB-first) into standard-width output samples in native byte
/// order. Packed bits are read from `src` and expanded samples are written to `dst`.
///
/// `bits_per_sample` is the packed depth (e.g. 10, 12, 14).
/// `num_samples` is the number of samples to extract.
/// `output_width` is the output sample width in bits (16 or 32).
///
/// # Panics
///
/// Panics if `dst` is too small for `num_samples` output samples.
fn unpack_bits(
src: &[u8],
dst: &mut [u8],
bits_per_sample: u8,
num_samples: usize,
output_width: u8,
) {
debug_assert!(output_width == 16 || output_width == 32);
let bps = u32::from(bits_per_sample);
match output_width {
16 => {
debug_assert!(dst.len() >= num_samples * 2);
let mut bit_offset: usize = 0;
for i in 0..num_samples {
let val = extract_bits_msb(src, bit_offset, bps);
dst[i * 2..i * 2 + 2].copy_from_slice(&(val as u16).to_ne_bytes());
bit_offset += bps as usize;
}
}
32 => {
debug_assert!(dst.len() >= num_samples * 4);
let mut bit_offset: usize = 0;
for i in 0..num_samples {
let val = extract_bits_msb(src, bit_offset, bps);
dst[i * 4..i * 4 + 4].copy_from_slice(&val.to_ne_bytes());
bit_offset += bps as usize;
}
}
_ => unreachable!(),
}
}
/// Extract a value of `num_bits` bits starting at `bit_offset` from an MSB-first packed byte
/// stream. Returns the value as a u32 (right-justified, zero-extended).
#[inline]
fn extract_bits_msb(src: &[u8], bit_offset: usize, num_bits: u32) -> u32 {
debug_assert!(num_bits <= 32);
let byte_idx = bit_offset / 8;
let bit_idx = bit_offset % 8;
// Read enough bytes to cover the value (up to 5 bytes for 32 bits crossing a byte boundary).
let mut acc: u64 = 0;
let bytes_needed = (bit_idx + num_bits as usize).div_ceil(8);
for i in 0..bytes_needed {
acc = (acc << 8) | u64::from(src[byte_idx + i]);
}
// Shift right to align the value to bit 0 and mask.
let shift = bytes_needed * 8 - bit_idx - num_bits as usize;
((acc >> shift) & ((1u64 << num_bits) - 1)) as u32
}
impl ReadoutLayout {
pub(crate) fn samples_per_out_texel(&self) -> u16 {
match self.planar_config {
@@ -1241,7 +1384,7 @@ impl ReadoutLayout {
// pixel data.
pub(crate) fn chunk_row_bytes(&self, width: u32) -> TiffResult<usize> {
let data_samples = self.samples_per_out_texel();
let data_row_bits = (u64::from(width) * u64::from(self.tiff_bits_per_sample))
let data_row_bits = (u64::from(width) * u64::from(self.output_bits_per_sample))
.checked_mul(u64::from(data_samples))
.ok_or(TiffError::LimitsExceeded)?;
Ok(data_row_bits.div_ceil(8).try_into()?)
+3 -2
View File
@@ -43,6 +43,7 @@ test_image_sum!(test_image_sum_f64, F64, f64);
/// Tests that a decoder can be constructed for an image and the color type
/// read from the IFD and is of the appropriate type, but the type is
/// unsupported.
#[allow(dead_code)]
fn test_image_color_type_unsupported(file: &str, expected_type: ColorType) {
let path = PathBuf::from(TEST_IMAGE_DIR).join(file);
let img_file = File::open(path).expect("Cannot find test image!");
@@ -121,7 +122,7 @@ fn test_gray_u8() {
#[test]
fn test_gray_u12() {
test_image_color_type_unsupported("12bit.cropped.tiff", ColorType::Gray(12));
test_image_sum_u16("12bit.cropped.tiff", ColorType::Gray(12), 10973);
}
#[test]
@@ -160,7 +161,7 @@ fn test_rgb_u8() {
#[test]
fn test_rgb_u12() {
test_image_color_type_unsupported("12bit.cropped.rgb.tiff", ColorType::RGB(12));
test_image_sum_u16("12bit.cropped.rgb.tiff", ColorType::RGB(12), 32919);
}
#[test]