スマホのセンサーで方位を知る(アンドロイド地図アプリの開発 その9)

アンドロイド地図アプリの開発。今日は第9回目です。
今回はスマホに搭載されているセンサーで方位を測ってみようというものです。測った方位をもとに地図を回転してやれば、常に地図の方位と自分の向きが合うので地図が見やすくなります。
というか、グーグルマップでもカーナビ用のアプリでもみんな基本はそうなっているので、開発中のアプリにもその機能を搭載してやろうということです。
地図をある角度で回転させるということはすでにできているので、あとはセンサーの数値を取り出すだけです。
alasixosaka.hatenablog.com
スマホのセンサーを使うので、エミュレータでは無理なので今回は実機を使うことになります。

方位を知るためのセンサー

スマホには色々なセンサーが搭載されています。この中で、方位を知る方法は大まかに2通りの方法があるようです。

  1. GPSを使う方法
  2. 地磁気センサーと加速度センサーを使う方法

GPSを使う方法は、メリットとしては、どうせGPSを使って現在地を測るのだから、ついでに方位まで知ることができるので一石二鳥になる。デメリットは、あくまで移動してきた方向を知ることができるだけで、例えばじっとしているときに今どっちに向いているかはわからないということ。
地磁気センサーと加速度センサーを使う場合は、じっとしていても向いている方位がわかるのでリアルタイム性に優れている。ただ、余分にセンサーを使うことになるので消費電力が増えることになる。
どちらも、一長一短だが、今回は地磁気センサーと加速度センサーを使う方法を試してみた。消費電力の問題はモバイルバッテリーで何とかできると考えている。
ちなみに、GPSを使う方法では、location.getBearing()という関数を使うと方位が得られる。

なぜ 地磁気センサーと加速度センサーを使うのか

スマホには地磁気センサーが搭載されていて、方位がわかるようになっている。でも、端末が傾いていると傾きが反映されないので、方位がうまく測れないらしい。そこで、加速度センサーを使って端末自体の傾き、水平なのか少し立っているのかを調べて計算するらしい。あまり細かいことはわからないけど。詳しいことを書いてあるサイトは少ないけど、下記のサイトの解説は割とわかりやすく書いてあった。
方位角を計算したいけど全然分かんない – dalomo
論より証拠なのでとりあえず、ソースコードの載っているサイトを参考に、プログラムを書いてやってみる。ところが、問題が!!

値のふらつきが大きい

参考サイトのソースコードを使うと確かに、値を得ることができるのですが、値がふらついて安定しません。イベントリスナーを使って値をとってくるので、値が変わるたびにイベントリスナーが呼び出され、地図を回転しようとすると非常に細かい間隔で地図が微妙に動くことになります。(Mapsforgeの描画がそこまで追随できるかという問題もあるとは思いますが)
なので、もう少し値のふらつきを減らす必要があります。地図の向きを整えるだけなので、正直あまり精度は不要で、おおまかに10度刻みとか5度刻みでもよいと思っています。

ローパスフィルターは効果が弱い。

色々調べると、ローパスフィルターをかけると良いという記事があり、やっては見たもののあまり効果的でなく、ふらつきが若干ましになる程度。もっと効果的な方法が必要です。

センサーの取得間隔を長くする。

さらに調べると、どうやら地磁気センサーに関してはセンサーの取得間隔を調整できるらしい。加速度センサーはダメ見たい。
そこで簡単なプログラムを書いて実験してみた。ソースは下記の通り。

public class MainActivity extends AppCompatActivity implements SensorEventListener {
    private SensorManager m_sensorManager;
    private TextView m_val_x_TextView;
    private TextView m_val_y_TextView;
    private TextView m_val_z_TextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        m_sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);
        m_val_x_TextView = findViewById(R.id.val_x);
        m_val_y_TextView = findViewById(R.id.val_y);
        m_val_z_TextView = findViewById(R.id.val_z);
    }
    @Override protected void onResume() {
        super.onResume();
        // Event Listener登録
        Sensor accel = m_sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        m_sensorManager.registerListener(this, accel, (int)1e6);
    }

    @Override protected void onPause() {
        super.onPause();
        // Event Listener登録解除
        m_sensorManager.unregisterListener(this);
    }

    @Override
    public void onSensorChanged(SensorEvent event) {
        if(event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD){
            m_val_x_TextView.setText(String.format("%.3f", event.values[0]));
            m_val_y_TextView.setText(String.format("%.3f", event.values[1]));
            m_val_z_TextView.setText(String.format("%.3f", event.values[2]));
        }
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {

    }
}

地磁気センサーを動かして、x,y,zそれぞれの値を画面に表示するだけのシンプルなプログラム。onRsumeとonPauseを使っているのは、作法に従っただけで、センサーの消費電力を抑えるための工夫で、画面が隠れて別アプリが起動するとセンサーをオフにするようにしている。
やっていることは、m_sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE); でセンサーマネージャーをインスタンス化して、
Sensor accel = m_sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
m_sensorManager.registerListener(this, accel, (int)1e6);
でリスナー登録をしていて、その時の引数で取得間隔を調整している。値はマイクロ秒単位で、(int)1e6の部分が相当する。今回は1秒間隔にしている。
ちなみに、加速度センサーでやってみたがやっぱりうまくいかないようだった。Sensor accel= ・・・のところはその名残で、地磁気センサーなので、mgfieldとかなんとかがふさわしいのだけれどそのままになっている。
値の取得は、

 @Override
 public void onSensorChanged(SensorEvent event) {
        if(event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD){
            m_val_x_TextView.setText(String.format("%.3f", event.values[0]));
            m_val_y_TextView.setText(String.format("%.3f", event.values[1]));
            m_val_z_TextView.setText(String.format("%.3f", event.values[2]));
        }
    }

の部分で、得られた値をテキストビューで表示するだけである。

f:id:alasixOsaka:20190907112557p:plain
地磁気センサーの値を1秒間隔で取得すると安定する。
1秒間隔にするとまあまあいい感じである。

1秒間隔で方位を取得する。

間隔を調整できたので、方位を計算して表示するアプリに適用してみる。

public class MainActivity extends AppCompatActivity {
    private SensorManager sensorManager = null;
    private SensorEventListener sensorEventListener = null;

    private float[] fAccell = null;
    private float[] fMagnetic = null;
    float[] saveAcceleVal  = new float[3];
    float[] saveMagneticVal = new float[3];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);

        sensorEventListener = new SensorEventListener() {
            @Override
            public void onSensorChanged(SensorEvent event) {
                switch (event.sensor.getType()){
                    case Sensor.TYPE_ACCELEROMETER:
                        fAccell = event.values.clone();
                        LowPassFilter(fAccell);
                        break;
                    case Sensor.TYPE_MAGNETIC_FIELD:
                        fMagnetic = event.values.clone();
                        LowPassFilter2(fMagnetic);
                        float[] inR = new float[9];
                        SensorManager.getRotationMatrix(inR, null, fAccell,fMagnetic);
                        float[] outR = new float[9];
                        SensorManager.remapCoordinateSystem(inR, SensorManager.AXIS_X, SensorManager.AXIS_Y, outR);
                        float[] fAttitude = new float[3];
                        SensorManager.getOrientation(outR, fAttitude);
                        String buf =
                                "---------- Orientation --------\n" +
                                        String.format( "方位角\n\t%d\n", (int)rad2deg( fAttitude[0] )) +
                                        String.format( "前後の傾斜\n\t%d\n", (int)rad2deg( fAttitude[1] )) +
                                        String.format( "左右の傾斜\n\t%d\n", (int)rad2deg( fAttitude[2] ));
                        TextView t = (TextView) findViewById( R.id.textView1 );
                        t.setText( buf );
                        break;
                }
                //if (fAccell != null && fMagnetic != null){

                //}
            }

            @Override
            public void onAccuracyChanged(Sensor sensor, int accuracy) {

            }
        };
    }
    private float rad2deg( float rad ) {
        return rad * (float) 180.0 / (float) Math.PI;
    }
    protected void onStart() { // ⇔ onStop
        super.onStart();

        sensorManager.registerListener(
                sensorEventListener,
                sensorManager.getDefaultSensor( Sensor.TYPE_ACCELEROMETER ),
                (int)1e6 );
        sensorManager.registerListener(
                sensorEventListener,
                sensorManager.getDefaultSensor( Sensor.TYPE_MAGNETIC_FIELD ),
                (int)1e6 );
    }

    protected void onStop() { // ⇔ onStart
        super.onStop();

        sensorManager.unregisterListener( sensorEventListener );
    }

    final float filterVal = 0.8f;

    public void LowPassFilter(float[] target ){
        float outVal[] = new float[3];
        outVal[0] = (float)(saveAcceleVal[0] * filterVal
                + target[0] * (1-filterVal));
        outVal[1] = (float)(saveAcceleVal[1] * filterVal
                + target[1] * (1-filterVal));
        outVal[2] = (float)(saveAcceleVal[2] * filterVal
                + target[2] * (1-filterVal));

        //現在の測定値を次の計算に使うため保存する
        saveAcceleVal = target.clone();

        //加速度センサーから得た値を書き換える
        fAccell = outVal.clone();
        return ;
    }
    public void LowPassFilter2(float[] target ){
        float outVal[] = new float[3];
        outVal[0] = (float)(saveMagneticVal[0] * filterVal
                + target[0] * (1-filterVal));
        outVal[1] = (float)(saveMagneticVal[1] * filterVal
                + target[1] * (1-filterVal));
        outVal[2] = (float)(saveMagneticVal[2] * filterVal
                + target[2] * (1-filterVal));

        //現在の測定値を次の計算に使うため保存する
        saveMagneticVal = target.clone();

        //加速度センサーから得た値を書き換える
        fMagnetic = outVal.clone();
        return ;
    }
}

色々試してみた名残が残っていて、一応ローパスフィルターもかけてあるし。また、センサー取得間隔も加速度センサーのリスナー登録の部分にも書いてあって不細工なプログラムになっているが、まあ動けばいいやということで。

f:id:alasixOsaka:20190907112653p:plain
1秒間隔で方位を取得。角度は小数点以下は四捨五入している。

いい感じなので、地図アプリの方に書き込んでみた。長くなるので詳細は書かないが、RotateMapViewerクラスを書き換えている。createControls()のところにRotate Buttonを登録する部分があるが、その部分をキャンセルしてセンサーマネジャーとイベントリスナーを登録し、onResume()とonPause()はそのままコピー、それ以外の処理の部分はクラスの直下に書き込む形にしてある。

f:id:alasixOsaka:20190907113200p:plain
相変わらずベルリンの地図だが、地図の回転はいいかんじになった。
これで、GPSでの位置取得と合わせれば、地図アプリとしては使えるものになりそうだ。そうすると、GPXファイルで作ったルートの表示やPOIの表示ができれば、やろうとしていることの9割がたはできたことになる。

参考にしたサイト
Androidデバイスの方位を調べる – Linux & Android Dialy
加速度センサーと地磁気センサーを使うには(AndroidMode編) | 自己啓発。人生について考える
SensorManager#registerListener()で値の取得間隔を設定できるらしいが… - fresh digitable
AndroidでSensorManagerを使って磁気センサー(コンパス)の方位を取得する - 酢ろぐ!
[Android 開発] 端末の傾きと方位角を求める - Open MagicVox.net
[Android 開発] GPS を使う - Open MagicVox.net
方位角を計算したいけど全然分かんない – dalomo