投稿日:2025年12月29日
生成AIを使ってキャラクターを3Dモデル化し、アニメーションさせてみました。
来年は、最近始めたBlender を使って3Dキャラクターを制作し、3Dプリンターで出力できるレベルのスキルを身につけたいと思います。
それに先行してキャラクターに相応しいモジュールが何かないかと色々調べていたら、可愛らしい瞳を再現できるものを見つけました。
タイトルにも含まれているWaveshare 0.71インチ DualEye LCD というモジュールです。

▲DualEye LCD一式。
左が丸型ツインディスプレイ。
中央がマイコンボードへの接続ケーブル。
右がディスプレイカバー。
ディスプレイカバーはレンズになっているため、瞳の存在感を高めることができます。
Waveshareのサイトを参考に進めようとしましたが、下図のようにRaspberry Pi Picoという記述がある割にはPicoに関するドキュメントが一切見当たりません。

▲サイトの下にスクロールしてもESP32とArduinoの記述はありますがPicoに関するドキュメントは存在しません。
サポートにメールで質問してみました。
返事は下記です。
Hello, we currently do not have a program adapted for this 0.71 screen on Raspberry Pi.
This screen uses the GC9D01 driver and has an SPI communication interface.
Here is the relevant program for using it on Raspberry Pi: https://github.com/Marc4t/GC9D01
翻訳すると、
こんにちは。現在、Raspberry Pi のこの 0.71 画面に適合したプログラムはありません。
この画面は GC9D01 ドライバーを使用し、SPI 通信インターフェイスを備えています。
Raspberry Pi で使用するための関連プログラムは次のとおりです: https://github.com/Marc4t/GC9D01
だそうです。
どうしたものかとGemini PROに相談したところGC9D01込みのMycroPythhonのソースコードを出力してくれました。
下記になります。
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(20, Pin.OUT).value(1)
Pin(21, 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()▲これをMacに接続したPico WHのThonnyに記述しPico WHに保存します。
220行目のmain()関数内、221〜228行目が下表のピン接続の番号になります。
233行目、audrate=60000000、(60MHz) に設定されています。
Raspberry Pi Pico (RP2040) はこの速度を出せますが、ブレッドボードや長いジャンパーワイヤーで接続している場合、ノイズで表示が乱れる可能性があります。もし読者の環境で動かない場合のために、「表示がおかしい場合は 30000000 (30MHz) 程度に下げてみてください」と書き添えると親切です。
下表と合うようにすることが必要になります。
眼球の細かい動きなどはコメントを参照にしてください。
LCDピンとPico WHのピンの配置は下表になります。
| LCDピン | Pico Wの接続先 | 役割 |
|---|---|---|
| VCC | 3V3 | 電源 |
| GND | GND | 接地 |
| DIN | GP11 | SPIデータ |
| CLK | GP10 | SPIクロック |
| CS1 | GP9 | 左目 選択 |
| CS2 | GP13 | 右目 選択 |
| DC | GP8 | コマンド制御 |
| RST1 | GP12 | 左目 リセット |
| RST2 | GP15 | 右目 バックライト |
| BL1 | GP20 | 左目 バックライト |
| BL2 | GP21 | 右目 リセット |
以下はモジュールの解説になります。

▲裏側。
片方のディスプレイサイズ160x160pxの明記、GC9D01はドライバチップの名称。
中央部はLCDピンになります。

▲ピンにケーブルを接続しました。

▲上表を参考にPico WHに接続します。
注意としてケーブルの接続が終わってからMacに繋げるなど、電源をONにしてください。
電源をONの状態でケーブルを抜き差しすると基盤に不慮の電圧がかかり破損の原因になります。

▲接続できました。
大きさの比率はこんな感じです。
Pico WHをMacに接続し動かしてみました。
何かに背中を押されるように今回のものを作り上げました。
冒頭にも書きましたが、来年はBlender を使って3Dキャラクターを制作し、3Dプリンターで出力できるレベルのスキルを身につけたいと考えています。
さらに、出力した3Dキャラクターを自由に動かせるようになればと思っています。
現実的にどこまで可能かは分かりませんが、まずは表情アニメーションの一環として、瞳が動く仕組みを作ってみました。
今回はPico WHを使用しましたが、同じくPython系のプロセッサを搭載した、より小型のボードであるPicossci 2 Tiny(RP2350A) でも試してみたいと考えています。
最後までお読みいただき、ありがとうございました。







