👈

指差し確認をApple Watchと機械学習を使って検出する

2020/10/12に公開8

「(ホスト名)ヨシ!」

Apple Watchと機械学習を使って指差し確認のジェスチャーを検出してみました。

腕を振り下ろすジェスチャーをすると

動作をアプリが検知すると判定結果が表示されます。

仕組みは

  • Apple Watch上でセンサーの情報をリアルタイムで取得する
  • 機械学習にデータを入力して指差しの動作か判定する

という作りになっています。Apple Watchのみで動作しています。

Core MLでActivity Classification

指差しの特徴的な動作の判定はCore MLを使いました。Core MLは機械学習のフレームワークで画像認識などの他にアクティビティ(ジェスチャーやモーションなど)を分類することができます。watchOSでも利用できるのでAppleWatchだけで機械学習を利用することができます。

下記はiOSでCore MLを使ったときの記事です。
AirPods Proのモーションセンサーを使って体の動きを機械学習で判定する

腕の動きなどのモーションから動作を分類するにはActivity Classificationを使ってモデルを作成します。Activity ClassificationはWWDC19で登場しました、下記のビデオで紹介されています。

Building Activity Classification Models in Create ML - WWDC 2019 - Videos - Apple Developer

モーションデータを収集する

モデルを作る為には学習データが必要なのでApple Watchのセンサー情報を取得します。加速度や姿勢などを取得する為にCMMotionManagerを使ってCMDeviceMotionの情報を取得しました。以下のような情報が取得できます。

  • attitude(姿勢)
  • rotationRate(角速度)
  • gravity(重力加速度)
  • userAcceleration(加速度)

CMMotionManager | Apple Developer Documentation
CMDeviceMotion | Apple Developer Documentation

それぞれのセンサーの情報についてはこちらの記事が参考になりました。
[watchOS 3] Apple Watch でデバイスモーションを取得する | Developers.IO

CMDeviceMotionを取得する最小限のコードはこちらです。CMMotionManagerを初期化してstartDeviceMotionUpdatesを呼び出すとリアルタイムでセンサーの情報が取得できます。

import CoreMotion

class InterfaceController: WKInterfaceController {

    let motionManager = CMMotionManager()

    override func awake(withContext context: Any?) {
        // Configure interface objects here.
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        motionManager.startDeviceMotionUpdates(to: OperationQueue.main) { (motion, error) in
            if let motion = motion {
                print(motion)
            }
        }
    }

    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        motionManager.stopDeviceMotionUpdates()
    }

}

Activity Classificationはcsv形式のデータを学習に使うことができるのでcsvでファイルに保存しました。以下はCMDeviceMotionをcsvに保存する為に用意したclassです。


import Foundation
import CoreMotion

class MotionWriter {

    var file: FileHandle?
    var filePath: URL?
    var sample: Int = 0

    func open(_ filePath: URL) {
        do {
            FileManager.default.createFile(atPath: filePath.path, contents: nil, attributes: nil)
            let file = try FileHandle(forWritingTo: filePath)
            var header = ""
            header += "acceleration_x,"
            header += "acceleration_y,"
            header += "acceleration_z,"
            header += "attitude_pitch,"
            header += "attitude_roll,"
            header += "attitude_yaw,"
            header += "gravity_x,"
            header += "gravity_y,"
            header += "gravity_z,"
            header += "quaternion_x,"
            header += "quaternion_y,"
            header += "quaternion_z,"
            header += "quaternion_w,"
            header += "rotation_x,"
            header += "rotation_y,"
            header += "rotation_z"
            header += "\n"
            file.write(header.data(using: .utf8)!)
            self.file = file
            self.filePath = filePath
        } catch let error {
            print(error)
        }
    }

    func write(_ motion: CMDeviceMotion) {
        guard let file = self.file else { return }
        var text = ""
        text += "\(motion.userAcceleration.x),"
        text += "\(motion.userAcceleration.y),"
        text += "\(motion.userAcceleration.z),"
        text += "\(motion.attitude.pitch),"
        text += "\(motion.attitude.roll),"
        text += "\(motion.attitude.yaw),"
        text += "\(motion.gravity.x),"
        text += "\(motion.gravity.y),"
        text += "\(motion.gravity.z),"
        text += "\(motion.attitude.quaternion.x),"
        text += "\(motion.attitude.quaternion.y),"
        text += "\(motion.attitude.quaternion.z),"
        text += "\(motion.attitude.quaternion.w),"
        text += "\(motion.rotationRate.x),"
        text += "\(motion.rotationRate.y),"
        text += "\(motion.rotationRate.z)"
        text += "\n"
        file.write(text.data(using: .utf8)!)
        sample += 1
    }

    func close() {
        guard let file = self.file else { return }
        file.closeFile()
        print("\(sample) sample")
        self.file = nil
    }

    static func getDocumentPath() -> URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    }

    static func makeFilePath() -> URL {
        let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let formatter: DateFormatter = DateFormatter()
        formatter.dateFormat = "yyyyMMdd_HHmmss"
        let filename = formatter.string(from: Date()) + ".csv"
        let fileUrl = url.appendingPathComponent(filename)
        print(fileUrl.absoluteURL)
        return fileUrl
    }
}

上記のコードで保存したcsvの中身の例です。

acceleration_x,acceleration_y,acceleration_z,attitude_pitch,attitude_roll,attitude_yaw,gravity_x,gravity_y,gravity_z,quaternion_x,quaternion_y,quaternion_z,quaternion_w,rotation_x,rotation_y,rotation_z
-0.0005294680595397949,-0.008063554763793945,0.0019785165786743164,-1.210240477816587,-0.09722531347868993,0.7676882731375848,-0.03424650803208351,0.9357008934020996,-0.3511280119419098,-0.5118766033173053,-0.24983662041168503,0.33325888748058907,0.7513337340570188,0.012952059507369995,-0.0006224224343895912,0.007129376288503408
0.005400359630584717,0.0005992650985717773,0.008286893367767334,-1.2099068607442764,-0.09720912055779071,0.7677881162817181,-0.03427114710211754,0.9355831146240234,-0.35143956542015076,-0.5117379593494398,-0.24980889230118059,0.33332149239519626,0.7514097380207874,0.031857673078775406,-0.020558711141347885,-0.0069893053732812405
0.008753269910812378,0.01565605401992798,-0.0001914501190185547,-1.2096581148871826,-0.09756746343547806,0.7677775778543993,-0.03441974148154259,0.9354952573776245,-0.35165876150131226,-0.5115835361958537,-0.24990579952845374,0.3334310724136612,0.7514340111849414,0.014613972045481205,-0.051618095487356186,-0.010356074199080467
0.01355704665184021,0.00416332483291626,-0.011299610137939453,-1.2097087369919899,-0.09839744490860201,0.767459175124107,-0.03470693528652191,0.9355131387710571,-0.351582795381546,-0.511504531020328,-0.25014337890041605,0.3335193253684389,0.7513695302857648,-0.00706129614263773,-0.05275659263134003,-0.021678369492292404

...

指差しのジェスチャーを3回記録したcsvファイルをGoogle Spreadsheetでグラフに表示するとこのような感じになりました、表示は加速度(x, y, z)のデータです。

なお、データ収集は、指差しの動作の他に、何もしていないニュートラルな状態のデータも収集しました。

保存したファイルはmacOSで学習に使うためにWCSessionを利用してApple WatchからiOSアプリに転送しました。
WCSessionを使ってファイル転送するときの注意点

参考までに、モーションデータを取得するためのwatchOSアプリと、ファイルを受け取るためのiOSアプリのソースコードはこちらです。
yorifuji/watchOS-motion-writer

モデルの作成

集めたcsvファイルはフォルダに分けて保存します、フォルダが分類するカテゴリになります。

Xcodeに付属するCreate ML Appを使ってActivityClassificationを選択します。

Training Dataと可能であればTesting DataをセットしてTrainを実行すると

トレーニングが行われます。

完了するとモデル(.mlmodelファイル)がダウンロードできます。

アプリへの組み込み

認識用のwatchOSアプリを作成して先ほど用意した.mlmodelファイルをプロジェクトに追加します。

データ収集アプリと同じようにCMMotionManagerを使ってセンサーを情報を取得するコードを追加します。センサー情報をモデルに入力して推論を実行します。

watchOSアプリのソースコード全体はこちらです。
yorifuji/YubisashiClassifier

Core MLで推論しているclassのコードの全体です。

import Foundation
import CoreML
import CoreMotion

protocol YubisashiMotionClassifierDelegate: class {
    func motionDidDetect(results: [(String, Double)])
}

class YubisashiMotionClassifier {

    weak var delegate: YubisashiMotionClassifierDelegate?

    static let configuration = MLModelConfiguration()
    let model = try! YubisashiClassifier_30(configuration: configuration)

    static let predictionWindowSize = 130
    let acceleration_x = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let acceleration_y = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let acceleration_z = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let attitude_pitch = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let attitude_roll = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let attitude_yaw = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let gravity_x = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let gravity_y = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let gravity_z = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let rotation_x = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let rotation_y = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)
    let rotation_z = try! MLMultiArray(
        shape: [predictionWindowSize] as [NSNumber],
        dataType: MLMultiArrayDataType.double)

    private var predictionWindowIndex = 0

    func process(deviceMotion: CMDeviceMotion) {

        if predictionWindowIndex == YubisashiMotionClassifier.predictionWindowSize {
            return
        }

        acceleration_x[[predictionWindowIndex] as [NSNumber]] = deviceMotion.userAcceleration.x as NSNumber
        acceleration_y[[predictionWindowIndex] as [NSNumber]] = deviceMotion.userAcceleration.y as NSNumber
        acceleration_z[[predictionWindowIndex] as [NSNumber]] = deviceMotion.userAcceleration.z as NSNumber
        attitude_pitch[[predictionWindowIndex] as [NSNumber]] = deviceMotion.attitude.pitch as NSNumber
        attitude_roll[[predictionWindowIndex] as [NSNumber]] = deviceMotion.attitude.roll as NSNumber
        attitude_yaw[[predictionWindowIndex] as [NSNumber]] = deviceMotion.attitude.yaw as NSNumber
        gravity_x[[predictionWindowIndex] as [NSNumber]] = deviceMotion.gravity.x as NSNumber
        gravity_y[[predictionWindowIndex] as [NSNumber]] = deviceMotion.gravity.y as NSNumber
        gravity_z[[predictionWindowIndex] as [NSNumber]] = deviceMotion.gravity.z as NSNumber
        rotation_x[[predictionWindowIndex] as [NSNumber]] = deviceMotion.rotationRate.x as NSNumber
        rotation_y[[predictionWindowIndex] as [NSNumber]] = deviceMotion.rotationRate.y as NSNumber
        rotation_z[[predictionWindowIndex] as [NSNumber]] = deviceMotion.rotationRate.z as NSNumber

        predictionWindowIndex += 1

        if predictionWindowIndex == YubisashiMotionClassifier.predictionWindowSize {
            DispatchQueue.global().async {
                self.predict()
                DispatchQueue.main.async {
                    self.predictionWindowIndex = 0
                }
            }
        }
    }

    private func predict() {

        let input = YubisashiClassifier_30Input(
            acceleration_x: acceleration_x,
            acceleration_y: acceleration_y,
            acceleration_z: acceleration_z,
            attitude_pitch: attitude_pitch,
            attitude_roll: attitude_roll,
            attitude_yaw: attitude_yaw,
            gravity_x: gravity_x,
            gravity_y: gravity_y,
            gravity_z: gravity_z,
            rotation_x: rotation_x,
            rotation_y: rotation_y,
            rotation_z: rotation_z)

        guard let result = try? model.prediction(input: input) else { return }
        let sorted = result.labelProbability.sorted {
            return $0.value > $1.value
        }

        delegate?.motionDidDetect(results: sorted)
    }
}

(追記)バックグラウンドでの動作

Apple Watchは手首をおろすとスクリーンがオフになってフォアグラウンドで起動中のアプリは停止します、モーションデータの取得も止まります。これを回避するにはいくつか方法があるようですが、watchOS6以降では拡張ランタイムセッションが利用可能で、バックグラウンドでもアプリを動かし続けることができるようです。ただし、バックグラウンドでの使用用途には制限があるかもしれませんので目的によっては審査でリジェクトされる可能性はあります。

Using Extended Runtime Sessions | Apple Developer Documentation

Discussion

tokentokentokentoken

有用な情報をありがとうございました。こちらの記事を参考に、自分も Apple Watch
で Activity を分類してみました。
まだ training データが10個しかないので追加が必要だとは思っていますが、3分類のうちの1つにずっと固まっています。
こちらの記事を参考に CoreMotion から取れる16個のデータ全てを使っていますが、記事の途中からは quaternion は使わなくなっているように見えます。最終的には学習データには使わなかったでしょうか。

そして学習用入力データですが、自分は3回の試行を1回分のデータとして10個入れましたが、1回の試行にした方がよいものでしょうか。
試行錯誤をくり返せば答えに到達しそうですが、かなりの試行錯誤が必要そうなので、もしすでに知見がありましたらお知らせいただけると助かります。
Zenn の使用は初めてなのでここで質問してよいものかわかっていないですが試しに投稿してみます。

yorifujiyorifuji

記載の通りquaternionについては利用しませんでした。
このケースではaccelerationのみで学習してもそれ以外のパラメータと組み合わせたケースと精度に大きな違いが出なかった記憶があります。
この辺はジェスチャーの動きによって試行錯誤が必要そうなイメージを受けました。

学習用のデータはWWDC18のビデオを参考にして、prediction windowが一定の間隔であれば1ファイルに複数回のジェスチャーを含んでも良さそうに見えましたので、それに倣って3回程度で1ファイルにしていました。
トータルでは50〜100回ほどのデータを用意しました、数を増やすと体感的には精度が良くなった印象があります。

実装時にpredict()のinputのパラメータ(上記のコードではYubisashiClassifier_30Input)にlstmを含めるか選択できますが、今回のケースではそれぞれの判定が独立していたので含めませんでした。逆にlstmを含めると直前の判定の影響を受けて誤って分類するケースが多かったように覚えています。

https://developer.apple.com/jp/documentation/coreml/core_ml_api/making_predictions_with_a_sequence_of_inputs/

参考になれば幸いです。

tokentokentokentoken

適確な回答をありがとうございました。
acceleration だけでもよさそうだったということですね。試行錯誤してみます。

私はまだ3回1ファイルで10ファイル程度しか作成していなかったですが50-100ファイル(もしくは20-33ファイル程度?)ほど用意されたということですね。こちらも方針はあっていそうなことがわかったので、数を増やしてみます。

たしかにpredict()のインプットには前の結果を投入していたため、分類が周期的に変化していました。0初期化したMLMultiArrayを投入してみます。

追記 2021/01/03 18:23
CreateMLにデータを追加したい場合、毎回 New Project する必要があるでしょうか?
後からデータを追加する方法が見つけられませんでした。もしご存知であれば教えていただけると助かります。

yorifujiyorifuji

一度学習した後にデータを追加する方法は僕も知りません。
自分の場合はプロジェクト内で左側のmodel sourcesから新しいモデルを追加することが多いです。学習をゼロから始めることには変わりないですがプロジェクトの使い回しできるので。

Taichi ShimizuTaichi Shimizu

大変有益な情報ありがとうございます。キチンと実装の仕方を解説している記事は国内外でも未だ少ないのではないでしょうか。

参照にさせていただきCoreMLのモデルを組んだのですが、モデルから自動生成されたクラスによって不要なlstmの入力が求められてしまい、詰まっています。おっしゃる下記の点に関してもしよろしければ参考になりそうな記事、ヒントを頂くことは可能でしょうか。

実装時にpredict()のinputのパラメータ(上記のコードではYubisashiClassifier_30Input)にlstmを含めるか選択できますが、

何卒宜しくお願い申し上げます。

Taichi ShimizuTaichi Shimizu

失礼。理解解決しました。YubisashiMotionClassifier.swiftの119行目のコメントアウトされているここですね。

// var stateOut: MLMultiArray? = nil

shatoshato

はじめまして,大変興味深い記事でした.
素人質問で恐縮なのですが,watchOS-motion-writerを実際にアップルウォッチにインストールした後,apple watch上のアプリではstartとstopを遷移するボタンが出てくるのですが,具体的にどのようにしてcsvファイルを取得するのでしょうか?

教えていただければ幸いです.