Command Line Tool for displaying the .bin files interpreted by our iqube Busanzeiger firmware.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iqube-cli/anim.py

289 lines
9.1 KiB

#!/usr/bin/env python3
"""
Convert BMP/GIF images to binary animation format.
written by me, refactored by claude sonnet 4.5
2025-12-28
"""
import argparse
from PIL import Image, ImageSequence
STATIC = 1
ANIMATION = 2
def bmp_to_bitmap_fn(im):
"""Create a function that converts image to bitmap data."""
def fn(img):
# Find bounding box of black pixels
x = [xc for xc in range(im.size[0]) for yc in range(im.size[1])
if sum(im.getpixel((xc, yc))) == 0]
y = [yc for xc in range(im.size[0]) for yc in range(im.size[1])
if sum(im.getpixel((xc, yc))) == 0]
if not x or not y:
return img, 0, 0
xmin, xmax = min(x), max(x)
ymin, ymax = min(y), max(y)
xsize = (xmax - xmin)
ysize = (ymax - ymin)
print(f"Image bounds: x={xmin}-{xmax}, y={ymin}-{ymax}, size={xsize}x{ysize}")
for y in range(ysize):
for x in range(xsize):
if im.size[0] >= x and sum(im.getpixel((xmin + x, ymin + y))) == 0:
pos = ((y * xsize) + x) // 8
bit = ((y * xsize) + x) % 8
if pos < len(img):
img[pos] |= 1 << bit
return img, xsize, ysize
return fn
def extract_frames_from_image(image_path):
"""
Extract all frames from an image file (GIF or static).
Returns a list of PIL Image objects.
"""
img = Image.open(image_path)
frames = []
# Convert to RGB if needed
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
if image_path.endswith(".gif"):
with Image.open(image_path) as im_gif:
index = 0
for img in ImageSequence.Iterator(im_gif):
frame_copy = img.copy()
if frame_copy.mode != 'RGB':
frame_copy = frame_copy.convert('RGB')
frames.append(frame_copy)
if len(frames) == 0:
try:
# Try to extract all frames (works for GIFs)
for frame_num in range(img.n_frames):
print("frame is ", frame_num)
img.seek(frame_num)
# Copy the frame to prevent issues with lazy loading
frame_copy = img.copy()
if frame_copy.mode != 'RGB':
frame_copy = frame_copy.convert('RGB')
frames.append(frame_copy)
print(f"Extracted frame {frame_num} from {image_path}")
except AttributeError:
# Not an animated image, just use the single frame
frames.append(img)
print(f"Loaded static image from {image_path}")
return frames
def extract_all_frames(image_paths, mode):
"""
Extract frames from all input images based on mode.
Args:
image_paths: List of image file paths
mode: 'frame' for sequential frames, 'differential' for alternating pattern
Returns:
List of PIL Image objects representing animation frames
"""
all_frames = []
if mode == 'differential':
# Differential mode: extract frames from each image, then create alternating pattern
if len(image_paths) != 2:
raise ValueError("Differential mode requires exactly 2 images (off and on states)")
off_frames = extract_frames_from_image(image_paths[0])
on_frames = extract_frames_from_image(image_paths[1])
# Use only the first frame from each for differential mode
off_frame = off_frames[0]
on_frame = on_frames[0]
# Create pattern: multiple off frames followed by on frame
all_frames = [off_frame] * 14 + [on_frame]
print(f"Created differential animation: 14 off frames + 1 on frame")
elif mode == 'frame':
# Frame mode: concatenate all frames from all images
for path in image_paths:
frames = extract_frames_from_image(path)
all_frames.extend(frames)
print(f"Total frames collected: {len(all_frames)}")
else:
raise ValueError(f"Unknown mode: {mode}. Use 'frame' or 'differential'")
return all_frames
def get_frame_dimensions(frames):
"""Get the maximum dimensions needed for all frames."""
max_width = 0
max_height = 0
for frame in frames:
# Find bounding box
x = [xc for xc in range(frame.size[0]) for yc in range(frame.size[1])
if sum(frame.getpixel((xc, yc))) == 0]
y = [yc for xc in range(frame.size[0]) for yc in range(frame.size[1])
if sum(frame.getpixel((xc, yc))) == 0]
if x and y:
width = max(x) - min(x)
height = max(y) - min(y)
max_width = max(max_width, width)
max_height = max(max_height, height)
return max_width, max_height
def create_animation_binary(image_paths, output_path, startx, starty,
update_interval, mode):
"""
Create binary animation file from BMP/GIF images.
Args:
image_paths: List of image file paths
output_path: Output binary file path
startx: X coordinate for animation start
starty: Y coordinate for animation start
update_interval: Frame update interval
mode: 'frame' for one image per frame, 'differential' for alternating states
"""
# Extract all frames from input images based on mode
frames = extract_all_frames(image_paths, mode)
if not frames:
raise ValueError("No frames extracted from input images")
# Determine dimensions
frame_width, frame_height = get_frame_dimensions(frames)
print(f"Using frame dimensions: {frame_width}x{frame_height}")
# Convert frames to bitmap functions
animation = [bmp_to_bitmap_fn(frame) for frame in frames]
# Single frame case: write as static object
if len(animation) == 1:
print("Single frame detected, creating static object")
with open(output_path, "wb") as fx:
# Write magic header
fx.write(bytearray([0x42, 0x4e, 0x17, 0xee]))
# Write number of objects
fx.write(bytearray([1]))
# Write static object
fx.write(bytearray([STATIC, startx, starty]))
fx.write(bytearray([frame_width, frame_height]))
img = bytearray([0] * ((frame_width * frame_height + 7) // 8))
img, xsize, ysize = animation[0](img)
fx.write(img)
file_size = fx.tell()
print(f"Total size: {file_size} bytes")
if file_size >= 50000:
print("WARNING: File size exceeds 50000 bytes!")
print(f"Static image created: {output_path}")
return
# Multiple frames: write as animation
with open(output_path, "wb") as fx:
# Write magic header
fx.write(bytearray([0x42, 0x4e, 0x17, 0xee]))
# Write number of objects
fx.write(bytearray([2]))
# Write static background object (empty)
fx.write(bytearray([STATIC, 0, 0]))
fx.write(bytearray([120, 60]))
fx.write(bytearray([0] * ((120 * 60 + 7) // 8)))
# Write animation object
fx.write(bytearray([ANIMATION, startx, starty]))
fx.write(bytearray([frame_width, frame_height, len(animation), update_interval]))
# Write animation frames
for i, bmp_fun in enumerate(animation):
img = bytearray([0] * ((frame_width * frame_height + 7) // 8))
img, xsize, ysize = bmp_fun(img)
print(f"Frame {i}: start={fx.tell()}")
fx.write(img)
file_size = fx.tell()
print(f"Total size: {file_size} bytes")
if file_size >= 50000:
print("WARNING: File size exceeds 50000 bytes!")
print(f"Animation binary created: {output_path}")
def main():
parser = argparse.ArgumentParser(
description='Convert BMP/GIF images to binary animation format',
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
'images',
nargs='+',
help='Image file(s) (BMP or GIF). GIF frames are extracted automatically. Single frame creates static object, multiple frames create animation.'
)
parser.add_argument(
'--output',
type=str,
default='animation.bin',
help='Output binary file path'
)
parser.add_argument(
'--start-x',
type=int,
default=0,
help='X coordinate for start position'
)
parser.add_argument(
'--start-y',
type=int,
default=0,
help='Y coordinate for start position'
)
parser.add_argument(
'--update-interval',
type=int,
default=3,
help='Update interval (0=every tick, 1=every second tick, etc.)'
)
parser.add_argument(
'--mode',
choices=['frame', 'differential'],
default='frame',
help='Animation mode: "frame" = each image/frame is a frame, "differential" = alternate between 2 images (requires exactly 2 images)'
)
args = parser.parse_args()
create_animation_binary(
args.images,
args.output,
args.start_x,
args.start_y,
args.update_interval,
args.mode
)
if __name__ == '__main__':
main()