概要
生命感のあるロボット掃除機で触れた通り、家電や機械の生命化というテーマで制作を行っております。
机の電気スタンドがある日生き物っぽく見え笑、机で何かを行うときにパートナーの様に行動してくれたら面白いなと思い、下記の機能を実装してみました。
-
STEP1: 机に置いたオブジェクトを自動追尾して照らす
-
STEP2: PC上で流した音に合わせリアルタイムに踊る
STEP2の背景としては10~20代で色々な野外フェスに行っていたころ、特にクラブミュージックのエリアでお客さんがお酒を飲みながら思い思いに身体を揺らしている、でもそれをマクロに見ると空間全体のグルーヴが生まれているという状態がすごく楽しかったというのが原体験にあります。それをワンルーム一人暮らしでも体験できないか?特にコロナ禍の中、少しでも繋がっている感覚を得られないかという問いでもあります。
STEP1: 机上オブジェクトの自動追尾
机の上のノートを自動検出して電気スタンドの照明位置をトラッキングします。サーボモータ3個と木材でアーム部分を試作しました。
方針としては逆運動方程式を解いて照明の座標を算出させることを最初に考えましたが、照明のフード内部にカメラを配置し、常に検出物とのズレを少なくするようにフィードバックを掛けた制御を行う方向としました。
カメラで取得した画像から、対象物が常に中心になるようにx、y方向に相当するサーボモータ角度をずらしていきます。
机上のオブジェクトの検出
色々とアプローチはありますが、ここではOpenCVの色抽出機能にて「白い物(=ノートや本の紙面)」を検出し、その面積の中心点を導出するというやり方でやってみました。
この難点は、机が白い場合は通用しないので、矩型パターンの抽出や、対象物を指し示す手の形を認識させるなどが考えられます。
ヘッド部へのカメラの設置
ラズパイカメラだとFFCケーブルの長さが足りないため、Webカメラ(Amazonで一番安かったLogicool製)から基板を引っこ抜き使用しています。マイクもついているため、今後呼びかけ等によるインタラクションにも拡張が行えそうです。
Serial dataの送受信
制御の流れとしては、カメラで取得した画像からPC上で画像解析と角度情報を算出(Python)し、その結果をArduinoに送信し、ArduinoでPWMに変換し3つのサーボを動かします。
Python上でシリアルデータをArduinoに送る場合、通常1byteのデータ長だとの角度情報及びモーターの固有Noを表現できないため、マルチデータとして送り方、受け方をそれぞれ中身を定義してやる必要があります。
詳細は以降のサンプルスケッチ参照ですが下記構造のパケットとなります。
[サーボ1のヘッダー][サーボ1の上位8bit][サーボ1の下位8bit]
[サーボ2のヘッダー][サーボ2の上位8bit][サーボ2の下位8bit]
[サーボ3のヘッダー][サーボ3の上位8bit][サーボ3の下位8bit]
サンプルスケッチ
ここまで合わせたコードは下記になります。Python(PC)側と、Arduino(サーボモータのドライバとして)側とそれぞれ記載します。
Python(PC)側
import cv2
import numpy as np
import time
import serial
# 0 <= h <= 179 (色相) OpenCVではmax=179なのでR:0(180),G:60,B:120となる
# 0 <= s <= 255 (彩度) 黒や白の値が抽出されるときはこの閾値を大きくする
# 0 <= v <= 255 (明度) これが大きいと明るく,小さいと暗い
LOW_COLOR = np.array([0, 0, 180])
HIGH_COLOR = np.array([255, 100, 255])
AREA_RATIO_THRESHOLD = 0.005
now_degree_x = 560
now_degree_y = 460
move_degree_x = 0
move_degree_y = 0
val_size = 4
values = [90, 60, 40, 110]
ser = serial.Serial('/dev/cu.usbmodem141101', 19200, timeout=0.1)
def find_specific_color(frame,AREA_RATIO_THRESHOLD,LOW_COLOR,HIGH_COLOR):
"""
指定した範囲の色の物体の座標を取得する関数
frame: 画像
AREA_RATIO_THRESHOLD: area_ratio未満の塊は無視する
LOW_COLOR: 抽出する色の下限(h,s,v)
HIGH_COLOR: 抽出する色の上限(h,s,v)
"""
# 高さ,幅,チャンネル数
h,w,c = frame.shape
# hsv色空間に変換
hsv = cv2.cvtColor(frame,cv2.COLOR_BGR2HSV)
cv2.imshow('hsv',hsv)
# 色を抽出する
ex_img = cv2.inRange(hsv,LOW_COLOR,HIGH_COLOR)
cv2.imshow('ex_img',ex_img)
# 輪郭抽出
contours,hierarchy = cv2.findContours(ex_img,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
# 面積を計算
areas = np.array(list(map(cv2.contourArea,contours)))
if len(areas) == 0 or np.max(areas) / (h*w) < AREA_RATIO_THRESHOLD:
# 見つからなかったらNoneを返す
print("the area is too small")
return None
else:
# 面積が最大の塊の重心を計算し返す
max_idx = np.argmax(areas)
max_area = areas[max_idx]
result = cv2.moments(contours[max_idx])
x = int(result["m10"]/result["m00"])
y = int(result["m01"]/result["m00"])
return (x,y)
def test():
img = cv2.imread("sample.jpg")
# 位置を抽出
pos = find_specific_color(
img,
AREA_RATIO_THRESHOLD,
LOW_COLOR,
HIGH_COLOR
)
if pos is not None:
cv2.circle(img,pos,10,(0,0,255),-1)
cv2.imwrite("result.jpg",img)
def move(x_move, y_move):
#x,yの動く方向をアームのθ1、θ2、θ3のどれを動かすかに変換
theta1 = int (x_move * 180 /1200) # x_move: 0~1200
theta2 = int (180 - (y_move * 180 /1200)) # x_move: 0~1200
theta3 = 30
theta4 = 100
# limitation
if theta1 >= 180:
theta1 = 180
if theta1 <= 0:
theta1 =0
if theta2 >= 140:
theta2 = 140
if theta2 <= 60:
theta2 =60
if theta3 >= 50:
theta3 = 50
if theta3 <= 20:
theta3 =20
values = [theta1, theta2, theta3, theta4]
isValids = [False for x in range(val_size)]
print(values)
for i in range(val_size):
head = 128+i
high = (values[i] >> 7) & 127
low = values[i] & 127
headByte = head.to_bytes(1, 'big')
highByte = high.to_bytes(1, 'big')
lowByte = low.to_bytes(1, 'big')
ser.write(headByte)
ser.write(highByte)
ser.write(lowByte)
time.sleep(0.04)
#while True:
def main():
global now_degree_x
global now_degree_y
global move_degree_x
global move_degree_y
# webカメラを扱うオブジェクトを取得
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
#h,w,c = frame.shape
if ret is False:
print("cannot read image")
continue
# 位置を抽出
pos = find_specific_color(
frame,
AREA_RATIO_THRESHOLD,
LOW_COLOR,
HIGH_COLOR
)
if pos is not None:
# 抽出した座標に丸を描く
cv2.circle(frame,pos,10,(0,0,255),-1)
move_degree_x = int(now_degree_x - (pos[0] - 800)*0.3) #近づく早さ:0.1 xの中心:600とした
#move_degree_y = int(now_degree_y - (pos[1] - 500)*0.3)
move_degree_y = int(now_degree_y - (pos[1] - 500)*0.3)
print(pos[0],pos[1],move_degree_x,move_degree_y)
if move_degree_x <= 0:
move_degree_x = 0
if move_degree_x >= 1300:
move_degree_x = 1300
if move_degree_y <= 0:
move_degree_y = 0
if move_degree_y >= 1200:
move_degree_y = 1200
print(pos[0],pos[1],move_degree_x,move_degree_y)
cv2.circle(frame,(move_degree_x,move_degree_y),10,(255,0,0),-1)
# move
move(move_degree_x, move_degree_y)
now_degree_x = move_degree_x
now_degree_y = move_degree_y
# 画面に表示する
cv2.imshow('frame',frame)
# キーボード入力待ち
key = cv2.waitKey(1) & 0xFF
# qが押された場合は終了する
if key == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
if __name__ == '__main__':
main()
Arduino側
#include <Servo.h>
#define SW 12
Servo servo1;
Servo servo2;
Servo servo3;
Servo servo4;
const int val_size = 4;
int values[val_size] = {0, 0, 0, 0};
bool isValids[val_size] = {false, false, false, false};
int deg1 = 90;
int deg2 = 130;
int deg3 = 20;
int deg4 = 90;
void setup(){
Serial.begin(9600);
servo1.attach(9); //D9ピンをサーボの信号線として設定
servo2.attach(6); //D10ピンをサーボの信号線として設定
servo3.attach(5); //D11ピンをサーボの信号線として設定
servo4.attach(3);
}
void loop(){
servo1.write(deg1); // サーボの角度を制御
servo2.write(deg2);
servo3.write(deg3);
servo3.write(deg4);
//serialによる角度受信
if (Serial.available() >= 3*val_size+1) {
int head = Serial.read();
if (head == 128){
bool isValids[val_size] = {false, false, false};
}
//3servoの値読み取り
for (int i=0; i<val_size; i++){
if (head == 128+i) {
int high = Serial.read();
int low = Serial.read();
values[i] = (high<<7) + low;
Serial.print("[");
Serial.print(i, DEC);
Serial.print("]");
if (0 <= values[i] <= 1023) {
Serial.print("[");
Serial.print(values[i], DEC);
if (head == 128+val_size-1) {
Serial.println("]");
}else{
Serial.print("] ");
}
isValids[i] = true;
}
}
}
//servoへ角度指示
deg1 = constrain(values[0], 0 ,180);
deg2 = constrain(values[1], 0 ,180);
deg3 = constrain(values[2], 0 ,180);
servo1.write(deg1); // サーボの角度を制御
servo2.write(deg2);
servo3.write(deg3);
Serial.print(deg1, DEC);
Serial.print(deg2, DEC);
Serial.print(deg3, DEC);
}
}
結果としてなかなか楽しい感じに机の上のノートに追従してくれる様になりました。白色の検出閾値(彩度、輝度)、物体の移動に追従する速度、サーボモータの可動域等色々なパラメータを調整する必要はあります。
反省としてサーボのトルクに荷重が見合っておらず、そもそもフード部分が乗らなかったことと、今にも壊れそうな動きとなってしまいました。(弱々しさが逆に可愛らしいという意見がありましたが)
以下のSTEP2において骨格強化を図ります。
STEP2: オーディオリアクティブ化
ハードウェア見直し
動きの改善を行うため既製品のロボットアーム(SainSmart製)をベースに改造します。1万円ほどであり、本格的なロボットアームには及びませんが、関節全体にボールベアリングの処理や、2000円弱のサーボモータが3個も付いておりお得感があります。
このサーボモータはMG966R(トルク9.4kg・cm~)であり、STEP1でのSG90(1.8kgf・cm)と比べて剛性感もかなりアップしました(逆にゴツさが出てしまいましたが)。
この既製アームに対し、ヘッド部の作成と、骨格の作成(=可動角度の変更)を加えています。
ヘッド部の作成
ランプはLEDテープの切り貼りにより作成しました。Hueの様に色温度の変更や、オーディオと連携した明滅により気分をあげる演出なども行えます。
丸い穴2つがカメラ&マイクを設置する箇所です。こちらのプレートを下記のツメで固定します。
ソフトウェア制御
リアルタイムに音楽に応じた動きをする上で、オーディオ信号をハード制御へ変換する手法について検討しました。
一番単純なのは通常のマイクで音量レベルを拾うというものですが、リズム(BPM)の解析や、低音〜高音のスペクトラムに合わせて動き方を変えるというオーディオ解析ができた方がゆくゆくは良さそうです。
またあくまで私の環境は音楽の再生はYouTube(Webブラウザ)やiTunes等PC上で行うことが多いということから、まずはPC上で流れている音楽を扱うことを目指します。
PC上の音楽をリアルタイムにスペクトル変換し各周波数に分割して扱い、それぞれに適切なアームのモータ角への変換を行い、シリアルデータに変換し、ArduinoへUSB経由で送る、という一連の処理が必要です。
TouchDesigner
オーディオ解析周りを行うソフトとして色々と探していたところ、TouchDesignerに行きつきました。メディアアートな方々にはメジャーなソフトの様ですが、非常に機能が充実している(ゆえに初見ではとっつきづらい)、基本的にはノーコードでのシステム構築可能、海外ユーザーが多く解説サイトなど情報の裾野が広いといったところが特徴かと思っています。
結果的に本作品はかなりの領域をこのソフトで構築できました。以降で流れを解説していきます。
Webブラウザ上の音(YouTube等)のTouchDesignerへの受け渡し
Audio Outへ出ていく音を通常はPC内の他のソフトへのInputとして扱うことはできないため、Loopbackというソフトで仮想的なデバイス構成するということで解決します。
(使いやすいソフトなのですが、無料版として使用すると30分連続利用でノイズに変換されます…)
一方でTouchDesigner側でAudio DeviceとしてLoopbackを選択することで、Webブラウザの音を入力として扱うことができます。
TouchDesigner によるオーディオ解析とサーボモータ角度への変換
TouchDesigner内で処理する、機能モジュール流れとしては下記のような感じです。
audiodevin(Loopbackからの音声入力)
↓
audioAnalysis(周波数領域に分割しGain等の調整、及びここでKick,Snare取得の簡易な調整が行えました)
↓
select(Kick、Snareを切り出し0↔︎1のパルス化)
↓
math(ここでは数回のパルスで一回のループになるような前処理を加えています)
↓
audiofilter(滑らかなアームの動きになるように、ローパスフィルターを加えています)
↓
chopexec(onValueChange関数を使ってシリアルデータを生成するコードを書いています)
実際の処理になります(画面散らかったままですみません)。
Kick、Snareの音色は曲ごとにばらつきが大きく、ちゃんと拾うには曲ごとにパラメータの調整が必要であり、この辺りは機械学習の領域となってきそうですが、ひとまずは手動での調整によりSnareとKickがある程度しっかり検出されています。
動作のデザインとして、まずはSnareが2拍目4拍目に入ることが多いため、そのタイミングで首を縦に上下するような動きをさせる様に、Snareの検出パルスを角度変化に変換してみます。
一方で横ノリのゆったりした動きを表現したいため、Kickの検出を拠り所として首や足元の横回転をさせます。暫定でKick2発で首の横振り、Kick3発で腰の横回転としました。
これが最終形ではなく、人のダンスに近づけるように、全ての関節が連動する様な動きと、あとはSnare,Kickという入力からある程度BPMの推測をし、そのリズムに合わすというところが次の目標です。
最終的にSnare,Kickの検出パルスにローパスフィルタを掛けて変化をなましています。人間の体の反応(瞳孔など筋肉の収縮)に近いのがこのカーブであり、サーボモータにも優しいです。
この時定数の変更により、動きのなまし方を変えられます。
ちなみに最初ローパスフィルタなしで作ってみたときの動きはグルーヴとはほど遠い。。。↓
サーボ角度のArduinoへの受け渡し
Arduino側のコードはSTEP1と変わらずです。
TouchDesigner側でchopexecモジュールに下記関数の記載をし、
def onValueChange(channel, sampleIndex, val, prev):
head = 128
high = (int(val) >> 7) & 127
low = int(val) & 127
headByte = head.to_bytes(1, 'big')
highByte = high.to_bytes(1, 'big')
lowByte = low.to_bytes(1, 'big')
op('serial1').sendBytes(headByte,highByte,lowByte)
return
serialモジュールでUSBポートの設定を行うと、TouchDesignerから直接シリアルデータで外部ハードウェアの制御が行えます。この辺色んな使い方想定されていて本当に懐が広いです、TouchDesigner。(ただし使用している人みたこと無いので、公式ドキュメント必死で読み解きました)
結果
キメの多い曲だと楽しい感じになります。Sparkle!
生命感のある動きについては詰め切れていないので、もう少し3Dモデルでのシミュレート含めてやってみたいなと思いつつ。。。また見た目もシンプルで電気スタンドっぽいものに設計し直したいのですが、それも保留中です。
(もし面白いと感じていただける声ありましたら、ブラッシュアップしていきたいと思っています。)
ひとまず家で闇のなか一緒に踊ってみます。
…
参考文献
投稿者の人気記事
-
TKD
さんが
2021/02/16
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する