mirror of
https://github.com/irmen/prog8.git
synced 2026-10-24 18:02:40 +00:00
314 lines
10 KiB
Python
Executable File
314 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
IZX0 Image File Format Specification & Exporter Script
|
|
======================================================
|
|
|
|
Overview:
|
|
---------
|
|
The IZX0 format is a planar image format designed for Amiga OCS/ECS/AGA hardware.
|
|
It stores image metadata, a hardware-compatible color palette, and individually
|
|
compressed bitplane streams using the ZX0 compression algorithm (via Salvador).
|
|
|
|
File Layout:
|
|
------------
|
|
+-----------------------------------------------------------------------------+
|
|
| Fixed Binary Header (52 Bytes, Big-Endian) |
|
|
+-----------------------------------------------------------------------------+
|
|
| Palette Data (Variable length = palette_data_size) |
|
|
| - ECS Mode (is_aga=0): Array of UWORD ($0RGB, 2 bytes/color) |
|
|
| - AGA Mode (is_aga=1): Array of ULONG (0x00RRGGBB, 4 bytes/color) |
|
|
+-----------------------------------------------------------------------------+
|
|
| Concatenated Bitplane ZX0 Streams |
|
|
| - Plane 0 Stream (Size = plane_sizes[0]) |
|
|
| - Plane 1 Stream (Size = plane_sizes[1]) |
|
|
| - ... |
|
|
| - Plane N Stream (Size = plane_sizes[N-1]) |
|
|
+-----------------------------------------------------------------------------+
|
|
|
|
Fixed Binary Header Memory Map (52 Bytes Total):
|
|
------------------------------------------------
|
|
Offset (Hex) Offset (Dec) Type Description
|
|
-------------------------------------------------------------------------------
|
|
0x00 0 4 Bytes Magic Identifier ('IZX0')
|
|
0x04 4 UWORD 16-bit Word-aligned Width (e.g., 320)
|
|
0x06 6 UWORD Height in pixels (e.g., 256)
|
|
0x08 8 UWORD Number of bitplanes (1..8)
|
|
0x0A 10 UBYTE is_aga Flag (0 = ECS, 1 = AGA)
|
|
0x0B 11 UBYTE Reserved / Padding Byte (0x00)
|
|
0x0C 12 UWORD Number of Palette Entries (2^nPlanes)
|
|
0x0E 14 UWORD Palette Data Size in Bytes
|
|
0x10 16 ULONG[8] Array of Compressed Sizes (Planes 0..7)
|
|
0x30 48 ULONG Total compressed bitmap data size
|
|
-------------------------------------------------------------------------------
|
|
Total Header Size: 52 Bytes (0x34)
|
|
|
|
Palette Entry Specifications:
|
|
-----------------------------
|
|
1. ECS Mode (is_aga = 0):
|
|
Each entry is a 16-bit UWORD in $0RGB format (Accurate 8-to-4 bit rounding):
|
|
- Bits 15-12 : Unused (0)
|
|
- Bits 11-8 : Red (4-bit, 0x0..0xF)
|
|
- Bits 7-4 : Green (4-bit, 0x0..0xF)
|
|
- Bits 3-0 : Blue (4-bit, 0x0..0xF)
|
|
Total Size = num_palette_entries * 2 bytes.
|
|
|
|
2. AGA Mode (is_aga = 1):
|
|
Each entry is a 32-bit ULONG in 0x00RRGGBB (ARGB) format:
|
|
- Bits 31-24 : Unused / Alpha (0x00)
|
|
- Bits 23-16 : Red (8-bit, 0x00..0xFF)
|
|
- Bits 15-8 : Green (8-bit, 0x00..0xFF)
|
|
- Bits 7-0 : Blue (8-bit, 0x00..0xFF)
|
|
Total Size = num_palette_entries * 4 bytes.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from PIL import Image
|
|
|
|
|
|
def compress_zx0(data_bytes):
|
|
"""Compresses raw bytes using salvador executable (ZX0 format)."""
|
|
temp_raw = "_temp_plane.raw"
|
|
temp_zx0 = "_temp_plane.zx0"
|
|
|
|
with open(temp_raw, "wb") as f:
|
|
f.write(data_bytes)
|
|
|
|
try:
|
|
subprocess.run(
|
|
["salvador", temp_raw, temp_zx0],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
with open(temp_zx0, "rb") as f:
|
|
compressed_data = f.read()
|
|
finally:
|
|
if os.path.exists(temp_raw):
|
|
os.remove(temp_raw)
|
|
if os.path.exists(temp_zx0):
|
|
os.remove(temp_zx0)
|
|
|
|
return compressed_data
|
|
|
|
|
|
def channel_8to4(color: int) -> int:
|
|
"""Accurate conversion of a single 8-bit color channel value to 4 bits."""
|
|
return (color * 15 + 135) >> 8
|
|
|
|
|
|
def build_ecs_palette(palette_rgb, num_colors):
|
|
"""Builds a flat UWORD array suitable for LoadRGB4(ViewPort, table, count).
|
|
|
|
Format: $0RGB (16 bits per color, accurately converted using channel_8to4)
|
|
"""
|
|
ecs_table = bytearray()
|
|
|
|
for i in range(num_colors):
|
|
r = channel_8to4(palette_rgb[i * 3])
|
|
g = channel_8to4(palette_rgb[i * 3 + 1])
|
|
b = channel_8to4(palette_rgb[i * 3 + 2])
|
|
|
|
rgb12 = ((r & 0x0F) << 8) | ((g & 0x0F) << 4) | (b & 0x0F)
|
|
ecs_table.extend(struct.pack(">H", rgb12))
|
|
|
|
return bytes(ecs_table)
|
|
|
|
|
|
def build_aga_palette(palette_rgb, num_colors):
|
|
"""Builds a flat ULONG array of ARGB 32-bit colors (0x00RRGGBB).
|
|
|
|
Format: 32 bits per color (Alpha=0x00, Red=8bit, Green=8bit, Blue=8bit)
|
|
"""
|
|
aga_table = bytearray()
|
|
|
|
for i in range(num_colors):
|
|
r8 = palette_rgb[i * 3]
|
|
g8 = palette_rgb[i * 3 + 1]
|
|
b8 = palette_rgb[i * 3 + 2]
|
|
|
|
argb32 = (r8 << 16) | (g8 << 8) | b8
|
|
aga_table.extend(struct.pack(">I", argb32))
|
|
|
|
return bytes(aga_table)
|
|
|
|
|
|
def export_izx0(input_path, output_path, force_aga=False, max_colors=256):
|
|
img = Image.open(input_path)
|
|
|
|
# 1. Quantize image if necessary
|
|
if img.mode != "P":
|
|
if img.mode in ("RGBA", "LA") or (
|
|
img.mode == "P" and "transparency" in img.info
|
|
):
|
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
if img.mode != "RGBA":
|
|
img = img.convert("RGBA")
|
|
background.paste(img, mask=img.split()[3])
|
|
img = background
|
|
|
|
img = img.quantize(colors=max_colors, method=Image.Quantize.FLOYDSTEINBERG)
|
|
|
|
width, height = img.size
|
|
|
|
# Calculate 16-bit word-aligned width
|
|
words_per_row = (width + 15) // 16
|
|
aligned_width = words_per_row * 16
|
|
|
|
# 2. Determine required bitplanes
|
|
_, max_val = img.getextrema()
|
|
num_planes = max_val.bit_length() if max_val > 0 else 1
|
|
|
|
# Auto-detect AGA if > 5 bitplanes or command line flag passed
|
|
is_aga = 1 if (force_aga or num_planes > 5) else 0
|
|
mode_str = "AGA (32-bit ARGB)" if is_aga else "ECS (16-bit $0RGB)"
|
|
|
|
print(
|
|
f"Image: {width}x{height} (Aligned: {aligned_width}x{height}), Planes: {num_planes} [{mode_str}]"
|
|
)
|
|
|
|
pixel_bytes = img.tobytes()
|
|
compressed_planes = []
|
|
|
|
# 3. Process and compress bitplanes individually
|
|
for plane in range(num_planes):
|
|
plane_mask = 1 << plane
|
|
raw_bytes = bytearray()
|
|
|
|
for y in range(height):
|
|
row_start = y * width
|
|
row_pixels = pixel_bytes[row_start : row_start + width]
|
|
|
|
current_byte = 0
|
|
bit_count = 0
|
|
|
|
for pixel in row_pixels:
|
|
bit = 1 if (pixel & plane_mask) else 0
|
|
current_byte = (current_byte << 1) | bit
|
|
bit_count += 1
|
|
|
|
if bit_count == 8:
|
|
raw_bytes.append(current_byte)
|
|
current_byte = 0
|
|
bit_count = 0
|
|
|
|
if bit_count > 0:
|
|
current_byte <<= 8 - bit_count
|
|
raw_bytes.append(current_byte)
|
|
|
|
# Pad scanline to 16-bit word boundary
|
|
if len(raw_bytes) % 2 != 0:
|
|
raw_bytes.append(0)
|
|
|
|
c_data = compress_zx0(bytes(raw_bytes))
|
|
compressed_planes.append(c_data)
|
|
print(f" Plane {plane}: {len(raw_bytes)} -> {len(c_data)} bytes")
|
|
|
|
# 4. Extract Palette Data
|
|
num_palette_entries = 1 << num_planes
|
|
raw_palette_rgb = img.getpalette()[: num_palette_entries * 3]
|
|
|
|
if is_aga:
|
|
palette_bytes = build_aga_palette(raw_palette_rgb, num_palette_entries)
|
|
else:
|
|
palette_bytes = build_ecs_palette(raw_palette_rgb, num_palette_entries)
|
|
|
|
palette_data_size = len(palette_bytes)
|
|
|
|
# 5. Build Fixed 52-Byte Binary Header (Big-Endian)
|
|
plane_sizes = [0] * 8
|
|
for i in range(num_planes):
|
|
plane_sizes[i] = len(compressed_planes[i])
|
|
compressed_payload_size = sum(plane_sizes)
|
|
|
|
header = struct.pack(
|
|
">4sHHHBB HH 9I",
|
|
b"IZX0",
|
|
aligned_width,
|
|
height,
|
|
num_planes,
|
|
is_aga, # 1 = AGA, 0 = ECS
|
|
0, # Reserved byte
|
|
num_palette_entries, # 2^num_planes
|
|
palette_data_size, # Byte count of palette block
|
|
*plane_sizes,
|
|
compressed_payload_size,
|
|
)
|
|
|
|
# 6. Write Output File
|
|
with open(output_path, "wb") as f:
|
|
f.write(header)
|
|
f.write(palette_bytes)
|
|
for c_data in compressed_planes:
|
|
f.write(c_data)
|
|
|
|
file_size = os.path.getsize(output_path)
|
|
print(f"\nSaved '{output_path}' ({file_size} bytes total)")
|
|
print(
|
|
f"Header: 52B | Palette: {palette_data_size}B ({num_palette_entries} entries) | Streams: {compressed_payload_size}B"
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
prog="amiga-export-izx0",
|
|
description="Convert image files (PNG/BMP/etc.) into the IZX0 planar compressed Amiga format.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
|
|
# Positional Arguments
|
|
parser.add_argument(
|
|
"input",
|
|
type=str,
|
|
help="Path to source image file",
|
|
)
|
|
parser.add_argument(
|
|
"output",
|
|
type=str,
|
|
help="Path for target IZX0 destination file",
|
|
)
|
|
|
|
# Optional Arguments
|
|
parser.add_argument(
|
|
"-aga",
|
|
"--aga",
|
|
action="store_true",
|
|
help="Force 32-bit ARGB palette mode (automatically enabled if bitplanes > 5)",
|
|
)
|
|
parser.add_argument(
|
|
"-c",
|
|
"--max-colors",
|
|
type=int,
|
|
default=256,
|
|
choices=range(2, 257),
|
|
metavar="[2-256]",
|
|
help="Max colors for quantization palette reduction (default: 256)",
|
|
)
|
|
|
|
if len(sys.argv) == 1:
|
|
parser.print_help()
|
|
return
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.exists(args.input):
|
|
parser.error(f"Input image file '{args.input}' does not exist.")
|
|
|
|
try:
|
|
export_izx0(
|
|
input_path=args.input,
|
|
output_path=args.output,
|
|
force_aga=args.aga,
|
|
max_colors=args.max_colors,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error during export: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|