TFT Robot Eyes
Time: ~120 min
Prerequisite: OLED Display (familiarity with SPI, framebuffer drawing) + Ultrasonic & Timing
Learning Objectives
By the end of this module you will be able to:
- Interface a GC9A01 round TFT display over SPI from MicroPython on the RP2350
- Draw animated eye graphics using framebuffer operations
- Create reactive eye animations that respond to ultrasonic distance
- Implement smooth eyelid-closing animations using geometry
- Combine multiple sensors with display output in a real-time loop
Introduction
Your robot has personality — but it can't show it. A tiny round TFT display changes that. Mount a 1.28" GC9A01 round TFT (240×240 pixels, 65K colors) as a robot "eye" and suddenly your machine feels alive.
In this module, you'll draw an animated eye on the round display and wire it to the HC-SR04 ultrasonic sensor. When something approaches, the eye squints and closes — like a reflex. Move away, and the eye opens wide again.
This combines everything you've learned: SPI communication, sensor reading, real-time animation loops, and coordinate geometry.
What You'll Build
- A static eye — iris, pupil, and highlights on the round TFT
- Blinking animation — eyelids that close and open smoothly
- Distance-reactive eyes — ultrasonic sensor controls how open the eye is
- (Bonus) Pupil tracking — the pupil shifts left/right based on sensor input
Hardware You Need
- Raspberry Pi Pico 2 (RP2350) or Pico 2 W
- 1.28" Round TFT Display Module (GC9A01 driver, 240×240, SPI interface)
- HC-SR04 Ultrasonic Sensor (from your robot kit)
- Breadboard and jumper wires
- (Optional) Second display for two eyes
About Your Display
The 1.28" round TFT modules sold under brands like EYEWINK use the GC9A01 display controller. Despite some listings saying 128×160 (that's the ST7735 rectangular module), the round 1.28" version is 240×240 pixels with a circular visible area. The GC9A01 communicates over SPI at up to 80 MHz.
Part 1: Display Setup (~20 min)
Wiring
The GC9A01 connects via SPI. Here's the wiring for SPI bus 0:
| Display Pin | Pico Pin | Purpose |
|---|---|---|
| VCC | 3V3 | Power (3.3V) |
| GND | GND | Ground |
| SCL (SCK) | GP18 | SPI Clock |
| SDA (MOSI) | GP19 | SPI Data |
| DC | GP16 | Data/Command |
| CS | GP17 | Chip Select |
| RST | GP20 | Reset |
Power
The GC9A01 module runs at 3.3V. Do NOT connect VCC to 5V — it will damage the display. The Pico's 3V3(OUT) pin provides enough current.
Installing the Driver
You need the gc9a01py driver — a pure-Python MicroPython driver for the GC9A01. It's a single .py file with zero dependencies beyond stock MicroPython.
Download gc9a01py.py from the gc9a01py repository and copy it to your Pico:
That's it — no firmware compilation, no WiFi needed.
Why gc9a01py and not gc9a01_mpy?
The gc9a01_mpy driver is a C module that must be compiled into custom firmware — powerful but complex to set up. The gc9a01py driver is the same author's pure-Python version. It has the same API and the same GC9A01 init sequence, just runs a bit slower since it's interpreted Python. For our eye animation at 240×240, the performance is more than sufficient.
⚡ Hands-on Tasks
✅ Task 1 — Hello Round Display
import gc9a01py as gc9a01
from machine import Pin, SPI
# Initialize SPI bus
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
# Initialize display
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240,
height=240,
rotation=0
)
# Initialize and clear screen
tft.init()
tft.fill(gc9a01.BLACK)
You should see the round display light up with a black screen. If you see garbage pixels or nothing, check your wiring — especially DC and RST.
✅ Task 2 — Draw a Colored Circle
The display is round with a 240×240 pixel buffer. The center is at (120, 120). Let's fill it with a solid color and draw a circle:
import gc9a01py as gc9a01
from machine import Pin, SPI
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240, height=240
)
tft.init()
# White background
tft.fill(gc9a01.WHITE)
# Draw a blue filled circle (the iris)
# We'll draw it manually using horizontal lines
CX, CY = 120, 120 # Center
RADIUS = 60
for y in range(-RADIUS, RADIUS + 1):
# Width of circle at this y position: x² + y² = r²
x_span = int((RADIUS**2 - y**2) ** 0.5)
tft.hline(CX - x_span, CY + y, 2 * x_span, gc9a01.BLUE)
You should see a blue circle on a white background — the beginnings of an eye!
Why Manual Circle Drawing?
The gc9a01 driver provides hline, vline, fill_rect, and fill — but not a filled circle primitive. Drawing a filled circle with horizontal lines (scanlines) is the classic approach: for each row, calculate how wide the circle is at that height using the Pythagorean theorem.
Checkpoint — Display Working
You can initialize the GC9A01 and draw colored shapes. The pattern is similar to the OLED: clear → draw → (the GC9A01 driver writes directly, no explicit show() needed).
Part 2: Drawing an Eye (~30 min)
The Anatomy of a Cartoon Eye
A convincing cartoon eye has these layers (drawn back to front):
┌─────────────────────┐
│ 1. White sclera │ ← white filled circle (full display)
│ 2. Colored iris │ ← blue/green/brown circle
│ 3. Black pupil │ ← smaller black circle
│ 4. White highlight │ ← tiny white circle (reflection)
│ 5. Eyelid (upper) │ ← skin-colored region that covers top
│ 6. Eyelid (lower) │ ← skin-colored region that covers bottom
└─────────────────────┘
Helper: Filled Circle Function
Since we'll draw many circles, let's make a reusable function:
def fill_circle(tft, cx, cy, r, color):
"""Draw a filled circle at (cx, cy) with radius r."""
for y in range(-r, r + 1):
x_span = int((r * r - y * y) ** 0.5)
tft.hline(cx - x_span, cy + y, 2 * x_span + 1, color)
⚡ Hands-on Tasks
✅ Task 3 — Complete Static Eye
Draw all the layers to create a realistic-looking eye:
import gc9a01py as gc9a01
from machine import Pin, SPI
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240, height=240
)
tft.init()
def fill_circle(tft, cx, cy, r, color):
for y in range(-r, r + 1):
x_span = int((r * r - y * y) ** 0.5)
tft.hline(cx - x_span, cy + y, 2 * x_span + 1, color)
# Color definitions (RGB565)
WHITE = gc9a01.WHITE
BLACK = gc9a01.BLACK
BLUE = gc9a01.color565(30, 80, 180) # Iris blue
SKIN = gc9a01.color565(255, 220, 180) # Eyelid skin tone
CX, CY = 120, 120 # Eye center
# 1. Sclera (white of the eye) — fills the round display
tft.fill(WHITE)
# 2. Iris — colored ring
fill_circle(tft, CX, CY, 65, BLUE)
# 3. Pupil — black center
fill_circle(tft, CX, CY, 30, BLACK)
# 4. Highlight — reflection spot (top-right of pupil)
fill_circle(tft, CX + 20, CY - 20, 10, WHITE)
You should see a convincing cartoon eye staring at you from the round display!
Experiment: Change BLUE to other colors — try green (30, 160, 60), brown (139, 90, 43), or make a robot-red (200, 30, 30).
✅ Task 4 — Eyelid Function
The key to animation is the eyelid. We draw it as a skin-colored region that covers the top (and optionally bottom) of the eye. The openness parameter (0.0–1.0) controls how much of the eye is visible:
def draw_eye(tft, openness=1.0, pupil_y_offset=0):
"""
Draw a complete eye with eyelid position.
Args:
openness: 1.0 = fully open, 0.0 = fully closed
pupil_y_offset: vertical pupil shift (-30 to +30)
"""
CX, CY = 120, 120
EYE_R = 110 # Visible eye radius
IRIS_R = 65
PUPIL_R = 30
HIGHLIGHT_R = 10
BLUE = gc9a01.color565(30, 80, 180)
SKIN = gc9a01.color565(255, 220, 180)
# 1. Sclera
tft.fill(SKIN) # Start with skin (acts as eyelid background)
fill_circle(tft, CX, CY, EYE_R, gc9a01.WHITE)
# 2. Iris
fill_circle(tft, CX, CY + pupil_y_offset, IRIS_R, BLUE)
# 3. Pupil
fill_circle(tft, CX, CY + pupil_y_offset, PUPIL_R, gc9a01.BLACK)
# 4. Highlight
fill_circle(tft, CX + 20, CY - 20 + pupil_y_offset, HIGHLIGHT_R, gc9a01.WHITE)
# 5. Upper eyelid — covers from top down based on openness
lid_y = int(CY - EYE_R + (1.0 - openness) * EYE_R * 1.1)
if lid_y > CY - EYE_R:
tft.fill_rect(0, 0, 240, lid_y, SKIN)
# 6. Lower eyelid — covers from bottom up based on openness
lower_lid_y = int(CY + EYE_R - (1.0 - openness) * EYE_R * 1.1)
if lower_lid_y < CY + EYE_R:
tft.fill_rect(0, lower_lid_y, 240, 240 - lower_lid_y, SKIN)
Test it with different values:
draw_eye(tft, openness=1.0) # Fully open
draw_eye(tft, openness=0.5) # Half closed
draw_eye(tft, openness=0.1) # Almost shut
draw_eye(tft, openness=0.0) # Fully closed
Smooth Eyelid Shape
The rectangular eyelid works but looks a bit harsh. For a more natural look, you can draw the eyelid as a filled arc. Replace the fill_rect calls with scanline-based curves:
# Curved upper eyelid
for y in range(0, lid_y):
# Calculate how far this scanline extends (elliptical shape)
dy = abs(y - lid_y)
curve_factor = max(0, 1.0 - (dy / EYE_R) ** 2)
half_w = int(120 * (curve_factor ** 0.3))
tft.hline(CX - half_w, y, 2 * half_w + 1, SKIN)
This creates a natural curved eyelid. Experiment with the exponent to change the curve shape.
Checkpoint — Eye Drawing
You have a function that draws a complete eye with variable openness. The openness parameter is what we'll connect to the ultrasonic sensor.
Part 3: Blinking Animation (~20 min)
⚡ Hands-on Tasks
✅ Task 5 — Periodic Blink
A convincing blink closes quickly (~100 ms) and opens slightly slower (~150 ms). Let's create a blink animation:
import gc9a01py as gc9a01
from machine import Pin, SPI
import time
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240, height=240
)
tft.init()
def fill_circle(tft, cx, cy, r, color):
for y in range(-r, r + 1):
x_span = int((r * r - y * y) ** 0.5)
tft.hline(cx - x_span, cy + y, 2 * x_span + 1, color)
def draw_eye(tft, openness=1.0):
CX, CY = 120, 120
EYE_R = 110
IRIS_R = 65
PUPIL_R = 30
BLUE = gc9a01.color565(30, 80, 180)
SKIN = gc9a01.color565(255, 220, 180)
tft.fill(SKIN)
fill_circle(tft, CX, CY, EYE_R, gc9a01.WHITE)
fill_circle(tft, CX, CY, IRIS_R, BLUE)
fill_circle(tft, CX, CY, PUPIL_R, gc9a01.BLACK)
fill_circle(tft, CX + 20, CY - 20, 10, gc9a01.WHITE)
lid_y = int(CY - EYE_R + (1.0 - openness) * EYE_R * 1.1)
if lid_y > CY - EYE_R:
tft.fill_rect(0, 0, 240, lid_y, SKIN)
lower_lid_y = int(CY + EYE_R - (1.0 - openness) * EYE_R * 1.1)
if lower_lid_y < CY + EYE_R:
tft.fill_rect(0, lower_lid_y, 240, 240 - lower_lid_y, SKIN)
def blink(tft):
"""Perform one blink cycle."""
# Close (fast — 5 steps)
for i in range(5, -1, -1):
draw_eye(tft, openness=i / 5.0)
# Pause closed
time.sleep_ms(50)
# Open (slightly slower — 7 steps)
for i in range(8):
draw_eye(tft, openness=i / 7.0)
# Main loop — blink every 3-5 seconds
draw_eye(tft, openness=1.0)
while True:
time.sleep_ms(3000 + int(time.ticks_ms() % 2000)) # Random-ish interval
blink(tft)
Animation Frame Rate
Each draw_eye() call redraws the entire 240×240 display. At 60 MHz SPI, a full-screen update takes about 15–20 ms. With 5–8 steps per blink, the full blink animation takes ~100–160 ms — fast enough to look natural.
If the animation feels slow, you can:
- Increase SPI baudrate to
80_000_000 - Reduce the number of animation steps
- Only redraw the eyelid region instead of the full screen (optimization for Part 4)
Checkpoint — Blinking Eye
Your robot eye blinks periodically. The animation looks natural because we use fewer frames for closing (fast) than opening (slow) — just like real eyes.
Part 4: Distance-Reactive Eyes (~30 min)
Now the fun part — connecting the ultrasonic sensor to control the eye.
Wiring the HC-SR04
If you haven't already, wire the ultrasonic sensor:
| HC-SR04 Pin | Pico Pin | Purpose |
|---|---|---|
| VCC | VBUS (5V) | Power |
| GND | GND | Ground |
| TRIG | GP14 | Trigger pulse |
| ECHO | GP15 | Echo return |
Voltage Divider on ECHO
The HC-SR04 ECHO pin outputs 5V, but the Pico GPIO is 3.3V. Use a voltage divider (1kΩ + 2kΩ) or a level shifter. See the Ultrasonic & Timing tutorial for details.
⚡ Hands-on Tasks
✅ Task 6 — Read Distance
Quick test to verify the ultrasonic sensor works:
from machine import Pin
import time
trigger = Pin(14, Pin.OUT)
echo = Pin(15, Pin.IN)
def measure_distance_cm():
"""Measure distance in cm using HC-SR04."""
trigger.low()
time.sleep_us(2)
trigger.high()
time.sleep_us(10)
trigger.low()
# Wait for echo to go high
timeout = time.ticks_us()
while echo.value() == 0:
if time.ticks_diff(time.ticks_us(), timeout) > 30000:
return -1 # Timeout
pass
start = time.ticks_us()
# Wait for echo to go low
while echo.value() == 1:
if time.ticks_diff(time.ticks_us(), start) > 30000:
return -1 # Timeout
pass
end = time.ticks_us()
duration = time.ticks_diff(end, start)
distance = (duration * 0.0343) / 2
return distance
# Test
while True:
d = measure_distance_cm()
print(f"Distance: {d:.1f} cm")
time.sleep_ms(200)
✅ Task 7 — Eye Reacts to Distance
Map the ultrasonic distance to eye openness:
- > 50 cm → fully open (1.0) — nothing nearby
- 20–50 cm → proportionally closing — something approaching
- < 10 cm → fully closed (0.0) — reflex!
import gc9a01py as gc9a01
from machine import Pin, SPI
import time
# --- Display setup ---
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240, height=240
)
tft.init()
# --- Ultrasonic setup ---
trigger = Pin(14, Pin.OUT)
echo = Pin(15, Pin.IN)
def fill_circle(tft, cx, cy, r, color):
for y in range(-r, r + 1):
x_span = int((r * r - y * y) ** 0.5)
tft.hline(cx - x_span, cy + y, 2 * x_span + 1, color)
def draw_eye(tft, openness=1.0):
CX, CY = 120, 120
EYE_R = 110
IRIS_R = 65
PUPIL_R = 30
BLUE = gc9a01.color565(30, 80, 180)
SKIN = gc9a01.color565(255, 220, 180)
tft.fill(SKIN)
fill_circle(tft, CX, CY, EYE_R, gc9a01.WHITE)
fill_circle(tft, CX, CY, IRIS_R, BLUE)
fill_circle(tft, CX, CY, PUPIL_R, gc9a01.BLACK)
fill_circle(tft, CX + 20, CY - 20, 10, gc9a01.WHITE)
lid_y = int(CY - EYE_R + (1.0 - openness) * EYE_R * 1.1)
if lid_y > CY - EYE_R:
tft.fill_rect(0, 0, 240, lid_y, SKIN)
lower_lid_y = int(CY + EYE_R - (1.0 - openness) * EYE_R * 1.1)
if lower_lid_y < CY + EYE_R:
tft.fill_rect(0, lower_lid_y, 240, 240 - lower_lid_y, SKIN)
def measure_distance_cm():
trigger.low()
time.sleep_us(2)
trigger.high()
time.sleep_us(10)
trigger.low()
timeout = time.ticks_us()
while echo.value() == 0:
if time.ticks_diff(time.ticks_us(), timeout) > 30000:
return -1
pass
start = time.ticks_us()
while echo.value() == 1:
if time.ticks_diff(time.ticks_us(), start) > 30000:
return -1
pass
end = time.ticks_us()
return (time.ticks_diff(end, start) * 0.0343) / 2
def distance_to_openness(dist_cm):
"""Map distance to eye openness (0.0 - 1.0)."""
if dist_cm < 0:
return 1.0 # Sensor error — keep open
if dist_cm < 10:
return 0.0 # Very close — fully closed
if dist_cm > 50:
return 1.0 # Far away — fully open
# Linear mapping: 10cm → 0.0, 50cm → 1.0
return (dist_cm - 10) / 40.0
# --- Main loop ---
BLINK_INTERVAL_MS = 4000
last_blink = time.ticks_ms()
prev_openness = 1.0
draw_eye(tft, 1.0)
while True:
# Read distance
dist = measure_distance_cm()
target = distance_to_openness(dist)
# Smooth transition (exponential moving average)
openness = prev_openness * 0.6 + target * 0.4
prev_openness = openness
# Draw eye at current openness
draw_eye(tft, openness)
# Periodic blink (only if eye is mostly open)
now = time.ticks_ms()
if openness > 0.8 and time.ticks_diff(now, last_blink) > BLINK_INTERVAL_MS:
# Quick blink
for i in range(5, -1, -1):
draw_eye(tft, openness=i / 5.0)
time.sleep_ms(50)
for i in range(8):
draw_eye(tft, openness=i / 7.0)
last_blink = time.ticks_ms()
time.sleep_ms(50)
Smoothing with EMA
The exponential moving average (0.6 * old + 0.4 * new) prevents the eye from jittering when the ultrasonic sensor returns noisy readings. The 0.4 factor means the eye responds quickly but not instantly. Increase it for snappier response, decrease for smoother motion.
This is the same filtering technique from Advanced: Sensor Theory — applied to animation instead of data logging.
Checkpoint — Reactive Eye
Move your hand toward the sensor. The eye closes as you approach and opens as you pull away. The smoothing makes the motion feel organic rather than robotic.
Part 5: Enhancements (Optional, ~20 min)
✅ Challenge 1 — Pupil Size Reacts to Distance
Real pupils dilate in darkness and constrict in bright light. Make the pupil radius change with distance — larger when far (relaxed), smaller when close (alarmed):
Hint
Add a pupil_scale parameter to draw_eye():
✅ Challenge 2 — Two Eyes on Two Displays
Wire a second GC9A01 on SPI bus 1 (or share SPI bus 0 with a different CS pin). Draw both eyes — make one slightly offset from the other for a cross-eyed look when objects are very close!
Hint — Second Display on Same SPI
cs_left = Pin(17, Pin.OUT)
cs_right = Pin(22, Pin.OUT) # Different CS pin
tft_left = gc9a01.GC9A01(spi, dc=Pin(16, Pin.OUT), cs=cs_left, ...)
tft_right = gc9a01.GC9A01(spi, dc=Pin(16, Pin.OUT), cs=cs_right, ...)
Draw to each display separately — they share clock and data lines but are selected independently via CS.
✅ Challenge 3 — Emotion Expressions
Add different eye shapes for emotions:
| Emotion | Technique |
|---|---|
| Happy | Draw a curved line below the iris (smile crease), eyelids slightly lowered |
| Angry | Diagonal eyelid — lower on one side using angled scanlines |
| Surprised | Iris and pupil smaller, eyelids fully open, add extra highlight |
| Sleepy | Eyelids at 0.3 openness, slow drift animation |
Hint — Angled Eyelid for Angry Expression
Instead of a horizontal eyelid line, use a sloped line:
✅ Challenge 4 — Look Around
Make the pupil and iris shift horizontally based on time — the eye "looks around" when nothing is nearby:
Hint
Add a look_x parameter to draw_eye() that offsets the iris and pupil:
What You Discovered
Key Concepts
| Concept | What You Learned |
|---|---|
| GC9A01 | Round TFT controller, 240×240 pixels, SPI interface |
| SPI vs I2C | SPI is much faster (~60 MHz vs ~400 kHz) — needed for color displays |
| Scanline drawing | Filled circles via horizontal lines using Pythagorean theorem |
| Animation loop | Clear → draw layers → repeat, target 20+ fps |
| Sensor-driven animation | Map sensor values to visual parameters (distance → openness) |
| EMA smoothing | Same filter technique, new application: smooth animation |
Performance Notes
| Metric | Value |
|---|---|
| SPI clock | 60 MHz |
| Full screen redraw | ~15–20 ms |
| Max frame rate | ~50–60 fps |
| Ultrasonic reading | ~30 ms (including timeout) |
| Combined loop time | ~50–80 ms per frame |
The RP2350's dual Cortex-M33 cores at 150 MHz handle this comfortably. The bottleneck is SPI transfer time, not computation.
What's Next?
- Two-eye setup: Mount two displays as robot eyes for a complete face
- Integrate with robot behavior: Show different expressions during line following, obstacle avoidance, or idle states
- Add more sensors: Use the light sensor for pupil dilation, IMU for eye movement following tilt
- Sprite-based eyes: Pre-render eye frames as bitmaps for faster, more detailed animations
Key Code Reference
import gc9a01py as gc9a01
from machine import Pin, SPI
# Display init
spi = SPI(0, baudrate=60_000_000, sck=Pin(18), mosi=Pin(19))
tft = gc9a01.GC9A01(
spi,
dc=Pin(16, Pin.OUT),
cs=Pin(17, Pin.OUT),
reset=Pin(20, Pin.OUT),
backlight=Pin(21, Pin.OUT),
width=240, height=240
)
tft.init()
# Colors (RGB565)
tft.fill(gc9a01.WHITE)
blue = gc9a01.color565(30, 80, 180)
# Drawing primitives
tft.fill(color) # Fill entire screen
tft.fill_rect(x, y, w, h, color) # Filled rectangle
tft.hline(x, y, length, color) # Horizontal line
tft.vline(x, y, length, color) # Vertical line
tft.pixel(x, y, color) # Single pixel
# Filled circle (custom)
def fill_circle(tft, cx, cy, r, color):
for y in range(-r, r + 1):
x_span = int((r * r - y * y) ** 0.5)
tft.hline(cx - x_span, cy + y, 2 * x_span + 1, color)