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

公開:2020/10/12
更新:2020/10/12
15 min読了の目安(約13700字TECH技術記事
Likes19

「(ホスト名)ヨシ!」

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