投稿日:2026年1月12日

前回 に引き続きWaveshare 0.71インチ DualEye LCD を検証したいと思います。

部品類はできるだけコンパクトにまとめたいと考え、Raspberry Pi Pico よりも小型のマイコンボードを探していました。
そこで見つけたのが RP2040-Zero です。
標準のPicoと同じRP2040プロセッサを搭載しているため、前回のプログラムも容易に移植できると考え、購入に至りました。
最初にお見せします。
今回作ったものはこちら▼です。

196_01

▲2つ購入しました。
サイズはこんな感じです。

▲表面。
電源はUSB Type-C。
RP2040-Zeroの品名とBOOT、RESETスイッチがあります。
GPIOピンはこのような配置になっています。
GP17〜GP25は裏面に配置されています。

196_03-01

▲裏面。
ラズパイのマークが刻印されているプロセッサー。
四角の基盤がGP17〜GP25とGND(接地)。

▲別途購入したピン ヘッダーを適宜に切り、はんだ付けします。

▲はんだ付け。
小さいので慎重に行います。

196_07

▲接続しました。
よりコンパクトにしたいのなら、ピンヘッダーではなく直接ケーブルをはんだ付けしても良いと思います。

Pico WHでは、BL1(左目 バックライト)をGP20に。
BL2(右目 リセット)をGP21に繋げました。
今回のRP2040-ZeroにはGP20 と GP21 がピン接続できないので、代わりにGP0 と GP1 を使用します。
改修したMicroPythonになります。

import time
import math
import random
import framebuf
from machine import Pin, SPI

# ==========================================
# 1. GC9D01 ドライバ
# ==========================================
class GC9D01:
    def __init__(self, spi, dc_pin, cs_pin, rst_pin, width=160, height=160, rotation=0):
        self.spi = spi
        self.dc = Pin(dc_pin, Pin.OUT)
        self.cs = Pin(cs_pin, Pin.OUT)
        self.rst = Pin(rst_pin, Pin.OUT)
        self.width = width
        self.height = height
        self._rotation = rotation
        self.init_display()

    def write_cmd(self, cmd, data=None):
        self.cs(0)
        self.dc(0)
        self.spi.write(bytearray([cmd]))
        if data:
            self.dc(1)
            self.spi.write(bytearray(data))
        self.cs(1)

    def write_data(self, data):
        self.cs(0)
        self.dc(1)
        self.spi.write(data)
        self.cs(1)

    def reset(self):
        self.rst(1)
        time.sleep(0.01)
        self.rst(0)
        time.sleep(0.1)
        self.rst(1)
        time.sleep(0.1)

    def init_display(self):
        self.reset()
        self.write_cmd(0xFE)
        self.write_cmd(0xEF)
        self.write_cmd(0x80, b'\xFF')
        self.write_cmd(0x81, b'\xFF')
        self.write_cmd(0x82, b'\xFF')
        self.write_cmd(0x83, b'\xFF')
        self.write_cmd(0x84, b'\xFF')
        self.write_cmd(0x85, b'\xFF')
        self.write_cmd(0x86, b'\xFF')
        self.write_cmd(0x87, b'\xFF')
        self.write_cmd(0x88, b'\xFF')
        self.write_cmd(0x89, b'\xFF')
        self.write_cmd(0x8A, b'\xFF')
        self.write_cmd(0x8B, b'\xFF')
        self.write_cmd(0x8C, b'\xFF')
        self.write_cmd(0x8D, b'\xFF')
        self.write_cmd(0x8E, b'\xFF')
        self.write_cmd(0x8F, b'\xFF')
        self.write_cmd(0x3A, b'\x05')
        self.write_cmd(0xEC, b'\x01')
        self.write_cmd(0x74, b'\x02\x0E\x00\x00\x00\x00\x00')
        self.write_cmd(0x98, b'\x3E')
        self.write_cmd(0x99, b'\x3E')
        self.write_cmd(0xB5, b'\x0D\x0D')
        self.write_cmd(0x60, b'\x38\x0F\x79\x67')
        self.write_cmd(0x61, b'\x38\x11\x79\x67')
        self.write_cmd(0x64, b'\x38\x17\x71\x5F\x79\x67')
        self.write_cmd(0x65, b'\x38\x13\x71\x5B\x79\x67')
        self.write_cmd(0x6A, b'\x00\x00')
        self.write_cmd(0x6C, b'\x22\x02\x22\x02\x22\x22\x50')
        self.write_cmd(0x6E, b'\x03\x03\x01\x01\x00\x00\x0f\x0f\x0d\x0d\x0b\x0b\x09\x09\x00\x00\x00\x00\x0a\x0a\x0c\x0c\x0e\x0e\x10\x10\x00\x00\x02\x02\x04\x04')
        self.write_cmd(0xBF, b'\x01')
        self.write_cmd(0xF9, b'\x40')
        self.write_cmd(0x9B, b'\x3B')
        self.write_cmd(0x93, b'\x33\x7F\x00')
        self.write_cmd(0x7E, b'\x30')
        self.write_cmd(0x70, b'\x0D\x02\x08\x0D\x02\x08')
        self.write_cmd(0x71, b'\x0D\x02\x08')
        self.write_cmd(0x91, b'\x0E\x09')
        self.write_cmd(0xC3, b'\x1F')
        self.write_cmd(0xC4, b'\x1F')
        self.write_cmd(0xC9, b'\x1F')
        self.write_cmd(0xF0, b'\x53\x15\x0A\x04\x00\x3E')
        self.write_cmd(0xF2, b'\x53\x15\x0A\x04\x00\x3A')
        self.write_cmd(0xF1, b'\x56\xA8\x7F\x33\x34\x5F')
        self.write_cmd(0xF3, b'\x52\xA4\x7F\x33\x34\xDF')
        
        self.set_rotation(self._rotation)
        
        self.write_cmd(0x3A, b'\x05')
        self.write_cmd(0xB0, b'\x00')
        self.write_cmd(0xB1, b'\x00\x00')
        self.write_cmd(0xB4, b'\x00')
        self.write_cmd(0x11)
        time.sleep(0.2)
        self.write_cmd(0x29)
        self.write_cmd(0x2C)

    def set_rotation(self, rotation):
        self._rotation = rotation % 4
        rotations = [0xC8, 0x68, 0x08, 0xA8]
        self.write_cmd(0x36, bytearray([rotations[self._rotation]]))

    def set_window(self, x0, y0, x1, y1):
        self.write_cmd(0x2A, bytearray([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
        self.write_cmd(0x2B, bytearray([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
        self.write_cmd(0x2C)
        
    def display_buffer(self, buffer):
        self.set_window(0, 0, self.width - 1, self.height - 1)
        self.write_data(buffer)

    def fill_screen(self, color):
        high = (color >> 8) & 0xFF
        low = color & 0xFF
        buffer = bytearray([high, low] * 160)
        self.set_window(0, 0, self.width - 1, self.height - 1)
        for _ in range(self.height):
            self.write_data(buffer)

# ==========================================
# 2. 目玉クラス
# ==========================================
class Eye:
    def __init__(self, display, shared_buffer, cx, cy, gaze_offset_x=0.0):
        self.tft = display
        self.buffer = shared_buffer
        self.fb = framebuf.FrameBuffer(self.buffer, 160, 160, framebuf.RGB565)
        
        self.cx = cx
        self.cy = cy
        
        self.pupil_w = 48
        self.pupil_h = 58
        
        self.gaze_offset_x = gaze_offset_x
        
        self.cur_x = 0
        self.cur_y = 0
        
        self.WHITE = 0xFFFF
        self.BLACK = 0x0000

    def draw_filled_ellipse(self, cx, cy, rx, ry, color):
        """塗りつぶされた楕円を描画する"""
        for dy in range(-ry, ry + 1):
            if ry == 0: break
            ratio = (dy / ry) ** 2
            if ratio >= 1.0: continue
            w = int(rx * math.sqrt(1.0 - ratio))
            self.fb.fill_rect(cx - w, cy + dy, w * 2, 1, color)

    def update(self, target_x, target_y, eyelid_h=0):
        # 視線計算
        tx = target_x + self.gaze_offset_x
        self.cur_x += (tx - self.cur_x) * 0.2
        self.cur_y += (target_y - self.cur_y) * 0.2
        
        # --- 黒目の位置計算 ---
        
        # 1. まず外側に「めいっぱい」動けるようにマージンを大きくマイナスにする
        margin_x = -35 
        margin_y = -10

        max_move_x = (160 // 2) - self.pupil_w - margin_x
        max_move_y = (160 // 2) - self.pupil_h - margin_y
        
        px = int(self.cx + self.cur_x * max_move_x)
        py = int(self.cy + self.cur_y * max_move_y)

        # 2. 【ここが修正点】内側(寄り目)に行き過ぎないように制限(クランプ)する
        # 内側への移動許容量 (中心から何ピクセルまで寄っていいか)
        inward_limit = 10 

        if self.gaze_offset_x > 0: 
            # 左目 (Left Display) の場合
            # 画面右方向(+X)が「内側/鼻側」
            if px > self.cx + inward_limit:
                px = self.cx + inward_limit
        else:
            # 右目 (Right Display) の場合
            # 画面左方向(-X)が「内側/鼻側」
            if px < self.cx - inward_limit:
                px = self.cx - inward_limit

        # --- 描画 ---
        # 1. 白目 (全画面)
        self.fb.fill(self.WHITE)
        
        # 2. 黒目 (楕円)
        self.draw_filled_ellipse(px, py, self.pupil_w, self.pupil_h, self.BLACK)
        
        # 3. キャッチライト
        catch_r = 8
        catch_x = px - 18
        catch_y = py - 18
        self.draw_filled_ellipse(catch_x, catch_y, catch_r, catch_r, self.WHITE)
        
        # 4. まばたき (白いまぶた + 黒い線)
        if eyelid_h > 0:
            h_int = int(eyelid_h)
            self.fb.fill_rect(0, 0, 160, h_int, self.WHITE)
            
            line_y = h_int
            if line_y >= 160:
                line_y = 159
            self.fb.hline(0, line_y, 160, self.BLACK)
            
        # 転送
        self.tft.display_buffer(self.buffer)

# ==========================================
# 3. メイン処理
# ==========================================
def main():
    SCK = 10
    MOSI = 11
    DC = 8
    
    CS_LEFT = 9
    RST_LEFT = 12
    CS_RIGHT = 13
    RST_RIGHT = 15
    
    Pin(0, Pin.OUT).value(1)
    Pin(1, Pin.OUT).value(1)
    
    spi = SPI(1, baudrate=60000000, sck=Pin(SCK), mosi=Pin(MOSI))

    # 初期化
    tft_left = GC9D01(spi, dc_pin=DC, cs_pin=CS_LEFT, rst_pin=RST_LEFT)
    tft_right = GC9D01(spi, dc_pin=DC, cs_pin=CS_RIGHT, rst_pin=RST_RIGHT)
    
    tft_left.set_rotation(1)
    tft_right.set_rotation(3)

    tft_left.fill_screen(0x0000)
    tft_right.fill_screen(0x0000)

    draw_buffer = bytearray(160 * 160 * 2)

    offset_in = 15   
    offset_down = 10 
    cross_eye_val = 0.40

    eye_l = Eye(tft_left, draw_buffer, 
                cx=80 + offset_in, cy=80 + offset_down, 
                gaze_offset_x=cross_eye_val)
                
    eye_r = Eye(tft_right, draw_buffer, 
                cx=80 - offset_in, cy=80 + offset_down, 
                gaze_offset_x=-cross_eye_val)

    next_move = 0
    tx, ty = 0, 0
    
    # まばたき管理変数
    blink_state = 0
    blink_h = 0
    blink_count = 0  # 連続まばたきの残り回数
    next_blink = time.ticks_add(time.ticks_ms(), 1000)

    while True:
        now = time.ticks_ms()
        
        # 視線移動
        if now > next_move:
            # 大きく動かす設定(外側へ行くための設定)
            tx = (random.random() * 2.0) - 1.0 
            ty = (random.random() * 2.0) - 1.0 
            next_move = now + random.randint(500, 2500)
            if random.random() > 0.7:
                tx, ty = 0, 0

        # --- ダブルブリンク制御ロジック ---
        if blink_state == 0: 
            if now > next_blink:
                blink_state = 1
                blink_count = 2 
        
        elif blink_state == 1: 
            blink_h += 320 
            if blink_h >= 160: 
                blink_h = 160
                blink_state = 2
                next_blink = now + 30 
                
        elif blink_state == 2: 
            if now > next_blink:
                blink_state = 3

        elif blink_state == 3: 
            blink_h -= 320 
            if blink_h <= 0:
                blink_h = 0
                blink_count -= 1
                if blink_count > 0:
                    blink_state = 4 
                    next_blink = now + 80 
                else:
                    blink_state = 0
                    next_blink = now + random.randint(1000, 3000)
        
        elif blink_state == 4:
            if now > next_blink:
                blink_state = 1

        # 描画
        eye_l.update(tx, ty, eyelid_h=blink_h)
        eye_r.update(tx, ty, eyelid_h=blink_h)

if __name__ == "__main__":
    main()

▲230、231行目がGP0、GP1への接続の記述。
その他は前回と同じです。

LCDピンRP2040-Zeroのピンの配置は下表になります。

LCDピンRP2040-Zeroの接続先役割
VCC3V3電源
GNDGND接地
DINGP11SPIデータ
CLKGP10SPIクロック
CS1GP9左目 選択
CS2GP13右目 選択
DCGP8コマンド制御
RST1GP12左目 リセット
RST2GP15右目 バックライト
BL1GP0左目 バックライト
BL2GP1右目 リセット

再度お見せします。
今回作ったものはこちら▼です。

まとめ

現在Blender を習得中です。
下図のようなキャラクターを作っており、上手くできたら3Dプリンターを購入し、今回のDualEyeモジュールを組み込んでみようと思っています。
できればAI機能を搭載し、会話しながら踊ったりさせたいのですが、もう少し先になりそうです。

がんばって、世の中を「あっ!」と驚かせるようなものを作りたいと思います。

最後まで読んでいただき、ありがとうございました。

Blenderで制作中のキャラクター。

Raspberry Piの記事