Note

Hello, welcome to the SunFounder Raspberry Pi & Arduino & ESP32 Enthusiasts Community on Facebook! Dive deeper into Raspberry Pi, Arduino, and ESP32 with fellow enthusiasts.

Why Join?

  • Expert Support: Solve post-sale issues and technical challenges with help from our community and team.

  • Learn & Share: Exchange tips and tutorials to enhance your skills.

  • Exclusive Previews: Get early access to new product announcements and sneak peeks.

  • Special Discounts: Enjoy exclusive discounts on our newest products.

  • Festive Promotions and Giveaways: Take part in giveaways and holiday promotions.

๐Ÿ‘‰ Ready to explore and create with us? Click [here] and join today!

4.13 Joystick-Controlled Eye๏ƒ

Introduction

In this lesson, you will build a Joystick-Controlled Eye, displayed on a 128ร—64 SSD1306 OLED screen. A two-axis analog joystick provides X/Y movement, which is read using the Fusion HAT+โ€™s ADC channels. The OLED shows a stylized โ€œeyeโ€ whose pupil smoothly follows the direction of the joystick.

The project demonstrates:

  • Reading multi-axis analog input

  • Smooth value normalization and dead-zone handling

  • Geometric constraints using an ellipse

  • Real-time graphics rendering on a monochrome OLED

As you move the joystick, the iris and pupil glide around inside the eye without crossing the outer sclera boundary.


What Youโ€™ll Need

The following components are required for this project:

COMPONENT INTRODUCTION

PURCHASE LINK

Jumper Wires

BUY

Joystick Module

-

OLED Display Module

-

Fusion HAT+

-

Raspberry Pi

-


Wiring Diagram

Refer to the wiring diagram for assembling the components:

../_images/4.13_joystick_eye_bb.png

Setup Steps

  1. Install the required libraries:

    sudo pip3 install adafruit-circuitpython-ssd1306 --break
    
  2. Run the example from the ai-lab-kit directory:

    cd ~/ai-lab-kit/python/
    sudo python3 4.13_JoystickEye.py
    
  3. When the script runs:

    • Moving the joystick left/right/up/down moves the pupil accordingly

    • Pupil movement is smoothed and constrained inside an elliptical eye

    • The display updates around 50 FPS

    • Press Ctrl+C to exit


Code

Here is the Python script for the Joystick-Controlled Eye:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Joystick-controlled eye on a 128x64 SSD1306 OLED.
- Joystick X/Y on ADC A0/A1 (0..4095)
- Pupil moves inside a drawn eye based on joystick deflection
"""

import time
from math import sqrt
from fusion_hat.adc import ADC
from PIL import Image, ImageDraw, ImageFont
import adafruit_ssd1306
import board

# ===== OLED setup =====
WIDTH, HEIGHT = 128, 64
i2c = board.I2C()
oled = adafruit_ssd1306.SSD1306_I2C(WIDTH, HEIGHT, i2c, addr=0x3C)
oled.fill(0)
oled.show()

# Framebuffer for drawing
image = Image.new("1", (WIDTH, HEIGHT))
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()

# ===== Joystick (two ADC channels) =====
# Adjust channel names to your hardware if needed (e.g., 'A1','A2')
joy_x = ADC('A0')  # X axis
joy_y = ADC('A1')  # Y axis

# ===== Mapping helpers =====
def linear_map(x, in_min, in_max, out_min, out_max):
   """Map x from one range to another (float)."""
   return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min

def clamp(v, vmin, vmax):
   """Clamp v to [vmin, vmax]."""
   return vmax if v > vmax else vmin if v < vmin else v

# ===== Eye layout (tweak to your taste) =====
# Eye center and sizes
EYE_CX, EYE_CY = WIDTH // 2, HEIGHT // 2
EYE_W, EYE_H   = 90, 48          # outer ellipse width/height (sclera)
IRIS_R         = 10              # iris radius (white ring, optional)
PUPIL_R        = 7               # pupil radius (black)
BORDER         = 2               # outer eye border thickness in pixels

# Pupil movement limits (keep pupil inside sclera with some margin)
# We'll approximate the inside of the eye as an ellipse and limit the pupil center
PUPIL_MARGIN = PUPIL_R + 3
MAX_X = (EYE_W // 2) - PUPIL_MARGIN
MAX_Y = (EYE_H // 2) - PUPIL_MARGIN

# ===== Joystick normalization settings =====
# Joystick nominal center near 2048; define a dead zone and smoothing
ADC_MIN, ADC_MAX = 0.0, 4095.0
ADC_CENTER_X     = 2048.0
ADC_CENTER_Y     = 2048.0
DEADZONE         = 120.0     # +/- counts considered centered (no move)
SMOOTH_ALPHA     = 0.35      # EMA smoothing factor for normalized values

# State for smoothing
nx_smooth, ny_smooth = 0.0, 0.0

def read_joystick_norm():
   """
   Read joystick and produce normalized values in [-1, 1] for X and Y,
   with dead zone and smoothing. Y is inverted so up is positive.
   """
   global nx_smooth, ny_smooth

   rx = float(joy_x.read())
   ry = float(joy_y.read())

   # Raw offset from center
   dx = rx - ADC_CENTER_X
   dy = ry - ADC_CENTER_Y

   # Deadzone
   if abs(dx) < DEADZONE: dx = 0.0
   if abs(dy) < DEADZONE: dy = 0.0

   # Normalize to [-1, 1]
   # Use the larger of (center-min) / (max-center) to reduce asymmetry
   span_x_pos = ADC_MAX - ADC_CENTER_X
   span_x_neg = ADC_CENTER_X - ADC_MIN
   span_y_pos = ADC_MAX - ADC_CENTER_Y
   span_y_neg = ADC_CENTER_Y - ADC_MIN

   nx = dx / (span_x_pos if dx >= 0 else span_x_neg) if dx != 0 else 0.0
   ny = dy / (span_y_pos if dy >= 0 else span_y_neg) if dy != 0 else 0.0

   # Invert Y so pushing stick up moves pupil up
   ny = -ny

   # Clamp to [-1, 1]
   nx = clamp(nx, -1.0, 1.0)
   ny = clamp(ny, -1.0, 1.0)

   # Exponential smoothing
   nx_smooth = SMOOTH_ALPHA * nx + (1.0 - SMOOTH_ALPHA) * nx_smooth
   ny_smooth = SMOOTH_ALPHA * ny + (1.0 - SMOOTH_ALPHA) * ny_smooth

   # Re-clamp after smoothing
   nx_smooth_clamped = clamp(nx_smooth, -1.0, 1.0)
   ny_smooth_clamped = clamp(ny_smooth, -1.0, 1.0)
   return nx_smooth_clamped, ny_smooth_clamped

def pupil_target_from_norm(nx, ny):
   """
   Map normalized joystick values [-1..1] to a legal pupil center inside the eye.
   We limit by ellipse equation (x/MAX_X)^2 + (y/MAX_Y)^2 <= 1.
   If outside, project back to the ellipse boundary.
   """
   # Proposed offsets
   px = nx * MAX_X
   py = ny * MAX_Y

   # Check ellipse boundary; if outside, project back
   if (px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y) > 1.0:
      # Normalize direction to ellipse edge
      # Scale so that (px/MAX_X, py/MAX_Y) lies on unit circle
      k = 1.0 / sqrt((px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y))
      px *= k
      py *= k

   # Final pupil center
   cx = int(EYE_CX + px)
   cy = int(EYE_CY + py)
   return cx, cy

def draw_eye(cx, cy):
   """
   Draw the eye (sclera outline + filled sclera) and the pupil at (cx, cy).
   Monochrome: 1 = white (ON), 0 = black (OFF).
   """
   draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0)

   # Outer eye (white sclera with border)
   # Outer ellipse
   x0 = EYE_CX - EYE_W // 2
   y0 = EYE_CY - EYE_H // 2
   x1 = EYE_CX + EYE_W // 2
   y1 = EYE_CY + EYE_H // 2

   # Border: draw multiple outlines to simulate thickness
   for t in range(BORDER):
      draw.ellipse((x0 - t, y0 - t, x1 + t, y1 + t), outline=1, fill=0)
   # Filled sclera (white)
   draw.ellipse((x0 + 1, y0 + 1, x1 - 1, y1 - 1), outline=0, fill=1)

   # Optional iris ring (white ring around pupil); comment out if undesired
   if IRIS_R > PUPIL_R:
      draw.ellipse((cx - IRIS_R, cy - IRIS_R, cx + IRIS_R, cy + IRIS_R), outline=0, fill=0)
      draw.ellipse((cx - (IRIS_R - 2), cy - (IRIS_R - 2), cx + (IRIS_R - 2), cy + (IRIS_R - 2)), outline=0, fill=1)

   # Pupil (black)
   draw.ellipse((cx - PUPIL_R, cy - PUPIL_R, cx + PUPIL_R, cy + PUPIL_R), outline=0, fill=0)

   # Optional text (for debugging)
   # txt = "X/Y"
   # tw, th = font.getsize(txt)
   # draw.text((2, 2), txt, font=font, fill=1)

def main():
   try:
      while True:
            # Read joystick โ†’ normalized [-1..1]
            nx, ny = read_joystick_norm()
            # Compute pupil center within eye bounds
            cx, cy = pupil_target_from_norm(nx, ny)
            # Draw and show
            draw_eye(cx, cy)
            oled.image(image)
            oled.show()
            time.sleep(0.02)  # ~50 FPS
   except KeyboardInterrupt:
      oled.fill(0)
      oled.show()
      print("\nExited.")

if __name__ == "__main__":
   main()

Understanding the Code

  1. Joystick Analog Reading

    Two ADC channels read X and Y positions (0..4095). The script converts these values into normalized motion [-1..1] with:

    • Dead-zone handling

    • Direction inversion (up = positive Y)

    • Smoothing using exponential moving average (EMA)

    def read_joystick_norm():
       """
       Read joystick and produce normalized values in [-1, 1] for X and Y,
       with dead zone and smoothing. Y is inverted so up is positive.
       """
       global nx_smooth, ny_smooth
    
       rx = float(joy_x.read())
       ry = float(joy_y.read())
    
       # Raw offset from center
       dx = rx - ADC_CENTER_X
       dy = ry - ADC_CENTER_Y
    
    ...
    
  2. Eye Geometry

    The eye is drawn as:

    • An ellipse for the sclera (white)

    • Optional outer border

    • Optional iris ring

    • A black pupil

    def draw_eye(cx, cy):
       """
       Draw the eye (sclera outline + filled sclera) and the pupil at (cx, cy).
       Monochrome: 1 = white (ON), 0 = black (OFF).
       """
       draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0)
    
       # Outer eye (white sclera with border)
       # Outer ellipse
       x0 = EYE_CX - EYE_W // 2
       y0 = EYE_CY - EYE_H // 2
       x1 = EYE_CX + EYE_W // 2
       y1 = EYE_CY + EYE_H // 2
    
       ...
    
  3. Pupil Movement Constraint

    The pupil center is constrained by the ellipse equation:

    (px/MAX_X)^2 + (py/MAX_Y)^2 <= 1

    If input pushes the pupil outside the eye, the script projects it back onto the ellipse boundary.

    def pupil_target_from_norm(nx, ny):
       """
       Map normalized joystick values [-1..1] to a legal pupil center inside the eye.
       We limit by ellipse equation (x/MAX_X)^2 + (y/MAX_Y)^2 <= 1.
       If outside, project back to the ellipse boundary.
       """
       # Proposed offsets
       px = nx * MAX_X
       py = ny * MAX_Y
    
       # Check ellipse boundary; if outside, project back
       if (px*px) / (MAX_X*MAX_X) + (py*py) / (MAX_Y*MAX_Y) > 1.0:
       ...
    
  4. Rendering

    Each frame:

    • The eye background is redrawn

    • Pupil position is computed

    • The image buffer is sent to the OLED

    • Display refreshes at ~50 FPS

  5. Main Loop

    Continuously reads joystick โ†’ normalizes โ†’ computes pupil โ†’ redraws eye โ†’ updates display.


Troubleshooting

  • Joystick movement feels jumpy

    • Increase SMOOTH_ALPHA

    • Increase DEADZONE

  • Eye looks stretched or small

    Adjust:

    EYE_W, EYE_H = 90, 48
    
  • Pupil clips outside the eye

    Increase:

    PUPIL_MARGIN = PUPIL_R + 3
    
  • Joystick axes reversed

    Swap ADC channels or flip signs.


Try It Yourself

  1. Blink Animation

    Add timed eyelid closing/opening.

  2. Angry / Happy Eyebrow Mode

    Draw stylized brows reacting to joystick input.

  3. Follow-the-Dot Game

    Add a moving target dot and make the eye โ€œtrackโ€ it.

  4. Add Button to Toggle Modes

    Switch between eye styles: robot eye, cartoon eye, cat eye.

  5. Add Multi-Eye Display

    Render two eyes that move together or independently.

These extensions turn the simple joystick eye into a powerful OLED animation and interaction demo.