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