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)
- 전력 공급: ESP32의 3.3V 출력은 디스플레이 두 개를 감당하기에 충분하지만, 점퍼 와이어가 너무 길거나 헐거우면 전압 강하로 인해 화면이 떨릴 수 있습니다. 가급적 짧고 굵은 선을 사용하세요.
- 공유 핀 vs 독립 핀: * SCL, SDA, DC는 두 디스플레이가 사이좋게 나눠 쓰는 공유 핀입니다.
- CS와 RES는 각 눈을 따로 제어하기 위한 독립 핀입니다. (이걸 합치면 어제 겪으셨던 초기화 지옥이 다시 찾아옵니다!)
- BL (Backlight) 핀: 만약 8핀 모듈을 사용 중이시라면 BL 핀을 3.3V에 직접 연결하거나, 남는 GPIO에 연결해 밝기 조절(PWM) 용도로 쓰실 수 있습니다. (7핀 모듈은 내부적으로 VCC에 묶여 있어 생략 가능합니다.)
“SPI 통신의 효율을 극대화하기 위해 클럭과 데이터 라인은 공유하되, 각 디스플레이의 개별 제어를 위해 CS와 Reset 라인을 분리하여 하드웨어적 안정성을 확보했습니다.”


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")



답글 남기기