← Quay lại Blog
navigationroboticsnavigationprogramming

Kalman Filter cho Robot: Sensor Fusion thực hành

Hướng dẫn Kalman Filter và EKF — kết hợp IMU, encoder, GPS để định vị robot chính xác hơn.

Nguyễn Anh Tuấn25 tháng 1, 202613 phút đọc
Kalman Filter cho Robot: Sensor Fusion thực hành

Vấn đề: Tại sao một sensor không đủ?

Mỗi sensor trên robot đều có hạn chế riêng:

Kalman Filter giải quyết bằng cách kết hợp tất cả sensors, lấy ưu điểm của mỗi sensor và bù đắp nhược điểm. Kết quả: ước lượng vị trí chính xác hơn bất kỳ sensor đơn lẻ nào.

Sensor fusion kết hợp nhiều nguồn dữ liệu để định vị chính xác

Kalman Filter -- Trực giác

Hãy tưởng tượng bạn đang ước lượng vị trí một robot di chuyển trong hành lang:

  1. Predict: "Dựa trên vận tốc hiện tại, mình nghĩ robot sẽ ở vị trí X sau 0.1 giây" (dùng model)
  2. Update: "Sensor GPS nói robot ở vị trí Y" (dùng measurement)
  3. Fuse: "Model nói X, sensor nói Y. Model tốt hơn GPS ở short-term, nên mình tin model 70%, GPS 30% → vị trí ước lượng Z"

Kalman Filter tự động tính tỷ lệ tin tưởng (Kalman Gain) dựa trên uncertainty (covariance) của mỗi nguồn thông tin. Sensor nào ít noise hơn → được "tin" nhiều hơn.

Toán học Kalman Filter

State vector

Với robot 2D, state vector thường gồm:

x = [x, y, theta, v, omega]^T

Predict step

x_predicted = F * x_prev + B * u
P_predicted = F * P_prev * F^T + Q

Update step

y = z - H * x_predicted          # Innovation (measurement residual)
S = H * P_predicted * H^T + R    # Innovation covariance
K = P_predicted * H^T * S^(-1)   # Kalman Gain
x_updated = x_predicted + K * y  # Updated state
P_updated = (I - K * H) * P_predicted  # Updated covariance

Ý nghĩa Kalman Gain

Python Implementation -- Linear Kalman Filter

import numpy as np
import matplotlib.pyplot as plt

class KalmanFilter:
    """Linear Kalman Filter for robot localization."""

    def __init__(self, dim_state: int, dim_measurement: int, dim_control: int = 0):
        self.n = dim_state
        self.m = dim_measurement

        # State estimate and covariance
        self.x = np.zeros((self.n, 1))         # State vector
        self.P = np.eye(self.n) * 1000.0       # High initial uncertainty

        # System matrices
        self.F = np.eye(self.n)                # State transition
        self.H = np.zeros((self.m, self.n))    # Observation
        self.Q = np.eye(self.n) * 0.01         # Process noise
        self.R = np.eye(self.m) * 1.0          # Measurement noise
        self.B = np.zeros((self.n, dim_control)) if dim_control > 0 else None

    def predict(self, u: np.ndarray = None):
        """Predict step."""
        if u is not None and self.B is not None:
            self.x = self.F @ self.x + self.B @ u
        else:
            self.x = self.F @ self.x
        self.P = self.F @ self.P @ self.F.T + self.Q
        return self.x.copy()

    def update(self, z: np.ndarray):
        """Update step with measurement."""
        z = z.reshape(-1, 1)
        y = z - self.H @ self.x                      # Innovation
        S = self.H @ self.P @ self.H.T + self.R      # Innovation covariance
        K = self.P @ self.H.T @ np.linalg.inv(S)     # Kalman Gain

        self.x = self.x + K @ y
        I = np.eye(self.n)
        self.P = (I - K @ self.H) @ self.P
        return self.x.copy()

    def get_state(self) -> np.ndarray:
        return self.x.flatten()

    def get_covariance(self) -> np.ndarray:
        return self.P.copy()

Ví dụ: Robot 1D với encoder + GPS

# Mô phỏng robot di chuyển trên đường thẳng
np.random.seed(42)
dt = 0.1          # 10 Hz
duration = 30.0   # 30 seconds
velocity = 1.0    # 1 m/s constant velocity

# Ground truth
times = np.arange(0, duration, dt)
true_positions = velocity * times

# Noisy measurements
encoder_noise_std = 0.1   # Encoder: low noise, but drifts
gps_noise_std = 3.0       # GPS: high noise, no drift
gps_rate = 1.0            # GPS at 1 Hz

encoder_readings = true_positions + np.cumsum(
    np.random.normal(0, encoder_noise_std * dt, len(times))
)
gps_readings = true_positions + np.random.normal(0, gps_noise_std, len(times))

# Kalman Filter setup
kf = KalmanFilter(dim_state=2, dim_measurement=1)

# State: [position, velocity]
kf.F = np.array([[1, dt],
                  [0, 1]])    # Constant velocity model
kf.H = np.array([[1, 0]])    # We measure position only
kf.Q = np.array([[0.01, 0],
                  [0, 0.01]]) # Process noise
kf.R = np.array([[gps_noise_std**2]])  # GPS measurement noise
kf.x = np.array([[0], [velocity]])     # Initial state

# Run filter
kf_positions = []
kf_velocities = []
uncertainties = []

for i, t in enumerate(times):
    # Predict (every step)
    kf.predict()

    # Update with GPS (every 1 second = every 10 steps)
    if i % int(gps_rate / dt) == 0:
        kf.update(np.array([gps_readings[i]]))

    state = kf.get_state()
    kf_positions.append(state[0])
    kf_velocities.append(state[1])
    uncertainties.append(np.sqrt(kf.P[0, 0]))

# Plot results
fig, axes = plt.subplots(3, 1, figsize=(12, 10))

axes[0].plot(times, true_positions, 'g-', label='Ground Truth', linewidth=2)
axes[0].plot(times, encoder_readings, 'b.', label='Encoder (drift)', alpha=0.3, markersize=2)
axes[0].scatter(times[::10], gps_readings[::10], c='r', s=20, label='GPS (noisy)', alpha=0.5)
axes[0].plot(times, kf_positions, 'k-', label='Kalman Filter', linewidth=1.5)
axes[0].set_ylabel('Position (m)')
axes[0].set_title('Sensor Fusion: Encoder + GPS')
axes[0].legend()

axes[1].plot(times, np.array(kf_positions) - true_positions, 'k-', label='KF Error')
axes[1].fill_between(times,
    -2 * np.array(uncertainties),
    2 * np.array(uncertainties),
    alpha=0.2, color='blue', label='2-sigma uncertainty')
axes[1].set_ylabel('Error (m)')
axes[1].set_title('Estimation Error with Uncertainty Bounds')
axes[1].legend()

axes[2].plot(times, kf_velocities, 'k-', label='Estimated Velocity')
axes[2].axhline(y=velocity, color='g', linestyle='--', label='True Velocity')
axes[2].set_ylabel('Velocity (m/s)')
axes[2].set_xlabel('Time (s)')
axes[2].set_title('Velocity Estimation')
axes[2].legend()

plt.tight_layout()
plt.savefig('kalman_filter_demo.png', dpi=150)
plt.show()

Extended Kalman Filter (EKF) cho hệ phi tuyến

Linear Kalman Filter giả sử hệ thống là tuyến tính (linear). Nhưng robot thực tế không tuyến tính -- quay heading angle theta làm cho x, y thay đổi theo sin/cos.

EKF xử lý bằng cách linearize (tuyến tính hóa) tại operating point, thay F bằng Jacobian matrix.

EKF cho differential-drive robot

class ExtendedKalmanFilter:
    """EKF for differential-drive robot localization."""

    def __init__(self):
        # State: [x, y, theta]
        self.x = np.zeros((3, 1))
        self.P = np.eye(3) * 0.1

        # Noise
        self.Q = np.diag([0.1, 0.1, 0.05])   # Process noise
        self.R_gps = np.diag([2.0, 2.0])      # GPS noise
        self.R_imu = np.array([[0.01]])        # IMU heading noise

    def predict(self, v: float, omega: float, dt: float):
        """
        Predict using motion model.
        v: linear velocity (from wheel encoders)
        omega: angular velocity (from gyroscope)
        """
        theta = self.x[2, 0]

        # Nonlinear motion model
        # x_new = x + v * cos(theta) * dt
        # y_new = y + v * sin(theta) * dt
        # theta_new = theta + omega * dt
        dx = np.array([
            [v * np.cos(theta) * dt],
            [v * np.sin(theta) * dt],
            [omega * dt]
        ])
        self.x = self.x + dx

        # Normalize theta to [-pi, pi]
        self.x[2, 0] = (self.x[2, 0] + np.pi) % (2 * np.pi) - np.pi

        # Jacobian of motion model (F = df/dx)
        F = np.array([
            [1, 0, -v * np.sin(theta) * dt],
            [0, 1,  v * np.cos(theta) * dt],
            [0, 0, 1]
        ])

        self.P = F @ self.P @ F.T + self.Q
        return self.x.copy()

    def update_gps(self, gps_x: float, gps_y: float):
        """Update with GPS measurement (x, y)."""
        z = np.array([[gps_x], [gps_y]])
        H = np.array([
            [1, 0, 0],
            [0, 1, 0]
        ])
        self._update(z, H, self.R_gps)

    def update_imu_heading(self, heading: float):
        """Update with IMU compass heading."""
        z = np.array([[heading]])
        H = np.array([[0, 0, 1]])

        # Handle angle wrapping in innovation
        y = z - H @ self.x
        y[0, 0] = (y[0, 0] + np.pi) % (2 * np.pi) - np.pi

        S = H @ self.P @ H.T + self.R_imu
        K = self.P @ H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        self.x[2, 0] = (self.x[2, 0] + np.pi) % (2 * np.pi) - np.pi
        self.P = (np.eye(3) - K @ H) @ self.P

    def _update(self, z, H, R):
        """Generic update step."""
        y = z - H @ self.x                    # Innovation
        S = H @ self.P @ H.T + R              # Innovation covariance
        K = self.P @ H.T @ np.linalg.inv(S)   # Kalman Gain

        self.x = self.x + K @ y
        self.P = (np.eye(3) - K @ H) @ self.P

    def get_state(self):
        return self.x.flatten()  # [x, y, theta]

Simulation: EKF Sensor Fusion (Encoder + IMU + GPS)

# Mô phỏng robot chạy vòng tròn
np.random.seed(42)
dt = 0.05      # 20 Hz control
duration = 60  # 60 seconds

# Robot parameters
v_true = 0.5           # 0.5 m/s
omega_true = 0.1       # Turning rate (rad/s) → vòng tròn

times = np.arange(0, duration, dt)
n_steps = len(times)

# Ground truth trajectory (circle)
true_x, true_y, true_theta = [0.0], [0.0], [0.0]
for i in range(1, n_steps):
    true_theta.append(true_theta[-1] + omega_true * dt)
    true_x.append(true_x[-1] + v_true * np.cos(true_theta[-1]) * dt)
    true_y.append(true_y[-1] + v_true * np.sin(true_theta[-1]) * dt)

true_x, true_y, true_theta = np.array(true_x), np.array(true_y), np.array(true_theta)

# Noisy sensor readings
encoder_v = v_true + np.random.normal(0, 0.05, n_steps)      # Encoder velocity
imu_omega = omega_true + np.random.normal(0, 0.02, n_steps)  # Gyroscope
gps_x = true_x + np.random.normal(0, 1.5, n_steps)           # GPS x
gps_y = true_y + np.random.normal(0, 1.5, n_steps)           # GPS y
imu_heading = true_theta + np.random.normal(0, 0.1, n_steps) # Compass

# Run EKF
ekf = ExtendedKalmanFilter()
est_x, est_y, est_theta = [], [], []

for i in range(n_steps):
    # Predict with encoder + gyro (every step, 20 Hz)
    ekf.predict(encoder_v[i], imu_omega[i], dt)

    # Update with GPS (every 1 second = every 20 steps)
    if i % 20 == 0:
        ekf.update_gps(gps_x[i], gps_y[i])

    # Update with IMU heading (every 5 steps = 4 Hz)
    if i % 5 == 0:
        ekf.update_imu_heading(imu_heading[i])

    state = ekf.get_state()
    est_x.append(state[0])
    est_y.append(state[1])
    est_theta.append(state[2])

# Plot
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Trajectory
axes[0].plot(true_x, true_y, 'g-', linewidth=2, label='Ground Truth')
axes[0].scatter(gps_x[::20], gps_y[::20], c='r', s=15, alpha=0.5, label='GPS')
axes[0].plot(est_x, est_y, 'b-', linewidth=1.5, label='EKF Estimate')
axes[0].set_xlabel('X (m)')
axes[0].set_ylabel('Y (m)')
axes[0].set_title('Robot Trajectory -- EKF Sensor Fusion')
axes[0].legend()
axes[0].set_aspect('equal')
axes[0].grid(True, alpha=0.3)

# Error over time
error = np.sqrt((np.array(est_x) - true_x)**2 + (np.array(est_y) - true_y)**2)
axes[1].plot(times, error, 'b-', label='Position Error (EKF)')
axes[1].set_xlabel('Time (s)')
axes[1].set_ylabel('Error (m)')
axes[1].set_title('Localization Error Over Time')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('ekf_sensor_fusion.png', dpi=150)
plt.show()

Kết quả EKF sensor fusion -- ước lượng chính xác hơn từng sensor đơn lẻ

ROS 2 robot_localization Package

Trong thực tế, bạn không cần implement EKF từ đầu. ROS 2 có package robot_localization cung cấp EKF và UKF nodes sẵn.

Config robot_localization

# ekf.yaml -- Config cho robot_localization EKF node
ekf_filter_node:
  ros__parameters:
    frequency: 30.0
    sensor_timeout: 0.1
    two_d_mode: true   # Robot 2D (ground robot)

    # World frame
    map_frame: map
    odom_frame: odom
    base_link_frame: base_link
    world_frame: odom

    # Sensor 1: Wheel odometry
    odom0: /wheel_odom
    odom0_config: [true, true, false,   # x, y, z
                   false, false, true,   # roll, pitch, yaw
                   true, true, false,    # vx, vy, vz
                   false, false, true,   # vroll, vpitch, vyaw
                   false, false, false]  # ax, ay, az

    # Sensor 2: IMU
    imu0: /imu/data
    imu0_config: [false, false, false,   # x, y, z
                  false, false, true,    # roll, pitch, yaw
                  false, false, false,   # vx, vy, vz
                  false, false, true,    # vroll, vpitch, vyaw
                  true, true, false]     # ax, ay, az
    imu0_differential: true   # Use differential mode for heading

    # Sensor 3: GPS (optional, requires navsat_transform)
    # odom1: /gps/odom
    # odom1_config: [true, true, false, ...]

    # Process noise
    process_noise_covariance: [0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0.06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0.03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0.03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0.06, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0.025, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0.025, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0.04, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.02, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.01, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.015]

Launch file

# ekf_launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory
import os

def generate_launch_description():
    config = os.path.join(
        get_package_share_directory('my_robot_localization'),
        'config', 'ekf.yaml'
    )

    return LaunchDescription([
        Node(
            package='robot_localization',
            executable='ekf_node',
            name='ekf_filter_node',
            output='screen',
            parameters=[config],
            remappings=[
                ('odometry/filtered', '/odom/filtered'),
            ]
        ),
    ])

So sánh KF, EKF, UKF

Feature Kalman Filter Extended KF (EKF) Unscented KF (UKF)
Hệ thống Linear only Nonlinear (linearize) Nonlinear (sigma points)
Accuracy Optimal (linear) Xấp xỉ bậc 1 Xấp xỉ bậc 2
Tính Jacobian Không cần Cần (thủ công hoặc autodiff) Không cần
Tốc độ Nhanh nhất Nhanh Chậm hơn ~2x
Khi nào dùng Hệ linear Hệ nonlinear "nhẹ" Hệ highly nonlinear
ROS 2 support robot_localization robot_localization robot_localization

Trong thực tế, EKF là lựa chọn phổ biến nhất cho robot localization vì cân bằng tốt giữa accuracy và computational cost. UKF chỉ cần khi nonlinearity rất mạnh (ví dụ: underwater robot với complex dynamics).

Tài liệu tham khảo


Bài viết liên quan

Bài viết liên quan

IROS 2026: Papers navigation và manipulation đáng theo dõi
researchconferencerobotics

IROS 2026: Papers navigation và manipulation đáng theo dõi

Phân tích papers nổi bật về autonomous navigation và manipulation — chuẩn bị cho IROS 2026 Pittsburgh.

2/4/20267 phút đọc
Sim-to-Real Transfer: Train simulation, chạy thực tế
ai-perceptionresearchrobotics

Sim-to-Real Transfer: Train simulation, chạy thực tế

Kỹ thuật chuyển đổi mô hình từ simulation sang robot thật — domain randomization, system identification và best practices.

1/4/202612 phút đọc
IROS 2026 Preview: Những gì đáng chờ đợi
researchconferencerobotics

IROS 2026 Preview: Những gì đáng chờ đợi

IROS 2026 Pittsburgh — preview workshops, competitions và nghiên cứu navigation, manipulation hàng đầu.

30/3/20267 phút đọc