diff --git a/tests/decode_images.rs b/tests/decode_images.rs index c2ff3dd..8b0b067 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -998,3 +998,152 @@ fn test_gray_u12_hpredict() { // 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); } + +// ===== Sequential-value test functions ===== +// Generated by gen_seq_images.py using manual TIFF writer +// Each image has cycling 0..max values; any bit error changes the sum. +// 16x16 images (256 pixels per channel). +// Sub-byte checksums are sums of packed bytes (the decoder returns packed data). +// Planar checksums are first-plane only (read_image() reads one plane). + +// --- Sub-byte BlackIsZero --- +#[test] +fn test_seq_1c_3b() { + test_image_sum_u8("seq-1c-3b.tiff", ColorType::Gray(3), 5792); +} + +#[test] +fn test_seq_1c_5b() { + test_image_sum_u8("seq-1c-5b.tiff", ColorType::Gray(5), 18944); +} + +#[test] +fn test_seq_1c_7b() { + test_image_sum_u8("seq-1c-7b.tiff", ColorType::Gray(7), 24992); +} + +// --- Sub-byte WhiteIsZero --- +#[test] +fn test_seq_1c_3b_miniswhite() { + test_image_sum_u8("seq-1c-3b-miniswhite.tiff", ColorType::Gray(3), 5792); +} + +#[test] +fn test_seq_1c_5b_miniswhite() { + test_image_sum_u8("seq-1c-5b-miniswhite.tiff", ColorType::Gray(5), 18944); +} + +#[test] +fn test_seq_1c_6b_miniswhite() { + test_image_sum_u8("seq-1c-6b-miniswhite.tiff", ColorType::Gray(6), 25728); +} + +#[test] +fn test_seq_1c_7b_miniswhite() { + test_image_sum_u8("seq-1c-7b-miniswhite.tiff", ColorType::Gray(7), 24992); +} + +// --- Extended BlackIsZero --- +#[test] +fn test_seq_1c_10b() { + test_image_sum_u16("seq-1c-10b.tiff", ColorType::Gray(10), 32640); +} + +#[test] +fn test_seq_1c_12b() { + test_image_sum_u16("seq-1c-12b.tiff", ColorType::Gray(12), 32640); +} + +#[test] +fn test_seq_1c_14b() { + test_image_sum_u16("seq-1c-14b.tiff", ColorType::Gray(14), 32640); +} + +#[test] +fn test_seq_1c_24b() { + test_image_sum_u32("seq-1c-24b.tiff", ColorType::Gray(24), 32640); +} + +// --- Extended WhiteIsZero --- +#[test] +fn test_seq_1c_10b_miniswhite() { + test_image_sum_u16("seq-1c-10b-miniswhite.tiff", ColorType::Gray(10), 32640); +} + +#[test] +fn test_seq_1c_12b_miniswhite() { + test_image_sum_u16("seq-1c-12b-miniswhite.tiff", ColorType::Gray(12), 32640); +} + +#[test] +fn test_seq_1c_14b_miniswhite() { + test_image_sum_u16("seq-1c-14b-miniswhite.tiff", ColorType::Gray(14), 32640); +} + +#[test] +fn test_seq_1c_24b_miniswhite() { + test_image_sum_u32("seq-1c-24b-miniswhite.tiff", ColorType::Gray(24), 32640); +} + +// --- Extended HPredict --- +#[test] +fn test_seq_1c_10b_hpredict() { + test_image_sum_u16("seq-1c-10b-hpredict.tiff", ColorType::Gray(10), 32640); +} + +#[test] +fn test_seq_1c_12b_hpredict() { + test_image_sum_u16("seq-1c-12b-hpredict.tiff", ColorType::Gray(12), 32640); +} + +#[test] +fn test_seq_1c_14b_hpredict() { + test_image_sum_u16("seq-1c-14b-hpredict.tiff", ColorType::Gray(14), 32640); +} + +#[test] +fn test_seq_1c_24b_hpredict() { + test_image_sum_u32("seq-1c-24b-hpredict.tiff", ColorType::Gray(24), 32640); +} + +// --- RGB Contiguous --- +#[test] +fn test_seq_3c_10b_contig() { + test_image_sum_u16("seq-3c-10b-contig.tiff", ColorType::RGB(10), 294528); +} + +#[test] +fn test_seq_3c_12b_contig() { + test_image_sum_u16("seq-3c-12b-contig.tiff", ColorType::RGB(12), 294528); +} + +#[test] +fn test_seq_3c_14b_contig() { + test_image_sum_u16("seq-3c-14b-contig.tiff", ColorType::RGB(14), 294528); +} + +#[test] +fn test_seq_3c_24b_contig() { + test_image_sum_u32("seq-3c-24b-contig.tiff", ColorType::RGB(24), 294528); +} + +// --- RGB Planar (first plane only via read_image) --- +#[test] +fn test_seq_3c_10b_planar() { + test_image_sum_u16("seq-3c-10b-planar.tiff", ColorType::RGB(10), 32640); +} + +#[test] +fn test_seq_3c_12b_planar() { + test_image_sum_u16("seq-3c-12b-planar.tiff", ColorType::RGB(12), 32640); +} + +#[test] +fn test_seq_3c_14b_planar() { + test_image_sum_u16("seq-3c-14b-planar.tiff", ColorType::RGB(14), 32640); +} + +#[test] +fn test_seq_3c_24b_planar() { + test_image_sum_u32("seq-3c-24b-planar.tiff", ColorType::RGB(24), 32640); +} diff --git a/tests/gen_seq_images.py b/tests/gen_seq_images.py new file mode 100644 index 0000000..6a44da2 --- /dev/null +++ b/tests/gen_seq_images.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Generate sequential-value TIFF test images for bit-error detection. + +Uses manual TIFF structure writing to produce packed-sample images at +non-standard bit depths. Each image contains pixel values cycling 0..max, +so any bit-level decode error changes the checksum. + +Images are 16x16 (256 pixels per channel). + +Output: tests/images/seq-*.tiff + Rust test functions printed to stdout. +""" + +import numpy as np +import struct +import os +from pathlib import Path + +OUTDIR = Path(__file__).parent / "images" +OUTDIR.mkdir(exist_ok=True) + +W, H = 16, 16 +NPIX = W * H # 256 pixels per channel + + +def sequential_values(max_val, count): + """Generate cycling sequential values 0..max_val.""" + return [i % (max_val + 1) for i in range(count)] + + +def compute_sum(values): + """Compute checksum as sum of all sample values.""" + return sum(values) + + +def pack_bits(values, bits, width): + """Pack values into MSB-first bit stream, row by row. + + Each row is packed independently and padded to a byte boundary, + matching TIFF strip/row semantics. + """ + height = len(values) // width + all_bytes = bytearray() + for y in range(height): + row_vals = values[y * width:(y + 1) * width] + bit_buf = 0 + bit_count = 0 + for v in row_vals: + bit_buf = (bit_buf << bits) | v + bit_count += bits + while bit_count >= 8: + bit_count -= 8 + all_bytes.append((bit_buf >> bit_count) & 0xFF) + if bit_count > 0: + all_bytes.append((bit_buf << (8 - bit_count)) & 0xFF) + return bytes(all_bytes) + + +def pack_bits_multichannel_contig(values, bits, width, channels): + """Pack interleaved multichannel data: RGBRGBRGB... + + values is a flat list of length width*height*channels, already interleaved. + Rows are packed independently. + """ + height = len(values) // (width * channels) + all_bytes = bytearray() + for y in range(height): + start = y * width * channels + row_vals = values[start:start + width * channels] + bit_buf = 0 + bit_count = 0 + for v in row_vals: + bit_buf = (bit_buf << bits) | v + bit_count += bits + while bit_count >= 8: + bit_count -= 8 + all_bytes.append((bit_buf >> bit_count) & 0xFF) + if bit_count > 0: + all_bytes.append((bit_buf << (8 - bit_count)) & 0xFF) + return bytes(all_bytes) + + +def pack_bits_planar(values, bits, width, height, channels): + """Pack planar data: all R samples, then all G, then all B. + + values is a flat list of length width*height*channels. + First width*height are channel 0, next are channel 1, etc. + Each plane's rows are packed independently. + + Returns (bytes, plane_sizes) where plane_sizes is a list of byte counts per plane. + """ + plane_size = width * height + all_bytes = bytearray() + plane_byte_sizes = [] + for c in range(channels): + plane = values[c * plane_size:(c + 1) * plane_size] + plane_bytes = pack_bits(plane, bits, width) + plane_byte_sizes.append(len(plane_bytes)) + all_bytes.extend(plane_bytes) + return bytes(all_bytes), plane_byte_sizes + + +def apply_horizontal_predictor(values, bits, width): + """Apply TIFF horizontal differencing predictor. + + For each row, first sample is unchanged, subsequent samples become + (current - previous) mod 2^bits. + """ + height = len(values) // width + mod = 1 << bits + result = list(values) + for y in range(height): + for x in range(width - 1, 0, -1): + idx = y * width + x + result[idx] = (result[idx] - result[idx - 1]) % mod + return result + + +def apply_horizontal_predictor_rgb(values, bits, width, channels): + """Apply horizontal predictor for interleaved RGB data.""" + height = len(values) // (width * channels) + mod = 1 << bits + result = list(values) + stride = width * channels + for y in range(height): + row_start = y * stride + # Work backwards to avoid overwriting values we still need + for x in range(width - 1, 0, -1): + for c in range(channels): + idx = row_start + x * channels + c + prev = row_start + (x - 1) * channels + c + result[idx] = (result[idx] - result[prev]) % mod + return result + + +def write_tiff(path, raw_data, width, height, bits, photometric=1, + samples_per_pixel=1, planar=False, predictor=1, + plane_sizes=None): + """Write a valid TIFF file with precise IFD structure. + + photometric: 0=WhiteIsZero, 1=BlackIsZero, 2=RGB + plane_sizes: list of byte sizes per plane (for planar images) + """ + # For planar images, we need separate strips per plane + if planar and samples_per_pixel > 1: + num_strips = samples_per_pixel + if plane_sizes is None: + plane_size = len(raw_data) // samples_per_pixel + plane_sizes = [plane_size] * samples_per_pixel + else: + num_strips = 1 + plane_sizes = [len(raw_data)] + + # Collect IFD entries as (tag, type_id, count, inline_or_None, offset_data_or_None) + # We'll resolve offsets in a second pass + entries = [] + + def add_short(tag, value): + entries.append((tag, 3, 1, value, None)) + + add_short(256, width) # ImageWidth + add_short(257, height) # ImageLength + add_short(259, 1) # Compression = None + add_short(262, photometric) # PhotometricInterpretation + add_short(278, height) # RowsPerStrip = all rows + add_short(296, 1) # ResolutionUnit = None + + if samples_per_pixel > 1: + add_short(277, samples_per_pixel) # SamplesPerPixel + add_short(284, 2 if planar else 1) # PlanarConfiguration + + if predictor > 1: + add_short(317, predictor) # Predictor + + # These need offset data; will be filled in during layout + # Use sentinel tag numbers, actual entries added below + entries.sort(key=lambda e: e[0]) + + # Now compute layout + # We need to know all entries to compute IFD size + # Extra entries: BitsPerSample(258), StripOffsets(273), StripByteCounts(279), + # XResolution(282), YResolution(283) + total_entries = len(entries) + 5 + + ifd_offset = 8 + ifd_size = 2 + total_entries * 12 + 4 + + extra_start = ifd_offset + ifd_size + extra = bytearray() + + # BitsPerSample + if samples_per_pixel > 1: + bps_offset = extra_start + len(extra) + for _ in range(samples_per_pixel): + extra.extend(struct.pack('