From bb06503bc6e5e27c4bda3bb4359ec82cfb3b5ed5 Mon Sep 17 00:00:00 2001 From: Matt Singleton Date: Sat, 3 Feb 2024 14:02:03 -0600 Subject: initial checkin --- README | 22 +++++++++ convert_photo.py | 29 ++++++++++++ epdify.py | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ make_album_art.py | 58 ++++++++++++++++++++++++ mock_epd.py | 26 +++++++++++ rotate.py | 81 +++++++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 README create mode 100644 convert_photo.py create mode 100644 epdify.py create mode 100644 make_album_art.py create mode 100644 mock_epd.py create mode 100644 rotate.py diff --git a/README b/README new file mode 100644 index 0000000..7581d0f --- /dev/null +++ b/README @@ -0,0 +1,22 @@ +convert_photo.py +--- +cli tool for converting a photo in any format supported by pillow to a 7-color +cropped, scaled, and dithered bmp suitable for sending to the frame + +epdify.py +--- +library for converting images for display on the frame + +make_album_art.py +--- +cli tool for converting album art to an image suitable for display on the frame + +mock_epd.py +--- +dumb mock for the waveshare epd library so tooling can be developed without an +spi interface present + +rotate.py +--- +basic frame controller program that rotates through a collection of images. +works best if images are already scaled, cropped, and dithered. diff --git a/convert_photo.py b/convert_photo.py new file mode 100644 index 0000000..b1c9063 --- /dev/null +++ b/convert_photo.py @@ -0,0 +1,29 @@ +import argparse + +from PIL import Image, ImageOps + +from epdify import epdify, get_crop_box_and_orientation + +parser = argparse.ArgumentParser() +parser.add_argument("--dither-palette", default="perceived") +parser.add_argument("--final-palette", default="native") +parser.add_argument("-o", "--output-filename") +parser.add_argument("photo_filename") +args = parser.parse_args() + +img = Image.open(args.photo_filename) +img = ImageOps.exif_transpose(img) +is_portrait = img.width < img.height +box, orientation = get_crop_box_and_orientation(img.width, img.height, 0.6) +if orientation == "landscape": + size = (800, 480) +else: + size = (480, 800) +img = img.resize(size, resample=Image.Resampling.LANCZOS, box=box, reducing_gap=3) +img = epdify(img, args.dither_palette, args.final_palette) +if args.output_filename is None: + base, _ = args.photo_filename.rsplit('.', 1) + out_filename = f"{base}.epdified.bmp" +else: + out_filename = args.output_filename +img.save(out_filename, 'bmp') diff --git a/epdify.py b/epdify.py new file mode 100644 index 0000000..3bf1326 --- /dev/null +++ b/epdify.py @@ -0,0 +1,133 @@ +import os +import sys + +from PIL import Image, ImageDraw, ImageFont, ImagePalette, ImageOps + +_palettes = { + "perceived": ( + 34, 30, 53, # black + 233, 235, 234, # white + 52, 102, 77, # green + 50, 51, 116, # blue + 205, 86, 82, # red + 236, 216, 101, # yellow + 209, 121, 97, # orange + ), + "native": ( + 0, 0, 0, # black + 255, 255, 255, # white + 0, 255, 0, # green + 0, 0, 255, # blue + 255, 0, 0, # red + 255, 255, 0, # yellow + 255, 128, 0, # orange + ), +} + +_font_stacks = { + "nimbus": { + "bold": "/usr/share/fonts/urw-base35/NimbusSansNarrow-Bold.t1", + "regular": "/usr/share/fonts/urw-base35/NimbusSansNarrow-Regular.t1", + }, + "fira": { + "bold": "/usr/share/fonts/mozilla-fira/FiraSansCondensed-Medium.otf", + "regular": "/usr/share/fonts/mozilla-fira/FiraSansCondensed-Light.otf", + }, + "liberation": { + "bold": "/usr/share/fonts/liberation-narrow/LiberationSansNarrow-Bold.ttf", + "regular": "/usr/share/fonts/liberation-narrow/LiberationSansNarrow.ttf", + }, + "source": { + "bold": "/home/matt/Downloads/sourcesans/TTF/SourceSans3-Semibold.ttf", + "regular": "/home/matt/Downloads/sourcesans/TTF/SourceSans3-Medium.ttf", + }, + "plex": { + "bold": "/usr/share/fonts/ibm-plex-sans-fonts/IBMPlexSansCondensed-SemiBold.otf", + "regular": "/usr/share/fonts/ibm-plex-sans-fonts/IBMPlexSansCondensed-Medium.otf", + }, +} + +def _get_palette_image(palette): + img = Image.new("P", (0, 0)) + img.putpalette(palette) + return img + + +# "public" methods + + +def get_crop_box_and_orientation(width, height, ratio): + """ + returns the orientation and bounding box to crop an image with + the given height and width to the given aspect ratio. + """ + if width < height: + orientation = "portrait" + if width / height > ratio: + # trim width + px_to_trim = width - (height * ratio) + box = ( + px_to_trim / 2, + 0, + width - (px_to_trim / 2), + height + ) + else: + # trim height + px_to_trim = height - (width / ratio) + box = ( + 0, + px_to_trim / 2, + width, + height - (px_to_trim / 2) + ) + else: + orientation = "landscape" + if width / height > 1 / ratio: + # trim width + px_to_trim = width - (height * (1 / ratio)) + box = ( + px_to_trim / 2, + 0, + width - (px_to_trim / 2), + height + ) + else: + # trim height + px_to_trim = height - (width / (1 / ratio)) + box = ( + 0, + px_to_trim / 2, + width, + height - (px_to_trim / 2) + ) + return tuple(int(x) for x in box), orientation + + +def get_bounded_font(font_name, font_weight, text, target_size, min_size, max_width): + font_filename = _font_stacks[font_name.lower()][font_weight.lower()] + size = target_size + font = ImageFont.truetype(font_filename, size=size) + while size > min_size and font.getlength(text) > max_width: + size -= 1 + font = ImageFont.truetype(font_filename, size=size) + return font + + +def epdify(image, dither_palette="perceived", final_palette="native"): + """ + transforms the given input image into a scaled and dithered 7-color + image ready to be sent to the frame. expects image to be in the + correct aspect already (3x5 or 5x3), errors if not. + if 'simulate_perceived_palette' is True, returned image retains the + simulated paletted, suitable for reviewing images on a full-color + display, but not for loading on the frame. + """ + img = image.quantize( + colors=7, + dither=Image.Dither.FLOYDSTEINBERG, + palette=_get_palette_image(_palettes[dither_palette.lower()]), + method=Image.Quantize.LIBIMAGEQUANT, + ) + img.putpalette(_palettes[final_palette.lower()]) + return img diff --git a/make_album_art.py b/make_album_art.py new file mode 100644 index 0000000..ea821aa --- /dev/null +++ b/make_album_art.py @@ -0,0 +1,58 @@ +import argparse + +from PIL import Image, ImageDraw + +from epdify import epdify, get_bounded_font + +parser = argparse.ArgumentParser() +parser.add_argument("--font-face", default="fira") +parser.add_argument("--text-padding", default=20, type=int) +parser.add_argument("--text-padding-additional-right", default=60, type=int) +parser.add_argument("--dither-palette", default="perceived") +parser.add_argument("--final-palette", default="native") +parser.add_argument("-o", "--output-filename") +parser.add_argument("artist") +parser.add_argument("album") +parser.add_argument("cover_filename") +args = parser.parse_args() + +# open cover image and resize to target size +# don't crop, assume input image is reasonable close to being square +cover_img = Image.open(args.cover_filename) +cover_img = cover_img.resize((480, 480), resample=Image.Resampling.LANCZOS, reducing_gap=3) + +# find the optimal size for the album and artist names in the given font face +text_width = 480 - (args.text_padding * 2) - args.text_padding_additional_right +album_font = get_bounded_font(args.font_face, "bold", args.album, 60, 35, text_width) +artist_font = get_bounded_font(args.font_face, "regular", args.artist, min(55, album_font.size - 5), 30, text_width) +print(f"{album_font.size} {artist_font.size} : {args.artist} - {args.album}") + +# draw the album and artist names +text_img = Image.new("RGB", (480, 480), color="white") +draw = ImageDraw.Draw(text_img) +draw.text( + (args.text_padding, 320 - (artist_font.size + args.text_padding)), + args.album, + font=album_font, + fill="black", + anchor="ld", +) +draw.text( + (args.text_padding, 320 - (artist_font.size + args.text_padding)), + args.artist, + font=artist_font, + fill="black", + anchor="la", +) +text_img = text_img.rotate(90) + +# compose the final image, dither, and save +img = Image.new("RGB", (800, 480)) +img.paste(cover_img, (0,0)) +img.paste(text_img, (480, 0)) +img = epdify(img, args.dither_palette, args.final_palette) +if args.output_filename is None: + output_filename=f"{args.artist} - {args.album}.cover.bmp" +else: + output_filename = args.output_filename +img.save(output_filename, 'bmp') diff --git a/mock_epd.py b/mock_epd.py new file mode 100644 index 0000000..83e772b --- /dev/null +++ b/mock_epd.py @@ -0,0 +1,26 @@ +import logging + +logger = logging.getLogger(__name__) + +class EPD(object): + + def init(self): + logger.debug("init()") + + def Clear(self): + logger.debug("Clear()") + + def display(self, image): + logger.debug("display()") + + def getbuffer(self, image): + logger.debug("getbuffer()") + + def sleep(self): + logger.debug("sleep()") + +class epdconfig(object): + + @staticmethod + def module_exit(cleanup=True): + logger.debug(f"module_exit(cleanup={cleanup})") diff --git a/rotate.py b/rotate.py new file mode 100644 index 0000000..dabedf4 --- /dev/null +++ b/rotate.py @@ -0,0 +1,81 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- + +import argparse +import contextlib +import itertools +import logging +import os +import random +import sys +import time + +from PIL import Image,ImageDraw,ImageFont + +parser = argparse.ArgumentParser() +parser.add_argument("imgdir", help="location of dithered bmps to rotate") +parser.add_argument("--debug", action="store_true") +parser.add_argument("--delay", default=600, type=int) +parser.add_argument("--simulate", action="store_true") +parser.add_argument("--clear", action="store_true", help="clear panel first run") +args = parser.parse_args() + +if args.simulate: + import mock_epd as epd7in3f +else: + from waveshare_epd import epd7in3f + +log_format = "%(asctime)s %(levelname)s %(name)s: %(message)s" +if args.debug is True: + logging.basicConfig(level=logging.DEBUG, format=log_format) +else: + logging.basicConfig(level=logging.INFO, format=log_format) +logger = logging.getLogger(__name__) + +paths = [os.path.join(args.imgdir, f) for f in os.listdir(args.imgdir) + if f.endswith(".bmp") + and os.path.isfile(os.path.join(args.imgdir, f))] +random.shuffle(paths) +if args.simulate is True: + delay = args.delay +else: + delay = max(args.delay, 180) + +logger.info("hello!") +logger.info(f"rotating images in {args.imgdir} every {delay}s") + +@contextlib.contextmanager +def EPD(): + epd = epd7in3f.EPD() + try: + epd.init() + yield epd + finally: + epd.sleep() + +try: + iteration = 1 + for filename in itertools.cycle(paths): + with EPD() as epd: + if iteration == 1 and args.clear: + epd.Clear() + logger.info(f"displaying {filename}") + Himage = Image.open(filename) + epd.display(epd.getbuffer(Himage)) + logger.info(f"sleeping for {delay}s...") + time.sleep(delay) + iteration += 1 + +except IOError as e: + logger.info(e) + +except KeyboardInterrupt: + logger.info("ctrl + c") + logger.info("clearing the screen") + with EPD() as epd: + epd.Clear() + +finally: + logger.info("cleaning up") + epd7in3f.epdconfig.module_exit(cleanup=True) + logger.info("bye!") -- cgit v1.2.3