#!/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('