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.
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.
To use with CircuitPython, you need to first install a few libraries, into the lib folder on your CIRCUITPY drive. Then you need to update code.py with the example script.
Thankfully, we can do this in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, open the directory Window_Skull_Matrix/ and then click on the directory that matches the version of CircuitPython you're using and copy the contents of that directory to your CIRCUITPY drive.
Your CIRCUITPY

# 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 time.sleep(0.01) for angle in range(70, 90, 2): jaw_servo.angle = angle time.sleep(0.01) for angle in range(90, 110, 2): jaw_servo.angle = angle time.sleep(0.01) for angle in range(110, 90, -2): jaw_servo.angle = angle time.sleep(0.01) # 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().__init__(bitmap, pixel_shader=palette, 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.root_group = 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
# 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()
Page last edited January 21, 2025
Text editor powered by tinymce.