手乗りサイズ頭の作成(コントローラ入力から眼球1つの動作まで)
最近は手乗りサイズのドールヘッドに動く瞳を搭載する試みを進めています。
ガラスアイなどをサーボモータで動かすのもまた面白いですね。ただし一方で機構が小型で複雑。こちらはもう少し技術力高めてから挑みます。
今回は小型ディスプレイを2枚埋め込んでの実現を試みます。
振り返り含めて、つらつらとコードなどなど書いていきますので、新規に触れる人がいてもこれ読めば比較的簡単に実装できるのではないかなぁと。
事の発端はこういった商品をみつけまして、このディスプレイを使って動く瞳を実現してみたいと考えました。
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用のライブラリには眼球描画のデモが用意されています。ありがたく使わせていただきましょう。
プログラムに精通している方ならここだけで十分で、以降の記事は読む必要ないかもですね。
眼球描画に必要な素材は背景のeyeballと動くirisの、2つのbmp
eyeballを背景として、透過処理したirisが動き回ることで眼球の2軸回転を再現しています。
しかしpngなどと違いbmpは透過背景の指示が規格化されておらず、プログラムごとに設定やる必要があります。
iris_pal.make_transparent(0) # palette color #0 is our transparent background
とコード内にあるように、カラーパレットの#0を背景として設定しているようですね。
素材を新たに用意する場合はGIMPなどのソフトを使ってカラーパレットの順番を操作してやりましょう。
やり方はadafruitのこのページが丁寧です。
また、今回の画像描画に用いているライブラリ「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)
コントローラから目キョロキョロ pic.twitter.com/S45NyR5D9w
— 宮津 (@ykch__) 2023年4月22日
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軸を動かす!