ESP32와 원형 LCD(GC9A01)를 활용한 실감 나는 로봇 눈 만들기 (feat. MicroPython)

1.28 인치 7PIN 8PIN SPI TFT LCD 모듈 GC9A01 

프로젝트 개요: 왜 원형 LCD인가?

일반적인 사각형 디스플레이와 달리 GC9A01 원형 TFT LCD는 로봇의 눈을 표현하기에 최적화된 형태를 가집니다. 하지만 두 개의 고해상도(240×240) 화면을 동시에, 그것도 자연스럽게 구동하는 데는 여러 기술적 난관이 있었습니다.

🛠️ 주요 문제 해결 및 기술적 포인트

1. SPI 통신 속도 최적화 (60MHz의 발견)

초기에는 통신 안정성을 위해 낮은 속도를 고려했으나, 고해상도 이미지를 실시간으로 뿌리기엔 역부족이었습니다.

  • 해결: SPI 클럭 속도를 60MHz로 설정하여 데이터 전송 병목 현상을 해결했습니다. 신호가 더 날카롭고 정확하게 전달되면서 화면 깨짐 없이 부드러운 프레임 전환이 가능해졌습니다.

2. 하드웨어 리셋(Reset) 핀의 분리

두 디스플레이의 리셋 핀을 하나로 묶었을 때 발생하는 초기화 오류를 해결했습니다.

  • 해결: 각 LCD에 독립적인 리셋 핀을 할당하고, 순차적으로 tft.reset()을 수행하여 컨트롤러가 확실하게 명령을 인식하도록 개선했습니다.

3. 노이즈 없는 깨끗한 부팅 (Zero-Noise Start)

전원을 넣을 때 비디오 램(VRAM)의 무작위 데이터 때문에 나타나는 ‘지직거리는 노이즈’를 잡았습니다.

  • 해결: 하드웨어 리셋 직후, 눈 이미지를 그리기 전에 tft.fill(0) 명령으로 화면을 검은색으로 즉시 초기화하는 로직을 추가하여 고급스러운 부팅 시퀀스를 완성했습니다.

4. 시차 없는 동기화: 인터리빙(Interleaving) 기법

왼쪽 눈을 다 그리고 오른쪽 눈을 그리면 발생하는 미세한 시차(약 0.1~0.2초)는 로봇의 생동감을 떨어뜨립니다.

  • 해결: 전체 이미지를 한 번에 보내지 않고, 20줄씩 번갈아 가며(Interleaving) 양쪽 눈에 쏘는 방식을 도입했습니다. 이를 통해 우리 눈에는 양쪽 눈이 동시에 움직이는 것처럼 보이는 완벽한 동기화를 구현했습니다.

5. 코믹한 감정 표현 (윙크 및 사시 동작)

단순한 시선 이동을 넘어 로봇에게 성격을 부여했습니다.

  • 기능: 한쪽 눈의 통신만 열어 수행하는 윙크, 그리고 좌우 이미지를 교차하여 시선을 모으거나 벌리는 코믹 사시(Eye Gag) 패턴을 랜덤으로 배치해 생동감을 더했습니다.

💡 연결 시 주의사항 (Troubleshooting Tips)

  1. 전력 공급: ESP32의 3.3V 출력은 디스플레이 두 개를 감당하기에 충분하지만, 점퍼 와이어가 너무 길거나 헐거우면 전압 강하로 인해 화면이 떨릴 수 있습니다. 가급적 짧고 굵은 선을 사용하세요.
  2. 공유 핀 vs 독립 핀: * SCL, SDA, DC는 두 디스플레이가 사이좋게 나눠 쓰는 공유 핀입니다.
    • CS와 RES는 각 눈을 따로 제어하기 위한 독립 핀입니다. (이걸 합치면 어제 겪으셨던 초기화 지옥이 다시 찾아옵니다!)
  3. BL (Backlight) 핀: 만약 8핀 모듈을 사용 중이시라면 BL 핀을 3.3V에 직접 연결하거나, 남는 GPIO에 연결해 밝기 조절(PWM) 용도로 쓰실 수 있습니다. (7핀 모듈은 내부적으로 VCC에 묶여 있어 생략 가능합니다.)

“SPI 통신의 효율을 극대화하기 위해 클럭과 데이터 라인은 공유하되, 각 디스플레이의 개별 제어를 위해 CS와 Reset 라인을 분리하여 하드웨어적 안정성을 확보했습니다.”

라이브러리 gc9a01.py 다운로드

Python gc10.py

import gc9a01
import time
import random
import gc
from machine import Pin, SPI
import vga2_16x32 as font
#import NotoSansMono_32 as font

# 1. 하드웨어 설정 (최적화)
spi = SPI(1, baudrate=60000000, sck=Pin(0), mosi=Pin(1))
dc = Pin(2, Pin.OUT)

# 핀 설정 (L: 3,4 / R: 5,6)
cs_L, res_L = Pin(3, Pin.OUT, value=1), Pin(4, Pin.OUT, value=1)
cs_R, res_R = Pin(5, Pin.OUT, value=1), Pin(6, Pin.OUT, value=1)

# 2. 디스플레이 객체 생성
tft_L = gc9a01.GC9A01(spi, dc=dc, cs=cs_L, reset=res_L, rotation=1)
tft_R = gc9a01.GC9A01(spi, dc=dc, cs=cs_R, reset=res_R, rotation=3)

# 이미지 경로 데이터베이스
D = "/eye"
EYES = {
    "normal": (f"{D}/normal_L.raw", f"{D}/normal_R.raw"),
    "surprised": (f"{D}/surprised_L.raw", f"{D}/surprised_R.raw"),
    "closed": (f"{D}/closed_L.raw", f"{D}/closed_R.raw"),
    "right": (f"{D}/right_L.raw", f"{D}/right_R.raw"),
    "left": (f"{D}/left_L.raw", f"{D}/left_R.raw")
}

# 🎨 표준 8색 팔레트 (RGB565)
COLORS = [
    0xFFFF, # White
    0xFFE0, # Yellow
    0x07FF, # Cyan
    0x07E0, # Green
    0xF81F, # Magenta
    0xF800, # Red
    0x001F, # Blue
    0x0000  # Black
]

def draw_color_bars():
    """화면을 8개의 세로 막대로 나눕니다."""
    bar_width = 240 // len(COLORS)
    
    for i, color in enumerate(COLORS):
        tft_L.fill_rect(i * bar_width, 0, bar_width, 240, color)
        tft_R.fill_rect(i * bar_width, 0, bar_width, 240, color)

def draw_test_pattern():
    """하단에 추가적인 그레이스케일과 원형 패턴을 그립니다."""
    # 하단 1/4 지점에 흑백 그라데이션 (간소화)
    gray_steps = 16
    step_width = 240 // gray_steps
    for i in range(gray_steps):
        # 5비트 그레이 레벨 계산
        val = i * 2
        gray = (val << 11) | (val << 6) | val
        tft_L.fill_rect(i * step_width, 180, step_width, 60, gray)
        tft_R.fill_rect(i * step_width, 180, step_width, 60, gray)

def test():
    # 실행
    print("Displaying Test Pattern...")
    draw_color_bars()
    draw_test_pattern()

    # 중앙에 십자선 (정렬 확인용)
    tft_L.line(0, 120, 240, 120, 0x0000)
    tft_L.line(120, 0, 120, 240, 0x0000)

    tft_R.line(0, 120, 240, 120, 0x0000)
    tft_R.line(120, 0, 120, 240, 0x0000)



def send_data(tft, cs, path, y_range=(0, 240, 40)):
    """한쪽 눈에 이미지를 전송하는 공통 함수"""
    cs.value(0)
    try:
        with open(path, 'rb') as f:
            for y in range(*y_range):
                tft.blit_buffer(f.read(19200), 0, y, 240, 40)
    except: pass
    cs.value(1)

def show_sync(name):
    """좌우 이미지를 번갈아 전송하여 시차 최소화 (인터리빙)"""
    pL, pR = EYES[name]
    try:
        with open(pL, 'rb') as fL, open(pR, 'rb') as fR:
            for y in range(0, 240, 20):
                # 좌측 20줄
                cs_R.value(1); cs_L.value(0)
                tft_L.blit_buffer(fL.read(9600), 0, y, 240, 20)
                # 우측 20줄
                cs_L.value(1); cs_R.value(0)
                tft_R.blit_buffer(fR.read(9600), 0, y, 240, 20)
        cs_L.value(1); cs_R.value(1)
    except: pass

def init_eyes():
    """노이즈 방지 초기화"""
    for t in [tft_L, tft_R]:
        t.reset()
        t.fill(0)
        t.inversion_mode(True)
    show_sync("normal")



def dollar():
    """양쪽 눈에 큰 달러 표시($$$)를 출력합니다."""
    for t, cs in [(tft_L, cs_L), (tft_R, cs_R)]:
        cs.value(0)
        t.fill(0) # 배경을 검은색으로
        
        # 정의에 맞게 호출: text(font, "문자열", x, y, 글자색, 배경색)
        # 16x32 폰트이므로 세 글자면 가로 48픽셀 차지합니다.
        # 중앙 배치를 위해 x=96( (240-48)/2 ), y=104( (240-32)/2 ) 정도가 적당합니다.
        t.text(font, "$$$", 96, 104, 0xFFE0, 0x0000) 
        cs.value(1)
        
    time.sleep(2.0)
#     show_sync("normal") # 다시 원래 눈으로 복귀
    
def dollar2():
    """도형으로 큼직한 달러 기호를 직접 그립니다 (폰트 불필요)"""
    for t, cs in [(tft_L, cs_L), (tft_R, cs_R)]:
        cs.value(0)
        t.fill(0x0000) # 배경 검정
        
        gold = 0xFFE0
        # 3개의 달러를 나란히 배치
        for x_off in [50, 105, 160]:
            y = 90
            # S자 모양을 사각형 조각으로 구성
            t.fill_rect(x_off, y, 30, 8, gold)           # 상단 가로
            t.fill_rect(x_off, y + 26, 30, 8, gold)      # 중단 가로
            t.fill_rect(x_off, y + 52, 30, 8, gold)      # 하단 가로
            t.fill_rect(x_off, y, 8, 30, gold)           # 왼쪽 위 기둥
            t.fill_rect(x_off + 22, y + 30, 8, 30, gold) # 오른쪽 아래 기둥
            t.fill_rect(x_off + 12, y - 10, 6, 80, gold) # 중앙 수직선 (|)
        cs.value(1)
        
    time.sleep(2.0)

def demo_show():
    """로봇의 시스템 점검 및 각성 모드 데모"""
    cyan = 0x07FF
    red = 0xF800
    black = 0x0000
    
    print("데모 모드 시작!")

    # --- 1단계: 레이저 스캔 (Scan) ---
    for _ in range(2): # 2회 반복
        for y in range(0, 240, 8):
            for t, cs in [(tft_L, cs_L), (tft_R, cs_R)]:
                cs.value(0)
                # 이전 줄 지우기 (옵션)
                t.hline(0, y-8, 240, black)
                # 스캔 라인 그리기
                t.hline(0, y, 240, cyan)
                t.fill_rect(0, y+2, 240, 2, cyan) 
                cs.value(1)
            time.sleep_ms(10)

    # --- 2단계: 타겟 조준선 회전 (Targeting) ---
    for i in range(0, 100, 10):
        for t, cs in [(tft_L, cs_L), (tft_R, cs_R)]:
            cs.value(0)
            t.fill(black)
            # 중앙 원 (조준경)
            t.fill_rect(115, 70, 10, 100, cyan) # 세로선
            t.fill_rect(70, 115, 100, 10, cyan) # 가로선
            # 외곽 사각형들
            t.fill_rect(40, 40, i, 5, red)
            t.fill_rect(200-i, 200, i, 5, red)
            cs.value(1)
        time.sleep_ms(50)

    # --- 3단계: 디지털 글리치 (Glitch) ---
    for _ in range(15):
        rx = random.randint(0, 180)
        ry = random.randint(0, 180)
        rw = random.randint(20, 60)
        rh = random.randint(10, 30)
        for t, cs in [(tft_L, cs_L), (tft_R, cs_R)]:
            cs.value(0)
            t.fill_rect(rx, ry, rw, rh, random.getrandbits(16))
            cs.value(1)
        time.sleep_ms(30)

    # 마지막은 다시 정상으로 복귀
#     show_sync("normal")
    print("데모 종료!")
    
# --- 실행부 ---

test()
time.sleep(2)
demo_show()
time.sleep(2)

# dollar2()
# time.sleep(2)
# dollar_big()
# time.sleep(2)

init_eyes()





try:
    while True:
        time.sleep(random.uniform(0.5, 2.0))
        r = random.random()
        
        if r < 0.20: # 깜빡임
            show_sync("closed"); time.sleep_ms(130); show_sync("normal")
            
        elif r < 0.30: # 왼쪽 윙크
            send_data(tft_L, cs_L, EYES["closed"][0])
            time.sleep_ms(220)
            send_data(tft_L, cs_L, EYES["normal"][0])
            
        elif r < 0.40: # 오른쪽 윙크
            send_data(tft_R, cs_R, EYES["closed"][1])
            time.sleep_ms(220)
            send_data(tft_R, cs_R, EYES["normal"][1])
            
        elif r < 0.46: # 눈 모으기 사시 😉
            send_data(tft_L, cs_L, EYES["right"][0])
            send_data(tft_R, cs_R, EYES["left"][1])
            time.sleep(1.2); show_sync("closed"); time.sleep_ms(100); show_sync("normal")
            
        elif r < 0.52: # 눈 벌리기 사시 😜
            send_data(tft_L, cs_L, EYES["left"][0])
            send_data(tft_R, cs_R, EYES["right"][1])
            time.sleep(1.0); show_sync("closed"); time.sleep_ms(100); show_sync("normal")
            
        elif r < 0.80: # 시선 이동 (좌/우)
            show_sync(random.choice(["left", "right"]))
            time.sleep(1.5); show_sync("normal")
            
        elif r < 0.90: # 놀람
            show_sync("surprised"); time.sleep(1.0); show_sync("normal")
            
        gc.collect()

except KeyboardInterrupt:
    show_sync("closed")

코멘트

“ESP32와 원형 LCD(GC9A01)를 활용한 실감 나는 로봇 눈 만들기 (feat. MicroPython)” 에 하나의 답글

  1. micro2iot 아바타

    [구매정보] 블로그에서 사용한 부품 구매정보입니다.
    아래 링크를 통해 구매 시 소정의 수수료를 제공받으며, 채널 운영에 큰 도움이 됩니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다