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 |
|---|---|
- |
|
- |
|
- |
|
Raspberry Pi |
- |
Wiring Diagram
Refer to the wiring diagram for assembling the components:
Setup Steps
Install the required libraries:
sudo pip3 install adafruit-circuitpython-ssd1306 --break
Run the example from the
ai-lab-kitdirectory:cd ~/ai-lab-kit/python/ sudo python3 4.13_JoystickEye.py
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
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 ...
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 ...
Pupil Movement Constraint
The pupil center is constrained by the ellipse equation:
(px/MAX_X)^2 + (py/MAX_Y)^2 <= 1If 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: ...
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
Main Loop
Continuously reads joystick โ normalizes โ computes pupil โ redraws eye โ updates display.
Troubleshooting
Joystick movement feels jumpy
Increase
SMOOTH_ALPHAIncrease
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
Blink Animation
Add timed eyelid closing/opening.
Angry / Happy Eyebrow Mode
Draw stylized brows reacting to joystick input.
Follow-the-Dot Game
Add a moving target dot and make the eye โtrackโ it.
Add Button to Toggle Modes
Switch between eye styles: robot eye, cartoon eye, cat eye.
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.