AppleWatchをハンドトラッキング補助デバイスとして使えないか試してみた

この記事はSynamon Advent Calendar 2021の15日目です。

エンジニアの岡村です。先月、Apple Watch Series7を買いました。

www.apple.com

スマートウォッチは以前から欲しいなと思っていたのですが、せっかくiPhone使ってるんだから時計もApple Watchにしたい気持ちと、太陽光発電でメンテナンスフリーの腕時計に比べるとスタミナが……という気持ちがせめぎ合い、中々買えずにいました。ところが最近持っていた腕時計の調子が悪くなってしまい、S7の発売タイミングの良さもあったため買ってしまいました。現在一番使っている機能は睡眠管理です。

せっかくApple Watchを買ったので、このアドベントカレンダーという機会に、XR的な活用ができないか考えてみました。

最近のAppleはAirPodでの空間オーディオや、AirTagによる物を探す機能など、デバイス同士の位置関係を利用した機能を幾つかリリースしています。このような機能はARととても相性が良いため、Appleが発売されると噂されているARHMDも既存のAppleデバイスと連携する機能が付くんじゃないかと自分は想像していました。

もしApple Watchがそのような機能の一端を担うとしたらハンドトラッキングがじゃないかというのは多くの人が思い至るところではないでしょうか。丁度手首に付いているので手の位置を認識するにはおあつらえ向きです。現時点でも手を握る等によるジェスチャコントロールが出来るので、もしかしたらApple Watchを付けていたら指の動きまでトラッキング出来るようになるのではないでしょうか。

指の動きはともかく、Apple Watchの座標を使ったハンドトラッキングは頑張れば出来そうな気がするので、ちょっと作ってみることにします。

構想

丁度手元にどこのご家庭にもあるNreal Lightがあったため、これとApple Watchを連携させてハンドトラッキングを拡張してみる事にします。 f:id:Sokuhatiku:20211215183426j:plain www.nreal.ai

Nrealには元々ハンドトラッキング機能が付いており、両手を使って物を掴んだりジェスチャで操作したりといった事が可能です。が、デバイス前面にあるカメラを使ってのトラッキングである為、手を下ろしたり後ろに回したりした状態では機能を使えません。この制約をApple Watchを使うことで突破できないでしょうか。

Apple Watchのモーションセンサーの値をNrealにリアルタイムで送信し、ハンドトラッキング情報と組み合わせる事でNreal内の座標空間におけるApple Watchの位置を推定し、記録しておきます。 f:id:Sokuhatiku:20211214233958j:plain

ハンドトラッキングをロストしたら、Apple Watchのトラッキング情報を使って手の位置を推定し、トラッキングを続けます。 f:id:Sokuhatiku:20211214234517j:plain

これくらいならAppleWatch側でのモーションデータの取得、及びそのデータのNreal側へのリアルタイム送信さえ出来れば実現できそうな気がします。ただし、推定を真面目にやるとものすごく大変そうなので、その辺りは今回力を入れないようにします。

実装

Apple Watch(それどころかApple系全部)の開発は全くの未経験だった為、どのような機能が使えるのか調べながらの実装です。まず、デバイスのモーション情報はどうやらCoreMotionというAPIを利用して取得できるようです。

Core Motion | Apple Developer Documentation

そして、通信処理に関してはNWConnectionというAPIを使って普通にソケット通信が出来るようです。よってAndroidとの通信にはUDPソケット通信を使うことにします。

www.radical-dreamer.com

また、SwiftではこちらのMessagePackライブラリが簡単に使えそうだったのでMessagePackも採用しました。

github.com

Nreal側は安心と信頼のMessagePack for C#を使います。

github.com

データをサーバー(Nreal)に送る

とにもかくにもCoreMotionの情報をNreal側に送ることが出来なければ何も始まらないので、その辺りのコードを実装し、動かしてみます。

Apple Watch側のソースコードはこんな感じです。(Swift)

class MotionTrackerClient : ObservableObject {
    
    let motionManager = CMMotionManager()
    let connection:NWConnection!
    
    @Published var accelX:Double = 0
    @Published var accelY:Double = 0
    @Published var accelZ:Double = 0
    
    init(){
        // UDP接続
        let udpParams = NWParameters.udp;
        let endpoint = NWEndpoint.hostPort(host: "***.***.***.***", port:3000)
        connection = NWConnection(to:endpoint, using: udpParams)
        let connectionQueue = DispatchQueue(label:"WatchPositionTracker")
        connection.start(queue: connectionQueue)
        
        startMotionSensor()
    }

    func startMotionSensor(){
        //モーションセンサーの利用開始
        if (motionManager.isDeviceMotionAvailable ){
            motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
            motionManager.showsDeviceMovementDisplay = true
            
            motionManager.startDeviceMotionUpdates(
                to: OperationQueue.current!,
                withHandler: {(motion:CMDeviceMotion?,error: Error?) in self.updateMotion(motion: motion!)})
        }
    }
    
    func sendMotionDataManually(){
        //UIから叩いて現在のデータを送信
        sendMotionData(motion: motionManager.deviceMotion!)
    }

    func sendMotionData(motion:CMDeviceMotion){
        if connection.state == NWConnection.State.cancelled{
            let connectionQueue = DispatchQueue(label:"WatchPositionTracker")
            connection.start(queue: connectionQueue)
        }
        if connection.state != NWConnection.State.ready {
            return
        }
        
        //モーションセンサーの値の送信
        var writer = MessagePackWriter()
        let timestamp = motion.timestamp
        writer.pack(timestamp)
        
        let accel = motion.userAcceleration
        writer.pack(accel.x)
        writer.pack(accel.y)
        writer.pack(accel.z)
        
        let attitude = motion.attitude.quaternion
        writer.pack(attitude.x)
        writer.pack(attitude.y)
        writer.pack(attitude.z)
        writer.pack(attitude.w)
        
        let completion = NWConnection.SendCompletion.contentProcessed{(error:NWError?) in
        }
        connection.send(content: writer.data, completion: completion)
    }
    
    func updateMotion(motion:CMDeviceMotion){
        //モーション更新時のコールバック
        accelX = motion.userAcceleration.x
        accelY = motion.userAcceleration.y
        accelZ = motion.userAcceleration.z
        
    }
}

そして受信側はこんな感じです。(C#)

public class TrackingDataServer : MonoBehaviour
{
    private UdpClient _server;
    private readonly CancellationTokenSource _receiveLoopCanceller = new CancellationTokenSource();
    private readonly object _lockObject = new object();

    void Start()
    {
        // サーバーの初期化
        _server = new UdpClient(3000);
        NRInput.SetInputSource(InputSourceEnum.Hands);
        Task.Run(() => ReceiveLoop(_receiveLoopCanceller.Token));
    }

    private async Task ReceiveLoop(CancellationToken cancelToken)
    {
        // データ受信ループ
        while (!cancelToken.IsCancellationRequested)
        {
            var result = await _server.ReceiveAsync();

            var data = DeserializeTrackingData(result.Buffer);

            lock (_lockObject)
            {
                _lastTrackingData = data;
            }

            Debug.Log(
                $"time:{data.Timestamp}, accX:{data.Acceleration.x}, accY:{data.Acceleration.y}, accZ:{data.Acceleration.z}");
        }
    }

    private TrackingData DeserializeTrackingData(byte[] serializedBuffer)
    {
        // asyncメソッド内ではref構造体を使えないので別メソッド内でデシリアライズ
        var reader = new MessagePackReader(serializedBuffer);
        return TrackingData.Deserialize(ref reader);
    }

    private void OnDestroy()
    {
        _receiveLoopCanceller.Cancel();
    }
}

public readonly struct TrackingData
{
    // モーションデータをデシリアライズする為の構造体
    public readonly double Timestamp;
    public readonly Vector3 Acceleration;
    public readonly Quaternion Attitude;

    private TrackingData(double timestamp, Vector3 acceleration, Quaternion attitude)
    {
        Timestamp = timestamp;
        Acceleration = acceleration;
        Attitude = attitude;
    }

    public static TrackingData Default => new TrackingData(0d, Vector3.zero, Quaternion.identity);

    public static TrackingData Deserialize(ref MessagePackReader reader)
    {
        var timestamp = reader.ReadDouble();

        var accX = reader.ReadDouble();
        var accY = reader.ReadDouble();
        var accZ = reader.ReadDouble();

        var attX = reader.ReadDouble();
        var attY = reader.ReadDouble();
        var attZ = reader.ReadDouble();
        var attW = reader.ReadDouble();
      
        return new TrackingData(timestamp,
            new Vector3((float)accX, (float)accY, (float)accZ),
            new Quaternion((float)attX, (float)attY, (float)attZ, (float)attW));
    }
}

f:id:Sokuhatiku:20211215154543j:plain

動いた!

上のコードでは再接続に難があったり、Nreal側のアドレスが決め打ち等の問題はありますが、最低限今回の記事でやりたい画は実現できそうなので、後回しして先に進みます。

サーバーコードをNreal(コンピューティングユニット)に乗せ、ハンドトラッキングも付けてみましょう。

NrealとApple Watchの座標空間を合わせる

CoreMotionの値とNRSDKの値は全く異なる座標系を利用している為、片方をもう片方に合わせて変換してやらなくてはなりません。今回はNrealが主なのでNrealの採用している座標系、つまりUnityの座標系に合わせてCoreMotionの値を変換します。

Nrealのドキュメントによると、手首は掌方向がZ+、中指方向がY+となっているので、これを参考にApple Watchの座標系を変換します。

nrealsdkdoc.readthedocs.io

また、Apple公式のドキュメントによるとどうやら手前がZ+の右手座標系を採用しているようです。Unityは手前がZ-なので、Zの値を反転してやる必要があります。

Understanding Reference Frames and Device Attitude | Apple Developer Documentation

Apple WatchについてはどこがY軸でどこがZ軸でという情報は見つからなかったのですが、iPhoneに準拠するのであればディスプレイが向いている方向がZ+、そしてディスプレイの右がX+、上がY+だと思われます。

纏めると、座標系の関係は次の写真の様になると思われます。左下がApple Watchの座標系、もう一つがNrealにおける手首の座標系です。

f:id:Sokuhatiku:20211215045751p:plain

手首ボーンと時計本体の位置は画像の通り少し離れているのですが、今回その差は無視することにします。Z軸を反転したうえで90度回転させた座標補正を組み込み、加速度を取り出してNrealで可視化出来るようにしてみました。Apple Watchがあるであろう座標にオブジェクトを置き、位置が一目で分かるようにしています。

f:id:Sokuhatiku:20211215161637p:plain

成果物が以下のようなものになります。

結果

youtu.be

……ロケットパンチが出来上がりました。

何となく手を動かした方向に動いてはくれるのですが、すぐに加速してどこかに飛んで行ってしまいます。考えられる原因は通信経由で加速度を渡していることによる誤差の蓄積でしょうか。HWにも詳しくはないですが、そもそも加速度センサーはこういう用途で使うには誤差が大きいのかもしれません。もしこんな簡単な実装で実用に耐えうるものが出来るのなら既に公式がAPI作ってそうです。反省点、せめて移動量の推定値を出すくらいまではApple Watch側でやったほうが良かったですね。しかし真面目にやると無限に時間がかかるため今回はここまでです。ごめんなさい。

とりあえず最低限、Apple WatchからAndroidへ通信すること、モーションセンサの値を送信することが出来る事が分かっただけでも個人的には大きな収穫でした。Apple Watchは開発するには制約が厳しそうなデバイスだという印象だったのですが、思ったより色々遊べそうな感じです。

あと、AppleWatchは時間を表示するデバイスなので、画像を出すと何時ごろ作業してたのかバレバレですね。

ちなみに、Nrealでの開発や、結果で出したような動画の撮影方法は、同じアドベントカレンダーに参加している、こちらの記事に詳しく載っていますよ!(宣伝) synamon.hatenablog.com