3DVtuber向けの安価な無線ハンドトラッキングデバイスを作る。【IllumiTrack】
IllumiTrackを開発するに至る経緯
最近の3DVtuberのおうちフルトラ配信事情を見ていると大半がmocopiを使用し、一部カメラフルトラ、最近だと中国発のRebocapを利用してモーションキャプチャを行い配信みたいなパターンが増えてきています。しかしカメラフルトラを除いてハンドトラッキングまで無線でトラッキングしてる人はごく少数。
大体はカメラでハンドトラッキングを行う、有線のleapmotionを利用するなど。有線やカメラ方式は行動範囲やトラッキング範囲が制限される…なんてこともしばしば。
stretch sense(個人向け)やcontactGloves、Hi5などVR利用も含めた個人向け製品はいくつか出てますがまだまだ高価です。5万以下の製品は未だ出てこず。あくまで個人的な意見でしかないですがVtuber市場よりVR市場の方が市場価値は高いので個人Vtuber向け製品を開発する企業は少ないのかなと思ったり。IMU方式は今後開発進めるとして、まずは安価な手の回転、指の曲げが出来るデバイスが広く普及してくれたらもう少し個人Vのおうちフルトラ配信もやりやすくなるのではないかと考えてIllumiTrackの開発を始めました。
前回の試作品から学んだこと
以前私はオープンソースのLucidGlovesとSlimeVRを利用した無線のデータグローブを作成しました。
おうちフルトラ配信でも何度か使用していく内に課題点が見えてきたのでこの度、新しく無線式ハンドトラッキングデバイスを開発することにしました。
課題点は曲げセンサーのコストと耐久性、本体の大きさや重量、またSlimeVRを利用するとmocopiなどと組み合わせにくい欠点がありました。
あとやはりグローブは指が覆われてしまうので何かと不便だなと感じました。ですので今回はmocopiとの連携を視野にグローブ不要軽量かつ小型、高耐久なデバイスを目指します。
自作する場合のコストですが前回片手8000円ほどでしたので今回は片手5000円ほどにしたいと思います。
部品の選定
指のセンサーについて
曲げセンサーに関しては安価な市販品でも700円ほど。最近2段階曲げ可能な曲げセンサーが発売されましたがこちらは2000円。5本で1万円…正直消耗品として考えると高いなと思います。700円の曲げセンサーも根元の端子部分が弱く長期間使用するにはあまり向かないなと感じたので今回は複数のセンサーを検証し選定。
今回開発したIllumiTrackはESP32のADC入力(アナログ入力をデジタル信号に変換する)を利用する為、0v-3.3vに可変できるセンサーであればなんでも良いです。もちろん曲げセンサーも可。
静電容量式タッチセンサーのTTP223ですが人間の身体は導体として働くため、微弱な電流が流れます。それを利用してオンかオフかを判別します。しかしこのセンサーモジュールは中間値は取れない為パー又はグーにしかなりません。反応速度はかなり良かったです。
TEMT6000は明るさが強くなるにつれて信号が強くなります。こちらは中間値も取れる為グーパー中間曲げが可能です。しかしそれなりの照明の明るさが無いと信号が流れない為ボツになりました。部屋の照明では暗かった。
CdSセルは硫化カドミウム(CdS)を主成分とする光導電素子の一種で、光の当たる量によって抵抗値が変化します。反応速度はそこまで遅くなくある程度の照明輝度と完全に暗くすることが出来ればグーパー中間曲げが出来るので今回はこちらを採用しました。あと何より両手でも300円とコスパが良い。
因みにcontactsheetやyubitoraは静電容量式のセンサーを利用してるみたいですね。私もセンサーから作りたい(なおコスト)
WiFiモジュールについて
前回はESP32Wroom32を使用しWiFiとBT両方接続するかたちをとりましたがLDOやら起動電流で悩まされたので今回はバッテリー駆動前提で設計されていてADC入力が豊富かつ充電回路が組み込まれているXiao Speed ESP32S3モジュールにしました。外部アンテナ方式ですが本体は非常に小さいです。
また、今回はWiFiを経由しOSCのみで通信を行います。
Xiao Speed ESP32S3を採用して分かった課題点は以下にまとめます。
使用上特に支障はないですが扱いにくいので製品版では別モジュールを検討
・ほかのモジュールに比べると発熱が少し大きい(WiFi接続や充電時)
・バッテリーの充電電流が100mAと小さすぎる為充電に時間がかかる
(せめて200‐300mAは欲しい。充電回路保護回路が別で必須か)
・アンテナが若干不安定。アンテナを物理的に押すと全くつながらなくなる(ケースに余裕を持たせる必要がある)
・外部に充電回路を付けないで使用する場合ESP32S3に接続するバッテリー側に電源スイッチを付ける必要があるので充電中は起動状態になってしまう。
IMUセンサーについて
手の回転はIMUセンサーを使用します。安価なMPU6500やBMI160でも良いですがドリフトを気にするくらいならと今回はBNO080、BNO085前提で設計しています。
7/3 MPU6050に対応しました。
本体について
グローブ不採用にした結果手の甲にパーツを固定配置すればよいのではと思い試作してみましたが中指の骨が擦れて痛いのでボツに。
今の所握る方式がCdSの抵抗値変化に必要な明暗がしっかり取れる状態なのでSwitchのコントローラーのような形状で落ちつきました。類似製品がチラホラあるので本当は避けたかったのですが結果この形状が良いという判断に。
ここからはハードウェア、ファームウェア、ソフトウエア、に分けて紹介していきます。
ハードウェア
ファームウェア
去年、めんたいさん(@d52425)が独自に開発していたデータグローブ向けファームウェアをベースにAPモードを使用しWEBサーバーから送信先IPアドレス、ポート設定を保存する機能やIMUのクォータニオンの送信、バッテリー電圧の送信、EPPROMリセット信号の受信を追加しました。また、めんたいさんにデバッグご協力いただきました。ありがとうございます。
ハードウェアを販売した場合でもユーザー側で初期設定や初期化などなるべく複雑にならない様にAPモードでの初期設定や専用アプリからのEPPROMリセット機能を実装しております。
#include <Arduino.h>
#include <ArduinoOSCWiFi.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <EEPROM.h>
#include <SparkFun_BNO080_Arduino_Library.h>
#include <esp32-hal-cpu.h>
BNO080 myIMU;
#define I2C_ADDRESS 0x4B // I2Cアドレスを指定
#define BATTERY_PIN 7 // バッテリー電圧を読むピンの定義
//左手PIN定義
#define LEFT_LITTLE 1
#define LEFT_RING 2
#define LEFT_MIDDLE 3
#define LEFT_INDEX 4
#define LEFT_THUMB 9
//右手PIN定義
//#define RIGHT_LITTLE 1
//#define RIGHT_RING 2
//#define RIGHT_MIDDLE 3
//#define RIGHT_INDEX 4
//#define RIGHT_THUMB 9
//バッテリーの計測
float readBatteryVoltage() {
int sensorValue = analogRead(BATTERY_PIN);
float measuredVoltage = sensorValue * (3.3 / 4095.0); // ADC値を電圧に変換
float actualVoltage = measuredVoltage * ((220.0 + 100.0) / 100.0); // 実際の抵抗値を入力
float calibrationFactor = 1.055; // 調整後の校正係数
return actualVoltage * calibrationFactor; // 校正係数で調整
}
// グローバル変数の宣言を確認
extern char osc_host[16]; // OSCホストのアドレス
extern int osc_send_port; // OSC送信ポート
void sendBatteryLevel(const char* address) {
float batteryVoltage = readBatteryVoltage(); // バッテリーの電圧を読み取る
OscWiFi.send(osc_host, osc_send_port, address, batteryVoltage); // OSCで電圧データを送信
}
// WiFi settings are now stored in EEPROM
String ssid = "ssid"; // Default SSID
String password = "pass"; // Default Password
char osc_host[16] = "192.168.x.xx";
int osc_send_port = 3333;
WebServer server(80);
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), password.c_str());
Serial.print("Connecting to WiFi..");
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 7) {
Serial.print(".");
delay(1000);
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nConnected!");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nFailed to connect, reverting to AP mode");
WiFi.disconnect();
WiFi.mode(WIFI_MODE_AP);
WiFi.softAP("ESP32_Config", "configpass");
Serial.println("AP Mode: Connect to WiFi ESP32_Config with password 'configpass'");
Serial.println("Open a browser and go to 192.168.4.1 to configure.");
}
}
void resetSettings() {
// EEPROMの内容をクリア
for (int i = 0; i < 512; i++) {
EEPROM.write(i, 0);
}
EEPROM.commit();
Serial.println("Settings reset to default. Rebooting...");
delay(1000);
ESP.restart();
}
void setup() {
setCpuFrequencyMhz(80);
Serial.begin(115200);
EEPROM.begin(512);
Wire.begin(5, 6); // Set SDA and SCL to pins 5 and 6
if (!myIMU.begin(I2C_ADDRESS, Wire)) {
Serial.println("BNO085 not detected at the specified I2C address. Check your connections.");
for (int retries = 0; retries < 3; retries++) {
delay(1000); // Delay to allow the BNO080 to properly start
Serial.println("Attempting to reconnect to BNO085...");
if (myIMU.begin(I2C_ADDRESS, Wire)) {
Serial.println("BNO085 reconnected successfully.");
break;
}
}
} else {
Serial.println("BNO085 detected successfully.");
}
// Enable required sensors and report modes
myIMU.enableGyro(100);
//myIMU.enableAccelerometer(200);
//myIMU.enableMagnetometer(100);
myIMU.enableRotationVector(100); // 50Hz update rate
ssid = EEPROM.readString(0);
password = EEPROM.readString(100);
String host = EEPROM.readString(200);
osc_send_port = EEPROM.readInt(216);
if (!host.isEmpty()) {
strncpy(osc_host, host.c_str(), sizeof(osc_host) - 1);
osc_host[sizeof(osc_host) - 1] = '\0';
}
// SSIDまたはパスワードが空の場合、またはリセット直後はAPモードを強制
if (ssid.length() == 0 || password.length() == 0) {
WiFi.softAP("ESP32_Config", "configpass");
Serial.println("AP Mode: Connect to WiFi ESP32_Config with password 'configpass'");
Serial.println("Open a browser and go to 192.168.4.1 to configure.");
} else {
connectWiFi();
}
server.on("/", HTTP_GET, [&]() {
String html = "<!DOCTYPE html><html><body>"
"<h1>ESP32 Configuration</h1>"
"<form action=\"/config\" method=\"post\">"
"SSID: <input type=\"text\" name=\"ssid\" value=\"" + ssid + "\"><br>"
"Password: <input type=\"password\" name=\"pass\"><br>"
"OSC Host: <input type=\"text\" name=\"host\" value=\"" + String(osc_host) + "\"><br>"
"OSC Port: <input type=\"number\" name=\"port\" value=\"" + String(osc_send_port) + "\"><br>"
"<input type=\"submit\" value=\"Save\">"
"</form></body></html>";
server.send(200, "text/html", html);
});
server.on("/config", HTTP_POST, [&]() {
ssid = server.arg("ssid");
password = server.arg("pass");
strncpy(osc_host, server.arg("host").c_str(), 15);
osc_send_port = server.arg("port").toInt();
EEPROM.writeString(0, ssid);
EEPROM.writeString(100, password);
EEPROM.writeString(200, server.arg("host"));
EEPROM.writeInt(216, osc_send_port);
EEPROM.commit();
server.send(200, "text/plain", "Config saved! Rebooting...");
delay(1000);
ESP.restart();
});
server.begin();
pinMode(LEFT_LITTLE, INPUT);
pinMode(LEFT_RING, INPUT);
pinMode(LEFT_MIDDLE, INPUT);
pinMode(LEFT_INDEX, INPUT);
pinMode(LEFT_THUMB, INPUT);
//pinMode(RIGHT_LITTLE, INPUT);
//pinMode(RIGHT_RING, INPUT);
//pinMode(RIGHT_MIDDLE, INPUT);
//pinMode(RIGHT_INDEX, INPUT);
//pinMode(RIGHT_THUMB, INPUT);
}
void loop() {
server.handleClient();
// OSCメッセージの受信を継続的に確認
OscWiFi.parse();
// Check battery level and manage WiFi connection
float batteryVoltage = readBatteryVoltage();
if (batteryVoltage <= 3.2) {
Serial.println("Battery voltage low, disconnecting WiFi...");
WiFi.disconnect(true);
} else {
}
OscWiFi.subscribe(8888, "/ESP32/ResetLeftHandEEPROM", [](OscMessage& msg) {
if (msg.arg<int>(0) == 1) {
Serial.println("Reset signal received, resetting EEPROM...");
resetSettings();
}
});
//OscWiFi.subscribe(8888, "/ESP32/ResetRightHandEEPROM", [](OscMessage& msg) {
// if (msg.arg<int>(0) == 1) {
// Serial.println("Reset signal received, resetting EEPROM...");
// resetSettings();
// }
//});
// 左手のバッテリー電圧を読み取り、OSCで送信
sendBatteryLevel("/Left/Hand/Battery/Level");
// 右手のバッテリー電圧を読み取り、OSCで送信
//sendBatteryLevel("/Right/Hand/Battery/Level");
if (WiFi.status() == WL_CONNECTED) {
IPAddress ip = WiFi.localIP();
String ipString = String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]);
// 左手のIPアドレスを送信
OscWiFi.send(osc_host, osc_send_port, "/Left/IPAddress", ipString);
// 右手のIPアドレスを送信
//OscWiFi.send(osc_host, osc_send_port, "/Right/IPAddress", ipString);
float lit = analogRead(LEFT_LITTLE);
float rng = analogRead(LEFT_RING);
float mid = analogRead(LEFT_MIDDLE);
float idx = analogRead(LEFT_INDEX);
float tmb = analogRead(LEFT_THUMB);
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Little", lit);
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Ring", rng);
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Middle", mid);
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Index", idx);
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Thumb", tmb);
//float lit = analogRead(RIGHT_LITTLE);
//float rng = analogRead(RIGHT_RING);
//float mid = analogRead(RIGHT_MIDDLE);
//float idx = analogRead(RIGHT_INDEX);
//float tmb = analogRead(RIGHT_THUMB);
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Little", lit);
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Ring", rng);
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Middle", mid);
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Index", idx);
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Thumb", tmb);
}
if (myIMU.dataAvailable()) {
float quatI = myIMU.getQuatI();
float quatJ = myIMU.getQuatJ();
float quatK = myIMU.getQuatK();
float quatReal = myIMU.getQuatReal();
//左手の回転OSC送信メッセージ
OscWiFi.send(osc_host, osc_send_port, "/Left/Hand/Orientation", quatReal,quatI, quatJ, quatK);
//右手の回転OSC送信メッセージ
//OscWiFi.send(osc_host, osc_send_port, "/Right/Hand/Orientation", quatReal,quatI, quatJ, quatK);
}
delay(10);
}
三峰スズさんがMPU6050のファームウェアの左右の切り替えを簡単に出来るようにしてくださいました。
1が左手0が右手になります。コピペしてお使いください。本当にありがとうございます!
ソフトウエア
IllumiTrackより送信されたOSCメッセージの処理を行いスマホから受信したmocopiのモーションと合体させる専用アプリを開発しました。
ハンドトラッキングとmocopiの合成したモーションをVMCprotocolで送信できます。また、指の開度やスムージング調整、各トラッカーのバッテリー残量が確認できます。他のPCで利用する際などトラッカー側の送信先IPアドレスの変更時には初期化ボタンでESP32のEPPROMリセットがかけられます。
IllumiTrack 試作品の作り方
【ご注意・免責事項】
IllumiTrack DIYKitはあくまで製品発売前の試作品であり、開発者本人が組み立て、使用し大きな問題がない状態を確認した上で公開、販売しておりますがあくまで同人ハードなので本Kitを使用した事による損失損害についての責任を負うことは出来ません。購入者の責任において使用してください。ご理解の上購入し作成してください。
必要な部品
IllumiTrack DIYKit1個
センサーBNO080又はBNO085 2個 MPU6050も可
(指の曲げだけトラッキングしたい人は不要)
BNO080
BNO085
CdSセル 10個
Seeed Studio XIAO ESP32S3 2個
Lipoバッテリー保護回路付き(USB給電の方は不要)
サイズ30x40x10以下、容量500mah以下推奨、PHコネクタpitch2.0㎜。
+−端子の極性に注意!必要であればバッテリー側のケーブルを入れ替えてください。またLipoバッテリーの扱いには十分に注意してください。穴あけや強い衝撃を絶対に与えないように!
両手あたりバッテリー込で9000円ほどで作成可能です。(送料加味せず)
組み立て
長くなりますが動画を撮影しました。参考にして下さい。
https://www.youtube.com/watch?v=vNWg3om39Lk
①IllumiTrack PCBにESP32S3をはんだ付けする
(バッテリー端子には薄く予備はんだしておく)
②CdSとIMUセンサーをはんだ付けする(BNO080、085、MPU6050はVCC、GND、SCL、SDAのみはんだで問題ないです。また背面のブリッチははんだ不要)
③スイッチとコネクタをはんだ付けする
④アンテナを装着する
⑤バッテリーを接続する
⑥ケースを装着する
(アンテナがなるべく干渉しない位置に調整する)
⑦ネジで固定してバンドを巻いたら完成
ファームウェア書き込み
①VScodeのダウンロード後インストールを行う
②IllumiTrackFWのダウンロード解凍を行う。https://kirisamenanoha.booth.pm/items/5867856
③VScodeを起動し左タブのEXTENSIONSでPratformIO IDEをインストールしてください。インストール後Enableになっていることも確認。
④File→OpenFolderから解凍したフォルダを開く。
⑤USBケーブルでPCと接続し下の青いバーのアップロードを押す。(→)
⑥エラーになる場合、USBポートを変える、ケーブルを変える、ケースを外しBootボタンを押しながらUSBケーブルを接続しアップロード出来るか試してください。
⑦画像のシリアルコンソールのように表示されれば書き込みは完了です。
⑧MPU6050について
MPU6050用ファームウェアをビルドするとライブラリが足りないとエラーが出るのでplatformIOタブを開き左の一覧からlibraryを選択検索欄にjrowberg/I2Cdevlib-MPU6050@^1.0.0を入力。一覧からライブラリをクリックしてAdd to Projectを押して現在のプロジェクトに追加してください
初期設定
①バッテリ搭載したのみ電源スイッチを矢印の方向に入れる、もしくはUSB給電を行いスマホ、タブレット、ノートPC何れかのWiFi設定を開きESP32_Configを選択、パスワードにconfigpassと入力し接続する。(設定は1台ずつ行う)
②ブラウザを開きアドレスバーに192.168.4.1と入力しEnter
③ESP32のWEBサーバーに接続されるのでWiFiのSSID(2.4GHz)パスワード
送信先PCのIPアドレス、送信先ポート番号(基本3333にする)を入力しsaveを押す。もしSSIDパスワードを間違えて入力した場合は再度WiFi設定のESP32接続からやり直す。WiFi設定は問題なくIPアドレス、ポート番号を間違えた場合はWiFiルータの電源を切り再度ESP32にアクセスしsave後WiFiルータを起動する。
④saveが完了したらもう片方も同様に設定を行う。
トラッキングを行う
①Flexiosyncerのダウンロードと解凍を行う。
②初回起動時にアクセス許可の画面が出たら許可を押す
③VRMをロードする
④IllumiTrackの電源を入れる(バッテリー未搭載の場合はUSB給電)
(MPU系IMUセンサーは電源投入後数秒机に置いておく)
⑤手の回転指の曲げが出来るか確認。(出来ない場合ははんだ付けを確認)
⑥Mocopiを通常モードでセットアップしPCに送信する
(UDPポート番号は12351です)
⑦Mocopi受信にチェックを入れる
⑧必要に応じて手の角度、スムージング、開度を調整する。
特にキャリブレーションなどはありませんのでTposeの時に手が真横になるように手の角度を90度ずつ調整してください。スライダーや入植した値は
自動でセーブされ次回起動時に反映されます。
⑨閾値について
キャリブレーションボタンでキャリブレーションを行うことである程度
MINからMAXの値が自動で入力されるようになっています。
ESP32 のADCは入力電圧0~3.3Vに対して0~4095の値が出力されます。
部屋の明るさは照明によって変わるので部屋の照明に合わせて値を調整してください。目安ですがMinからMidMaxまでが1000から2000前後MAXが2500から3500前後だとしっかり曲げたり伸ばしたりが出来るはずです。
⑩ドリフトについてですが時間経過や誤差の蓄積によりMPU6050とMPU6500はドリフトが発生しやすいです。手が骨折したり。
酔リセット機能がまだ完璧ではない為ドリフトが発生したら手動で手の角度を指定する必要があります。大体は0度、90度、180度、-90度、-180度のどれか入力すればいい感じの角度になるはずです。詳しい方はリセット機能を実装しても良いでしょう。
⑪バッテリーと充電について
XIAOESP32S3の充電電流は1時間当たり100mahしかありません。500mahのバッテリーの場合充電に約5時間かかります。
また、消費電流もおよそ100maほどなので500mahのバッテリーを搭載の場合、IllmiTrackの稼働時間はおよそ4-5時間程度になります。
充電方法ですが電源を入れた状態でUSBケーブルを接続することでESP32S3の赤LEDが点滅に切り替わります。点滅中は充電され点滅が消灯すると充電が完了となります。充電中はIllmiTrackはWiFiに接続されPCに対してOSC送信が行われる状態になります。まれに接続出来ずにずっとAPモードが有効になり設定を行ったスマホやタブレットが接続されていることがあります。初期設定後はWiFiの設定からESP32Configの接続設定を削除するとよいでしょう。充電は目の届く範囲で行い、くれぐれも充電したまま放置などはお辞めください。(これはどのバッテリー搭載製品でも言えることですが)
実際のトラッキング
まとめ
今回は安価に無線ハンドトラッキングが行えるデバイスIllumiTrackを開発、試作しました。Mocopiと連携し全身から指まで無線でトラッキング出来るとすごく快適に表現が行えると感じました。安価にモーキャプ出来ることでおうちフルトラが更に身近になると私は考えております。
また、ファームウェア、ソフトウェアの基幹部分の開発をして下さっためんたいさん(https://x.com/d52425)本当にありがとうございました!!!めんたいのおかげで広く無線ハンドトラッキングを普及できる可能性が見えてきました。この場をお借りして感謝申し上げます。(干し芋リンク待ってます)
そして私の今年の2つ目の目標は安価なハンドトラッキングデバイスの販売になります。発売までにはまだハードウェアの課題が残っているので今後解決していきます。
今後とも霧雨なのはを応援して頂けると幸いです。XのフォローYouTubeチャンネルの登録もよろしくお願い致します。
https://twitter.com/kirisamenanoha