This project uses the Matrix Portal Creature Eyes code, so familiarize yourself with it here first. We'll be using custom graphics based on the ready-made skull, plus a bit of extra code for the servo motor jaw waggle.

Once you've followed the steps from the Creature Eyes project and have the set of eyes working, you can download the Project Zip from the code embed link below.

Copy the skull_bigger folder from the zip file's /eyes folder and paste it into the /eyes folder on you Matrix Portal CIRCUITPY drive.

Libraries

These are the libraries you'll need to have on the board:

  • adafruit_bus_device
  • adafruit_debouncer.mpy
  • adafruit_esp32spi
  • adafruit_imageload
  • adafruit_matrixportal
  • adafruit_motor
  • adafruit_requests.mpy
  • adafruit_slideshow.py
  • neopixel.mpy

These graphics have been designed to be seen through the cardboard skull's eye cutouts, using the same methods shown here.

Text Editor

Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.

Alternatively, you can use any text editor that saves simple text files.

Copy code.py from the zip file and place on the CIRCUITPY drive.

# SPDX-FileCopyrightText: 2020 John Park for Adafruit Industries
#
# SPDX-License-Identifier: MIT

"""
WINDOW SKULL for Adafruit Matrix Portal: animated spooky eyes and servomotor jaw
"""

# pylint: disable=import-error
import math
import random
import time
import board
import pwmio
import displayio
from adafruit_motor import servo
import adafruit_imageload
from adafruit_matrixportal.matrix import Matrix

pwm = pwmio.PWMOut(board.A4, duty_cycle=2 ** 15, frequency=50)
jaw_servo = servo.Servo(pwm)


def jaw_wag():
    for angle in range(90, 70, -2):  # start angle, end angle, degree step size
        jaw_servo.angle = angle
    for angle in range(70, 90, 2):
        jaw_servo.angle = angle
    for angle in range(90, 110, 2):
        jaw_servo.angle = angle
    for angle in range(110, 90, -2):
        jaw_servo.angle = angle


# TO LOAD DIFFERENT EYE DESIGNS: change the middle word here (between
# 'eyes.' and '.data') to one of the folder names inside the 'eyes' folder:
# from eyes.werewolf.data import EYE_DATA
# from eyes.cyclops.data import EYE_DATA
# from eyes.kobold.data import EYE_DATA
# from eyes.adabot.data import EYE_DATA
# from eyes.skull.data import EYE_DATA
# pylint: disable=wrong-import-position
from eyes.skull_bigger.data import EYE_DATA

# UTILITY FUNCTIONS AND CLASSES --------------------------------------------

# pylint: disable=too-few-public-methods
class Sprite(displayio.TileGrid):
    """Single-tile-with-bitmap TileGrid subclass, adds a height element
    because TileGrid doesn't appear to have a way to poll that later,
    object still functions in a displayio.Group.
    """

    def __init__(self, filename, transparent=None):
        """Create Sprite object from color-paletted BMP file, optionally
        set one color to transparent (pass as RGB tuple or list to locate
        nearest color, or integer to use a known specific color index).
        """
        bitmap, palette = adafruit_imageload.load(
            filename, bitmap=displayio.Bitmap, palette=displayio.Palette
        )
        if isinstance(transparent, (tuple, list)):  # Find closest RGB match
            closest_distance = 0x1000000  # Force first match
            for color_index, color in enumerate(palette):  # Compare each...
                delta = (
                    transparent[0] - ((color >> 16) & 0xFF),
                    transparent[1] - ((color >> 8) & 0xFF),
                    transparent[2] - (color & 0xFF),
                )
                rgb_distance = (
                    delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]
                )  # Actually dist^2
                if rgb_distance < closest_distance:  # but adequate for
                    closest_distance = rgb_distance  # compare purposes,
                    closest_index = color_index  # no sqrt needed
            palette.make_transparent(closest_index)
        elif isinstance(transparent, int):
            palette.make_transparent(transparent)
        super(Sprite, self).__init__(bitmap, pixel_shader=palette)
        self.height = bitmap.height


# ONE-TIME INITIALIZATION --------------------------------------------------

MATRIX = Matrix(bit_depth=6)
DISPLAY = MATRIX.display

# Order in which sprites are added determines the 'stacking order' and
# visual priority. Lower lid is added before the upper lid so that if they
# overlap, the upper lid is 'on top' (e.g. if it has eyelashes or such).
SPRITES = displayio.Group()
SPRITES.append(Sprite(EYE_DATA["eye_image"]))  # Base image is opaque
SPRITES.append(Sprite(EYE_DATA["lower_lid_image"], EYE_DATA["transparent"]))
SPRITES.append(Sprite(EYE_DATA["upper_lid_image"], EYE_DATA["transparent"]))
SPRITES.append(Sprite(EYE_DATA["stencil_image"], EYE_DATA["transparent"]))
DISPLAY.show(SPRITES)

EYE_CENTER = (
    (EYE_DATA["eye_move_min"][0] + EYE_DATA["eye_move_max"][0])  # Pixel coords of eye
    / 2,  # image when centered
    (EYE_DATA["eye_move_min"][1] + EYE_DATA["eye_move_max"][1])  # ('neutral' position)
    / 2,
)
EYE_RANGE = (
    abs(
        EYE_DATA["eye_move_max"][0]
        - EYE_DATA["eye_move_min"][0]  # Max eye image motion
    )
    / 2,  # delta from center
    abs(EYE_DATA["eye_move_max"][1] - EYE_DATA["eye_move_min"][1]) / 2,
)
UPPER_LID_MIN = (
    min(
        EYE_DATA["upper_lid_open"][0],  # Motion bounds of
        EYE_DATA["upper_lid_closed"][0],
    ),  # upper and lower
    min(EYE_DATA["upper_lid_open"][1], EYE_DATA["upper_lid_closed"][1]),  # eyelids
)
UPPER_LID_MAX = (
    max(EYE_DATA["upper_lid_open"][0], EYE_DATA["upper_lid_closed"][0]),
    max(EYE_DATA["upper_lid_open"][1], EYE_DATA["upper_lid_closed"][1]),
)
LOWER_LID_MIN = (
    min(EYE_DATA["lower_lid_open"][0], EYE_DATA["lower_lid_closed"][0]),
    min(EYE_DATA["lower_lid_open"][1], EYE_DATA["lower_lid_closed"][1]),
)
LOWER_LID_MAX = (
    max(EYE_DATA["lower_lid_open"][0], EYE_DATA["lower_lid_closed"][0]),
    max(EYE_DATA["lower_lid_open"][1], EYE_DATA["lower_lid_closed"][1]),
)
EYE_PREV = (0, 0)
EYE_NEXT = (0, 0)
MOVE_STATE = False  # Initially stationary
MOVE_EVENT_DURATION = random.uniform(0.1, 3)  # Time to first move
BLINK_STATE = 2  # Start eyes closed
BLINK_EVENT_DURATION = random.uniform(0.25, 0.5)  # Time for eyes to open
TIME_OF_LAST_MOVE_EVENT = TIME_OF_LAST_BLINK_EVENT = time.monotonic()


# MAIN LOOP ----------------------------------------------------------------

while True:
    NOW = time.monotonic()
    # Eye movement ---------------------------------------------------------

    if NOW - TIME_OF_LAST_MOVE_EVENT > MOVE_EVENT_DURATION:
        TIME_OF_LAST_MOVE_EVENT = NOW  # Start new move or pause
        MOVE_STATE = not MOVE_STATE  # Toggle between moving & stationary
        if MOVE_STATE:  # Starting a new move?
            MOVE_EVENT_DURATION = random.uniform(0.08, 0.17)  # Move time
            ANGLE = random.uniform(0, math.pi * 2)
            EYE_NEXT = (
                math.cos(ANGLE) * EYE_RANGE[0],  # (0,0) in center,
                math.sin(ANGLE) * EYE_RANGE[1],
            )  # NOT pixel coords
        else:  # Starting a new pause
            MOVE_EVENT_DURATION = random.uniform(0.04, 3)  # Hold time
            EYE_PREV = EYE_NEXT

    # Fraction of move elapsed (0.0 to 1.0), then ease in/out 3*e^2-2*e^3
    RATIO = (NOW - TIME_OF_LAST_MOVE_EVENT) / MOVE_EVENT_DURATION
    RATIO = 3 * RATIO * RATIO - 2 * RATIO * RATIO * RATIO
    EYE_POS = (
        EYE_PREV[0] + RATIO * (EYE_NEXT[0] - EYE_PREV[0]),
        EYE_PREV[1] + RATIO * (EYE_NEXT[1] - EYE_PREV[1]),
    )

    # Blinking -------------------------------------------------------------

    if NOW - TIME_OF_LAST_BLINK_EVENT > BLINK_EVENT_DURATION:
        TIME_OF_LAST_BLINK_EVENT = NOW  # Start change in blink
        BLINK_STATE += 1  # Cycle paused/closing/opening
        if BLINK_STATE == 1:  # Starting a new blink (closing)
            BLINK_EVENT_DURATION = random.uniform(0.03, 0.07)
        elif BLINK_STATE == 2:  # Starting de-blink (opening)
            BLINK_EVENT_DURATION *= 2
        else:  # Blink ended,
            BLINK_STATE = 0  # paused
            BLINK_EVENT_DURATION = random.uniform(BLINK_EVENT_DURATION * 3, 4)
            jaw_wag()
    if BLINK_STATE:  # Currently in a blink?
        # Fraction of closing or opening elapsed (0.0 to 1.0)
        RATIO = (NOW - TIME_OF_LAST_BLINK_EVENT) / BLINK_EVENT_DURATION
        if BLINK_STATE == 2:  # Opening
            RATIO = 1.0 - RATIO  # Flip ratio so eye opens instead of closes
    else:  # Not blinking
        RATIO = 0

    # Eyelid tracking ------------------------------------------------------

    # Initial estimate of 'tracked' eyelid positions
    UPPER_LID_POS = (
        EYE_DATA["upper_lid_center"][0] + EYE_POS[0],
        EYE_DATA["upper_lid_center"][1] + EYE_POS[1],
    )
    LOWER_LID_POS = (
        EYE_DATA["lower_lid_center"][0] + EYE_POS[0],
        EYE_DATA["lower_lid_center"][1] + EYE_POS[1],
    )
    # Then constrain these to the upper/lower lid motion bounds
    UPPER_LID_POS = (
        min(max(UPPER_LID_POS[0], UPPER_LID_MIN[0]), UPPER_LID_MAX[0]),
        min(max(UPPER_LID_POS[1], UPPER_LID_MIN[1]), UPPER_LID_MAX[1]),
    )
    LOWER_LID_POS = (
        min(max(LOWER_LID_POS[0], LOWER_LID_MIN[0]), LOWER_LID_MAX[0]),
        min(max(LOWER_LID_POS[1], LOWER_LID_MIN[1]), LOWER_LID_MAX[1]),
    )
    # Then interpolate between bounded tracked position to closed position
    UPPER_LID_POS = (
        UPPER_LID_POS[0] + RATIO * (EYE_DATA["upper_lid_closed"][0] - UPPER_LID_POS[0]),
        UPPER_LID_POS[1] + RATIO * (EYE_DATA["upper_lid_closed"][1] - UPPER_LID_POS[1]),
    )
    LOWER_LID_POS = (
        LOWER_LID_POS[0] + RATIO * (EYE_DATA["lower_lid_closed"][0] - LOWER_LID_POS[0]),
        LOWER_LID_POS[1] + RATIO * (EYE_DATA["lower_lid_closed"][1] - LOWER_LID_POS[1]),
    )

    # Move eye sprites -----------------------------------------------------

    SPRITES[0].x, SPRITES[0].y = (
        int(EYE_CENTER[0] + EYE_POS[0] + 0.5),
        int(EYE_CENTER[1] + EYE_POS[1] + 0.5),
    )
    SPRITES[2].x, SPRITES[2].y = (
        int(UPPER_LID_POS[0] + 0.5),
        int(UPPER_LID_POS[1] + 0.5),
    )
    SPRITES[1].x, SPRITES[1].y = (
        int(LOWER_LID_POS[0] + 0.5),
        int(LOWER_LID_POS[1] + 0.5),
    )

The code works the same as the Matrix Eyes project, with one small addition, the servo motor for the jaw. Here's how that is set up.

First, we'll import the servo code from the adafruit_motor library.

from adafruit_motor import servo

PWM

Then, we set up pulse width modulation on pin A4 and create the servo object.

pwm = pulseio.PWMOut(board.A4, duty_cycle=2 ** 15, frequency=50)
jaw_servo = servo.Servo(pwm)

Jaw Wag

We'll define a jaw_wag() function that we can call whenever we want to wiggle the jaw back and forth.

def jaw_wag():
    for angle in range(90, 70, -2):  # start angle, end angle, degree step size
        jaw_servo.angle = angle
    for angle in range(70, 90, 2):
        jaw_servo.angle = angle
    for angle in range(90, 110, 2):
        jaw_servo.angle = angle
    for angle in range(110, 90, -2):
        jaw_servo.angle = angle

Blink and Jaw

Finally, whenever there is a blink event in the code, we'll add in a jaw wag:

# Blinking -------------------------------------------------------------

    if NOW - TIME_OF_LAST_BLINK_EVENT > BLINK_EVENT_DURATION:
        TIME_OF_LAST_BLINK_EVENT = NOW  # Start change in blink
        BLINK_STATE += 1  # Cycle paused/closing/opening
        if BLINK_STATE == 1:  # Starting a new blink (closing)
            BLINK_EVENT_DURATION = random.uniform(0.03, 0.07)
        elif BLINK_STATE == 2:  # Starting de-blink (opening)
            BLINK_EVENT_DURATION *= 2
        else:  # Blink ended,
            BLINK_STATE = 0  # paused
            BLINK_EVENT_DURATION = random.uniform(BLINK_EVENT_DURATION * 3, 4)
            jaw_wag()

This guide was first published on Nov 09, 2020. It was last updated on Nov 09, 2020.

This page (Code the Window Skull) was last updated on Oct 14, 2021.

Text editor powered by tinymce.