#!/usr/bin/env python3 """Convert ANY image (or animated GIF/WEBP) and display it on the mask. Still image -> fit to the 46x58 oval display and show it. Animated GIF -> upload its frames as DIY images and PLAY-loop them. Usage: python image2mask.py photo.jpg python image2mask.py logo.png --fit cover --oval python image2mask.py dance.gif --max-frames 12 --fps 8 --loops 5 python image2mask.py photo.jpg --save out.bin # just save raw bytes, no mask python image2mask.py photo.jpg --preview # print ASCII preview, no mask Options: --fit contain|cover|stretch how to fit the image (default contain) --bg RRGGBB letterbox/oval background color (default 000000) --oval black out corners to match the oval panel --brightness N 0..255 (default 95) """ import argparse import asyncio import sys import colorface from mask import ShiningMask from exceptions import MaskNotFound def _hex(s): s = s.lstrip("#") return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)) def _ascii(image): img = image.convert("L") ramp = " .:-=+*#%@" out = [] for y in range(0, colorface.DISPLAY_H, 2): row = "".join(ramp[min(len(ramp) - 1, img.getpixel((x, y)) * len(ramp) // 256)] for x in range(colorface.DISPLAY_W)) out.append(row) return "\n".join(out) async def run(args): frames = colorface.load_frames( args.image, max_frames=args.max_frames, fit=args.fit, bg=_hex(args.bg), oval=args.oval, ) print(f"loaded {len(frames)} frame(s) from {args.image} (fit={args.fit}, oval={args.oval})") if args.preview: print(_ascii(frames[0])) return if args.save: with open(args.save, "wb") as fh: fh.write(colorface.encode(frames[0])) print(f"saved raw frame ({colorface.DISPLAY_W}x{colorface.DISPLAY_H} -> 8004 B) to {args.save}") return mask = ShiningMask(address=args.address, name_prefix=args.name_prefix) print("connecting ...") try: await mask.connect(timeout=20.0, attempts=8) except MaskNotFound as exc: print(f"could not find the mask: {exc}") return try: await mask.set_brightness(args.brightness) if len(frames) == 1: await mask.upload_raw_image(colorface.encode(frames[0]), index=1) print("image shown on the mask.") else: print(f"uploading {len(frames)} frames (one-time, ~{len(frames) * 4}s) ...") await mask.clear_diy() for i, fr in enumerate(frames, start=1): await mask.upload_frame(colorface.encode(fr), i) print(f" frame {i}/{len(frames)}", flush=True) print(f"animating {args.loops} loop(s) at {args.fps} fps (Ctrl+C to stop) ...") try: for _ in range(args.loops): for i in range(1, len(frames) + 1): await mask.play_frame(i) await asyncio.sleep(1.0 / args.fps) except KeyboardInterrupt: pass finally: await mask.disconnect() def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("image", help="path to any image or animated GIF/WEBP") ap.add_argument("--fit", choices=["contain", "cover", "stretch"], default="contain") ap.add_argument("--bg", default="000000", help="background RRGGBB") ap.add_argument("--oval", action="store_true", help="black out corners (oval shape)") ap.add_argument("--max-frames", type=int, default=16) ap.add_argument("--fps", type=float, default=8.0) ap.add_argument("--loops", type=int, default=10) ap.add_argument("--brightness", type=int, default=95) ap.add_argument("--save", help="save the raw 8004-byte frame to a file instead of uploading") ap.add_argument("--preview", action="store_true", help="print an ASCII preview instead of uploading") ap.add_argument("--address", help="connect to a specific BLE MAC") ap.add_argument("--name-prefix", default="MASK") asyncio.run(run(ap.parse_args())) if __name__ == "__main__": main()