Skip to content

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

  1. A static eye — iris, pupil, and highlights on the round TFT
  2. Blinking animation — eyelids that close and open smoothly
  3. Distance-reactive eyes — ultrasonic sensor controls how open the eye is
  4. (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:

# From the gc9a01py/lib/ directory:
mpremote cp gc9a01py.py :lib/gc9a01py.py

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

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():

# In the main loop:
pupil_scale = 0.5 + openness * 0.5  # 0.5x when closed, 1.0x when open
PUPIL_R = int(30 * pupil_scale)

✅ 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:

def draw_angry_lid(tft, slope=0.3):
    SKIN = gc9a01.color565(255, 220, 180)
    for x in range(240):
        lid_y = int(60 + slope * (x - 120))  # Angled line
        tft.vline(x, 0, max(0, lid_y), SKIN)

✅ 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:

import math

# In main loop (when nothing nearby):
look_x = int(25 * math.sin(time.ticks_ms() / 1500))
# Pass to draw_eye and offset iris/pupil cx by look_x

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)

← ToF Lidar Radar | Advanced Topics