コンテンツにスキップ

スマホから姿勢データを取得する

Author: Takashi

iOS の CoreMotion / Android の SensorManager が提供する OS フュージョン済みの姿勢データを、Flutter の EventChannel 経由で Dart 側にストリーミングする実装です。

姿勢データとは、デバイスの「向き・傾き」を表すデータです。 OS 内部でカルマンフィルタによって加速度・ジャイロ・磁気センサーを統合した結果が提供されるため、自前でフィルタを実装する必要はありません。

項目iOSAndroid
APICMDeviceMotion.attitudeTYPE_ROTATION_VECTOR
EventChannelimu_fusion/attitudeimu_fusion/attitude
参照フレーム.xArbitraryZVertical(磁北不要)地磁気北基準

出力データの形式は以下のとおりです。

キー内容
qw / qx / qy / qzdoubleクォータニオン
roll / pitch / yawdoubleオイラー角(ラジアン)
timestampintUnix タイムスタンプ(ミリ秒)

姿勢データは オイラー角クォータニオン の2種類の形式で提供されます。

デバイスの向きを3つの角度で表します。人間が最も直感的に理解しやすい形式です。

Pitch(前後)
↑ +
|
Roll ────┼──── Roll(左右)
− | +
|
↓ −
Yaw は上から見た水平回転
動き
Rollデバイスを縦に倒す(左右)スマホを右に傾ける
Pitchデバイスを横に倒す(前後)スマホを手前に傾ける
Yaw水平に回転する(方位)北 → 東 → 南 → 西

単位はラジアンで提供されます。UI 表示時は × 180 / π で度数に変換します。

弱点: ジンバルロック

ジンバルロックとは、3つの回転軸のうち2つが同じ方向を向いてしまい、1軸分の自由度が失われる現象です。

もともと「ジンバル」とはカメラや羅針盤を水平に保つための3重リング構造の機械部品です。 各リングが1軸の回転を担っていますが、ある角度になると2つのリングの回転軸が重なり、 1方向への回転が表現できなくなります。これをジンバルロックと呼びます。

オイラー角でも同じことが起こります。Pitch が ±90° になると Roll と Yaw の軸が重なり、 どちらを動かしても同じ回転になってしまいます。

  • 通常時(Pitch = 0°):

    • Roll 軸 ─── X方向
    • Pitch 軸 ── Y方向 ← 3軸がそれぞれ独立している
    • Yaw 軸 ─── Z方向
  • ジンバルロック発生時(Pitch = 90°):

    • Roll 軸 ─── Z方向
    • Pitch 軸 ── Y方向 ← Roll と Yaw が同じ Z方向を向いてしまう
    • Yaw 軸 ─── Z方向 ← 自由度が 3 → 2 に減少

姿勢を連続して記録・補間する用途では、ジンバルロックが発生するとデータが破綻します。 クォータニオンはこの問題が原理的に発生しないため、センサーフュージョンや 3D 描画の内部処理では標準的に使われています。

どの軸周りに、何度回転するか」を1つの式で表します。ジンバルロックが発生しません。

q = w + xi + yj + zk
w : cos(回転角 / 2)
x, y, z : 回転軸の方向 × sin(回転角 / 2)

クォータニオンは「回転角をそのまま使わず、半分にして cos・sin を取る」という特徴があります。

例: Z軸(画面の奥行き方向)周りに 90° 回転する場合

Section titled “例: Z軸(画面の奥行き方向)周りに 90° 回転する場合”
回転角の半分 = 90° / 2 = 45°
w = cos(45°) = 1/√2 ≈ 0.707
x = 0 (X軸方向には回転しない)
y = 0 (Y軸方向には回転しない)
z = sin(45°) = 1/√2 ≈ 0.707 (Z軸周りに回転する)
状態wxyz
静止(無回転)1.00.00.00.0
Z軸周りに 90° 回転0.7070.00.00.707
Z軸周りに 180° 回転0.00.00.01.0
オイラー角クォータニオン
直感的にわかりやすい
ジンバルロック発生する発生しない
計算コスト高い低い
姿勢の補間△(不自然になる)◎(SLERP で滑らか)
主な用途表示・UI演算・3D・フュージョン

OS は内部的にクォータニオンで計算し、表示用にオイラー角へ変換して提供しています。 今回の実装では両方を同時に受け取っています。

OS フュージョン済みデータ
クォータニオン (qw, qx, qy, qz) ← 演算・記録用
↓ 変換
オイラー角 (roll, pitch, yaw) ← 画面表示用

TYPE_ROTATION_VECTOR センサーから姿勢データを取得します。 SensorManager の静的メソッドでクォータニオンとオイラー角を算出します。

class RotationVectorHandler(
private val sensorManager: SensorManager,
) : EventChannel.StreamHandler {
private var listener: SensorEventListener? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
if (sensor == null) {
events.error("UNAVAILABLE", "Rotation vector sensor not available", null)
return
}
listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
// クォータニオン: q[0]=w, q[1]=x, q[2]=y, q[3]=z
val q = FloatArray(4)
SensorManager.getQuaternionFromVector(q, event.values)
// 回転行列 → オイラー角: orientation[0]=azimuth(yaw), [1]=pitch, [2]=roll
val rotMatrix = FloatArray(9)
SensorManager.getRotationMatrixFromVector(rotMatrix, event.values)
val orientation = FloatArray(3)
SensorManager.getOrientation(rotMatrix, orientation)
val payload = mapOf(
"qw" to q[0].toDouble(),
"qx" to q[1].toDouble(),
"qy" to q[2].toDouble(),
"qz" to q[3].toDouble(),
"yaw" to orientation[0].toDouble(),
"pitch" to orientation[1].toDouble(),
"roll" to orientation[2].toDouble(),
"timestamp" to System.currentTimeMillis(),
)
mainHandler.post { events.success(payload) }
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorManager.registerListener(
listener,
sensor,
SensorManager.SENSOR_DELAY_GAME,
)
}
override fun onCancel(arguments: Any?) {
sensorManager.unregisterListener(listener)
listener = null
}
}

EventSink.success() はメインスレッドから呼ぶ必要があるため、Handler(Looper.getMainLooper()) 経由で呼び出しています。

startDeviceMotionUpdates(using:to:withHandler:) で OS フュージョン済みの姿勢データを取得します。 参照フレームに .xArbitraryZVertical を指定することで、磁気センサーの較正なしに動作します。

final class AttitudeHandler: NSObject, FlutterStreamHandler {
private let manager: CMMotionManager
private let queue: OperationQueue
private let interval: TimeInterval
func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
guard manager.isDeviceMotionAvailable else {
return FlutterError(
code: "UNAVAILABLE",
message: "DeviceMotion not available",
details: nil
)
}
manager.deviceMotionUpdateInterval = interval
manager.startDeviceMotionUpdates(
using: .xArbitraryZVertical,
to: queue
) { motion, error in
guard let motion = motion else { return }
let q = motion.attitude.quaternion
let att = motion.attitude
DispatchQueue.main.async {
events([
"qw": q.w,
"qx": q.x,
"qy": q.y,
"qz": q.z,
"roll": att.roll,
"pitch": att.pitch,
"yaw": att.yaw,
"timestamp": Int(Date().timeIntervalSince1970 * 1000),
])
}
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
manager.stopDeviceMotionUpdates()
return nil
}
}

センサーコールバックは専用の OperationQueue で受け取り、EventSink への書き込みは DispatchQueue.main.async でメインスレッドに戻しています。

AttitudeData モデルでクォータニオンとオイラー角の両方を保持します。 UI ではオイラー角をラジアンから度数に変換して表示しています。

class AttitudeData {
final double qw;
final double qx;
final double qy;
final double qz;
final double roll; // ラジアン
final double pitch; // ラジアン
final double yaw; // ラジアン
final int timestampMs;
factory AttitudeData.fromMap(Map<dynamic, dynamic> map) {
return AttitudeData(
qw: (map['qw'] as num).toDouble(),
qx: (map['qx'] as num).toDouble(),
qy: (map['qy'] as num).toDouble(),
qz: (map['qz'] as num).toDouble(),
roll: (map['roll'] as num).toDouble(),
pitch: (map['pitch'] as num).toDouble(),
yaw: (map['yaw'] as num).toDouble(),
timestampMs: (map['timestamp'] as num).toInt(),
);
}
}

ImuService で EventChannel を Stream として公開します。

class ImuService {
ImuService._();
static final ImuService instance = ImuService._();
static const _attitudeChannel = EventChannel('imu_fusion/attitude');
Stream<AttitudeData>? _attitudeStream;
Stream<AttitudeData> get attitudeStream =>
_attitudeStream ??= _attitudeChannel
.receiveBroadcastStream()
.map((e) => AttitudeData.fromMap(e as Map));
}

UI では StreamBuilder でリアルタイムに更新します。

StreamBuilder<AttitudeData>(
stream: ImuService.instance.attitudeStream,
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final d = snapshot.data!;
final toDeg = 180 / pi;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Euler Angles (deg)'),
_ValueRow('Roll ', d.roll * toDeg),
_ValueRow('Pitch', d.pitch * toDeg),
_ValueRow('Yaw ', d.yaw * toDeg),
Text('Quaternion'),
_ValueRow('w', d.qw),
_ValueRow('x', d.qx),
_ValueRow('y', d.qy),
_ValueRow('z', d.qz),
],
);
},
)
  • 姿勢データは OS が内部でカルマンフィルタ処理した結果であり、生センサーより安定している
  • クォータニオンはジンバルロックが発生しないため、3D 演算や姿勢補間(SLERP)に適している
  • オイラー角(Roll / Pitch / Yaw)は直感的だが、特定の角度でジンバルロックが発生する
  • iOS の参照フレーム .xArbitraryZVertical は磁北を必要とせず、屋内でも安定して動作する
  • EventSink への書き込みは必ずメインスレッドから行う