はじめに
こちらの記事はフリューアドベントカレンダー22日目の記事になります。
こんにちは。
ピクトリンク事業部開発部の里形と申します。
先日、SPAJAMというハッカソンに参加いたしました。
その準備の際、AirPods Proのジャイロセンサーを使って顔の向きを検知する実装を仕込んでいたため、その紹介をしようと思います。(当日は使いませんでしたが...)
実装内容
AirPods Proのジャイロセンサーを扱うには、CoreMotionライブラリに含まれるCMHeadphoneMotionManagerというクラスを使用します。
HeadDirectionは、上下左右と正面および方向不明の6パターンをenumで定義したものです。
また、isMotionActiveはジャイロセンサーを使用しているかどうかをStateで管理するための変数です。
import CoreMotion
@MainActor
class AirPodsMotionViewModel: ObservableObject {
private let motionManager = CMHeadphoneMotionManager()
@Published var currentDirection: HeadDirection = .unknown
@Published var isMotionActive: Bool = false
@Published var errorMessage: String?
init() {
guard CMHeadphoneMotionManager.authorizationStatus() != .denied else {
errorMessage = "モーションセンサーへのアクセスが拒否されています"
return
}
guard motionManager.isDeviceMotionAvailable else {
errorMessage = "AirPodsのモーションセンサーが利用できません"
return
}
}
}
また、ジャイロセンサーから動作を検知するためには、Info.plistでPrivacy - Motion Usage Descriptionの設定をしてあげる必要があります。この設定がない場合は、アプリがクラッシュします。
次に、ジャイロセンサーの更新を検知し、ヨー角度とピッチ角度を取得する関数を宣言します。
func startMotionUpdates() {
guard !isMotionActive else { return }
errorMessage = nil
// モーション更新を開始
motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in
guard let self = self else { return }
if let error = error {
Task { @MainActor in
self.errorMessage = "モーション検知エラー: \(error.localizedDescription)"
self.stopMotionUpdates()
}
return
}
guard let motion = motion else { return }
// ヨー角度とピッチ角度を取得
let yaw = motion.attitude.yaw
let pitch = motion.attitude.pitch
Task { @MainActor in
self.yawValue = yaw
self.pitchValue = pitch
// 方向を判定
self.currentDirection = self.determineDirection(from: yaw, pitch: pitch)
}
}
isMotionActive = true
}
determineDirection(from: yaw, pitch: pitch)は、ピッチ角度とヨー角度の変化量を比較し、上下左右の方向判定をつけたものです。
角度検知のみを行う場合は必要ありませんが、顔の向きを検知するという便宜上、実装した方が便利だと思います。
private let leftThreshold: Double = 0.5
private let rightThreshold: Double = -0.5
private let upThreshold: Double = 0.3
private let downThreshold: Double = -0.2
private func determineDirection(from yaw: Double, pitch: Double) -> HeadDirection {
// ピッチの変化が大きい場合は上下を優先
if abs(pitch) > abs(yaw) {
if pitch > upThreshold {
return .up
} else if pitch < downThreshold {
return .down
}
}
// ヨー角度での左右判定
if yaw > leftThreshold {
return .left
} else if yaw < rightThreshold {
return .right
} else {
return .center
}
}
最後に、モーション取得を終了する処理を実装します。
func stopMotionUpdates() {
guard isMotionActive else { return }
motionManager.stopDeviceMotionUpdates()
isMotionActive = false
currentDirection = .unknown
yawValue = 0.0
pitchValue = 0.0
isNodding = false
pitchHistory.removeAll()
}
以上の実装を任意のViewなどから呼び出せば、AirPods Proのジャイロセンサーを使って、顔の向きを検知することができます。
応用
顔の向きの状態を上下左右と正面で扱うことで、頷きの検知などを楽に実装できるようになります。
今回は、正面->下->正面と下->正面->下の順で状態が変化した場合は、頷きをしたと判定する実装を例として紹介します。
@Published var isNodding: Bool = false
@Published var nodCount: Int = 0
private var pitchHistory: [Double] = []
private let historySize = 8
private var lastNodTime: Date = .distantPast
private let nodCooldownInterval: TimeInterval = 0.8
private func detectNodding(pitch: Double) {
// ピッチ履歴を更新
pitchHistory.append(pitch)
if pitchHistory.count > historySize {
pitchHistory.removeFirst()
}
// 履歴が十分でない場合は処理しない
guard pitchHistory.count >= historySize else { return }
// クールダウン期間中は処理しない
guard Date().timeIntervalSince(lastNodTime) > nodCooldownInterval else { return }
// 頷きパターンを検知
if isNodPattern() {
isNodding = true
nodCount += 1
lastNodTime = Date()
// 短時間だけ頷き状態を表示
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.isNodding = false
}
}
}
private func isNodPattern() -> Bool {
let recentHistory = Array(pitchHistory.suffix(historySize))
// 正面の閾値(中立位置近く)
let neutralThreshold: Double = 0.1
// パターン1: 正面→下→正面
let pattern1 = checkPattern(
history: recentHistory,
start: { abs($0) < neutralThreshold }, // 正面
middle: { $0 < downThreshold * 0.7 }, // 下
end: { abs($0) < neutralThreshold } // 正面
)
// パターン2: 下→正面→下
let pattern2 = checkPattern(
history: recentHistory,
start: { $0 < downThreshold * 0.7 }, // 下
middle: { abs($0) < neutralThreshold }, // 正面
end: { $0 < downThreshold * 0.7 } // 下
)
return pattern1 || pattern2
}
private func checkPattern(
history: [Double],
start: (Double) -> Bool,
middle: (Double) -> Bool,
end: (Double) -> Bool
) -> Bool {
let firstThird = history.prefix(3)
let middleThird = history.dropFirst(3).prefix(4)
let lastThird = history.suffix(3)
let hasStartCondition = firstThird.contains(where: start)
let hasMiddleCondition = middleThird.contains(where: middle)
let hasEndCondition = lastThird.contains(where: end)
return hasStartCondition && hasMiddleCondition && hasEndCondition
}
同様に、左右と正面の状態を活用することで、横向きに顔を振った時の判定を追加したりなどもできるかもしれません。
まとめ
AirPods Proのジャイロセンサーはかなり正確で、実際にビルドして試してみると非常に楽しいものでした。
実機さえあれば意外と手軽に試すことができるので、ぜひ試してみていただけると嬉しいです。
活用としては、ラジコンの操作とかを顔の向きでできるようになったりしたら面白そうだなーと考えたりしています。
素敵な有効活用の方法が思いついたらぜひ教えてください。