角度(方位)の平均を出す

角度(方位)の平均を出す方法についてです。
すぐ使えるサンプルプログラムと解説つき。





普通、平均は
= 値の合計値/値の総数
で計算できますが、値の範囲が0〜360内で収まるべき角度や方位の場合、単純に合計を総数で割っても平均がでません。
0度と90度の平均は45度
(0+90)/2 = 45
ですが、
350度と10度の平均は0度(または360度)
(350+10)/2 = 0(または360)
です。
(350+10)/2 = 180ではありません。
それをどうやって計算するの?というテーマです。


350度と10度の平均のイメージ。0度(360度)になる計算をしたい。

角度の平均についてぐぐると色々ヒットしますが、3行以上は読めない上「んでプログラム上でどうするの」という分かりやすいサイトがなかったので、自作しました。


利用される想定としては、一定期間に連続してデータの入力がある状況で、ある期間でのデータを滑らかにできます。
1秒間に何度もデータイベントが発生するよな、ジョイスティックの向きや、携帯端末の方角測位などに向いています。イベントの情報をそのまま採用すると、手の微妙な震えで向きが小刻みにブレるので滑らかにしたい などです。
ただジョイスティックの情報の多くはx,y方向の2次元ベクトル情報で取れることが多いので、その場合は単位ベクトル合算の方が向いていると思います。



「いいからサンプルよこせ」的な方はこちら。eclipseのサンプルです。

DirectionAverager.zip


以下、解説。

解説

DirectionAverager.java 角度を平均化するクラス
Sample.java 角度を平均化をテスト実行するエントリクラス

平均化するアルゴリズムは、簡単に言うと以下のとおりです。

  • 平均を出したい角度をArrayList等配列にキャッシュする
  • キャッシュされた角度配列を正規化する
  • 正規化された角度配列の中身を、通常の「 = 値の合計値/値の総数」で平均値を出す

です。


仮に、1秒間の間に2回角度データが取得でき、それが0度と90度だったとします。その1秒間の平均の角度は45度になります。
{ 0, 90 }
というデータならば、この配列内のデータを単純に足して総数で割れば平均化できます。
しかし、
{ 350, 10 }
という角度の平均は、0であって欲しいわけです。

この場合、角度の平均化の難点は、0度(360度)です。データがここをまたぐと、平均が正常に出せません。円グラフの形で想像してもらえればわかりやすいですが、350と10の平均は180ではなく0(360)であるべきなのです。


350度と10度の平均のイメージ。北北西(350度)と北北東(10度)との平均の方向として真北(0度)が取得したい。
(350+10)/2 = 180 で真南になる180度になっては困る。

そこでデータを以下のように正規化し、平均計算ができるようにします。

  • キャッシュされている値を、「前の値との差分」に変える
  • その差分を、キャッシュされている最初の値に加算していく

です。

数値で説明したほうがわかりやすいと思うので、 { 350, 10, 45 } というデータで説明します。
{ 350,  10,  45 } を正規化し、
{ 350, 370, 405 } にします。
こうすることで、通常の平均計算で角度の平均が取得できます。 10度というのは370度で、45度というのは405度、というわけです。

以下、データの正規化の手順です。

  • 一つ前の値からの差分をだす
    • { 0, 20(350→10の差分), 35(10→45の差分) }
  • この{ 0, 20, 35 }を、キャッシュ最初の350へ順に加算していく
    • { 350(350+0),370(350+20),405(370+35) }
  • 得られた{ 350,370,405 }の平均を出す。

という手法です。


基本原理は以上です。以下はサンプルコードの解説です。

ソースコードの解説

DirectionAverager.java

import java.util.ArrayList;

/**
 * 方向を平均化するクラス
 * 
 * @author ootanAW
 *
 */
public class DirectionAverager {

	// 定数
	/** 方向バッファサイズ */
	private static final int BUFFER_MAX = 15;
	
	/** 方向イベントの角度をそのまま保持する配列 */
	private ArrayList<Float> nextRotateArray;
	/** 保持している角度の変動量を保持する配列(作業用) */
	private ArrayList<Float> nextRotateArray2;
	/** 方向+変動量の値を保存する配列(0~360を超える。2度は362度になったりする) */
	private ArrayList<Float> nextRotateArray3;

	/** 最新の回転角度 */
	private float nextRotate;
	
	/**
	 * コンストラクタ
	 */
	public DirectionAverager(){
		nextRotateArray = new ArrayList<Float>(BUFFER_MAX);
		nextRotateArray2 = new ArrayList<Float>(BUFFER_MAX);
		nextRotateArray3 = new ArrayList<Float>(BUFFER_MAX);

		nextRotate = 0;
	}
	
	/**
	 * 角度設定
	 * @param rotate
	 */
	public void setRotate(float rotate){
		nextRotate = rotate;
		synchronized(nextRotateArray){
			nextRotateArray.add(rotate);
			if(BUFFER_MAX < nextRotateArray.size()){
				nextRotateArray.remove(0);
			}
			
			// 方向バッファの内容から角度の変動量を作成する
			angleNormalize(nextRotateArray2);
			
			// 方向バッファ+変動量から正規化された角度を作る(0~359を超える。2度は362度になったりする)
			float preNum = nextRotateArray.get(0);
			nextRotateArray3.clear();
			for(float f : nextRotateArray2){
				preNum += f;
				nextRotateArray3.add(preNum);
			}
		}
	}
	
	/**
	 * 角度取得
	 * @return
	 */
	public float getRotate(){
		
		float next = 0;
		synchronized(nextRotateArray3){
			if(nextRotateArray3.size() <= 1){
				next = nextRotate;
			}else{
				next = 0;
				for(float f : nextRotateArray3){
					next += f;
				}
				next /= nextRotateArray3.size();
			}
		}
		
		return next;
	}
	
	/**
	 * 角度配列を、正規化する
	 * nextRotateArrayの中身を正規化する
	 * 
	 * 例:
	 * 10,20,40,80 を
	 *  0,10,30,40 にする
	 * 
	 * 2, 1, 0,359,358 を
	 * 0,-1,-1, -1, -1 にする
	 * 
	 * 358,1,5, 0,359 を
	 * 0,  3,2,-5, -1 にする
	 * 
	 * @param result
	 */
	private void angleNormalize(ArrayList<Float> result){
		synchronized(nextRotateArray){
			result.clear();
			result.ensureCapacity(nextRotateArray.size());
			float preNum = 0;
			result.add(preNum);
			for(int i=0; i<nextRotateArray.size()-1; i++){
				float num1 = nextRotateArray.get(i);
				float num2 = nextRotateArray.get(i+1);
				float distance = num2 - num1;
				float distanceABS = Math.abs(distance);
				float distance2 = distance;
				if((360/2) < distanceABS){
					if(distance < 0){
						distance2 += 360;
					}
					if(0 < distance){
						distance2 -= 360;
					}
				}
				float num = preNum + distance2;
				result.add(num);
			}
		}
	}
	
	/**
	 * 与えたれた値を0~360の間に丸め込む
	 * @return
	 */
	public float rounding(float angle){
		float result = angle;
		if(result < 0){
			result = (result%360)+360;	// -10は350にする
		}
		if(360 < result){
			result = (result%360);	// 370は10にする
		}
		return result;
	}
	
	/**
	 * バッファクリア
	 */
	public void clear(){
		synchronized(nextRotateArray){
			nextRotateArray.clear();
			nextRotateArray2.clear();
			nextRotateArray3.clear();
			nextRotate = 0;
		}
	}
	
	/**
	 * デバッグ機能:方向配列トレース
	 * @return
	 */
	public void debug_trace(){
		synchronized (nextRotateArray) {
			System.out.print("生角度:");
			for(float f : nextRotateArray){
				System.out.print(String.format(",%4d",(int)f));
			}
			System.out.println("");
			System.out.print("正規化:");
			for(float f : nextRotateArray3){
				System.out.print(String.format(",%4d",(int)f));
			}
			System.out.println("");
		}
	}
}

平均化を行うクラスです。setRotate()で角度を内部配列にキャッシュしてゆき、getRotate()でキャッシュされている角度の平均値を取得します。

nextRotateArray
nextRotateArray2
nextRotateArray3

という配列があり、nextRotateArray に角度の生データを格納してゆき、nextRotateArray2 に上記説明の「前の角度との差分」を格納します。そして最終的に nextRotateArray3 に正規化された角度データが格納されます。あとは、nextRotateArray3 内のデータの平均を計算すればよいだけです。


Sample.java

/**
 * 方向平均化サンプル
 * @author ootanAW
 *
 */
public class Sample {
	
	/** 平均化オブジェクト */
	private static DirectionAverager dAverager;
	
	/**
	 * 開始
	 * @param args
	 */
	public static void main(String[] args) {
		
		dAverager = new DirectionAverager();
		
		// テスト1
		System.out.println("テスト1");
		float data[] = {0.0f, 90.0f};		// 平均をとりたい角度。0度と90度の平均は45度になります
		proc(dAverager, data);			// 平均化処理
		dAverager.clear();			// 角度キャッシュクリア
		
		// テスト2(0から180まで45度ずつ
		System.out.println("テスト2");
		float data2[] = {0.0f, 45.0f, 90.0f, 135.0f, 180.0f};	// 0,45,90,135,180度の平均は平均90度になります
		proc(dAverager, data2);
		dAverager.clear();
		
		// テスト3(0度をまたぐデータ
		System.out.println("テスト3");
		float data3[] = {350.0f, 10.0f};	// 平均0度
		proc(dAverager, data3);
		dAverager.clear();
		
		// テスト4(テスト3に45度を加えたデータ
		System.out.println("テスト4");
		float data4[] = {350.0f, 10.0f, 45};	// 平均15度
		proc(dAverager, data4);
		dAverager.clear();
    }
	
	/**
	 * 平均化処理
	 * @param dAverager
	 * @param data
	 */
	private static void proc(DirectionAverager dAverager, float data[]){
		for(float angel : data){
			dAverager.setRotate(angel);	// 【1】
		}
		float rotate = dAverager.getRotate();	// 【2】
		System.out.println("平均角度:"+ rotate +"("+ dAverager.rounding(rotate)+")度");
		dAverager.debug_trace();
		System.out.println("");
	}
}

これは、角度平均化 DirectionAverager クラスを使って、テスト1〜4の、4パターンの平均化結果を出力しているだけのコードです。
テスト1〜4で用意されているdata1〜data4の値を、それぞれproc()の中でDirectionAveragerに設定してゆき【1】、そして平均化した値を取り出しています【2】。

このサンプルを実行すると以下のように出力されます。

テスト1
平均角度:45.0(45.0)度
生角度:,   0,  90
正規化:,   0,  90

テスト2
平均角度:90.0(90.0)度
生角度:,   0,  45,  90, 135, 180
正規化:,   0,  45,  90, 135, 180

テスト3
平均角度:360.0(360.0)度
生角度:, 350,  10
正規化:, 350, 370

テスト4
平均角度:375.0(15.0)度
生角度:, 350,  10,  45
正規化:, 350, 370, 405

注目はテスト3と4です。
テスト3では、{ 350, 10 } という角度が与えられており、このままこの2値の平均を出したら180になっていまいますが、処理結果は理想の360度が取得できています。

生角度:, 350,  10
正規化:, 350, 370

注目はここで、生の角度は { 350, 10 } ですが、正規化された後のデータは { 350, 370 } になっています。
テスト4も同様、正規化によって { 350, 10, 45 } が { 350, 370, 405 } になっています。

余談

もし、使用するシステム側で-10度を350度、370度を10度と認識していないのなら、得られたこの値を0〜360に丸め込みます。
DirectionAverager#rounding()がその処理で、引数の値を0〜360に丸め込んで返します。