aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatt Singleton <matt@xcolour.net>2024-02-03 14:02:03 -0600
committerMatt Singleton <matt@xcolour.net>2024-02-03 14:02:03 -0600
commitbb06503bc6e5e27c4bda3bb4359ec82cfb3b5ed5 (patch)
treec771393004197b698c20b3778829054a1e9b3896
initial checkin
-rw-r--r--README22
-rw-r--r--convert_photo.py29
-rw-r--r--epdify.py133
-rw-r--r--make_album_art.py58
-rw-r--r--mock_epd.py26
-rw-r--r--rotate.py81
6 files changed, 349 insertions, 0 deletions
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!")