手乗りサイズ頭の作成(コントローラ入力から眼球1つの動作まで)

最近は手乗りサイズのドールヘッドに動く瞳を搭載する試みを進めています。

ガラスアイなどをサーボモータで動かすのもまた面白いですね。ただし一方で機構が小型で複雑。こちらはもう少し技術力高めてから挑みます。

今回は小型ディスプレイを2枚埋め込んでの実現を試みます。

振り返り含めて、つらつらとコードなどなど書いていきますので、新規に触れる人がいてもこれ読めば比較的簡単に実装できるのではないかなぁと。

事の発端はこういった商品をみつけまして、このディスプレイを使って動く瞳を実現してみたいと考えました。

www.waveshare.com

RaspberriPi picoでよく知られているrp2040を用いていて、それに240x240ipsディスプレイをspiで繋げて一体の商品とした代物ですね。少し前は3000円程度で買えた記憶なのですが、どうにも値上がりしています。

今回の目標としている構成は、
USBコントローラ-[USB]→PC(RaspberryPi)-[USBx2]→RP2040-LCD-1.28

といった感じです。コントローラのアナログスティックの値をPCで読み込み、PCから2枚のRP2040-LCD-1.28へ信号を送る方式ですね。

RaspberryPiを使うならspi出力で2枚のディスプレイの描画した方が手っ取り早いかもしれません。

ディスプレイドライバICにはGC9A01が搭載されており、CircuitPython用のライブラリには眼球描画のデモが用意されています。ありがたく使わせていただきましょう。
プログラムに精通している方ならここだけで十分で、以降の記事は読む必要ないかもですね。

github.com

眼球描画に必要な素材は背景のeyeballと動くirisの、2つのbmp

eyeballを背景として、透過処理したirisが動き回ることで眼球の2軸回転を再現しています。

しかしpngなどと違いbmpは透過背景の指示が規格化されておらず、プログラムごとに設定やる必要があります。

iris_pal.make_transparent(0)  # palette color #0 is our transparent background

とコード内にあるように、カラーパレットの#0を背景として設定しているようですね。

素材を新たに用意する場合はGIMPなどのソフトを使ってカラーパレットの順番を操作してやりましょう。

やり方はadafruitのこのページが丁寧です。

learn.adafruit.com

また、今回の画像描画に用いているライブラリ「adafruit_imageload」ではBMPは16bit以下でなければ読み込めない等の問題もあります。32bitだったかも?

GIMPであれば

画像→モード→インデックス→最大色数を指定して変換

で問題なく変換できたかと思います。

次はPC-rp2040間の通信です。こちらにはUSB_CDCライブラリを用います。GPIOを触らずUSBでシリアル通信が実現できるのでお手軽です。

注意することとしてはcode.py内にusb_cdcをenableするよう記述してもうまく機能しません。

新たにboot.pyを用意してやり

import usb_cdc
usb_cdc.enable(console=True, data=True)    # Enable console and data

といった具合に記述してあげましょう。
こうすることで2つ目のUSB CDCが動いてくれます。
例えばprint()などでコンソールに表示していたポートがCOM1とすると、USBでのデータのやり取り用のポートを新たにCOM2といった形で用意してくれるんですね。
具体的なCOM番号はデバイスマネージャーで確認しましょう。

COM1のままに通信やらせてくれよと思っちゃいますが、RELPに割り当てられているだとか混戦するだとか。よくわからん!

さて、PCから描画する瞳の座標2値が送られてくるとして、それを受け取り、描画します。コードは以下の通り。

CircuitPythonなので、code.pyとして書き込みましょう。

import time, math, random
import board, busio
import displayio
import adafruit_imageload
import gc9a01
import usb_cdc

if usb_cdc.data is None:
    print("Need to enable USB CDC serial data in boot.py!")
    while True:
        pass

usbl = usb_cdc.data
usb_cdc.data.timeout = 5

displayio.release_displays()

dw, dh = 240,240  # display dimensions
position_x=0
position_y=0

# load our eye and iris bitmaps
eyeball_bitmap, eyeball_pal = adafruit_imageload.load("imgs/eye0_ball2.bmp")
iris_bitmap, iris_pal = adafruit_imageload.load("imgs/eye0_iris0.bmp")
iris_pal.make_transparent(0)  # palette color #0 is our transparent background

# compute or declare some useful info about the eyes
iris_w, iris_h = iris_bitmap.width, iris_bitmap.height  # iris is normally 110x110
iris_cx = dw//2 - iris_w//2 + 20
iris_cy = dh//2 - iris_h//2
r = 20  # allowable deviation from center for iris

# rp2040-lcd-1.28 pin assign
tft_clk  = board.GP10
tft_mosi = board.GP11
tft_rst = board.GP12
tft_dc  = board.GP8
tft_cs  = board.GP9
tft_bl  = board.GP25
spi0 = busio.SPI(clock=tft_clk, MOSI=tft_mosi)

def read_data():
    data = usb_cdc.data.readline()
    return data

if usb_cdc.data is None:
    print("Need to enable USB CDC serial data in boot.py!")

def clamp(n, smallest, largest):
    return max(smallest, min(n, largest))
        
# class to help us track eye info (not needed for this use exactly, but I find it interesting)
class Eye:
    def __init__(self, spi, dc, cs, rst, rot=0, eye_speed=0.25, twitch=2):
        display_bus = displayio.FourWire(spi, command=dc, chip_select=cs, reset=rst)
        display = gc9a01.GC9A01(display_bus, width=dw, height=dh, rotation=rot, backlight_pin=tft_bl)
        main = displayio.Group()
        display.show(main)
        self.display = display
        self.eyeball = displayio.TileGrid(eyeball_bitmap, pixel_shader=eyeball_pal)
        self.iris = displayio.TileGrid(iris_bitmap, pixel_shader=iris_pal, x=iris_cx,y=iris_cy)
        main.append(self.eyeball)
        main.append(self.iris)
        self.x, self.y = iris_cx, iris_cy
        self.tx, self.ty = self.x, self.y
        self.next_time = time.monotonic()
        self.eye_speed = eye_speed
        self.twitch = twitch

    def update(self):
        self.x = self.x * (1-self.eye_speed) + self.tx * self.eye_speed # "easing"
        self.y = self.y * (1-self.eye_speed) + self.ty * self.eye_speed
        self.iris.x = int( self.x )
        self.iris.y = int( self.y )
        self.tx = iris_cx + position_x
        self.ty = iris_cy + position_y
        self.display.refresh()

# a list of all the eyes, in this case, only one
the_eyes = [
    Eye( spi0, tft_dc, tft_cs,  tft_rst, rot=0),
]

while True:
    for eye in the_eyes:
        data = read_data().decode('utf-8').strip()
        if data:
            try:
                position_x, position_y = [int(x) for x in data.split(',')]
            except ValueError:
                pass
        clamp(position_x,-r,r)
        clamp(position_y,-r,r)
        eye.update()
time.sleep(0.1)

RaspberryPi側

import serial
import random
import time
import pygame

#comports = serial.tools.list_ports.comports()
device_port = '/dev/ttyACM1'
pygame.init()

# コントローラの初期化
pygame.joystick.init()
joystick = pygame.joystick.Joystick(0)
joystick.init()

# connect to the device
device = serial.Serial(device_port, baudrate=115200, timeout=0.1)

x=0
y=0
x_past=0
y_past=0
flag = True

# generate random x and y values to send to the device
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
    #x = random.randint(-55, 55)
    #y = random.randint(-55, 55)
    x = int(joystick.get_axis(0) * 50)
    y = int(joystick.get_axis(1) * 50)
    if abs(x - x_past) > 1 or abs(y - y_past) > 1:
     flag = True
#     print("T")
    else:
     flag = False
#     print("F")
    # send the x and y values to the device
    if flag:
     device.write("{},{}\n".format(x, y).encode())
     print(x,y)
    x_past = x
    y_past = y
    time.sleep(0.1)

WindowsだとCOM1とかですけどもUbuntuとかだと/dev/ttyACM1とかになります。あとはUSB接続の順番で個体名とポート名が一致しなくなることがあります。接続手順をルールするか、udevのルールファイル?を作ってやることで回避できる模様。ディスプレイを2台接続するときに試しましょう。

しかし、どうにも描画はスムーズじゃないですね。改善余地アリですが、なかなか難しい。改善案教えてください。

 

さてここで新たな問題が。

CircuitPythonのrp2040をUSB接続したままRaspberryPiを起動しようとするとうまく立ち上がりません。
エラーコードを見るにおそらくUSBブートを試みて失敗という感じでしょうか。
CircuitPythonだから立ち上がらないのか、ほかにUSBメモリ接続しておいて先に読み込むようにしておきブートするか、あるいはRaspberryPiのバージョンを2とか3とかに落とすか。ほかに取りうる手段あるでしょうが今回はRaspberryPi起動後に物理スイッチオンしてUSB接続するという力業で回避します。
USBbootのEnableできればいいんですけどね。需要ないんでしょう…

ともかく!コントローラからRaspberryPiを経てrp2040に眼球描画して動かすということは達成できました。

次の目標は2台に増やす(これはあんまり難しくなさそう)

さらに次は頭を完成させる。3Dモデリング頑張ろう。

もう一段階先の目標は首3軸を動かす!