9軸センサーMPU9250へのカルマンフィルタ適用
MPU-9250 9軸センサモジュールは、3軸加速度、3軸ジャイロ、3軸コンパスセンサが1チップに内蔵され、コンパクトでいろいろなアプリケーションに使われている。このブログの中でもラズドローンの姿勢を測定するのに使った。ジャイロの出力値が角速度であるので、数値積分して姿勢角を算出するが、積分計算を含むためドリフト誤差を持つ。一方、加速度はX軸、Y軸、Z軸の加速度の大きさから幾何学的に姿勢角が算出できるがノイズ成分がでてくる。そこで、参考図書「カルマンフィルタの基礎」(足立修一・丸田一郎共著、東京電機大学出版局)のカルマンフィルタの応用としての相補フィルタの適用例を参考にして、姿勢角を精度よく算出できたので、その作成過程を記録に残すことにした。
●使用した9軸センサモジュール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との接続
Raspberry Pi 4BとMPU-9250は、CSをVDDIOに接続してI2C接続とした。AD0=LでI2Cスレーブアドレスは0x68とした。
●MPU9250のXYZ軸方向
●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)は大きく変化している。
<ジャイロ読取り値(オフセット補正後)>
ゼロ点からのズレ量、X軸:-0.121、Y軸:0.171を補正した結果、読取り値はほぼゼロ点付近となり、積分値(deg)の大幅な変化はない。
(2)カルマンフィルタの効果
カルマンフィルタを通した角度データ(赤色線)はジャイロ積分データ(緑色線)のドリフトや、加速度から計算した角度データ(青色線)のノイズの影響を排除したデータとなっている。
<ジャイロの角速度と積分データ>
ジャイロの角速度データ(黒線)と積分データ(緑線)をプロットした。角速度は高い周波数で変化している。サンプリング周期は5msec.
●MPU-9250-9軸センサの傾きを3D描画
カルマンフィルタを用いた相補フィルタを適用して、姿勢角を得て、3D表示。3D表示はVPythonを使った。
●MPU-9250-9軸センサの傾きをグラフ表示
ジャイロの角速度と加速度から算出した傾斜角と、カルマンフィルタを通して補正した傾斜角をグラフ表示した。グラフはpygameを使って描画。