calvin sewornu
Published © MIT

Robot Eyes: Smooth Animated Eyes on Raspberry Pi

A lightweight robot face engine with smooth, expressive eye animations running on a Raspberry Pi and 1.8" ST7735 TFT display.

BeginnerFull instructions provided1.2 hours80
Robot Eyes: Smooth Animated Eyes on Raspberry Pi

Things used in this project

Hardware components

Raspberry Pi 5
Raspberry Pi 5
×1
1.8 Inch ST7735 LCD Display
×1
Female/Female Jumper Wires
Female/Female Jumper Wires
×1

Software apps and online services

Raspbian
Raspberry Pi Raspbian

Story

Read more

Code

robot_face.py

Python
import time
import random
from robot_eyes_engine import RobotEyes


# ============================================================
# ROBOT FACE
# Fast + Smooth + Seamless Animation
# ============================================================

eyes = RobotEyes(rotation=90)


# ============================================================
# FAST LOOK
# ============================================================

def look_left():
    eyes.look_left(0.22)


def look_right():
    eyes.look_right(0.22)


def look_center():
    eyes.look_center(0.18)


def look_up():
    eyes.look_up(0.20)


def look_down():
    eyes.look_down(0.20)


# ============================================================
# NATURAL BLINK
# ============================================================

def blink():
    eyes.blink()


# ============================================================
# QUICK DOUBLE BLINK
# ============================================================

def double_blink():

    eyes.blink()
    time.sleep(0.06)
    eyes.blink()


# ============================================================
# NATURAL LOOK AROUND
# ============================================================

def look_around():

    direction = random.choice([
        "left",
        "right",
        "up",
        "down",
        "center"
    ])

    if direction == "left":
        look_left()

    elif direction == "right":
        look_right()

    elif direction == "up":
        look_up()

    elif direction == "down":
        look_down()

    else:
        look_center()


# ============================================================
# RANDOM EXPRESSION
# ============================================================

def random_expression():

    expression = random.choice([
        "normal",
        "happy",
        "surprised",
        "angry"
    ])

    eyes.set_expression(expression)


# ============================================================
# TALKING / LISTENING STYLE ANIMATION
# ============================================================

def listening():

    eyes.normal()

    time.sleep(0.3)

    look_left()

    time.sleep(0.25)

    look_right()

    time.sleep(0.25)

    look_center()


# ============================================================
# HAPPY REACTION
# ============================================================

def happy_reaction():

    eyes.happy()

    time.sleep(0.6)

    eyes.normal()


# ============================================================
# SURPRISE REACTION
# ============================================================

def surprise_reaction():

    eyes.surprised()

    time.sleep(0.45)

    eyes.normal()


# ============================================================
# QUICK ANGRY REACTION
# ============================================================

def angry_reaction():

    eyes.angry()

    time.sleep(0.5)

    eyes.normal()


# ============================================================
# STARTUP ANIMATION
# ============================================================

def startup():

    # Start sleeping
    eyes.sleep()

    time.sleep(0.4)

    # Wake up
    eyes.normal()

    time.sleep(0.15)

    # Quick scan
    look_left()
    look_right()
    look_center()

    eyes.blink()


# ============================================================
# IDLE FACE
# ============================================================

def idle():

    # Small random movement
    look_around()

    # Short natural pause
    time.sleep(
        random.uniform(0.25, 0.7)
    )

    # Sometimes blink
    if random.random() < 0.35:

        blink()

        time.sleep(
            random.uniform(0.15, 0.4)
        )

    # Occasionally look back at center
    if random.random() < 0.45:

        look_center()

        time.sleep(
            random.uniform(0.2, 0.5)
        )


# ============================================================
# MAIN
# ============================================================

try:

    startup()

    while True:

        # ----------------------------------------
        # Mostly normal idle behavior
        # ----------------------------------------

        chance = random.random()


        # Normal looking around
        if chance < 0.60:

            idle()


        # Happy
        elif chance < 0.70:

            happy_reaction()


        # Quick blink
        elif chance < 0.82:

            blink()


        # Double blink
        elif chance < 0.88:

            double_blink()


        # Surprise
        elif chance < 0.93:

            surprise_reaction()


        # Angry
        elif chance < 0.96:

            angry_reaction()


        # Listening / scanning
        else:

            listening()


        # Very short pause before next action
        time.sleep(
            random.uniform(0.08, 0.25)
        )


except KeyboardInterrupt:

    print("\nRobot face stopped.")


finally:

    eyes.close()

robot_eyes_engine.py

Python
import time
import math
from PIL import Image, ImageDraw
import st7735


class RobotEyes:

    def __init__(self, rotation=90):

        # ==================================================
        # ST7735 DISPLAY
        # ==================================================

        self.disp = st7735.ST7735(
            port=0,
            cs=st7735.BG_SPI_CS_BACK,
            dc="GPIO24",
            backlight="GPIO22",
            rst="GPIO25",
            rotation=rotation,
            invert=False,
            spi_speed_hz=4000000
        )

        self.disp.begin()

        # Display size after rotation=90
        self.WIDTH = 160
        self.HEIGHT = 124

        # ==================================================
        # EYE SETTINGS
        # ==================================================

        self.eye_width = 38
        self.eye_height = 52

        self.left_x = 25
        self.right_x = 97

        self.center_y = 36

        # Current eye movement
        self.offset_x = 0
        self.offset_y = 0

        # Current expression
        self.expression = "normal"

        # Colors
        self.background = (0, 0, 0)
        self.eye_color = (255, 255, 255)

        self.draw()


    # ======================================================
    # CREATE IMAGE
    # ======================================================

    def create_image(self):

        return Image.new(
            "RGB",
            (self.WIDTH, self.HEIGHT),
            self.background
        )


    # ======================================================
    # DRAW ONE EYE
    # ======================================================

    def draw_eye(self, draw, x, y):

        w = self.eye_width
        h = self.eye_height

        # Apply smooth eye movement
        x += self.offset_x
        y += self.offset_y


        # --------------------------------------------------
        # NORMAL
        # --------------------------------------------------

        if self.expression == "normal":

            draw.rounded_rectangle(
                (
                    x,
                    y,
                    x + w,
                    y + h
                ),
                radius=10,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # HAPPY
        # --------------------------------------------------

        elif self.expression == "happy":

            draw.arc(
                (
                    x,
                    y + 5,
                    x + w,
                    y + h + 15
                ),
                200,
                340,
                fill=self.eye_color,
                width=6
            )


        # --------------------------------------------------
        # ANGRY
        # --------------------------------------------------

        elif self.expression == "angry":

            draw.polygon(
                [
                    (x, y + 12),
                    (x + w, y),
                    (x + w, y + h),
                    (x, y + h - 8)
                ],
                fill=self.eye_color
            )


        # --------------------------------------------------
        # SURPRISED
        # --------------------------------------------------

        elif self.expression == "surprised":

            draw.rounded_rectangle(
                (
                    x - 3,
                    y - 6,
                    x + w + 3,
                    y + h + 6
                ),
                radius=14,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # SLEEP
        # --------------------------------------------------

        elif self.expression == "sleep":

            draw.rounded_rectangle(
                (
                    x + 5,
                    y + h // 2 - 3,
                    x + w - 5,
                    y + h // 2 + 3
                ),
                radius=3,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # BLINK
        # --------------------------------------------------

        elif self.expression == "blink":

            draw.rounded_rectangle(
                (
                    x + 4,
                    y + h // 2 - 3,
                    x + w - 4,
                    y + h // 2 + 3
                ),
                radius=3,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # WINK LEFT
        # --------------------------------------------------

        elif self.expression == "wink_left":

            if x < self.WIDTH // 2:

                draw.rounded_rectangle(
                    (
                        x + 4,
                        y + h // 2 - 3,
                        x + w - 4,
                        y + h // 2 + 3
                    ),
                    radius=3,
                    fill=self.eye_color
                )

            else:

                draw.rounded_rectangle(
                    (
                        x,
                        y,
                        x + w,
                        y + h
                    ),
                    radius=10,
                    fill=self.eye_color
                )


        # --------------------------------------------------
        # WINK RIGHT
        # --------------------------------------------------

        elif self.expression == "wink_right":

            if x >= self.WIDTH // 2:

                draw.rounded_rectangle(
                    (
                        x + 4,
                        y + h // 2 - 3,
                        x + w - 4,
                        y + h // 2 + 3
                    ),
                    radius=3,
                    fill=self.eye_color
                )

            else:

                draw.rounded_rectangle(
                    (
                        x,
                        y,
                        x + w,
                        y + h
                    ),
                    radius=10,
                    fill=self.eye_color
                )


        # --------------------------------------------------
        # LOOK UP
        # --------------------------------------------------

        elif self.expression == "look_up":

            draw.rounded_rectangle(
                (
                    x,
                    y - 8,
                    x + w,
                    y + h - 8
                ),
                radius=10,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # LOOK DOWN
        # --------------------------------------------------

        elif self.expression == "look_down":

            draw.rounded_rectangle(
                (
                    x,
                    y + 8,
                    x + w,
                    y + h + 8
                ),
                radius=10,
                fill=self.eye_color
            )


        # --------------------------------------------------
        # DEFAULT
        # --------------------------------------------------

        else:

            draw.rounded_rectangle(
                (
                    x,
                    y,
                    x + w,
                    y + h
                ),
                radius=10,
                fill=self.eye_color
            )


    # ======================================================
    # DRAW BOTH EYES
    # ======================================================

    def draw(self):

        image = self.create_image()
        draw = ImageDraw.Draw(image)

        self.draw_eye(
            draw,
            self.left_x,
            self.center_y
        )

        self.draw_eye(
            draw,
            self.right_x,
            self.center_y
        )

        self.disp.display(image)


    # ======================================================
    # SMOOTH MOVEMENT
    # ======================================================

    def move(self, target_x, target_y, duration=0.5):

        start_x = self.offset_x
        start_y = self.offset_y

        fps = 30
        frames = max(1, int(duration * fps))

        for frame in range(frames + 1):

            progress = frame / frames

            # Smooth ease-in / ease-out
            smooth = (
                0.5
                - 0.5 * math.cos(progress * math.pi)
            )

            self.offset_x = int(
                start_x +
                (target_x - start_x) * smooth
            )

            self.offset_y = int(
                start_y +
                (target_y - start_y) * smooth
            )

            self.draw()

            time.sleep(1 / fps)


    # ======================================================
    # LOOK LEFT
    # ======================================================

    def look_left(self, duration=0.5):

        self.expression = "normal"

        self.move(
            -10,
            0,
            duration
        )


    # ======================================================
    # LOOK RIGHT
    # ======================================================

    def look_right(self, duration=0.5):

        self.expression = "normal"

        self.move(
            10,
            0,
            duration
        )


    # ======================================================
    # LOOK CENTER
    # ======================================================

    def look_center(self, duration=0.5):

        self.expression = "normal"

        self.move(
            0,
            0,
            duration
        )


    # ======================================================
    # LOOK UP
    # ======================================================

    def look_up(self, duration=0.5):

        self.expression = "normal"

        self.move(
            0,
            -8,
            duration
        )


    # ======================================================
    # LOOK DOWN
    # ======================================================

    def look_down(self, duration=0.5):

        self.expression = "normal"

        self.move(
            0,
            8,
            duration
        )


    # ======================================================
    # DIAGONAL LOOKING
    # ======================================================

    def look_up_left(self, duration=0.5):

        self.expression = "normal"

        self.move(
            -8,
            -7,
            duration
        )


    def look_up_right(self, duration=0.5):

        self.expression = "normal"

        self.move(
            8,
            -7,
            duration
        )


    def look_down_left(self, duration=0.5):

        self.expression = "normal"

        self.move(
            -8,
            7,
            duration
        )


    def look_down_right(self, duration=0.5):

        self.expression = "normal"

        self.move(
            8,
            7,
            duration
        )


    # ======================================================
    # EXPRESSIONS
    # ======================================================

    def normal(self):

        self.expression = "normal"
        self.draw()


    def happy(self):

        self.expression = "happy"
        self.draw()


    def angry(self):

        self.expression = "angry"
        self.draw()


    def surprised(self):

        self.expression = "surprised"
        self.draw()


    def sleep(self):

        self.expression = "sleep"
        self.draw()


    # ======================================================
    # BLINK
    # ======================================================

    def blink(self):

        previous = self.expression

        self.expression = "blink"
        self.draw()

        time.sleep(0.12)

        self.expression = previous
        self.draw()


    # ======================================================
    # DOUBLE BLINK
    # ======================================================

    def double_blink(self):

        self.blink()

        time.sleep(0.08)

        self.blink()


    # ======================================================
    # WINK
    # ======================================================

    def wink_left(self):

        self.expression = "wink_left"
        self.draw()

        time.sleep(0.3)

        self.normal()


    def wink_right(self):

        self.expression = "wink_right"
        self.draw()

        time.sleep(0.3)

        self.normal()


    # ======================================================
    # SET EXPRESSION
    # ======================================================

    def set_expression(self, expression):

        expression = expression.lower()

        valid = [
            "normal",
            "happy",
            "angry",
            "surprised",
            "sleep",
            "blink",
            "wink_left",
            "wink_right"
        ]

        if expression in valid:

            self.expression = expression
            self.draw()

        else:

            print("Unknown expression:", expression)


    # ======================================================
    # SCAN
    # ======================================================

    def scan(self):

        self.look_left(0.7)

        time.sleep(0.3)

        self.look_right(1.2)

        time.sleep(0.3)

        self.look_left(1.2)

        time.sleep(0.3)

        self.look_center(0.7)


    # ======================================================
    # ROBOT IDLE MOVEMENT
    # ======================================================

    def idle_animation(self):

        self.look_center(0.4)

        time.sleep(1.5)

        self.look_left(0.6)

        time.sleep(0.5)

        self.look_center(0.6)

        time.sleep(1)

        self.blink()

        time.sleep(1)

        self.look_right(0.6)

        time.sleep(0.5)

        self.look_center(0.6)


    # ======================================================
    # CLOSE
    # ======================================================

    def close(self):

        self.expression = "sleep"

        self.offset_x = 0
        self.offset_y = 0

        self.draw()

        time.sleep(0.2)

        try:
            self.disp.set_backlight(0)
        except:
            pass

README.md

Markdown
MIT License

Copyright (c) 2026 Calvin Sewornu Kofi Mawulorm

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files, to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.

Credits

calvin sewornu
5 projects • 3 followers
Thanks to stratosb.

Comments