code.py
  1  # SPDX-FileCopyrightText: 2020 John Park for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  WINDOW SKULL for Adafruit Matrix Portal: animated spooky eyes and servomotor jaw
  7  """
  8  
  9  # pylint: disable=import-error
 10  import math
 11  import random
 12  import time
 13  import board
 14  import pwmio
 15  import displayio
 16  from adafruit_motor import servo
 17  import adafruit_imageload
 18  from adafruit_matrixportal.matrix import Matrix
 19  
 20  pwm = pwmio.PWMOut(board.A4, duty_cycle=2 ** 15, frequency=50)
 21  jaw_servo = servo.Servo(pwm)
 22  
 23  
 24  def jaw_wag():
 25      for angle in range(90, 70, -2):  # start angle, end angle, degree step size
 26          jaw_servo.angle = angle
 27      for angle in range(70, 90, 2):
 28          jaw_servo.angle = angle
 29      for angle in range(90, 110, 2):
 30          jaw_servo.angle = angle
 31      for angle in range(110, 90, -2):
 32          jaw_servo.angle = angle
 33  
 34  
 35  # TO LOAD DIFFERENT EYE DESIGNS: change the middle word here (between
 36  # 'eyes.' and '.data') to one of the folder names inside the 'eyes' folder:
 37  # from eyes.werewolf.data import EYE_DATA
 38  # from eyes.cyclops.data import EYE_DATA
 39  # from eyes.kobold.data import EYE_DATA
 40  # from eyes.adabot.data import EYE_DATA
 41  # from eyes.skull.data import EYE_DATA
 42  # pylint: disable=wrong-import-position
 43  from eyes.skull_bigger.data import EYE_DATA
 44  
 45  # UTILITY FUNCTIONS AND CLASSES --------------------------------------------
 46  
 47  # pylint: disable=too-few-public-methods
 48  class Sprite(displayio.TileGrid):
 49      """Single-tile-with-bitmap TileGrid subclass, adds a height element
 50      because TileGrid doesn't appear to have a way to poll that later,
 51      object still functions in a displayio.Group.
 52      """
 53  
 54      def __init__(self, filename, transparent=None):
 55          """Create Sprite object from color-paletted BMP file, optionally
 56          set one color to transparent (pass as RGB tuple or list to locate
 57          nearest color, or integer to use a known specific color index).
 58          """
 59          bitmap, palette = adafruit_imageload.load(
 60              filename, bitmap=displayio.Bitmap, palette=displayio.Palette
 61          )
 62          if isinstance(transparent, (tuple, list)):  # Find closest RGB match
 63              closest_distance = 0x1000000  # Force first match
 64              for color_index, color in enumerate(palette):  # Compare each...
 65                  delta = (
 66                      transparent[0] - ((color >> 16) & 0xFF),
 67                      transparent[1] - ((color >> 8) & 0xFF),
 68                      transparent[2] - (color & 0xFF),
 69                  )
 70                  rgb_distance = (
 71                      delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]
 72                  )  # Actually dist^2
 73                  if rgb_distance < closest_distance:  # but adequate for
 74                      closest_distance = rgb_distance  # compare purposes,
 75                      closest_index = color_index  # no sqrt needed
 76              palette.make_transparent(closest_index)
 77          elif isinstance(transparent, int):
 78              palette.make_transparent(transparent)
 79          super(Sprite, self).__init__(bitmap, pixel_shader=palette)
 80          self.height = bitmap.height
 81  
 82  
 83  # ONE-TIME INITIALIZATION --------------------------------------------------
 84  
 85  MATRIX = Matrix(bit_depth=6)
 86  DISPLAY = MATRIX.display
 87  
 88  # Order in which sprites are added determines the 'stacking order' and
 89  # visual priority. Lower lid is added before the upper lid so that if they
 90  # overlap, the upper lid is 'on top' (e.g. if it has eyelashes or such).
 91  SPRITES = displayio.Group()
 92  SPRITES.append(Sprite(EYE_DATA["eye_image"]))  # Base image is opaque
 93  SPRITES.append(Sprite(EYE_DATA["lower_lid_image"], EYE_DATA["transparent"]))
 94  SPRITES.append(Sprite(EYE_DATA["upper_lid_image"], EYE_DATA["transparent"]))
 95  SPRITES.append(Sprite(EYE_DATA["stencil_image"], EYE_DATA["transparent"]))
 96  DISPLAY.show(SPRITES)
 97  
 98  EYE_CENTER = (
 99      (EYE_DATA["eye_move_min"][0] + EYE_DATA["eye_move_max"][0])  # Pixel coords of eye
100      / 2,  # image when centered
101      (EYE_DATA["eye_move_min"][1] + EYE_DATA["eye_move_max"][1])  # ('neutral' position)
102      / 2,
103  )
104  EYE_RANGE = (
105      abs(
106          EYE_DATA["eye_move_max"][0]
107          - EYE_DATA["eye_move_min"][0]  # Max eye image motion
108      )
109      / 2,  # delta from center
110      abs(EYE_DATA["eye_move_max"][1] - EYE_DATA["eye_move_min"][1]) / 2,
111  )
112  UPPER_LID_MIN = (
113      min(
114          EYE_DATA["upper_lid_open"][0],  # Motion bounds of
115          EYE_DATA["upper_lid_closed"][0],
116      ),  # upper and lower
117      min(EYE_DATA["upper_lid_open"][1], EYE_DATA["upper_lid_closed"][1]),  # eyelids
118  )
119  UPPER_LID_MAX = (
120      max(EYE_DATA["upper_lid_open"][0], EYE_DATA["upper_lid_closed"][0]),
121      max(EYE_DATA["upper_lid_open"][1], EYE_DATA["upper_lid_closed"][1]),
122  )
123  LOWER_LID_MIN = (
124      min(EYE_DATA["lower_lid_open"][0], EYE_DATA["lower_lid_closed"][0]),
125      min(EYE_DATA["lower_lid_open"][1], EYE_DATA["lower_lid_closed"][1]),
126  )
127  LOWER_LID_MAX = (
128      max(EYE_DATA["lower_lid_open"][0], EYE_DATA["lower_lid_closed"][0]),
129      max(EYE_DATA["lower_lid_open"][1], EYE_DATA["lower_lid_closed"][1]),
130  )
131  EYE_PREV = (0, 0)
132  EYE_NEXT = (0, 0)
133  MOVE_STATE = False  # Initially stationary
134  MOVE_EVENT_DURATION = random.uniform(0.1, 3)  # Time to first move
135  BLINK_STATE = 2  # Start eyes closed
136  BLINK_EVENT_DURATION = random.uniform(0.25, 0.5)  # Time for eyes to open
137  TIME_OF_LAST_MOVE_EVENT = TIME_OF_LAST_BLINK_EVENT = time.monotonic()
138  
139  
140  # MAIN LOOP ----------------------------------------------------------------
141  
142  while True:
143      NOW = time.monotonic()
144      # Eye movement ---------------------------------------------------------
145  
146      if NOW - TIME_OF_LAST_MOVE_EVENT > MOVE_EVENT_DURATION:
147          TIME_OF_LAST_MOVE_EVENT = NOW  # Start new move or pause
148          MOVE_STATE = not MOVE_STATE  # Toggle between moving & stationary
149          if MOVE_STATE:  # Starting a new move?
150              MOVE_EVENT_DURATION = random.uniform(0.08, 0.17)  # Move time
151              ANGLE = random.uniform(0, math.pi * 2)
152              EYE_NEXT = (
153                  math.cos(ANGLE) * EYE_RANGE[0],  # (0,0) in center,
154                  math.sin(ANGLE) * EYE_RANGE[1],
155              )  # NOT pixel coords
156          else:  # Starting a new pause
157              MOVE_EVENT_DURATION = random.uniform(0.04, 3)  # Hold time
158              EYE_PREV = EYE_NEXT
159  
160      # Fraction of move elapsed (0.0 to 1.0), then ease in/out 3*e^2-2*e^3
161      RATIO = (NOW - TIME_OF_LAST_MOVE_EVENT) / MOVE_EVENT_DURATION
162      RATIO = 3 * RATIO * RATIO - 2 * RATIO * RATIO * RATIO
163      EYE_POS = (
164          EYE_PREV[0] + RATIO * (EYE_NEXT[0] - EYE_PREV[0]),
165          EYE_PREV[1] + RATIO * (EYE_NEXT[1] - EYE_PREV[1]),
166      )
167  
168      # Blinking -------------------------------------------------------------
169  
170      if NOW - TIME_OF_LAST_BLINK_EVENT > BLINK_EVENT_DURATION:
171          TIME_OF_LAST_BLINK_EVENT = NOW  # Start change in blink
172          BLINK_STATE += 1  # Cycle paused/closing/opening
173          if BLINK_STATE == 1:  # Starting a new blink (closing)
174              BLINK_EVENT_DURATION = random.uniform(0.03, 0.07)
175          elif BLINK_STATE == 2:  # Starting de-blink (opening)
176              BLINK_EVENT_DURATION *= 2
177          else:  # Blink ended,
178              BLINK_STATE = 0  # paused
179              BLINK_EVENT_DURATION = random.uniform(BLINK_EVENT_DURATION * 3, 4)
180              jaw_wag()
181      if BLINK_STATE:  # Currently in a blink?
182          # Fraction of closing or opening elapsed (0.0 to 1.0)
183          RATIO = (NOW - TIME_OF_LAST_BLINK_EVENT) / BLINK_EVENT_DURATION
184          if BLINK_STATE == 2:  # Opening
185              RATIO = 1.0 - RATIO  # Flip ratio so eye opens instead of closes
186      else:  # Not blinking
187          RATIO = 0
188  
189      # Eyelid tracking ------------------------------------------------------
190  
191      # Initial estimate of 'tracked' eyelid positions
192      UPPER_LID_POS = (
193          EYE_DATA["upper_lid_center"][0] + EYE_POS[0],
194          EYE_DATA["upper_lid_center"][1] + EYE_POS[1],
195      )
196      LOWER_LID_POS = (
197          EYE_DATA["lower_lid_center"][0] + EYE_POS[0],
198          EYE_DATA["lower_lid_center"][1] + EYE_POS[1],
199      )
200      # Then constrain these to the upper/lower lid motion bounds
201      UPPER_LID_POS = (
202          min(max(UPPER_LID_POS[0], UPPER_LID_MIN[0]), UPPER_LID_MAX[0]),
203          min(max(UPPER_LID_POS[1], UPPER_LID_MIN[1]), UPPER_LID_MAX[1]),
204      )
205      LOWER_LID_POS = (
206          min(max(LOWER_LID_POS[0], LOWER_LID_MIN[0]), LOWER_LID_MAX[0]),
207          min(max(LOWER_LID_POS[1], LOWER_LID_MIN[1]), LOWER_LID_MAX[1]),
208      )
209      # Then interpolate between bounded tracked position to closed position
210      UPPER_LID_POS = (
211          UPPER_LID_POS[0] + RATIO * (EYE_DATA["upper_lid_closed"][0] - UPPER_LID_POS[0]),
212          UPPER_LID_POS[1] + RATIO * (EYE_DATA["upper_lid_closed"][1] - UPPER_LID_POS[1]),
213      )
214      LOWER_LID_POS = (
215          LOWER_LID_POS[0] + RATIO * (EYE_DATA["lower_lid_closed"][0] - LOWER_LID_POS[0]),
216          LOWER_LID_POS[1] + RATIO * (EYE_DATA["lower_lid_closed"][1] - LOWER_LID_POS[1]),
217      )
218  
219      # Move eye sprites -----------------------------------------------------
220  
221      SPRITES[0].x, SPRITES[0].y = (
222          int(EYE_CENTER[0] + EYE_POS[0] + 0.5),
223          int(EYE_CENTER[1] + EYE_POS[1] + 0.5),
224      )
225      SPRITES[2].x, SPRITES[2].y = (
226          int(UPPER_LID_POS[0] + 0.5),
227          int(UPPER_LID_POS[1] + 0.5),
228      )
229      SPRITES[1].x, SPRITES[1].y = (
230          int(LOWER_LID_POS[0] + 0.5),
231          int(LOWER_LID_POS[1] + 0.5),
232      )