9軸センサーMPU9250へのカルマンフィルタ適用

ジャイロ3D表示
ジャイロ傾斜角の3D表示

ジャイログラフ表示
ジャイロ傾斜角のグラフ表示

MPU-9250 9軸センサモジュールは、3軸加速度、3軸ジャイロ、3軸コンパスセンサが1チップに内蔵され、コンパクトでいろいろなアプリケーションに使われている。このブログの中でもラズドローンの姿勢を測定するのに使った。ジャイロの出力値が角速度であるので、数値積分して姿勢角を算出するが、積分計算を含むためドリフト誤差を持つ。一方、加速度はX軸、Y軸、Z軸の加速度の大きさから幾何学的に姿勢角が算出できるがノイズ成分がでてくる。そこで、参考図書「カルマンフィルタの基礎」(足立修一・丸田一郎共著、東京電機大学出版局)のカルマンフィルタの応用としての相補フィルタの適用例を参考にして、姿勢角を精度よく算出できたので、その作成過程を記録に残すことにした。

●使用した9軸センサモジュールMPU-9250

9250チップ
MPU-9250

ストロベリー リナックス社販売:価格2,200円(税込)

・InvenSense社(インベンセンス)の9軸センサ MPU-9250をモジュールにしたもので、1チップに3軸加速度、3軸ジャイロ、3軸コンパスセンサが内蔵されている。3軸コンパスセンサは旭化成AK8963。
◇加速度
 ・測定レンジ ±2 / ±4 / ±8 / ±16g、分解能:16ビット
◇ジャイロ
 ・測定レンジ ±250 / ±500 / ±1000 / ±2000dps(°/sec)、分解能:16ビット
◇コンパス(AK8963)
 ・測定レンジ:±4800μT、分解能:14ビット/16ビット

●ラズパイとMPU-9250との接続

ラズパイとの接続
ラズパイとの接続はI2C

Raspberry Pi 4BとMPU-9250は、CSをVDDIOに接続してI2C接続とした。AD0=LでI2Cスレーブアドレスは0x68とした。

●MPU9250のXYZ軸方向

MPU-9250 軸方向

●MPU9250からのデータ取得プログラム

MPU9250.py
このモジュールを使ったサンプルプログラムでは、単純に加速度、ジャイロ角速度、コンパス磁気を読取り、表示している。

# -*- coding: utf-8 -*-
import smbus
import time

I2C = smbus.SMBus(1)	# I2C smbus

MP9250_ADR = 0x68	# MPU9250 I2C slave address
AK8963_ADR = 0x0C	# AK8963 I2C slave address
DEVICE_ID = 0x71	## Device id

# MPU-9250 Register Addresses
SMPLRT_DIV     = 0x19
CONFIG         = 0x1A
GYRO_CONFIG    = 0x1B
ACCEL_CONFIG   = 0x1C
ACCEL_CONFIG_2 = 0x1D
INT_PIN_CFG    = 0x37
INT_STATUS     = 0x3A
ACCEL_OUT      = 0x3B
TEMP_OUT       = 0x41
GYRO_OUT       = 0x43
PWR_MGMT_1     = 0x6B
WHO_AM_I       = 0x75

# Gyro Full Scale Index
GFS_250  = 0
GFS_500  = 1
GFS_1000 = 2
GFS_2000 = 3
GYR_FS = [0x00,0x08, 0x10, 0x18]		# ジャイロFSレンジのBit設定 Reg29[4:3]
GYR_RNG = [250.0, 500.0, 1000.0, 2000.0]	# ジャイロFSレンジ(単位=deg/sec)

# Accel Full Scale Index
AFS_2G   = 0
AFS_4G   = 1
AFS_8G   = 2
AFS_16G  = 3
ACC_FS = [0x00, 0x08, 0x10,0x18]	# 加速度FSレンジのBit設定 Reg28[4:3]
ACC_RNG = [2.0, 4.0, 8.0, 16.0]		# 加速度FSレンジ(単位=G)

# AK8963 Register Addresses
AK8963_ST1     = 0x02	# ステータス
AK8963_MAG_OUT = 0x03	# 測定データ
AK8963_CNTL1   = 0x0A	# コントロール
AK8963_ASAX    = 0x10	# 感度調整値

# CNTL1 Mode select
AK8963_MODE_DOWN   = 0x00	# パワーダウンモード
AK8963_MODE_ONE    = 0x01	# 単発測定モード
AK8963_MODE_C8HZ   = 0x02	# 連続測定モード1(8Hz)
AK8963_MODE_C100HZ = 0x06	# 連続測定モード2(100Hz)
AK8963_BIT_14      = 0x00	# 14bits output
AK8963_BIT_16      = 0x10	# 16bits output


class MPU9250:
	def __init__(self, mpuAdr=MP9250_ADR):
		self.mpuAdr = mpuAdr
		self.configMPU9250(gfsNo=GFS_250, afsNo=AFS_2G)	# Default
		self.configAK8963(mode=AK8963_MODE_C8HZ, mfs=AK8963_BIT_16)	# Default

	def searchDevice(self):	# Device Check
		who_am_I = I2C.read_byte_data(self.mpuAdr, WHO_AM_I)
		if(who_am_I == DEVICE_ID):
			return True
		else:
			return False

	def configMPU9250(self, gfsNo, afsNo):	# Configure MPU-9250
		if gfsNo < 0 or gfsNo > 3:
			gfsNo = 3
		self.gres = GYR_RNG[gfsNo] / 32768.0

		if afsNo < 0 or afsNo > 3:
			afsNo = 3
		self.ares = ACC_RNG[afsNo] / 32768.0

		I2C.write_byte_data(self.mpuAdr, PWR_MGMT_1, 0x00)	# sleep off
		time.sleep(0.1)
		I2C.write_byte_data(self.mpuAdr, PWR_MGMT_1, 0x01)	# auto select clock source
		time.sleep(0.1)
		I2C.write_byte_data(self.mpuAdr, CONFIG, 0x03)	# DLPF_CFG(Low Pass Filter 41Hz)
		I2C.write_byte_data(self.mpuAdr, SMPLRT_DIV, 0x04)	# sample rate divider
		I2C.write_byte_data(self.mpuAdr, GYRO_CONFIG, GYR_FS[gfsNo])	# gyro full scale select
		I2C.write_byte_data(self.mpuAdr, ACCEL_CONFIG, ACC_FS[afsNo])	# accel full scale select
		I2C.write_byte_data(self.mpuAdr, ACCEL_CONFIG_2, 0x03)	# A_DLPFCFG(Low Pass Filter 41Hz)
		I2C.write_byte_data(self.mpuAdr, INT_PIN_CFG, 0x02)	# BYPASS_EN
		time.sleep(0.1)

	def configAK8963(self, mode, mfs):	# Configure AK8963
		if mfs == AK8963_BIT_14:
			self.mres = 4912.0/8190.0
		else: #  mfs == AK8963_BIT_16:
			self.mres = 4912.0/32760.0

		I2C.write_byte_data(AK8963_ADR, AK8963_CNTL1, 0x00)
		time.sleep(0.01)
		I2C.write_byte_data(AK8963_ADR, AK8963_CNTL1, 0x0F)	# set read FuseROM mode
		time.sleep(0.01)
		data = I2C.read_i2c_block_data(AK8963_ADR, AK8963_ASAX, 3)	# read coef data
		self.magCoef = [0., 0., 0.]
		for ax in range(3):
			self.magCoef[ax] = (data[ax] - 128) / 256.0 + 1.0

		I2C.write_byte_data(AK8963_ADR, AK8963_CNTL1, 0x00)	# set power down mode
		time.sleep(0.01)
		I2C.write_byte_data(AK8963_ADR, AK8963_CNTL1, (mfs | mode))# set scale&continous mode
		time.sleep(0.01)

	def checkDataReady(self):
		dataReady = I2C.read_byte_data(self.mpuAdr, INT_STATUS)
		if dataReady & 0x01:
			return True
		else:
			return False

	# Read accelerometer and gyro
	def readAccGyro(self):
		data = I2C.read_i2c_block_data(self.mpuAdr, ACCEL_OUT, 14)
		# ACC=data[0:5], TEMP=data[6:7], GYRO=data[8:13] ---- MSB:LSB
		accData = [0., 0., 0.]
		for ax in range(3):	# set accelerometer data
			accData[ax] = self.byte2data(data[ax*2], data[ax*2+1])	# (MSB, LSB)
			accData[ax] = round(accData[ax] * self.ares, 3)
		gyrData = [0., 0., 0.]
		for ax in range(3):	# set gyro data
			gyrData[ax] = self.byte2data(data[8+ax*2], data[9+ax*2])	# (MSB, LSB)
			gyrData[ax] = round(gyrData[ax] * self.gres, 3)
		return accData, gyrData


	# Read mag
	def readMag(self):
		magData = [0., 0., 0.]
		dataReady = I2C.read_byte_data(AK8963_ADR, AK8963_ST1)
		if dataReady & 0x01 :	# Check data ready
			data = I2C.read_i2c_block_data(AK8963_ADR, AK8963_MAG_OUT, 7)
			# MAG=data[0:5] --- (LSB:MSB), OverFlow=data[6]
			if (data[6] & 0x08) != 0x08:	# Check overflow
				for ax in range(3):
					magData[ax] = self.byte2data(data[ax*2+1], data[ax*2])	# (MSB, LSB)
					magData[ax] = round(magData[ax] * self.mres * self.magCoef[ax], 3)
		return magData

	# Read temperature
	def readTemp(self):
		data = I2C.read_i2c_block_data(self.mpuAdr, TEMP_OUT, 2)
		tempData = self.byte2data(data[0], data[1])	# (MSB, LSB)
		tempData = round((tempData / 333.87 + 21.0), 3)
		return tempData

	def byte2data(self, byteMSB, byteLSB):
		value = (byteMSB << 8) | byteLSB
		value = - (value & 0x8000) | (value & 0x7FFF)	# 2の補数
		return value

if __name__ == '__main__':
	mpu9250 = MPU9250(MP9250_ADR)        # 0x68
	if mpu9250.searchDevice():
		print("Who am I? OK")
	else:
		print("Who am I? Error")

	mpu9250.configMPU9250(gfsNo = GFS_2000, afsNo = AFS_16G)
	# Full Scale : Gyro=2000deg/sec, Acc=16g
	mpu9250.configAK8963(mode = AK8963_MODE_C8HZ, mfs = AK8963_BIT_16)
	# mode=Continous 8Hz, 16bit output

	for k in range(1000):
		if not mpu9250.checkDataReady():
			print("Data not ready")
			continue
		accXYZ, gyrXYZ = mpu9250.readAccGyro()
		magXYZ = mpu9250.readMag()
		temp = mpu9250.readTemp()

		strAcc ="Acc = {:7.2f}, {:7.2f}, {:7.2f}, ".format(accXYZ[0], accXYZ[1], accXYZ[2])
		strGyro ="Gyro = {:7.2f}, {:7.2f}, {:7.2f}, ".format(gyrXYZ[0], gyrXYZ[1], gyrXYZ[2])
		strMag ="Mag = {:7.2f}, {:7.2f}, {:7.2f}, ".format(magXYZ[0], magXYZ[1], magXYZ[2])
		strTemp ="Temp = {:7.2f}".format(temp)
		print(strAcc + strGyro + strMag + strTemp)

●カルマンフィルタの計算

 カルマンフィルタの導出については、前述の参考図書「カルマンフィルタの基礎」に記載してあり、MATLABのプログラム事例が記載してあるので、ここではその結果だけを使って、pythonプログラムに書き直した。尚、記号もこの参考図書の記述スタイルと同じとした。


多変数時系列データ \(\boldsymbol{y} (k)\)が次の線形離散時間状態空間モデルで記述されると仮定。

\(\begin{eqnarray}  
\boldsymbol{x}(k+1) &=& \boldsymbol{Ax}(k) + \boldsymbol {Bv}(k) \\
\boldsymbol{y}(k) &=& \boldsymbol{Cx}(k) + \boldsymbol{w}(k) \\
\end{eqnarray}\)

    \(\boldsymbol {A} : n \times n\)行列、\(\boldsymbol {B} : n \times r\)行列、\(\boldsymbol {C} : p \times n\)行列
    \(\boldsymbol{y}(k) : p\)次元時系列ベクトル
    \(\boldsymbol{x}(k) : n\)次元状態ベクトル
    \(\boldsymbol{v}(k) : \)平均値ベクトル\(0、共分散行列\boldsymbol {Q}のr\)次元システム雑音ベクトル
    \(\boldsymbol{w}(k) : \)平均値ベクトル\(0、共分散行列\boldsymbol {R}のp\)次元観測雑音ベクトル

<予測ステップ>
 事前状態推定値:
  \(\boldsymbol{\hat{x}}(k|k-1) = \boldsymbol{A\hat{x}}(k-1)\)
 事前誤差共分散行列
  \(\boldsymbol {P}(k|k-1) = \boldsymbol {AP}(k-1) \boldsymbol {A^T} + \boldsymbol {BQB^T} \)

<フィルタリングステップ>
 カルマンゲイン行列:
  \(\boldsymbol {G}(k) = \displaystyle{\frac{\boldsymbol{P}(k|k-1)\boldsymbol {C^T}} {\boldsymbol {CP}(k|k-1) \boldsymbol {C^T} + \boldsymbol {R}}} \)
 状態推定値:
  \(\boldsymbol{\hat{x}}(k) = \boldsymbol{\hat{x}}(k|k-1) + \boldsymbol {G}(k) \{\boldsymbol{y}(k)\ -\ \boldsymbol{C\hat{x}}(k|k-1)\}\)
 事後誤差共分散行列:
  \(\boldsymbol{P}(k) = \{\boldsymbol{I} – \boldsymbol{G}(k) \boldsymbol{C}\} \boldsymbol{P}(k|k-1) \)

  \(\boldsymbol{\hat{x}}(k|k-1)\) : 時刻\(k-1\)までに利用可能なデータに基づいた時刻\(k\)における\(\boldsymbol {x}\)の事前予測推定値
  \(\boldsymbol{\hat{x}}(k) = \hat{x}(k|k)\): 時刻\(k\)までに利用可能なデータを用いた時刻\(k\)において求める\(\boldsymbol {x}\)のフィルタリング推定値

●カルマンフィルタのpythonモジュール

KalmanFilter.py
上述の計算式を、kalmanFilterという関数にした。この関数の引数、戻り値は次の通り。ここで、u(k)は制御入力であるが、今回は使わない。

関数 kalmanFilter(A, B, Bu, C, Q, R, u, y, xhat, P)
引数:
  A, B, Bu, C : 対象システム
   x(k+1) = A * x(k) + B * v(k) + Bu * u(k)
   y(k) = C * x(k) + w(k)
  Q, R : 雑音 v, wの共分散行列。v, w は正規性白色雑音
   E[v(k)] = E[w(k)] = 0
   E[v(k)’v(k)] = Q,  E[w(k)’w(k)] = R であることを想定
  u: 状態更新前時点での制御入力 u(k-1)
  y: 状態更新前時点での観測出力 y(k)
  xhat, P : 更新前の状態推定値 xhat(k-1)、誤差共分散行列 P(k-1)
戻り値:
  xhat_new: 更新後の状態推定値 xhat(k)
  P_new: 更新後の誤差共分散行列 P(k)
  G: カルマンゲイン G(k)

# -*- coding: utf-8 -*-
import numpy as np

def kalmanFilter(A, B, Bu, C, Q, R, u, y, xhat, P):
	## 予測ステップ
	nn = A.shape[0]
	#事前状態推定値
	xhat_m = A @ xhat + Bu @ u
	# 事前誤差共分散行列
	P_m = A @ P @ A.T + B @ Q @ B.T

	## フィルタリングステップ
	#カルマンゲイン行列
	G = P_m @ C.T / (C @ P_m @ C.T + R)
	# 状態推定値
	xhat_new = xhat_m + G @ (y - C @ xhat_m)
	# 事後誤差共分散行列
	I = np.eye(nn)
	P_new = (I - G @ C) @ P_m
	return xhat_new, P_new, G

●相補フィルタを用いた傾き角の計算

ジャイロセンサの測定誤差\(e_{1}(k)\) は、1次低域通過フィルタ\(\mu(k)\) とバイアス成分\(\beta(k)\) を用いて次の式で記述できる。
 \(e_{1}(k) = \mu(k) + \beta(k)\)
  ただし
   \(\mu(k+1) = a\mu(k) + (1-a)v_{1}(k), \mu(0) = \mu_{0}\)
     \(0 < a < 1\)
     \(v_{1}(k)\) : 平均値\(0\)、分散\(\sigma^{2}_{v_1}\)の正規性白色雑音
   \(\beta(k+1) = \beta(k), \beta(0) = \beta_{0}\)

加速度センサの測定誤差\(e_{2}(k)\)は、
  \(e_{2}(k)\ =\ v_{2}(k-1)\)
    \(v_{2}(k):平均値0\)、分散\(σ^{2}_{v_2}で、v_{1}\)と独立した正規性白色雑音。


2つのセンサ出力\(y_{1}(k), y_{2}(k)\)は、真値を\(z(k)\)とすると、
  \(\begin{eqnarray}
  y_{1}(k) &=& z(k) + e_{1}(k)\\
  y_{2}(k) &=& z(k) + e_{2}(k)\\
  \end{eqnarray}\)

相補フィルタの観測方程式は
 \(\begin{eqnarray}
 y(k)&=&y_{1}(k)\ –\ y_{2}(k)\\
 &=& e_{1}(k)\ –\ e_{2}(k)\\
 &=& \mu(k) + \beta(k)\ –\ e_{2}(k)\\
 &=& \boldsymbol {cx}(k)
 \end{eqnarray}\)
 ここで
   \(\boldsymbol {c} = \left[\array{1&1&-1}\right]\)   \(\boldsymbol{x}(k) = \left[\array{\mu(k)\\\beta(k)\\e_{2}(k)}\right]\)

状態推定値\(\hat{x}(k)\)からそれぞれのセンサ測定誤差は
  \(\begin{eqnarray}
  \hat{e_{1}}(k) &=& \hat{x_{1}}(k) + \hat{x_{2}}(k)\\
  \hat{e_{2}}(k) &=& \hat{x_{3}}(k)
  \end{eqnarray}\)

補正された測定値\(\hat{y}(k)\)は次の式から求められる。
  \(\begin{eqnarray}
  \hat{y}(k) &=& y(k)\ -\ \hat{x_{1}}(k)\ -\ \hat{x_{2}}(k)\\
  \hat{y}(k) &=& y(k)\ –\ \hat{x_{3}}(k)
  \end{eqnarray}\)

計算には次のパラメータ、初期値を用いた。

 \(\boldsymbol {A} = \left[\array{a&0&0\\0&1&0\\0&0&0}\right]\)  \(\boldsymbol {B} = \left[\array{1-a&0\\0&0\\0&1}\right]\)  \(\boldsymbol {P}(0) = \left[\array{1000&0&0\\0&1000&0\\0&0&1000}\right]\)

 \(\boldsymbol {Q} = \left[\array{60&0\\0&60}\right]\)  \(R = 10^{-4}\)

●カルマンフィルタを用いた傾き角の算出プログラム

getAngle.py
MPU9250からジャイロ、加速度データを読み出し、カルマンフィルタを通して補正した角度データを出力する。
このプログラムでは、データをテキストデータとしてファイルに保存

# -*- coding: utf-8 -*-
import MPU9250 as MP
import time
import math
import KalmanFilter as KF
import numpy as np

mpu9250 = MP.MPU9250(MP.MP9250_ADR)        # 0x68
if not mpu9250.searchDevice():
	print("Who am I? Error")
	exit(1)

mpu9250.configMPU9250(gfsNo = MP.GFS_2000, afsNo = MP.AFS_16G)
# Full Scale : Gyro=2000deg/sec, Acc=16g
mpu9250.configAK8963(mode = MP.AK8963_MODE_C8HZ, mfs = MP.AK8963_BIT_16)
# mode=Continous 8Hz, 16bit output

def main():
	global preTime, preXdegS, preYdegS, preZdegS
	global xDegSum, yDegSum
	global xhat_k, yhat_k, xnP, ynP
	
	if not mpu9250.checkDataReady():
		return ""
	[accX, accY, accZ], [gyroX, gyroY, gyroZ] = mpu9250.readAccGyro()
	gyroX += gyroOffX
	gyroY += gyroOffY
	gyroZ += gyroOffZ

	# Gyro 角速度の積分
	nowTime = time.time()
	dT = nowTime - preTime
	xDegSum = xDegSum + (preXdegS + gyroX) / 2.0 * dT
	yDegSum = yDegSum + (preYdegS + gyroY) / 2.0 * dT
	preXdegS, preYdegS = gyroX, gyroY
	preTime = nowTime
	
	# 加速度から角度算出
	xDegAcc = math.degrees(math.atan(accY / math.sqrt(accX * accX + accZ * accZ)))
	yDegAcc = math.degrees(math.atan(-accX / math.sqrt(accY * accY + accZ * accZ)))

	# Kalman Filterによる補正(X軸、Y軸)
	xDeg = np.array([[xDegSum - xDegAcc]])
	yDeg = np.array([[yDegSum - yDegAcc]])
	# 時間更新
	xhat_k, xnP, G = KF.kalmanFilter(matA, matB, matBu, matC, matQ, nR, u, xDeg, xhat_k, xnP)
	yhat_k, ynP, G = KF.kalmanFilter(matA, matB, matBu, matC, matQ, nR, u, yDeg, yhat_k, ynP)
	# 推定値を用いて各センサ出力を補正
	xDeghat1 = xDegSum - xhat_k[0] -xhat_k[1]
	yDeghat1 = yDegSum - yhat_k[0] -yhat_k[1]

	dataStr = " {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}, {:10.4f}"\
		.format(nowTime - timeStart, xDegAcc, xDegSum, gyroX, xDeghat1[0], yDegAcc, yDegSum, gyroY, yDeghat1[0])
	return dataStr

# Gyro Offset
gyroOffX = 0.121
gyroOffY = -0.171
gyroOffZ = 0.0

# Initial Data
xDegSum, yDegSum = 0.0, 0.0
preXdegS, preYdegS = 0.0, 0.0
preTime = time.time()
timeStart= preTime

# Kalman Filter Parameter
a = 0.75
matA = np.array([[a, 0, 0], [0, 1, 0], [0, 0, 0]])
matB = np.array([[1-a, 0], [0, 0], [0, 1]])	
matBu = np.array([[0]])
matQ = np.array([[60, 0], [0, 60]])
nR = 1e-4
matC = np.array([[1, 1, -1]])
u = np.array([[0]])
xhat_k = np.array([[0.], [0.], [0.]])
yhat_k = np.array([[0.], [0.], [0.]])
xnP = np.eye(3) * 1000						# 誤差共分散
ynP = np.eye(3) * 1000						# 誤差共分散

fname = "degdata.txt"
fp = open(fname, 'w')

while True:
	try:
		dataStr = main()
		if len(dataStr) > 0:
			fp.write(dataStr+"\n")
	except KeyboardInterrupt:
		break
fp.close()

●カルマンフィルタを通した計算結果

(1)ジャイロ読取りデータのオフセット量

センサーを傾きの無い場所に置いた時に、ジャイロデータの読み取り値がゼロにならないので、読み取り値に対するオフセット量を設定した。
<ジャイロ読取り値(オフセット無し)>
ジャイロデータ(deg/sec)がゼロ点からズレているので、積分値(deg)は大きく変化している。

Offset設定前
<ジャイロ読取り値(オフセット無し)>

<ジャイロ読取り値(オフセット補正後)>

ゼロ点からのズレ量、X軸:-0.121、Y軸:0.171を補正した結果、読取り値はほぼゼロ点付近となり、積分値(deg)の大幅な変化はない。

Offset設定後
<ジャイロ読取り値(オフセット補正後)>
(2)カルマンフィルタの効果

カルマンフィルタを通した角度データ(赤色線)はジャイロ積分データ(緑色線)のドリフトや、加速度から計算した角度データ(青色線)のノイズの影響を排除したデータとなっている。

カルマンフィルタの効果

<ジャイロの角速度と積分データ>

ジャイロの角速度データ(黒線)と積分データ(緑線)をプロットした。角速度は高い周波数で変化している。サンプリング周期は5msec.

●MPU-9250-9軸センサの傾きを3D描画

カルマンフィルタを用いた相補フィルタを適用して、姿勢角を得て、3D表示。3D表示はVPythonを使った。

センサの傾きを3D表示

●MPU-9250-9軸センサの傾きをグラフ表示

ジャイロの角速度と加速度から算出した傾斜角と、カルマンフィルタを通して補正した傾斜角をグラフ表示した。グラフはpygameを使って描画。

傾斜角度の変化をフラフ表示