粒子滤波器实战:轻量级目标跟踪的鲁棒性实现
1. 项目概述:粒子滤波器在目标跟踪中的实战价值
粒子滤波器(Particle Filter)不是什么新潮的黑科技,而是计算机视觉领域里一块被反复打磨、验证过无数次的“老钢”。它不像深度学习模型那样需要海量标注数据和GPU集群,也不依赖于预训练权重的迁移能力;它更像一位经验丰富的老猎人——不靠蛮力,靠的是对目标运动规律、观测噪声特性的直觉判断,以及一套极其稳健的概率推理框架。我在做工业质检产线上的缺陷件追踪时,第一次用OpenCV+NumPy手撸粒子滤波器,就成功把一个在强反光金属表面滑动的小型工件稳定锁定了47秒,而当时用的还是2015年款的i5-4200U笔记本。这件事让我彻底信了:粒子滤波器的核心价值,从来不是“多快”,而是“多稳”、“多鲁棒”、“多可控”。
它解决的,是传统方法在真实场景中频频失守的几类典型问题:目标突然加速或急停(比如物流分拣带上的包裹被机械臂抓取瞬间)、短暂遮挡(两个目标交叉时互相挡住)、外观剧烈变化(目标旋转导致纹理朝向突变)、低分辨率或运动模糊图像下的定位漂移。这些情况,卡尔曼滤波器往往因线性高斯假设而失效,而YOLO+DeepSORT这类端到端方案又容易在遮挡后ID跳变。粒子滤波器则用“一群带权重的猜测点”来表征目标状态的整个概率分布,不假设形状、不强制线性,只靠重采样机制自然淘汰错误假设——这种“以量换质”的思路,恰恰是它在嵌入式设备、边缘计算节点、甚至单片机上仍能可靠运行的根本原因。
这篇文章,就是我过去三年在安防监控、农业无人机巡检、工业机器人引导三个不同场景中,把粒子滤波器从论文公式真正落地为可部署代码的全过程复盘。它不讲贝叶斯定理推导,不堆砌数学符号,而是聚焦于:如何设计一个真正能在你摄像头画面里跑起来、不飘、不炸、不卡顿的粒子滤波器。你会看到完整的Python实现(纯NumPy,无PyTorch/TensorFlow依赖),每一步参数背后的物理意义,以及那些只有亲手调过上百次滤波器才会知道的“手感”——比如为什么粒子数不能简单设为1000,为什么系统噪声标准差设成0.8比设成1.2在你的产线上效果更好,以及当目标消失3秒后,如何让滤波器优雅地“等待”而不是疯狂发散。如果你正被目标丢失、ID切换、实时性差这些问题困扰,或者想给现有跟踪系统加一道轻量级的“保险”,那这篇就是为你写的。
2. 粒子滤波器的整体设计与思路拆解
2.1 为什么选粒子滤波器?不是卡尔曼,也不是深度学习
在动手写代码前,必须先回答一个根本问题:为什么在这个项目里,粒子滤波器是比其他方案更优的选择?这不是技术情怀,而是工程决策。我见过太多团队一上来就冲着YOLOv8+ByteTrack去,结果在树莓派4B上跑出1.2帧/秒,还抱怨“算法太重”。粒子滤波器的价值,恰恰体现在它对硬件和数据的“低欲望”。
先看卡尔曼滤波器(KF)。它的数学非常优美,但前提是:系统模型必须是线性的,过程噪声和观测噪声必须服从高斯分布。可现实呢?目标在拐角处的转向是典型的非线性运动;摄像头自动白平衡导致的亮度突变,会让颜色直方图观测模型严重偏离高斯假设;甚至光照变化本身,就是一种非平稳、非高斯的干扰。KF在这种场景下,预测协方差矩阵会越扩越大,最终导致跟踪框“越飘越远”,直到完全脱靶。我曾在一个仓库AGV导航项目中,用KF跟踪地面二维码,结果AGV刚驶过一盏频闪的日光灯,KF的估计位置就偏移了1.7米——这已经不是误差,而是失效。
再看深度学习方案。它们强大,但代价高昂。一个轻量级的FairMOT模型,在Jetson Nano上推理一帧需要280ms,这意味着你最多只能处理3.5帧/秒。而很多工业场景要求的是15帧/秒以上的实时反馈,否则机械臂抓取动作就会滞后。更重要的是,深度学习模型是“黑箱”,当它跟丢时,你很难快速定位是数据标注问题、模型过拟合,还是当前场景超出了训练集分布。而粒子滤波器是“白盒”,每一个粒子的位置、速度、权重都清晰可见,你可以随时打印出权重分布直方图,一眼看出是系统模型不准,还是观测模型太敏感。
粒子滤波器的折中之道,在于它用计算资源换鲁棒性。它不追求单步最优,而是通过大量粒子(比如500个)并行模拟所有可能的状态演化路径,再用观测数据给每条路径打分(权重)。这个过程天然支持非线性、非高斯模型,且计算复杂度与粒子数呈线性关系(O(N)),而非深度学习的O(N²)或KF的O(N³)。这意味着,你可以在性能受限的设备上,通过合理控制粒子数量(比如从1000降到300),换取一个依然可用的、确定性的跟踪结果。这不是退而求其次,而是一种清醒的工程权衡。
2.2 核心架构:四个阶段的闭环迭代
粒子滤波器的运行,并非一个静态的函数调用,而是一个持续的、四阶段闭环迭代过程。理解这个循环,是写出稳定代码的前提。我把这个过程比喻成“老猎人的追踪术”:
初始化(Initialization)—— “划定搜索范围”
这不是随便撒一把粒子。你需要根据第一帧的目标ROI(Region of Interest),在状态空间里生成一组有代表性的初始猜测。状态空间通常包含目标的中心坐标(x, y)、宽度w、高度h,有时还包括速度(vx, vy)。粒子不是均匀撒在整张图上,而是围绕ROI中心,按一定标准差(比如ROI宽高的10%)进行高斯采样。这相当于猎人凭经验判断:“猎物大概就在这个圈里,而且最可能在中心附近”。预测(Prediction)—— “预判下一步”
每个粒子都根据一个“运动模型”向前走一步。最常用的是恒速模型(Constant Velocity):x_new = x_old + vx * dt,vx_new = vx_old + noise。这里的noise就是系统噪声,它代表了我们对目标运动不确定性的量化。关键点在于:这个噪声不是随便设的。如果设得太小(如0.01),粒子群会过于“死板”,无法适应目标的突然加速;设得太大(如5.0),粒子会迅速发散,失去聚集性。我通常的做法是,先用视频前10帧手动标出目标的真实位移,计算其标准差,再把这个值作为系统噪声的初始参考。这一步,是粒子滤波器能否“跟得上”的基础。更新(Update)—— “用眼睛确认”
这是粒子滤波器的“灵魂”。我们用当前帧的图像信息,给每个粒子打一个“可信度分数”(权重)。这个分数,由“观测模型”决定。最经典、也最实用的,是基于颜色直方图的相似度匹配。我们提取目标ROI的颜色直方图作为模板,再对每个粒子所“声称”的位置,提取一个同样大小的区域直方图,用巴氏距离(Bhattacharyya Distance)计算两者相似度,相似度越高,权重越大。这里有个致命陷阱:如果直接用原始像素值计算直方图,光照变化会瞬间摧毁整个系统。我的解决方案是,永远使用HSV色彩空间的H(色调)和S(饱和度)通道,忽略V(明度)通道。因为H和S对光照变化相对鲁棒,而V通道正是光照干扰的主战场。这一步,是粒子滤波器能否“认得准”的核心。重采样(Resampling)—— “淘汰弱者,复制强者”
经过更新,粒子的权重会变得极不均衡:少数几个粒子权重接近1,其余几百个权重趋近于0。如果不处理,后续计算将毫无意义(有效粒子数急剧下降)。重采样就是“优胜劣汰”的过程:根据权重,随机抽取N个新粒子(允许重复抽取),权重高的粒子被抽中的概率大,权重低的粒子则被淘汰。这确保了粒子群始终聚焦在最有可能的状态周围。但重采样也有副作用:它会引入“样本贫化”(Sample Impoverishment),即所有粒子都变得一模一样,失去了多样性。因此,重采样后,我会对每个新粒子添加一个微小的、符合系统噪声分布的扰动(jitter),相当于给“复制出来的后代”加一点变异,保持种群活力。
这四个阶段,构成了一个完美的闭环。它不追求一步到位,而是通过“预测-验证-修正”的不断迭代,在充满不确定性的世界里,为我们的目标画出一条最可信的轨迹。理解这个循环,比记住任何一行代码都重要。
2.3 方案选型的关键考量:为什么是纯NumPy?
在项目启动时,我面临一个选择:是用PyTorch的自动微分来构建一个可学习的粒子滤波器,还是坚持用最原始的NumPy?最终,我选择了后者。这不是守旧,而是基于三个硬性约束的务实决策。
第一个约束是部署环境。这个项目最终要烧录进一个国产RK3399嵌入式主板,它没有CUDA,没有PyTorch runtime,甚至连pip都不支持。它只保证有Python 3.7和NumPy 1.19。任何依赖高级框架的方案,在第一步就被否决了。NumPy是Cython编译的,底层是高度优化的BLAS库,在ARM架构上也能跑出接近原生C的速度。我实测过,用NumPy对500个粒子进行一次完整的预测+更新+重采样循环,在RK3399上耗时约18ms,完全满足30帧/秒的要求。
第二个约束是调试与可解释性。在调试阶段,我需要能随时暂停、查看任意一个粒子的完整状态(x, y, w, h, vx, vy, weight),并能用matplotlib把它画在图上。PyTorch的tensor是惰性求值的,中间变量难以捕获;而NumPy的ndarray是即时的、透明的。我可以写一行print(particles[0]),立刻看到第一个粒子的所有属性。这种“所见即所得”的调试体验,对于一个概率算法来说,是无价的。我记得有一次,跟踪突然在第127帧崩溃,我直接在重采样前加了一行plt.scatter(particles[:, 0], particles[:, 1], c=particles[:, -1]),一张图就暴露了问题:权重分布出现了双峰,说明观测模型把背景里的一个相似色块也当成了目标。这问题,用黑箱框架是绝不可能这么快定位的。
第三个约束是长期维护成本。一个用PyTorch写的粒子滤波器,两年后可能因为版本升级而无法兼容;而一个用NumPy写的,只要Python还在,它就永远能跑。我负责的上一个项目,代码是2018年写的,至今仍在客户现场稳定运行,只因为它的依赖列表里只有numpy和opencv-python这两个包。工程师的价值,不在于炫技,而在于交付一个五年后还能让人放心使用的系统。NumPy,就是那个最朴素、也最可靠的基石。
3. 核心细节解析与实操要点
3.1 状态向量设计:不止是(x, y),还有“为什么”
粒子滤波器的性能,一半取决于算法,另一半取决于你如何定义“目标的状态”。一个粗糙的状态向量,会让再好的算法也事倍功半。我见过太多人只用(x, y)两个维度,结果目标一缩放,跟踪就失效。下面是我经过多个项目验证的、最实用的状态向量设计。
基础状态(6维):[x, y, w, h, vx, vy]
这是最常用、也最推荐的起点。x, y是目标中心坐标,w, h是包围框的宽高,vx, vy是中心点的速度。为什么必须包含w, h?因为目标在靠近或远离摄像头时,其在图像中的尺寸会变化。如果只跟踪(x, y),当目标变大时,滤波器会误以为它“变胖了”,从而错误地调整位置。而把w, h作为状态的一部分,滤波器就能同时学习目标的“尺度变化规律”,大大提升在Z轴(深度)方向上的鲁棒性。vx, vy同理,它让滤波器具备了“惯性”,能平滑掉单帧的噪声抖动。
进阶状态(8维):[x, y, w, h, vx, vy, ax, ay]
当你发现目标有明显的加速度(比如AGV启动、无人机起飞),6维模型就会滞后。此时,加入加速度ax, ay,改用恒加速度模型(Constant Acceleration Model),能显著改善跟踪的响应速度。但代价是,状态空间维度增加,粒子需要更多样本来覆盖,计算量上升约30%。我的建议是:先用6维跑通,再用视频分析工具(如OpenCV的calcOpticalFlowFarneback)计算目标的真实加速度均值。如果这个均值大于0.5像素/帧²,再升级到8维。
关于归一化:所有状态变量,必须进行归一化处理,否则不同量纲(像素 vs 像素/帧)会导致优化过程混乱。我的做法是:
x, y:除以图像宽度和高度,映射到[0, 1]区间。w, h:除以图像对角线长度,同样映射到[0, 1]。vx, vy, ax, ay:除以一个经验值,比如max(width, height) / 30(假设目标最大移动速度为1/30图像宽/帧)。
这样做的好处是,系统噪声的标准差参数,可以统一设置在一个合理的、无量纲的范围内(比如0.01到0.1),极大简化了调参过程。
3.2 观测模型:颜色直方图的鲁棒性实践
观测模型是粒子滤波器的“眼睛”,它决定了滤波器“认不认识”目标。一个脆弱的观测模型,会让整个系统在光照、角度、遮挡面前不堪一击。我花了整整两个月,测试了超过15种不同的观测模型,最终锁定在“HSV空间+H-S双通道+自适应bin数”的组合上。下面是我的实操心得。
为什么放弃RGB?
RGB色彩空间对光照极其敏感。同一块红色布料,在正午阳光下和黄昏室内,其R、G、B三通道的绝对值可能相差3倍以上。用RGB直方图做匹配,等同于让滤波器在“色盲”状态下工作。我做过对比实验:在一段有明显明暗变化的走廊视频中,RGB直方图匹配的平均相似度得分波动高达±42%,而HSV的H-S通道波动仅为±7%。
为什么只用H和S,不用V?
H(色调)代表颜色的种类(红、绿、蓝),S(饱和度)代表颜色的纯度(鲜艳 vs 灰白),V(明度)代表亮度。在真实场景中,V是变化最剧烈的通道,它受环境光、阴影、反光的影响最大。而H和S,尤其是H,在物体材质不变的情况下,具有惊人的稳定性。一块蓝色牛仔布,无论是在室内灯光下还是室外阳光下,它的H值基本维持在200-240之间。因此,观测模型只计算H和S两个通道的二维直方图,完全忽略V通道。这一步,直接将光照鲁棒性提升了3倍以上。
如何确定直方图的bin数?
这是一个常被忽视,却至关重要的细节。bin数太少(如H:8 bins, S:4 bins),直方图过于粗糙,无法区分相似颜色;bin数太多(如H:64 bins, S:32 bins),直方图过于稀疏,单个bin的计数为0,导致巴氏距离计算失效。我的经验公式是:bins_H = max(8, min(32, int(np.sqrt(target_area))))bins_S = max(4, min(16, int(np.sqrt(target_area) // 2)))
其中target_area是初始ROI的像素面积。这个公式的意思是:目标越大,我们能分辨的色调和饱和度细节就越多;目标越小,我们就用更粗的粒度来避免噪声。在一次农业无人机识别玉米苗的项目中,这个自适应bin数让跟踪成功率从68%提升到了92%。
巴氏距离的计算与陷阱:
巴氏距离(Bhattacharyya Distance)的公式是:BD = 1 - sum(sqrt(p_i * q_i)),其中p_i和q_i分别是模板直方图和候选直方图的第i个bin的概率。它的值域是[0, 1],0表示完全相同,1表示完全不同。关键陷阱在于:必须对直方图进行L1归一化,使其所有bin之和为1。我曾因为忘记这一步,导致权重计算全乱,调试了整整一天。在代码里,我强制加上了template_hist /= template_hist.sum()和candidate_hist /= candidate_hist.sum(),并用assert np.allclose(template_hist.sum(), 1.0)做断言保护。
3.3 系统噪声与观测噪声:参数背后的物理世界
粒子滤波器的两个核心噪声参数——系统噪声(Process Noise)和观测噪声(Observation Noise)——不是调参游戏,而是对物理世界的建模。把它们设错,就像给猎人配了一副度数不对的眼镜,再好的技巧也白搭。
系统噪声(σ_process):
它代表了我们对“目标下一步会怎么动”这个预测的不确定性。它的单位是“像素/帧”(对于速度)或“像素”(对于位置)。一个常见的错误是,把它设成一个固定的经验值,比如0.1。这完全忽略了场景特性。我的做法是,用视频的前10帧,手动记录目标中心点的真实位移向量Δx, Δy,然后计算其标准差std_x, std_y。σ_process的初始值,就设为std_x和std_y。例如,在一个传送带项目中,目标匀速运动,std_x只有0.3像素/帧,那么σ_process就设为0.3;而在一个儿童游乐场的监控项目中,孩子跑动毫无规律,std_x高达2.8像素/帧,σ_process就必须设为2.8。记住:系统噪声不是越小越好,而是要真实反映目标的“调皮程度”。设得太小,滤波器会过度相信自己的预测,跟不上目标;设得太大,滤波器会过度怀疑自己,变得“瞻前顾后”,轨迹抖动。
观测噪声(σ_observation):
它不直接出现在代码里,而是隐含在观测模型的权重计算中。在巴氏距离模型里,σ_observation体现为“多大的相似度差异,才值得我们认真对待”。它的直观表现是:当目标被部分遮挡,或者发生轻微形变时,观测模型给出的相似度得分会下降。我们需要一个阈值,来判断这个下降是“正常波动”还是“严重异常”。我的经验是,用一个Sigmoid函数将巴氏距离BD映射为权重w:w = 1 / (1 + exp(k * (BD - bd0)))。其中bd0是“临界相似度”,k是陡峭度。bd0的设定,就等价于σ_observation。我通常把bd0设为前10帧观测相似度得分的中位数减去一个安全裕度(比如0.05)。这样,滤波器就能容忍正常的、小幅度的外观变化,而对真正的遮挡或目标丢失做出快速反应。
噪声的动态调整:
最成熟的实践,是让噪声参数随时间自适应。例如,当连续5帧的权重方差小于某个阈值时,说明目标很稳定,可以略微降低σ_process以提高精度;当连续3帧的最大权重低于0.3时,说明观测质量变差,可以略微提高σ_process以增强鲁棒性。我在一个风电叶片巡检项目中实现了这个逻辑,使跟踪在强风导致的相机抖动下,依然保持了99.2%的在线率。
4. 实操过程与核心环节实现
4.1 完整代码实现:从零开始的可运行脚本
下面是一份经过我多个项目验证、可直接运行的粒子滤波器Python实现。它不依赖任何深度学习框架,只使用numpy和opencv-python,并附有详尽的中文注释。你可以把它保存为particle_filter.py,然后用任何一段视频测试。
import numpy as np import cv2 from typing import Tuple, Optional, List class ParticleFilter: def __init__(self, num_particles: int = 500, state_dim: int = 6, system_noise_std: float = 0.5, resample_threshold: float = 0.5): """ 初始化粒子滤波器 Args: num_particles: 粒子总数,建议300-1000,需权衡精度与速度 state_dim: 状态向量维度,6=[x,y,w,h,vx,vy],8=[x,y,w,h,vx,vy,ax,ay] system_noise_std: 系统噪声标准差,单位为归一化后的像素/帧 resample_threshold: 有效粒子数比例阈值,低于此值触发重采样 """ self.num_particles = num_particles self.state_dim = state_dim self.system_noise_std = system_noise_std self.resample_threshold = resample_threshold # 初始化粒子状态数组 [num_particles, state_dim] self.particles = np.zeros((num_particles, state_dim)) # 初始化粒子权重数组 [num_particles] self.weights = np.ones(num_particles) / num_particles # 存储历史轨迹,用于可视化 self.trajectory = [] # 预分配内存,避免循环中频繁创建数组,提升性能 self.noise_buffer = np.zeros((num_particles, state_dim)) self.weights_buffer = np.zeros(num_particles) def initialize(self, roi: Tuple[int, int, int, int], img_shape: Tuple[int, int]): """ 根据初始ROI初始化粒子 Args: roi: (x, y, w, h) 的矩形框,x,y为左上角坐标 img_shape: (height, width) 图像尺寸,用于归一化 """ h, w = img_shape # 将ROI转换为归一化坐标系下的中心点、宽高 cx = (roi[0] + roi[2] / 2) / w cy = (roi[1] + roi[3] / 2) / h norm_w = roi[2] / np.sqrt(w*w + h*h) # 归一化到对角线长度 norm_h = roi[3] / np.sqrt(w*w + h*h) # 在中心点周围,按高斯分布生成粒子 # 位置噪声:ROI宽高的10% pos_noise = 0.1 * max(norm_w, norm_h) # 尺度噪声:ROI宽高的5% scale_noise = 0.05 * max(norm_w, norm_h) # 速度初始为0,噪声为0.01 vel_noise = 0.01 # 生成初始粒子 self.particles[:, 0] = cx + np.random.normal(0, pos_noise, self.num_particles) self.particles[:, 1] = cy + np.random.normal(0, pos_noise, self.num_particles) self.particles[:, 2] = norm_w + np.random.normal(0, scale_noise, self.num_particles) self.particles[:, 3] = norm_h + np.random.normal(0, scale_noise, self.num_particles) self.particles[:, 4] = np.random.normal(0, vel_noise, self.num_particles) self.particles[:, 5] = np.random.normal(0, vel_noise, self.num_particles) # 确保粒子在图像边界内(防止溢出) self.particles[:, 0] = np.clip(self.particles[:, 0], 0, 1) self.particles[:, 1] = np.clip(self.particles[:, 1], 0, 1) self.particles[:, 2] = np.clip(self.particles[:, 2], 0.01, 0.5) self.particles[:, 3] = np.clip(self.particles[:, 3], 0.01, 0.5) # 权重重置为均匀分布 self.weights.fill(1.0 / self.num_particles) def predict(self, dt: float = 1.0): """ 预测步骤:根据运动模型,更新每个粒子的状态 Args: dt: 时间步长,单位为帧,通常为1.0 """ # 对于6维状态,使用恒速模型 if self.state_dim == 6: # x = x + vx * dt self.particles[:, 0] += self.particles[:, 4] * dt # y = y + vy * dt self.particles[:, 1] += self.particles[:, 5] * dt # w, h 保持不变(可选:加入微小的尺度变化噪声) # vx, vy 加入系统噪声 self.particles[:, 4] += np.random.normal(0, self.system_noise_std * dt, self.num_particles) self.particles[:, 5] += np.random.normal(0, self.system_noise_std * dt, self.num_particles) # 确保粒子不越界 self.particles[:, 0] = np.clip(self.particles[:, 0], 0, 1) self.particles[:, 1] = np.clip(self.particles[:, 1], 0, 1) self.particles[:, 2] = np.clip(self.particles[:, 2], 0.01, 0.5) self.particles[:, 3] = np.clip(self.particles[:, 3], 0.01, 0.5) def _extract_histogram(self, img: np.ndarray, x: float, y: float, w: float, h: float, img_shape: Tuple[int, int]) -> np.ndarray: """ 从图像中提取指定位置和大小的HSV直方图(H-S双通道) Args: img: BGR格式的输入图像 x, y, w, h: 归一化后的状态,需转换为像素坐标 img_shape: (height, width) Returns: 2D直方图,shape为 (bins_H, bins_S) """ h_img, w_img = img_shape # 转换为像素坐标 px = int(x * w_img) py = int(y * h_img) pw = int(w * np.sqrt(w_img*w_img + h_img*h_img)) ph = int(h * np.sqrt(w_img*w_img + h_img*h_img)) # 计算ROI的左上角和右下角,确保不越界 x1 = max(0, px - pw // 2) y1 = max(0, py - ph // 2) x2 = min(w_img, px + pw // 2) y2 = min(h_img, py + ph // 2) if x1 >= x2 or y1 >= y2: # ROI太小或越界,返回一个全零直方图 return np.zeros((32, 16)) # 提取ROI区域 roi = img[y1:y2, x1:x2] # 转换为HSV hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 分离H和S通道 h_channel = hsv[:, :, 0] s_channel = hsv[:, :, 1] # 计算自适应bin数 area = (x2 - x1) * (y2 - y1) bins_H = max(8, min(32, int(np.sqrt(area)))) bins_S = max(4, min(16, int(np.sqrt(area) // 2))) # 计算2D直方图 hist, _, _ = np.histogram2d(h_channel.ravel(), s_channel.ravel(), bins=[bins_H, bins_S], range=[[0, 180], [0, 256]]) # L1归一化 if hist.sum() > 0: hist = hist / hist.sum() return hist def update(self, img: np.ndarray, template_hist: np.ndarray, img_shape: Tuple[int, int]): """ 更新步骤:根据当前图像,计算每个粒子的权重 Args: img: 当前帧图像(BGR) template_hist: 初始ROI的模板直方图 img_shape: (height, width) """ # 重置权重缓冲区 self.weights_buffer.fill(0.0) # 对每个粒子,提取其对应位置的直方图,并计算巴氏距离 for i in range(self.num_particles): # 获取第i个粒子的状态 x, y, w, h = self.particles[i, 0], self.particles[i, 1], self.particles[i, 2], self.particles[i, 3] # 提取候选直方图 candidate_hist = self._extract_histogram(img, x, y, w, h, img_shape) # 计算巴氏距离 # 确保两个直方图bin数一致,进行插值或裁剪 target_bins_H, target_bins_S = template_hist.shape if candidate_hist.shape != template_hist.shape: # 简单的双线性插值(实际项目中可用cv2.resize) candidate_hist = cv2.resize(candidate_hist, (target_bins_S, target_bins_H)).T # 计算巴氏距离 BD = 1 - sum(sqrt(p_i * q_i)) # 防止数值下溢,加一个极小值 eps = 1e-8 bd = 1.0 - np.sum(np.sqrt((template_hist + eps) * (candidate_hist + eps))) # 将BD映射为权重,使用Sigmoid函数 # bd0是临界值,k是陡峭度 bd0 = 0.3 # 可根据项目调整 k = 10.0 weight = 1.0 / (1.0 + np.exp(k * (bd - bd0))) self.weights_buffer[i] = weight # 归一化权重 weight_sum = np.sum(self.weights_buffer) if weight_sum > 0: self.weights = self.weights_buffer / weight_sum else: # 所有权重都为0,说明观测完全失败,重置为均匀分布 self.weights.fill(1.0 / self.num_particles) def estimate_state(self) -> Tuple[float, float, float, float]: """ 根据加权粒子,估计当前目标的最优状态(中心x,y,宽w,高h) Returns: (x, y, w, h) 归一化坐标 """ # 使用加权平均 x_est = np.sum(self.particles[:, 0] * self.weights) y_est = np.sum(self.particles[:, 1] * self.weights) w_est = np.sum(self.particles[:, 2] * self.weights) h_est = np.sum(self.particles[:, 3] * self.weights) return x_est, y_est, w_est, h_est def resample(self): """ 重采样步骤:根据权重,生成新的粒子集 """ # 计算有效粒子数 neff = 1.0 / np.sum(self.weights ** 2) # 如果有效粒子数低于阈值,则重采样 if neff < self.num_particles * self.resample_threshold: # 使用系统性重采样(Systematic Resampling),比多项式重采样更高效 # 生成累积分布 cumulative_sum = np.cumsum(self.weights) # 生成均匀分布的随机起点 start = np.random.random() / self.num_particles # 生成采样点 positions = (start + np.arange(self.num_particles) / self.num_particles) # 执行重采样 indices = np.searchsorted(cumulative_sum, positions) # 复制粒子 self.particles = self.particles[indices, :] # 重置权重为均匀分布 self.weights.fill(1.0 / self.num_particles) # 添加微小扰动(jitter),防止样本贫化 jitter_std = self.system_noise_std * 0.1 self.particles[:, 0] += np.random.normal(0, jitter_std, self.num_particles) self.particles[:, 1] += np.random.normal(0, jitter_std, self.num_particles) self.particles[:, 2] += np.random.normal(0, jitter_std * 0.5, self.num_particles) self.particles[:, 3] += np.random.normal(0, jitter_std * 0.5, self.num_particles) # 再次裁剪 self.particles[:, 0] = np.clip(self.particles[:, 0], 0, 1) self.particles[:, 1] = np.clip(self.particles[:, 1], 0, 1) self.particles[:, 2] = np.clip(self.particles[:, 2], 0.01, 0.5) self.particles[:, 3] = np.clip(self.particles[:, 3], 0.01, 0.5) # 使用示例 def main(): # 1. 打开视频 cap = cv2.VideoCapture("test_video.mp4") if not cap.isOpened():