はじめに
こんにちは、運動通信社のAndroidエンジニアの小林良です。
本日は、仕事で久々に高校数学が役に立ったので、
同じようなケースで悩んでいる方のために共有したいと思います。
Androidの重力加速度センサー
大抵のAndroid端末には重力加速度センサーが付いています。
これを使用して、現在の端末の向きを判定したい、という場合があります。
val sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager
val sensorGravity = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
sensorManager.registerListener(
this,
sensorGravity,
SensorManager.SENSOR_DELAY_NORMAL
)
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
// ...
}
override fun onSensorChanged(event: SensorEvent?) {
if (event != null) {
// いずれも約[-9.8] ~ [9.8]の範囲で変化する
val x = event.values[0]
val y = event.values[1]
val z = event.values[2]
}
}
端末の向きの判定とは
例えば「端末が縦を向いているとき」というのを判定したい、という場合、どのような実装が考えられるでしょうか?
- Y軸方向の重力加速度が大体9以上だったら縦向き?
- X軸とZ軸方向の重力加速度は絶対値で2以内?
受け取った値をシンプルに使う、と考えるとこのような実装が思いつくかと思います。
しかし、この実装方法だと以下の問題があります。
- 値を調整するときに3項目もあるので煩雑になる
- 重力加速度の絶対値の最大は9.8なので、3軸をバラバラに調整していると判定の範囲的にあり得ない値をとることがある
これは少し困ってしまいます。微妙な調整が必要なものだと、実装が簡単に壊れてしまうし、
パラメーターの調整だけで膨大な時間がかかってしまうことでしょう。
これをシンプルに解決する方法は無いのでしょうか?
ここでひとつ考えてみましょう。「端末が縦を向いている」を大体のイメージで表すと
以下の画像のようなイメージになると思います。
ここに、
- 重力加速度
- 端末の縦軸
- 大体縦だと思える範囲(円錐形)
を加えてみます。
この図では重力加速度と端末の縦軸が一致していますね。
それでは端末を少し傾けてみましょう。
これくらいの傾きだと、端末が縦向きだと判定できそうですね。
それではさらに端末を大きく傾けてみましょう。
重力加速度が大体縦だと思える範囲内を超えました。
これで端末は「縦向きではなくなった」と言えますね。
実装上の話なのですが、この場合は「端末が横向きになった」というよりは、端末の縦横の判定が頻繁に切り替わらないための
緩衝地帯に入った、というようなイメージですね。
以上、これらの図を見てみると「重力加速度と端末の縦軸のなす角が大体縦だと思える範囲内に入っていれば、縦向きだな」
となんとなく感じられると思います。
それではこの「重力加速度と縦軸の角度が範囲内に入っている」ということを
判定するために数学の力を借りましょう。
今回使用するものは「ベクトル」です。
ベクトルとは
ベクトルとは、大雑把に言うと
- 向き
- 大きさ
を持った概念です。よく矢印で表されることがあると思います。
内積
このベクトルですが、内積の計算によって2つのベクトルのなす角を導くことができます。
3次元のベクトル同士の内積の式は以下のようになります。
\vec{a} = (a_x, a_y, a_z) … ベクトルa \\
\vec{b} = (b_x, b_y, b_z) … ベクトルb \\
|\vec{a}| = \sqrt{a_x^2 + a_y^2 + a_z^2} … ベクトルaの長さ \\
|\vec{b}| = \sqrt{b_x^2 + b_y^2 + b_z^2} … ベクトルbの長さ \\
\vec{a}・\vec{b} = a_x・b_x + a_y・b_y + a_z・b_z … ① \\
\vec{a}・\vec{b} = |\vec{a}|・|\vec{b}|\cos\theta … ②
①、②よりコサインが求められます。
①、②の右辺は等しいので \\
a_x・b_x + a_y・b_y + a_z・b_z = |\vec{a}|・|\vec{b}|\cos\theta \\
両辺を|\vec{a}|・|\vec{b}|で割って \\
\cos\theta = \frac{a_x・b_x + a_y・b_y + a_z・b_z }{|\vec{a}|・|\vec{b}|}
これでコサインを導出することができました。一応、コサインをそのまま使って端末が縦向きかどうかを判定することもできます。
角度
このコサインのまま縦向きの判定に使用してもいいのですが、私たちが使用するのにはあまり直観的ではありません、
-1~1の範囲で変化するのですが、リニアに変化するわけではないので、値の調整が少し難しいですね。
では、これを角度に直せば扱いやすくなるのではないか、と思えます。
実際に角度に直してみましょう。
ここでは少々見慣れないアークコサインというものを使用します。
これは、コサインをラジアンに変換してくれる関数です。
\arccos(\cos\theta) = \theta
θラジアンは以下の式で表すことができます。
\theta = \theta° \times \frac{\pi}{180}[rad]
上記を変形すると、度数を導出することができます。
\theta° = \theta \times \frac{180}{\pi}
それでは試しに、π / 4ラジアンを度数に変換してみましょう
\arccos(\cos\frac{\pi}{4}) = \frac{\pi}{4} \\
\frac{\pi}{4} = \theta° \times \frac{\pi}{180}[rad] \\
\theta° = \frac{\pi}{4} \times \frac{180}{\pi} \\
\theta° = 45°
これで、2つのベクトルから算出したコサインを使用して、角度を導くことができました。
この角度が「端末が縦向きかどうか」を判定するための値になります。
実装
それでは上記の計算をプログラムで実装してみましょう。
プラットフォームはAndroid、言語はKotlinを使用しています。
まずベクトルクラスを作ります。
class Vec3(
val x: Float,
val y: Float,
val z: Float,
) {
// 内積(ax*bx + ay*by + az*bz)
fun dot(v: Vec3) = x * v.x + y * v.y + z * v.z
// 正規化(向きが同じで、length = 1のベクトルを算出)
fun normalize(): Vec3 {
val length = length()
return if (length.isNaN()) {
// ここのエラー処理は適当です
Vec3(0f, 1f, 0f)
} else {
Vec3(x / length, y / length, z / length)
}
}
// ベクトルの長さ(|a|や|b|にあたる)
fun length(): Float {
return sqrt(x * x + y * y + z * z)
}
override fun toString(): String {
return "x: $x, y: $y, z: $z"
}
}
それではこのベクトルクラスを使用して、端末が範囲内にいるかどうかの計算をしてみます。
override fun onSensorChanged(event: SensorEvent?) {
if (event != null) {
// いずれも約[-9.8] ~ [9.8]の範囲で変化する
val x = event.values[0] // 端末の画面向かって左が+, 右が-
val y = event.values[1] // 端末の画面向かって下が+, 上が-
val z = event.values[2] // 端末の画面向かって奥が+, 手前が-
// 35°を閾値に設定
val degreeThreshold = 35.0f
val isPortrait = isPortrait(Vec3(x, y, z), degreeThreshold)
}
}
fun isPortrait(deviceGravityAcc: Vec3, degreeThreshold: Float): Boolean {
// 端末にかかる重力加速度(正規化)
val normalizedDeviceGravityAcc = deviceGravityAcc.normalize()
// 端末の縦軸(正規化済み)
val deviceCenterAxis = Vec3(0f, 1f, 0f)
// 内積(正規化済みのベクトル同士なので長さで割らない)
val cos = normalizedDeviceGravityAcc.dot(deviceCenterAxis)
// cosを角度に変換
val degree = acos(cos) * (180 / PI)
/*
端末にかかる重力加速度と
端末を縦に持ったときの重力加速度(端末の縦軸)のなす角が
一定の範囲内だったら縦持ちしている判定
*/
return degree <= degreeThreshold
}
前半に出てきたイメージと、上記のコードとの対応は以下になります
- 重力加速度 = deviceGravityAcc
- 端末の縦軸 = deviceCenterAxis
- 大体縦だと思える範囲(円錐形) = degreeThreshold
これで、端末が縦向きになっているかどうかを判定することができるようになりました。
結果
上記の処理を使って、端末が縦向きかどうかを判定するアプリを作成しました。
おわりに
一般的なモバイルアプリケーションの開発で、自分で数学(特に線形代数)的な処理を実装することはあまりないとは思いますが
このような、端末のセンサーを利用した処理を実装する必要があったときに、
計算を簡単にすることができるので、いざという時のために頭の片隅に留めておくといいかな、と思います。