chore: drop palette and LZW, add tests for CIELab and TransparencyMask
This commit is contained in:
1 parent
dfd0191403
commit
30e04f3381
4 files changed
+137
-118
No files matched your search
+13
-4
@@ -591,10 +591,17 @@ impl Image {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
// CIE L*a*b* uses signed a/b channels whereas ICC L*a*b* uses unsigned
|
// CIE L*a*b* (TIFF6 Section 23, pp 110-115) encodes a/b as signed int8
|
||||||
// with a +128 bias. For 8-bit, the byte-level data is the same size — we
|
// (-128..127) while ICC L*a*b* uses unsigned with +128 bias. For 8-bit we
|
||||||
// re-bias a/b from signed to unsigned during expand_chunk so that callers
|
// re-bias a/b during expand_chunk so callers see the same encoding as ICCLab.
|
||||||
// can treat CIELab identically to ICCLab (both return Lab(8)).
|
//
|
||||||
|
// libtiff takes the same approach — accepts only 8-bit CIELab and converts
|
||||||
|
// without checking SampleFormat:
|
||||||
|
// https://libtiff.gitlab.io/libtiff/functions/TIFFcolor.html
|
||||||
|
// https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_getimage.c
|
||||||
|
//
|
||||||
|
// 16-bit CIELab would need L=uint16 + a/b=int16, which SampleFormat can't
|
||||||
|
// express (it applies uniformly). libtiff doesn't support it either.
|
||||||
PhotometricInterpretation::CIELab => match self.photometric_samples {
|
PhotometricInterpretation::CIELab => match self.photometric_samples {
|
||||||
3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)),
|
3 if matches!(self.bits_per_sample, 8) => Ok(ColorType::Lab(self.bits_per_sample)),
|
||||||
_ => Err(TiffError::UnsupportedError(
|
_ => Err(TiffError::UnsupportedError(
|
||||||
@@ -1033,6 +1040,7 @@ impl Image {
|
|||||||
| ColorType::YCbCr(n)
|
| ColorType::YCbCr(n)
|
||||||
| ColorType::Gray(n)
|
| ColorType::Gray(n)
|
||||||
| ColorType::Palette(n)
|
| ColorType::Palette(n)
|
||||||
|
| ColorType::Lab(n)
|
||||||
| ColorType::Multiband {
|
| ColorType::Multiband {
|
||||||
bit_depth: n,
|
bit_depth: n,
|
||||||
num_samples: _,
|
num_samples: _,
|
||||||
@@ -1044,6 +1052,7 @@ impl Image {
|
|||||||
| ColorType::YCbCr(n)
|
| ColorType::YCbCr(n)
|
||||||
| ColorType::Gray(n)
|
| ColorType::Gray(n)
|
||||||
| ColorType::Palette(n)
|
| ColorType::Palette(n)
|
||||||
|
| ColorType::Lab(n)
|
||||||
| ColorType::Multiband {
|
| ColorType::Multiband {
|
||||||
bit_depth: n,
|
bit_depth: n,
|
||||||
num_samples: _,
|
num_samples: _,
|
||||||
|
|||||||
+8
-107
@@ -3,8 +3,8 @@ use std::io::{self, Read, Seek};
|
|||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
use crate::tags::{
|
use crate::tags::{
|
||||||
ByteOrder, IfdPointer, PhotometricInterpretation, PlanarConfiguration, Predictor, SampleFormat,
|
ByteOrder, IfdPointer, PlanarConfiguration, Predictor, SampleFormat, Tag, TiffVariant, Type,
|
||||||
Tag, TiffVariant, Type, ValueBuffer,
|
ValueBuffer,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError,
|
ColorType, Directory, TiffError, TiffFormatError, TiffResult, TiffUnsupportedError, UsageError,
|
||||||
@@ -659,97 +659,20 @@ fn invert_colors(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert CIE L*a*b* (photometric=8) byte encoding to ICC L*a*b* (photometric=9).
|
/// Re-bias CIE L*a*b* a/b channels from signed to unsigned encoding.
|
||||||
///
|
///
|
||||||
/// CIELab stores a/b as signed int8 (-128..127). ICCLab stores them as uint8 with
|
/// CIELab (photometric=8) stores a/b as int8 where 0 is neutral.
|
||||||
/// a +128 bias (0..255, where 128 = neutral). The L channel is identical in both.
|
/// ICCLab (photometric=9) stores them as uint8 where 128 is neutral.
|
||||||
/// This re-biases a/b so that downstream code can treat CIELab the same as ICCLab.
|
/// Adding 128 (wrapping) converts between the two representations.
|
||||||
|
/// See libtiff's TIFFCIELabToXYZ for reference:
|
||||||
|
/// https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c
|
||||||
fn cielab_to_icclab(buf: &mut [u8]) {
|
fn cielab_to_icclab(buf: &mut [u8]) {
|
||||||
for pixel in buf.chunks_exact_mut(3) {
|
for pixel in buf.chunks_exact_mut(3) {
|
||||||
// L stays as-is; a and b get +128 (wrapping handles the two's complement)
|
|
||||||
pixel[1] = pixel[1].wrapping_add(128);
|
pixel[1] = pixel[1].wrapping_add(128);
|
||||||
pixel[2] = pixel[2].wrapping_add(128);
|
pixel[2] = pixel[2].wrapping_add(128);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand palette indices in `buf` to RGB using the color map.
|
|
||||||
///
|
|
||||||
/// `buf` contains packed palette indices at `bits_per_sample` depth. The color map
|
|
||||||
/// has layout `[R0..Rn, G0..Gn, B0..Bn]` with 16-bit entries. Each index is looked
|
|
||||||
/// up and replaced with an 8-bit RGB triplet (high byte of the 16-bit color map value).
|
|
||||||
///
|
|
||||||
/// Returns a new `Vec<u8>` with 3 bytes (R,G,B) per pixel. Sub-byte indices (1, 2, 4-bit)
|
|
||||||
/// are unpacked from the row-aligned packed representation.
|
|
||||||
fn expand_palette(
|
|
||||||
buf: &[u8],
|
|
||||||
color_map: &[u16],
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
bits_per_sample: u8,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let num_colors = 1usize << bits_per_sample;
|
|
||||||
let pixels = (width as usize) * (height as usize);
|
|
||||||
let mut rgb = Vec::with_capacity(pixels * 3);
|
|
||||||
|
|
||||||
if bits_per_sample == 8 {
|
|
||||||
for &idx in buf.iter().take(pixels) {
|
|
||||||
let i = idx as usize;
|
|
||||||
if i < num_colors {
|
|
||||||
rgb.push((color_map[i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[num_colors + i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[2 * num_colors + i] >> 8) as u8);
|
|
||||||
} else {
|
|
||||||
rgb.extend_from_slice(&[0, 0, 0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if bits_per_sample == 16 {
|
|
||||||
for chunk in buf.chunks(2).take(pixels) {
|
|
||||||
if chunk.len() < 2 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let i = u16::from_ne_bytes([chunk[0], chunk[1]]) as usize;
|
|
||||||
if i < num_colors {
|
|
||||||
rgb.push((color_map[i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[num_colors + i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[2 * num_colors + i] >> 8) as u8);
|
|
||||||
} else {
|
|
||||||
rgb.extend_from_slice(&[0, 0, 0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Sub-byte (1, 2, 4-bit): rows are byte-aligned with padding bits at end
|
|
||||||
let samples_per_byte = 8 / bits_per_sample as usize;
|
|
||||||
let bytes_per_row = ((width as usize) * bits_per_sample as usize).div_ceil(8);
|
|
||||||
|
|
||||||
for row in 0..height as usize {
|
|
||||||
let row_start = row * bytes_per_row;
|
|
||||||
let row_end = (row_start + bytes_per_row).min(buf.len());
|
|
||||||
let mut col = 0u32;
|
|
||||||
|
|
||||||
for &byte in &buf[row_start..row_end] {
|
|
||||||
for bit_idx in (0..samples_per_byte).rev() {
|
|
||||||
if col >= width {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let shift = bit_idx as u8 * bits_per_sample;
|
|
||||||
let mask = ((1u16 << bits_per_sample) - 1) as u8;
|
|
||||||
let i = ((byte >> shift) & mask) as usize;
|
|
||||||
if i < num_colors {
|
|
||||||
rgb.push((color_map[i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[num_colors + i] >> 8) as u8);
|
|
||||||
rgb.push((color_map[2 * num_colors + i] >> 8) as u8);
|
|
||||||
} else {
|
|
||||||
rgb.extend_from_slice(&[0, 0, 0]);
|
|
||||||
}
|
|
||||||
col += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rgb
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fix endianness. If `byte_order` matches the host, then conversion is a no-op.
|
/// Fix endianness. If `byte_order` matches the host, then conversion is a no-op.
|
||||||
fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) {
|
fn fix_endianness(buf: &mut [u8], byte_order: ByteOrder, bit_depth: u8) {
|
||||||
let host = ByteOrder::native();
|
let host = ByteOrder::native();
|
||||||
@@ -1476,28 +1399,6 @@ impl<R: Read + Seek> Decoder<R> {
|
|||||||
self.image.as_ref().and_then(|img| img.color_map.as_deref())
|
self.image.as_ref().and_then(|img| img.color_map.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand a palette-indexed image buffer to 8-bit RGB.
|
|
||||||
///
|
|
||||||
/// Takes the raw decoded buffer (palette indices at `bits_per_sample` depth) and
|
|
||||||
/// returns a new buffer with 3 bytes per pixel (R, G, B), using the image's color map.
|
|
||||||
/// Sub-byte indices (1, 2, 4-bit) are unpacked from the row-aligned representation.
|
|
||||||
///
|
|
||||||
/// Returns `None` if the current image is not a palette image or has no color map.
|
|
||||||
pub fn expand_palette_to_rgb(&self, indices: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
let image = self.image.as_ref()?;
|
|
||||||
let color_map = image.color_map.as_ref()?;
|
|
||||||
if image.photometric_interpretation != PhotometricInterpretation::RGBPalette {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(expand_palette(
|
|
||||||
indices,
|
|
||||||
color_map,
|
|
||||||
image.width,
|
|
||||||
image.height,
|
|
||||||
image.bits_per_sample,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a tag-entry map from a known offset.
|
/// Read a tag-entry map from a known offset.
|
||||||
///
|
///
|
||||||
/// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from
|
/// A TIFF [`Directory`], aka. image file directory aka. IFD, refers to a map from
|
||||||
|
|||||||
@@ -170,12 +170,6 @@ impl<R: Read> Read for LZWReader<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(weezl::LzwStatus::NoProgress) => {
|
Ok(weezl::LzwStatus::NoProgress) => {
|
||||||
// Some encoders omit the LZW end-of-information code. If we
|
|
||||||
// already produced output this call, treat it as a clean EOF
|
|
||||||
// rather than an error — the decoded data up to this point is valid.
|
|
||||||
if result.consumed_out > 0 {
|
|
||||||
return Ok(result.consumed_out);
|
|
||||||
}
|
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::UnexpectedEof,
|
io::ErrorKind::UnexpectedEof,
|
||||||
"no lzw end code found",
|
"no lzw end code found",
|
||||||
|
|||||||
+116
-1
@@ -5,7 +5,7 @@ use tiff::tags::{ByteOrder, Type};
|
|||||||
use tiff::ColorType;
|
use tiff::ColorType;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Seek};
|
use std::io::{Cursor, Read, Seek};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const TEST_IMAGE_DIR: &str = "./tests/images/";
|
const TEST_IMAGE_DIR: &str = "./tests/images/";
|
||||||
@@ -1057,3 +1057,118 @@ fn test_gray_u12_hpredict() {
|
|||||||
// Delta-encoded 12-bit values: [100, 200, 300, 400, 1000, 2000, 3000, 4000]
|
// Delta-encoded 12-bit values: [100, 200, 300, 400, 1000, 2000, 3000, 4000]
|
||||||
test_image_sum_u16("hpredict-1c-12b.tiff", ColorType::Gray(12), 11000);
|
test_image_sum_u16("hpredict-1c-12b.tiff", ColorType::Gray(12), 11000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- TransparencyMask ----
|
||||||
|
|
||||||
|
/// Build a minimal uncompressed TIFF in memory with the given photometric interpretation.
|
||||||
|
/// Returns a 2x2 image with the provided pixel data as a single strip.
|
||||||
|
fn build_raw_tiff(
|
||||||
|
photometric: u16,
|
||||||
|
bits_per_sample: u16,
|
||||||
|
samples_per_pixel: u16,
|
||||||
|
pixel_data: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let width: u16 = 2;
|
||||||
|
let height: u16 = 2;
|
||||||
|
let num_tags: u16 = 8;
|
||||||
|
let ifd_offset: u32 = 8;
|
||||||
|
let ifd_size = 2 + (num_tags as u32) * 12 + 4;
|
||||||
|
let strip_offset = 8 + ifd_size;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(b"II");
|
||||||
|
buf.extend_from_slice(&42u16.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&ifd_offset.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&num_tags.to_le_bytes());
|
||||||
|
|
||||||
|
// IFD entries (must be ascending tag order)
|
||||||
|
for &(tag, typ, value) in &[
|
||||||
|
(256u16, 3u16, width as u32), // ImageWidth
|
||||||
|
(257, 3, height as u32), // ImageLength
|
||||||
|
(258, 3, bits_per_sample as u32), // BitsPerSample
|
||||||
|
(259, 3, 1), // Compression = None
|
||||||
|
(262, 3, photometric as u32), // PhotometricInterpretation
|
||||||
|
(273, 4, strip_offset), // StripOffsets
|
||||||
|
(277, 3, samples_per_pixel as u32), // SamplesPerPixel
|
||||||
|
(279, 4, pixel_data.len() as u32), // StripByteCounts
|
||||||
|
] {
|
||||||
|
buf.extend_from_slice(&tag.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&typ.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&1u32.to_le_bytes());
|
||||||
|
buf.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.extend_from_slice(&0u32.to_le_bytes()); // next IFD = 0
|
||||||
|
buf.extend_from_slice(pixel_data);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transparency_mask() {
|
||||||
|
// 2x2 mask: values 0, 255, 128, 64
|
||||||
|
let data = build_raw_tiff(4, 8, 1, &[0, 255, 128, 64]);
|
||||||
|
let mut decoder = Decoder::new(Cursor::new(&data)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(8));
|
||||||
|
|
||||||
|
let result = decoder.read_image().unwrap();
|
||||||
|
match result {
|
||||||
|
DecodingSampleBuffer::U8(pixels) => {
|
||||||
|
// TransparencyMask is decoded as grayscale
|
||||||
|
assert_eq!(pixels.len(), 4);
|
||||||
|
assert_eq!(pixels[0], 0);
|
||||||
|
assert_eq!(pixels[1], 255);
|
||||||
|
assert_eq!(pixels[2], 128);
|
||||||
|
assert_eq!(pixels[3], 64);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected U8 buffer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CIE L*a*b* ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cielab_decode() {
|
||||||
|
// 2x2 CIELab image: 3 bytes per pixel (L, a, b)
|
||||||
|
// L=128, a=0 (signed neutral), b=0 (signed neutral)
|
||||||
|
// After re-biasing: a=128 (unsigned neutral), b=128 (unsigned neutral)
|
||||||
|
let pixels: Vec<u8> = vec![
|
||||||
|
128, 0, 0, // pixel 0: L=128, a=0, b=0 (neutral gray)
|
||||||
|
255, 50, 50, // pixel 1: L=255, a=+50, b=+50
|
||||||
|
100, 206, 206, // pixel 2: L=100, a=-50 (as i8), b=-50 (as i8)
|
||||||
|
0, 128, 128, // pixel 3: L=0, a=-128 (as i8), b=-128 (as i8)
|
||||||
|
];
|
||||||
|
|
||||||
|
let data = build_raw_tiff(8, 8, 3, &pixels);
|
||||||
|
let mut decoder = Decoder::new(Cursor::new(&data)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoder.colortype().unwrap(), ColorType::Lab(8));
|
||||||
|
|
||||||
|
let result = decoder.read_image().unwrap();
|
||||||
|
match result {
|
||||||
|
DecodingSampleBuffer::U8(decoded) => {
|
||||||
|
assert_eq!(decoded.len(), 12); // 4 pixels * 3 channels
|
||||||
|
|
||||||
|
// Pixel 0: L=128, a=0+128=128, b=0+128=128
|
||||||
|
assert_eq!(decoded[0], 128);
|
||||||
|
assert_eq!(decoded[1], 128);
|
||||||
|
assert_eq!(decoded[2], 128);
|
||||||
|
|
||||||
|
// Pixel 1: L=255, a=50+128=178, b=50+128=178
|
||||||
|
assert_eq!(decoded[3], 255);
|
||||||
|
assert_eq!(decoded[4], 178);
|
||||||
|
assert_eq!(decoded[5], 178);
|
||||||
|
|
||||||
|
// Pixel 2: L=100, a=206+128=78 (wrapping), b=206+128=78
|
||||||
|
assert_eq!(decoded[6], 100);
|
||||||
|
assert_eq!(decoded[7], 78);
|
||||||
|
assert_eq!(decoded[8], 78);
|
||||||
|
|
||||||
|
// Pixel 3: L=0, a=128+128=0 (wrapping), b=128+128=0
|
||||||
|
assert_eq!(decoded[9], 0);
|
||||||
|
assert_eq!(decoded[10], 0);
|
||||||
|
assert_eq!(decoded[11], 0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected U8 buffer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user