Files
image-tiff/tests/gen_seq_images.py
T
Lilith River 5090003bda test: add 27 sequential-value test images for bit-error detection
Generated by gen_seq_images.py using manual TIFF writer with packed
bit streams matching TIFF spec. Each 16x16 image has cycling 0..max
values so any bit-level decode error changes the checksum.

Test matrix:
- Sub-byte BlackIsZero: 3/5/7-bit
- Sub-byte WhiteIsZero: 3/5/6/7-bit
- Extended BlackIsZero: 10/12/14/24-bit
- Extended WhiteIsZero: 10/12/14/24-bit
- Extended HPredict: 10/12/14/24-bit
- RGB contiguous: 10/12/14/24-bit
- RGB planar: 10/12/14/24-bit (first plane only via read_image)
2026-03-10 06:02:19 -06:00

541 lines
18 KiB
Python

#!/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('<H', bits))
entries.append((258, 3, samples_per_pixel, None, bps_offset))
else:
entries.append((258, 3, 1, bits, None))
# StripOffsets - will point to strip data
# For multi-strip, we need an array of LONGs in extra data
strip_data_start = None # resolved after extra is complete
if num_strips == 1:
# Single strip: offset stored inline (resolved later)
entries.append((273, 4, 1, 'STRIP_OFFSET_PLACEHOLDER', None))
else:
# Multiple strips: offset array in extra data
strip_offsets_offset = extra_start + len(extra)
# Reserve space for strip offset LONGs (filled after we know strip_data_start)
strip_offsets_pos_in_extra = len(extra)
for _ in range(num_strips):
extra.extend(struct.pack('<I', 0)) # placeholder
entries.append((273, 4, num_strips, None, strip_offsets_offset))
# StripByteCounts
if num_strips == 1:
entries.append((279, 4, 1, plane_sizes[0], None))
else:
sbc_offset = extra_start + len(extra)
for ps in plane_sizes:
extra.extend(struct.pack('<I', ps))
entries.append((279, 4, num_strips, None, sbc_offset))
# XResolution
xres_offset = extra_start + len(extra)
extra.extend(struct.pack('<II', 72, 1))
entries.append((282, 5, 1, None, xres_offset))
# YResolution
yres_offset = extra_start + len(extra)
extra.extend(struct.pack('<II', 72, 1))
entries.append((283, 5, 1, None, yres_offset))
entries.sort(key=lambda e: e[0])
# Strip data starts after extra
strip_data_start = extra_start + len(extra)
# Fill in strip offset placeholders
if num_strips == 1:
# Update the inline value for tag 273
for i, (tag, typ, cnt, val, off) in enumerate(entries):
if tag == 273:
entries[i] = (273, 4, 1, strip_data_start, None)
break
else:
# Fill in the strip offset array in extra data
offset = strip_data_start
for s in range(num_strips):
pos = strip_offsets_pos_in_extra + s * 4
struct.pack_into('<I', extra, pos, offset)
offset += plane_sizes[s]
# Build the file
f = bytearray()
# Header
f.extend(b'II')
f.extend(struct.pack('<H', 42))
f.extend(struct.pack('<I', ifd_offset))
# IFD
f.extend(struct.pack('<H', len(entries)))
for tag, typ, count, inline_val, offset_val in entries:
f.extend(struct.pack('<H', tag))
f.extend(struct.pack('<H', typ))
f.extend(struct.pack('<I', count))
if inline_val is not None:
# Value fits inline
if typ == 3: # SHORT
f.extend(struct.pack('<H', inline_val))
f.extend(b'\x00\x00')
elif typ == 4: # LONG
f.extend(struct.pack('<I', inline_val))
else:
# Value is at offset
f.extend(struct.pack('<I', offset_val))
# Next IFD = 0
f.extend(struct.pack('<I', 0))
# Extra data
f.extend(extra)
# Strip data
f.extend(raw_data)
with open(path, 'wb') as fh:
fh.write(f)
def packed_byte_sum(values, bits, width):
"""Compute the sum of packed bytes containing the given sample values.
For sub-byte depths, the decoder returns packed bytes, not individual samples.
"""
raw = pack_bits(values, bits, width)
return sum(raw)
def gen_gray(bits, white_is_zero=False, h_predictor=False):
"""Generate a grayscale image at any bit depth."""
max_val = (1 << bits) - 1
vals = sequential_values(max_val, NPIX)
# For WhiteIsZero, the stored pixel values are inverted
# The decoder should invert them back, producing original sequential values
if white_is_zero:
stored = [max_val - v for v in vals]
else:
stored = list(vals)
# Apply predictor before packing (predictor operates on sample values)
if h_predictor:
stored = apply_horizontal_predictor(stored, bits, W)
raw = pack_bits(stored, bits, W)
photometric = 0 if white_is_zero else 1
predictor_tag = 2 if h_predictor else 1
suffix = ""
if white_is_zero:
suffix = "-miniswhite"
elif h_predictor:
suffix = "-hpredict"
name = f"seq-1c-{bits}b{suffix}.tiff"
path = OUTDIR / name
write_tiff(path, raw, W, H, bits, photometric=photometric,
predictor=predictor_tag)
# Compute expected checksum from the decoder's perspective.
# For sub-byte depths (<=8): decoder returns packed bytes, so checksum
# is the sum of the packed byte values.
# For supra-byte depths: decoder unpacks to u16/u32 container, so
# checksum is the sum of sample values.
if bits <= 8:
# After decoding: WhiteIsZero gets inverted back to original,
# predictor gets undone. Decoded samples = vals (the sequential values).
checksum = packed_byte_sum(vals, bits, W)
rust_fn = "test_image_sum_u8"
elif bits <= 16:
checksum = compute_sum(vals)
rust_fn = "test_image_sum_u16"
else:
checksum = compute_sum(vals)
rust_fn = "test_image_sum_u32"
color = f"Gray({bits})"
return name, rust_fn, color, checksum
def gen_rgb_contig(bits):
"""Generate an RGB contiguous (chunky) image."""
max_val = (1 << bits) - 1
vals = sequential_values(max_val, NPIX * 3)
checksum = compute_sum(vals)
raw = pack_bits_multichannel_contig(vals, bits, W, 3)
name = f"seq-3c-{bits}b-contig.tiff"
path = OUTDIR / name
write_tiff(path, raw, W, H, bits, photometric=2,
samples_per_pixel=3)
if bits <= 8:
rust_fn = "test_image_sum_u8"
elif bits <= 16:
rust_fn = "test_image_sum_u16"
else:
rust_fn = "test_image_sum_u32"
color = f"RGB({bits})"
return name, rust_fn, color, checksum
def gen_rgb_planar(bits):
"""Generate an RGB planar (separate) image."""
max_val = (1 << bits) - 1
# For planar: values are R plane, then G plane, then B plane
# Each plane has NPIX values
vals_r = sequential_values(max_val, NPIX)
vals_g = sequential_values(max_val, NPIX)
vals_b = sequential_values(max_val, NPIX)
# Use different starting points for each plane to differentiate them
vals_g = [(v + 100) % (max_val + 1) for v in vals_g]
vals_b = [(v + 200) % (max_val + 1) for v in vals_b]
all_vals = vals_r + vals_g + vals_b
raw, plane_sizes = pack_bits_planar(all_vals, bits, W, H, 3)
name = f"seq-3c-{bits}b-planar.tiff"
path = OUTDIR / name
write_tiff(path, raw, W, H, bits, photometric=2,
samples_per_pixel=3, planar=True, plane_sizes=plane_sizes)
# read_image() only returns the first plane for planar images.
# Checksum is just the R plane's sum.
# colortype() still reports RGB(N) (tested by existing planar tests).
if bits <= 16:
checksum = compute_sum(vals_r)
rust_fn = "test_image_sum_u16"
else:
checksum = compute_sum(vals_r)
rust_fn = "test_image_sum_u32"
color = f"RGB({bits})"
return name, rust_fn, color, checksum
def verify_with_tifffile(path, expected_vals, bits, description):
"""Verify the generated TIFF reads back correctly with tifffile."""
try:
import tifffile
with tifffile.TiffFile(str(path)) as t:
page = t.pages[0]
assert page.bitspersample == bits, \
f"{description}: BitsPerSample={page.bitspersample}, expected {bits}"
# Can't always read back sub-byte packed data with tifffile,
# but we can verify the structure
return True
except Exception as e:
print(f" WARNING: tifffile verification of {description} failed: {e}")
return False
def main():
results = []
print("Generating test images...\n")
# Sub-byte grayscale (BlackIsZero)
for bits in [3, 5, 7]:
r = gen_gray(bits)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# Sub-byte grayscale (WhiteIsZero)
for bits in [3, 5, 6, 7]:
r = gen_gray(bits, white_is_zero=True)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# Extended grayscale (BlackIsZero)
for bits in [10, 12, 14, 24]:
r = gen_gray(bits)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# Extended grayscale (WhiteIsZero)
for bits in [10, 12, 14, 24]:
r = gen_gray(bits, white_is_zero=True)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# Extended grayscale with horizontal predictor
for bits in [10, 12, 14, 24]:
r = gen_gray(bits, h_predictor=True)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# RGB contiguous
for bits in [10, 12, 14, 24]:
r = gen_rgb_contig(bits)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# RGB planar
for bits in [10, 12, 14, 24]:
r = gen_rgb_planar(bits)
print(f" {r[0]}: sum={r[3]}")
results.append(r)
# Verify structure with tifffile
print("\nVerifying TIFF structure...")
for name, _, _, _ in results:
path = OUTDIR / name
bits = int(name.split('-')[2].replace('b', '').split('.')[0])
verify_with_tifffile(path, None, bits, name)
# Print Rust test functions
print("\n// ===== Sequential-value test functions =====")
print("// Generated by gen_seq_images.py")
print("// Each image has cycling 0..max values; any bit error changes the sum.")
print()
categories = [
("Sub-byte BlackIsZero", []),
("Sub-byte WhiteIsZero", []),
("Extended BlackIsZero", []),
("Extended WhiteIsZero", []),
("Extended HPredict", []),
("RGB Contiguous", []),
("RGB Planar", []),
]
for name, rust_fn, color, checksum in results:
bits_str = name.split('-')[2] if '3c' not in name else name.split('-')[2]
bits_val = int(bits_str.replace('b', '').split('.')[0])
if "3c" in name and "contig" in name:
categories[5][1].append((name, rust_fn, color, checksum))
elif "3c" in name and "planar" in name:
categories[6][1].append((name, rust_fn, color, checksum))
elif "miniswhite" in name and bits_val < 8:
categories[1][1].append((name, rust_fn, color, checksum))
elif bits_val < 8:
categories[0][1].append((name, rust_fn, color, checksum))
elif "miniswhite" in name:
categories[3][1].append((name, rust_fn, color, checksum))
elif "hpredict" in name:
categories[4][1].append((name, rust_fn, color, checksum))
else:
categories[2][1].append((name, rust_fn, color, checksum))
for cat_name, items in categories:
if not items:
continue
print(f"// --- {cat_name} ---")
for name, rust_fn, color, checksum in items:
test_name = name.replace(".tiff", "").replace("-", "_")
print(f'#[test]')
print(f'fn test_{test_name}() {{')
print(f' {rust_fn}("{name}", ColorType::{color}, {checksum});')
print(f'}}')
print()
print()
print(f"// Total: {len(results)} test images generated")
if __name__ == '__main__':
main()