はじめに
先日、Open Hack U 2025 OSAKAというLINEヤフーさんが開催している学生ハッカソンへ参加してきました。
詳細は以下のWebサイトに掲載されていますので詳細はこちらをご覧ください。
そこで発表した作品について、今回は作品の概要と実際に機械学習のデータを取れるようになるまでを説明していきたいと思います。
今回使用したコードについては以下のGitHubにまとめてありますのでぜひご覧ください。(URL等は準備が出来次第後日ここに設置しますのでしばしお待ちください。)
使った言語や技術等
C#、Unity、Python、Onnx runtime、CatBoost
onnxruntime-unity(https://github.com/asus4/onnxruntime-unity)
Meta XR All-in-One SDK(https://assetstore.unity.com/packages/tools/integration/meta-xr-all-in-one-sdk-269657?locale=ja-JP&srsltid=AfmBOoqptpTJBH5ZA7ZlUR6sxlycykmBtTSyBQUF9EFws5hZwkANFBXK)
CatBoost(https://github.com/catboost/catboost)
作成したアプリケーションについて
今回作成したアプリケーションは、Meta XR All-in-One SDKというUnityのアセットに同梱されているVirtual Keyboard(VR又はMR空間上に表示される仮想キーボード)を、タブレット等のソフトウェアキーボードのように四本の指を使って操作させることが出来るようにするという物です。
デフォルトのVirtual Keyboardでは、コントローラーでの操作(トラッキングさらえたコントローラーからRayが飛んでいるので、それをポインターとして使う)とVirtual Handでの操作(トラッキングされた手の人差し指の指先をポインターとして使う)が出来るのですが、どちらも長文を打つのに適しているとはいえません。
そこで、
特定のキーを打った手の形(手の各ランドマーク座標、回転等)を機械学習し、その際に出力される学習済みモデルを用いて、Virtual Keyboardに手が触れた際にどのような文字を打とうとしているのかを推論する
というやり方が可能なのではないかと考えました。
つまり、今回のアプリケーションにおいては 「データを取得する」 フェーズと、「学習を実行する」 フェーズ、 「推論を実行する」 フェーズがあるということです。
本記事では先ほども述べた通り、「データを取得する」 フェーズについて説明していきます。
データを取得する方法について
今回は以下のような仕組みで行いました。
つまり、このフェーズで必要な機能は
- Meta Quest 3から得られるハンドトラッキングデータの抽出
- 押下したPCのキー情報と上記の情報の組み合わせ
となります。
では上記2つを作るために環境を構築していきましょう。
環境構築の際、私は非常に苦労したので(多分知識が少なすぎただけ)、細かく説明しようと思います。
環境の構築
今回用いたのは Unity(6000.0.38f1) です。
公式にUnity(2022.3.15f1)以降のverで使用可能と記述されていますが、少なくとも私の環境ではAndroid SDK/NDKのバージョンに関するエラーが発生し、解決が困難であったため、非推奨です。
元からあるMRのテンプレを用いてもよいのですが、不要なものを削除していくより最初から組み立てた方が楽そうだったのでそちらの方法で構築します。
まず、こちらを選択してプロジェクトを作成します。
そして、上記にあるURLから Meta XR All-in-One SDK を導入します。インストールが終わったらUnityを再起動するように勧められるので再起動します。
その後、 Interaction SDK Hand Skeleton Upgrade という画面が出てくるので Use OpenXR Hand の方を選択してください。
すると、このような画面になっているかと思います。
次にプロジェクトの設定を行っていきます。
編集→プロジェクト設定...と選択すると、ProjectSettings という名前のウインドウが出てきます。設定一覧の一番下に XR Plugin Management があるのでそれを選択。
「XRプラグイン管理をインストール」を押下すると以下のような画面になるので、PCとAndroid両方へ Oculus にチェックを入れましょう。
さて、ここまでくれば、あとは左上にある Meta XR ToolsからProject Setup Toolを選択し、PCとAndroid両方において Fix ALL と Apply All を選択しましょう。
これで、推奨される設定へ勝手に変更してくれます。
現在(2025/06/30)では、プラグインにOculus系のプラグインではなくOpenXR系のプラグインを使用することが推奨されています(以降の説明においてもOpenXRプラグインを使用しています)。以下のプラグインを使用することをお勧めします。
仮想キーボードとMRの設定
次に、今回最も大切な「仮想キーボード」とMRに適応するための設定を行います。
とはいっても、そんなに難しいものではありません。
ここからBuilding Blockを追加します。
追加する必要があるのは、"Camera Rig","Passthrough","Virtual Hand"です。
仮想キーボードの機能は廃止され、Unityのシステムキーボードオーバーレイに置き換えられました(引用元:https://developers.meta.com/horizon/documentation/unity/VK-unity-gettingstarted/?locale=ja_JP )。そのため、[Building Block] Camera Rigのインスペクターから追加する必要があります(以下の画像を参照してください)。
データを取得するプログラム
さて、ここからは、どのようにハンドトラッキングデータを抽出するようにしたか説明します。
主に、三つの情報を取得する必要があります。
- キーボードの端がどこにあるのか(Q,P,Z,Mキーがどこにあるのか)を最初に把握する
- キーの押下時、カメラの位置と手の全体の形がどこにあるのか把握する
キーボードの位置を把握する
全体的に、OVRVirtualKeyboard.cs内の以下のようなコードによって実行されています。
private void Real_keyboard_commit(string text)
{
string filePath = System.IO.Path.Combine(Application.dataPath, "Right_ringfinger_InputLog.txt");
var sb = new System.Text.StringBuilder();
sb.AppendLine("=== Input Log ===");
sb.AppendLine($"Input: {text}");
if (CNT == 0)
{
// 左人差し指の指先座標を取得
if (handLeft != null)
{
var skeleton = handLeft.GetComponent<OVRSkeleton>();
if(skeleton != null)
{
var indexTip = skeleton.Bones.FirstOrDefault(b =>
b.Id == OVRSkeleton.BoneId.XRHand_IndexTip || b.Id == OVRSkeleton.BoneId.Hand_IndexTip);
if(indexTip != null)
{
sb.AppendLine($"Left Index Finger Tip Position: {indexTip.Transform.position}");
//Qの位置を取得
}
}
}
CNT++;
}
else if (CNT == 1)
{
// 右人差し指の指先座標を取得
if (handRight != null)
{
var skeleton = handRight.GetComponent<OVRSkeleton>();
if(skeleton != null)
{
var indexTip = skeleton.Bones.FirstOrDefault(b =>
b.Id == OVRSkeleton.BoneId.XRHand_IndexTip || b.Id == OVRSkeleton.BoneId.Hand_IndexTip);
if(indexTip != null)
{
sb.AppendLine($"Right Index Finger Tip Position: {indexTip.Transform.position}\n");
//Pの位置を取得
}
}
}
CNT++;
}
else if (CNT == 2)
{
// 左人差し指の指先座標を取得
if (handLeft != null)
{
var skeleton = handLeft.GetComponent<OVRSkeleton>();
if(skeleton != null)
{
var indexTip = skeleton.Bones.FirstOrDefault(b =>
b.Id == OVRSkeleton.BoneId.XRHand_IndexTip || b.Id == OVRSkeleton.BoneId.Hand_IndexTip);
if(indexTip != null)
{
sb.AppendLine($"Left Index Finger Tip Position: {indexTip.Transform.position}");
//Zの位置を取得
}
}
}
CNT++;
}
else if (CNT == 3)
{
// 右人差し指の指先座標を取得
if (handRight != null)
{
var skeleton = handRight.GetComponent<OVRSkeleton>();
if(skeleton != null)
{
var indexTip = skeleton.Bones.FirstOrDefault(b =>
b.Id == OVRSkeleton.BoneId.XRHand_IndexTip || b.Id == OVRSkeleton.BoneId.Hand_IndexTip);
if(indexTip != null)
{
sb.AppendLine($"Right Index Finger Tip Position: {indexTip.Transform.position}\n");
//Mの位置を取得
}
}
}
CNT++;
}
else
{
if(System.IO.File.Exists(detaLogPath))
{
string detaContent = System.IO.File.ReadAllText(detaLogPath);
sb.AppendLine(detaContent);
}
}
System.IO.File.AppendAllText(filePath, sb.ToString());
}
最初の4行で、現実のキーボードを押下した際の、右手の各ボーンの座標を保存するRight_ringfinger_InputLog.txtへ書き込む準備を行っています。その後、何周したかを表す変数CNTの回数によって以下のように処理が分かれています。
- CNT=0: 左手人差し指の位置情報を記録(Qキー入力時を想定)
- CNT=1: 右手人差し指の位置情報を記録(Pキー入力時を想定)
- CNT=2: 左手人差し指の位置情報を記録(Zキー入力時を想定)
- CNT=3: 右手人差し指の位置情報を記録(Mキー入力時を想定)
- CNT>3: 別のファイル(detaLogPath)からデータを読み込んで追記
また、具体的にその位置情報をどのように取得したかを、左人差し指を例に説明します。
// 左人差し指の指先座標を取得
if (handLeft != null)
{
var skeleton = handLeft.GetComponent<OVRSkeleton>();
if(skeleton != null)
{
var indexTip = skeleton.Bones.FirstOrDefault(b =>
b.Id == OVRSkeleton.BoneId.XRHand_IndexTip || b.Id == OVRSkeleton.BoneId.Hand_IndexTip);
if(indexTip != null)
{
sb.AppendLine($"Left Index Finger Tip Position: {indexTip.Transform.position}");//Qの位置を取得
}
}
}
左手がMeta Quest 3によってトラッキングされている場合 (handLeft != null)、変数 skeletonにhandLeftのボーン情報を代入します。
skeleton.Bones.FirstOrDefault()では、人差し指の指先に該当するボーン要素がない場合はnullを返します。ですので、null出ない場合にのみ Left Index Finger Tip Positionを書き込むようにしています。また、FirstOrDefaultの中では、XRHandとHand両方のボーンIDに対応するようにはしていますが、基本的にMeta Quest 3ではXRHandが用いられるので不要です(ただしUnityの設定によるので注意すること)。
カメラの位置と手の全体の形を把握する
同じく、OVRVirtualKeyboard.cs内のGetCameraAndHandInfoによって把握しています。
コード全体は以下のようになっています。
private string GetCameraAndHandInfo()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("=== Periodic Log ===");
var cameraRig = FindObjectOfType<OVRCameraRig>();
if (cameraRig != null)
{
Vector3 leftcameraPos = cameraRig.leftEyeCamera.transform.position;
Vector3 rightcameraPos = cameraRig.rightEyeCamera.transform.position;
sb.AppendLine($"Left Camera Position: {leftcameraPos}");
sb.AppendLine($"Right Camera Position: {rightcameraPos}");
}
else
{
sb.AppendLine("Camera Rig not found.");
}
if (handLeft != null)
{
var leftSkeleton = handLeft.GetComponent<OVRSkeleton>();
if (leftSkeleton != null)
{
sb.AppendLine("--- Left Hand ---");
foreach (var bone in leftSkeleton.Bones)
{
sb.AppendLine($"Bone {bone.Id}: Position = {bone.Transform.position} , Rotation = {bone.Transform.rotation}");
}
}
}
if (handRight != null)
{
var rightSkeleton = handRight.GetComponent<OVRSkeleton>();
if (rightSkeleton != null)
{
sb.AppendLine("--- Right Hand ---");
foreach (var bone in rightSkeleton.Bones)
{
sb.AppendLine($"Bone {bone.Id}: Position = {bone.Transform.position} , Rotation = {bone.Transform.rotation}");
}
}
}
return sb.ToString();
}
カメラの座標取得に関しては、cameraRig.leftEyeCamera.transform.positionで行っていますが、手の各ボーン毎の座標取得に関しては先ほど説明したものと大差ありません。しかし、ここではすべてのボーンIDごとの座標を求める必要がある為、foreachを用いて各手のボーンをすべて出力するようにしています。
上記のコードで取得したデータ(キーボードの端の座標データを除く)は【DetaLog.txt】へ一時的に保管され、その後すべてのデータが【InputLog.txt】へと追記されます。
終わりに
#1では、データを取得するフェーズについて説明しました。実際、一番時間がかかった場所はここでした。各座標の取得方法についてどのようにしたらよいかわからなかったのが一番の原因ですね。
次回は、データを学習するフェーズについて説明していこうと思います。
拙い文章でしたが、最後まで読んでいただきありがとうございました。