简介:这是一套基于Python与TensorFlow实现的卡尔曼滤波目标追踪项目源码,适合熟悉Python基础、希望深入理解状态估计与滤波算法的学习者,也适用于无人机、视频监控等目标跟踪场景的算法验证与二次开发。资源共包含28个文件,压缩包大小946KB,主要文件类型有16个Python脚本、4个XML工程配置、1个TensorFlow事件日志及若干图片与说明文档,其中kalman.py、kalmatensorflow.py、KF.py分别对应滤波器核心实现、TensorFlow版本实现与基础滤波类。项目完整覆盖初始化、预测、更新、协方差计算及循环追踪流程,并附带目标检测与可视化模块,便于初学者对照源码理解每一个数学步骤。已有657人学习下载,适合作为入门卡尔曼滤波的实战参考,借助代码运行与调试快速掌握状态向量、协方差矩阵更新及多传感器数据融合的应用思路。
1. 卡尔纳曼滤波目标追踪的 Python 实现,为什么值得自己写一遍
把“卡尔纳曼滤波”输进搜索引擎的人,多数想要的东西其实叫卡尔曼滤波(Kalman Filter)。“卡尔纳曼”是常见口误,但不影响这个标题指向一个明确的诉求:用 Python 写一套能跑起来的目标追踪程序。目标追踪的经典架构是“检测 + 跟踪”,检测器(比如 YOLO、OpenCV 的背景分割)负责在每一帧里找出目标,跟踪器负责把前后帧的目标关联成同一条轨迹。卡尔曼滤波在中间扮演的角色是“预测 + 平滑”:用前一帧的状态预测当前帧目标在哪,等检测结果回来后再修正。这个机制解决的核心痛点是检测器偶尔丢帧、结果抖动、目标短时被遮挡时轨迹断裂。Python 生态里 OpenCV 自带跟踪接口,但那些接口封得太死,看不清滤波在干什么,出了问题也无从调试。自己实现一遍卡尔曼滤波,适合正在做视觉项目的工程师和想深入理解状态估计原理的开发者,整个过程两三百行代码,改造成本不高,收益却是对整套追踪系统的每个环节都有掌控力。
2. 卡尔曼滤波的状态建模,先把目标追踪问题翻译成数学语言
2.1 目标追踪里的状态向量怎么设计:位置与速度,还是带上加速度
卡尔曼滤波处理的是状态估计问题,目标追踪里“状态”就是目标在图像坐标系中的运动参数。最常见的做法是把状态向量设计成四维:
# 状态向量: [x, y, vx, vy] # x, y : 目标框中心点在图像中的像素坐标 # vx, vy : 目标在相邻帧间的像素位移速度这里的速度单位是“像素/帧”而不是“像素/秒”,因为卡尔曼滤波的预测步长是帧间隔,视频里每两帧之间的时间差基本恒定,直接用帧做时间单位能省掉时间戳对齐的麻烦,代码也更直观。要不要把加速度也加进去?取决于目标的运动模式。匀速运动模型(CV)够用的情况是视频帧率稳定、目标没有频繁加减速;如果真的在做舵机云台追踪那种场景,目标可能是直线移动的无人机或车辆,CV 模型就足够。加速度模型(CA)会引入两个额外状态维度,预测准确性在目标真正做匀加速运动时更好,但代价是状态协方差矩阵更容易发散,滤波器对噪声更敏感。
2.2 状态转移矩阵与观测矩阵,驱动追踪循环的两个关键矩阵
状态转移矩阵 F 描述的是“没有测量输入时,状态如何随时间演化”。对匀速模型,F 的形态是固定的:
import numpy as np dt = 1.0 # 帧间隔,按帧为单位取 1 F = np.array([ [1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1], ])这个矩阵的含义是:新一帧的 x 坐标等于上一帧的 x 加上速度 vx 乘以时间步长,速度本身保持不变。它描述的是“目标继续按当前速度滑行”的物理假设。
观测矩阵 H 解决的是“传感器的测量值如何映射到状态空间”。检测器输出的是目标框的 x, y 坐标,没有直接输出速度,所以观测矩阵要做的就是把四维状态压缩成二维:
H = np.array([ [1, 0, 0, 0], [0, 1, 0, 0], ])预测阶段的核心代码如下:
def predict(self): self.x = self.F @ self.x # 状态预测 self.P = self.F @ self.P @ self.F.T + self.Q # 协方差预测,Q 是过程噪声 return self.x逻辑说明:状态预测直接套矩阵乘法,协方差预测中的F @ P @ F.T表示不确定性随运动模型扩散,加上 Q 表示运动模型本身就不是精确的——目标可能突然加速或转弯,这些模型误差要通过过程噪声吸收。
更新阶段则把检测结果 z 融合进来:
def update(self, z): y = z - self.H @ self.x # 新息,衡量预测和测量的差异 S = self.H @ self.P @ self.H.T + self.R # 新息协方差 K = self.P @ self.H.T @ np.linalg.inv(S) # 卡尔曼增益 self.x = self.x + K @ y # 状态修正 self.P = (np.eye(4) - K @ self.H) @ self.P # 协方差更新 return self.x逻辑说明:新息 y 是检测值和预测值的差,S 是这个差的不确定性,卡尔曼增益 K 决定“预测和测量谁更可信”。当测量噪声 R 很大时,K 变小,滤波器更信任预测;当过程噪声 Q 很大时,K 变大,滤波器更愿意跟随测量。P 是状态协方差矩阵,跟踪对角线元素就能看到滤波器对每个状态量的置信程度。
2.3 过程噪声 Q 与测量噪声 R,卡尔曼滤波唯二需要调的参数
Q 和 R 是卡尔曼滤波的“性格开关”。R 由检测器的精度决定:用 YOLO 类目标检测模型时,检测框的中心点抖动幅度通常有几个像素,R 的主对角线设 5 到 20 都合理;用背景差分等传统方法时,检测结果更粗糙,R 要适当放大。
# 一个能直接跑的初始参数组合 Q = np.eye(4) * 0.1 # 过程噪声,假设目标运动接近匀速 R = np.eye(2) * 10.0 # 测量噪声,检测框中心抖动约 +-3 像素Q 的经验取值与目标运动急缓强相关。目标运动机动性强时 Q 要放大到 0.5 甚至 1.0,让滤波器对预测不那么自信,这样目标突然转弯时滤波结果能较快跟上;目标运动很平滑时 Q 保持小值,轨迹更稳定但响应变慢。R 过大表现为轨迹严重滞后于真实目标位置,R 过小表现为滤波后的轨迹仍然起伏很大,几乎起不到平滑作用。常见调参策略是先固定 R 为经验值,再调整 Q,观察预测框和检测框的重叠程度。
提示:以上公式用的是标准卡尔曼滤波形式,适合线性高斯场景。图像坐标中的目标运动近似线性,实际项目里四维状态 + 匀速模型已经覆盖大多数视觉追踪需求。如果目标运动有明显的曲线轨迹,就该换扩展卡尔曼滤波(EKF)或无损卡尔曼滤波(UKF),那要改动的是状态转移函数而非整个框架。
3. 用 Python 手写一个可用于目标追踪的卡尔曼滤波类
3.1 完整代码结构,一个类封住预测与更新两个接口
import numpy as np class KalmanBoxTracker: """用于目标追踪的卡尔曼滤波器 状态: [x, y, vx, vy] 观测: [x, y] """ def __init__(self, bbox): # bbox: [x1, y1, x2, y2],检测框左上角和右下角 x = (bbox[0] + bbox[2]) / 2.0 # 中心点 x y = (bbox[1] + bbox[3]) / 2.0 # 中心点 y self.x = np.array([x, y, 0, 0], dtype=np.float64) self.P = np.eye(4) * 10.0 # 初始协方差,速度不确定性设大 self.F = np.array([ [1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0], [0, 0, 0, 1], ], dtype=np.float64) self.H = np.array([ [1, 0, 0, 0], [0, 1, 0, 0], ], dtype=np.float64) self.Q = np.eye(4) * 0.1 self.R = np.eye(2) * 10.0 self.id = 0 self.hits = 0 # 连续匹配成功次数 self.no_loss_count = 0 # 连续丢失检测的次数 def predict(self): self.x = self.F @ self.x self.P = self.F @ self.P @ self.F.T + self.Q return self.x def update(self, bbox): z = np.array([(bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0]) y = z - self.H @ self.x S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.x = self.x + K @ y self.P = (np.eye(4) - K @ self.H) @ self.P self.hits += 1参数说明:初始协方差 P 设为对角值 10,表示对初始状态不自信,特别是速度分量完全未知,这一点很关键——初始化时把速度设为 0 是无奈之举,必须给协方差足够大的值让滤波器在头几帧尽快收敛。hits和no_loss_count两个计数用于后文的多目标管理,判断一个目标是否值得保留轨迹。
3.2 最小可运行的追踪循环,把滤波接到单目标视频上
将上面的类用在单目标场景时,代码的整体结构是读帧、预测、检测、更新四步:
import cv2 tracker = None first_frame = True cap = cv2.VideoCapture("test_video.mp4") while True: ret, frame = cap.read() if not ret: break # 用最简单的方式做检测:帧差法 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if first_frame: prev_gray = gray first_frame = False continue diff = cv2.absdiff(prev_gray, gray) _, thresh = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) detections = [] for cnt in contours: if cv2.contourArea(cnt) > 500: # 过滤噪声 x, y, w, h = cv2.boundingRect(cnt) detections.append([x, y, x + w, y + h]) if len(detections) > 0: bbox = max(detections, key=lambda b: (b[2]-b[0]) * (b[3]-b[1])) if tracker is None: tracker = KalmanBoxTracker(bbox) else: tracker.predict() tracker.update(bbox) x1, y1, x2, y2 = map(int, tracker.x[:2] - 0.0) # 这里只取位置分量 # 绘制时要取回完整的框,可结合检测框宽高扩展为中心点绘制 cv2.circle(frame, (int(tracker.x[0]), int(tracker.x[1])), 5, (0, 255, 0), -1) elif tracker is not None: tracker.predict() # 未检测到目标时只预测不更新 prev_gray = gray cv2.imshow("track", frame) if cv2.waitKey(30) & 0xFF == ord('q'): break逻辑说明:这个例子用帧差法代替深度模型,目的只有一个——把卡尔曼滤波的循环单独暴露出来。if tracker is None负责初始化,检测到目标后先 predict 再 update,顺序不能颠倒;目标丢失时只 predict 不 update,让滤波器用运动模型维持输出。这样处理之后,检测短暂中断几帧时,输出坐标不会跳到 0,而是沿着已有速度平滑外推。
3.3 状态向量与检测框宽度和高度的取舍,为什么只追踪中心点
上面的滤波类只追踪中心点坐标,检测框的宽高被丢弃了。大多数卡尔曼目标追踪实现会追踪完整四维状态 [cx, cy, w, h] 或 [cx, cy, s, r](s 是面积,r 是宽高比),这来自经典 Tracking-by-Detection 框架的设计。中心点 + 速度的方案足够覆盖中心点为一阶导数的简单场景,但在目标快速靠近摄像头时,宽高变化剧烈,只靠中心点在遮挡恢复后会丢失尺度信息。
实践中如果检测框是 YOLO 等模型输出的,宽高本身比较稳定,可以把状态扩展到六维:
# 六维状态: [cx, cy, w, h, vcx, vcy] # 状态转移矩阵对应扩展为 6x6,观测矩阵取前四维单位阵这个方案的优点在于目标匹配时可以同时参考位置和尺度,比只用中心点在处理重叠目标时更鲁棒。代价是协方差矩阵从 4x4 涨到 6x6,运算量小幅度上升,而目标追踪的实时性瓶颈通常在检测网络,不在滤波这一步。
4. 把卡尔曼滤波接入多目标追踪,数据关联是绕不开的工程问题
4.1 检测与跟踪的匹配机制,从暴力匹配到匈牙利算法
单目标场景不存在匹配问题,检测框直接喂给滤波器就行。多目标场景的核心问题是:当前帧检测出 N 个框,已有 M 条轨迹,怎么知道哪个检测框对应哪条轨迹?不关联就没法更新,或者错误关联会导致轨迹 ID 切换,这是多目标追踪里最典型的错误形态。
最简单的关联方法计算所有轨迹预测框和检测框之间的 IoU(交并比),大于阈值的配对生效。IoU 的计算朴素直接:
def iou(bbox1, bbox2): x1 = max(bbox1[0], bbox2[0]) y1 = max(bbox1[1], bbox2[1]) x2 = min(bbox1[2], bbox2[2]) y2 = min(bbox1[3], bbox2[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area1 = (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) area2 = (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) union = area1 + area2 - inter return inter / union if union > 0 else 0IoU 大于阈值就认为预测框和检测框对应同一个目标。但多对多场景直接暴力匹配会出问题:一个检测框可能和两条轨迹的 IoU 都大于阈值,这时候需要全局最优分配。常见解法是把代价矩阵丢给匈牙利算法(scipy.optimize.linear_sum_assignment),在“整体代价最小”的意义上做一对一分配。
from scipy.optimize import linear_sum_assignment cost_matrix = np.zeros((len(trackers), len(detections))) for i, trk in enumerate(trackers): pred_box = trk.predict() # 得到预测中心点 for j, det in enumerate(detections): cost_matrix[i, j] = -iou(pred_box_to_bbox(pred_box), det) # 取负号,求最小化 row_ind, col_ind = linear_sum_assignment(cost_matrix)参数说明:linear_sum_assignment返回的是一组行列索引配对,默认求最小代价,因此代价矩阵取 IoU 的负值。这个方案的问题是对漏检和遮挡没有记忆——某一帧检测不到某个目标,对应轨迹就分不到检测,下一帧检测恢复时又需要重新匹配。解决思路是引入“未匹配”状态和存活计数,连续未命中超过max_age帧的轨迹才删除,给目标留出短暂被遮挡的缓冲期。
4.2 马氏距离作为补充度量,利用卡尔曼滤波的协方差信息
IoU 只度量空间重叠,没有使用卡尔曼滤波内部的状态不确定性。如果目标沿着预测方向快速移动,检测框和预测框可能只有少量重叠但确实是同一个目标,IoU 阈值设得稍高就会漏配。这时可以用马氏距离替代或联合 IoU:
def mahalanobis_distance(z, x, S_inv): delta = z - x return np.sqrt(delta.T @ S_inv @ delta)马氏距离把协方差矩阵 S 纳入度量,S 描述预测的不确定性,距离在“预测不太确定的方向”上会被放大容忍度。实际项目中常见做法是两级匹配:先用马氏距离做硬条件过滤,超过卡方分布阈值的配对直接排除,再对剩余候选算 IoU 并执行匈牙利算法。这套流程和经典多目标追踪框架的处理逻辑一致,只不过那个框架里用的是更复杂的级联匹配,而本文这套更适合中小项目自研。
4.3 管理轨迹的生命周期,创建、更新、删除的策略
多目标追踪工程里,滤波只是状态估计的子模块,真正决定追踪效果的是目标管理逻辑。策略通常有三条:检测框首次出现时立即创建新轨迹,但要连续命中几帧才对外输出,避免单帧误检产生幽灵轨迹;轨迹连续丢失超过 N 帧(比如 30 帧)直接删除,避免长期空转占用计算资源;被遮挡期间轨迹只 predict 不 update,因为此时没有可信的测量输入。
# 每帧结束时统一清理 for trk in trackers: if trk.no_loss_count > max_age: trackers.remove(trk)参数说明:max_age是经验值,10 到 30 之间比较常用。设太小遮挡稍长轨迹就断了,设太大会留下大量空预测轨迹,后续匹配时产生错误关联。一个可用的初始化参数是:min_hits = 3,max_age = 20。这两个值应该暴露为配置项,在实际视频上观察轨迹断裂率和 ID Switch 次数来微调。OpenCV 里自带的追踪器接口(如cv2.TrackerKCF)没有暴露这套生命周期管理机制,这也是手写方案更具可调试性的原因之一。
提示:基于 stm32 与 opencv 的多模式舵机云台目标追踪这类硬件项目里,卡尔曼滤波的输出不是直接给用户看的,而是作为云台的期望角输入。决策模块需要的是位置与速度,状态向量中的 vx, vy 此时就是很有价值的信号——它们能用来预测目标接下来往哪儿移动,让云台提前转向,而不是等目标出了画面才追过去。
5. 卡尔曼滤波追踪的参数调优与验证,一个调试技巧解决大部分轨迹跳动问题
5.1 用模拟轨迹验证滤波器实现是否有误,先别急着上真实视频
追踪效果不好时,第一步要确认滤波实现本身有没有问题。用受控的模拟数据可以快速验证:生成一条匀速直线运动的轨迹,加上高斯白噪声作为检测结果,把带噪数据喂给卡尔曼滤波,看输出是否接近真实轨迹。
# 生成模拟轨迹:真实位置 + 高斯噪声 np.random.seed(42) frames = 100 true_x = np.linspace(0, 640, frames) true_y = np.linspace(100, 300, frames) det_x = true_x + np.random.normal(0, 5, frames) det_y = true_y + np.random.normal(0, 5, frames) # 滤波过程 x_est, y_est = [], [] filter = KalmanBoxTracker([det_x[0], det_y[0], det_x[0], det_y[0]]) for i in range(1, frames): filter.predict() filter.update([det_x[i], det_y[i], det_x[i], det_y[i]]) x_est.append(filter.x[0]) y_est.append(filter.x[1]) # 计算均方根误差 rmse = np.sqrt(np.mean((np.array(x_est) - true_x[1:])**2)) print(f"RMSE: {rmse:.2f} 像素")验证逻辑:匀速直线运动条件下,卡尔曼滤波的 RMSE 应该显著低于直接使用检测坐标的 RMSE。如果滤波后的误差反而更大,基本可以确定 F、H、Q、R 中至少有一个矩阵写错了。检查顺序是先看 F 和 H 维度是否匹配,再看 Q 和 R 是否出现数量级错误,最后确认 predict 和 update 的调用顺序。
5.2 Q 与 R 的具体调参手法,看残差曲线比盯着画面更有效
手动调参数时最常见的动作是盯着滤波轨迹看“平不平滑”,这依赖直觉但效率很低。更系统的方法是记录每一帧的新息(innovation)序列——也就是 update 步骤中y = z - H @ x的值。卡尔曼滤波的理论假设是新息为零均值白噪声,如果新息曲线持续有偏,说明模型有系统性偏差,比如目标在做匀加速运动而滤波器用的是匀速模型;如果新息噪声幅度远超 R 的标准差,说明 R 设小了,滤波器过度信任检测结果。
# 在 update 中记录新息 def update_with_log(self, bbox, log_list): z = np.array([(bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0]) y = z - self.H @ self.x log_list.append(np.linalg.norm(y)) # 新息的模长 # ... 后续更新逻辑不变新息模长的均值可以用来反馈调整 Q:均值过大说明预测和检测偏离严重,模型跟不上目标运动,需要增大 Q 让滤波器更快适应测量;均值虽然小但曲线高频震荡,则说明测量噪声影响过大,可以适当增大 R 换取更稳定的输出。这种数据驱动的方式比肉眼调参准确得多,尤其在参数要复用到不同视频场景的时候。
5.3 最后一个实用技巧,用卡尔曼速度分量做检测失效时的外推
追踪过程中检测器偶尔会连续几帧没有输出,这时滤波器不能等死。把no_loss_count大于 0 时的预测结果直接作为追踪输出,配合 vx、vy 速度分量做线性外推,能明显减少目标短暂出画面或遮挡后的轨迹断裂:
# 检测丢失时,用预测结果绘制位置 if len(detections) == 0: pred = tracker.predict() out_x, out_y = pred[0], pred[1] # 限制外推帧数,超过阈值后停止输出,避免目标早已大变向 tracker.no_loss_count += 1 if tracker.no_loss_count > 3: # 超过 3 帧仍无检测,把预测结果置信度标记为低 pass这个技巧的核心是一个简单的信任衰减逻辑:丢失的帧数越少,外推结果的可用性越高;超过 3 帧后,匀速假设越来越不可靠,输出虽然还在但需要降级处理——比如在画面上降低绘制透明度、云台追踪时减小控制增益。当检测恢复时,update 方法会自动把状态拉回真实位置,这就是卡尔曼滤波相比纯预测平滑方案的优势所在。调参时优先调整no_loss_count的阈值配合 Q 的大小,这两个参数协同工作基本能覆盖目标遮挡恢复的绝大多数场景。
本文还有配套的精品资源,点击获取