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.
289 lines
9.1 KiB
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() |