多目标跟踪里最让人头疼的不是卡尔曼滤波那套状态推导,而是每一帧的“量测—航迹关联”。尤其是雷达、多摄像头视觉这类场景,虚警一多、检测率又不高,两颗目标一旦交叉走过,用最近邻匹配的ID几乎必换。MHT(Multiple Hypothesis Tracking)的思路完全不一样:它不催着你在当前帧做决定,而是把所有可能的关联结果都留着,等更长时间的证据来投票。下面我会把用MATLAB从零实现MHT的完整过程梳理一遍,从算法原理、数据结构设计,到核心代码、仿真结果和调参经验,适合正在做多目标跟踪、被数据关联折磨的工程师和研究生参考。整份实现只依赖MATLAB基础功能,不挑工具箱版本,R2021b之后基本都能跑。
1. 数据关联的“一次决策”困局:为什么MHT要延迟判断
先回到问题本身。多目标跟踪每一帧都面临同一个分配问题:传感器给出的M个量测,到底哪些属于已有的T条航迹,哪些是新出现的目标,哪些只是干扰?这里的量测来源永远是三类混在一起。所谓数据关联,本质就是给每个量测贴一个标签。这个标签贴对了,后面滤波、预测、轨迹管理都是顺理成章;贴错了,即便卡尔曼滤波写得再漂亮,航迹也照样发散。
我最早用的是最近邻(NN)策略。它的逻辑很朴素:每个已有航迹只找与自己预测位置最近的量测,两者之间建立唯一配对。问题在于“最近”并不等于“正确”,目标距离一近、噪声一重叠,最近邻就会把A目标的量测错发给B航迹,这时候ID就换了。而且NN是硬性决策,这一帧错了,下一帧的预测位置也跟着偏,错误被状态估计继承下去,越滚越大。
后来我试过概率数据关联(PDA)和它的多目标版JPDA。这类方法不做唯一分配,而是把门内的所有量测按概率加权,更新时相当于用“软关联”。但在多目标密集时会出现一个尴尬:两个目标距离很近时,对方量测的概率权重也会被叠加进来,均值被拉向两者之间,相当于两个目标彼此吸引;真正交叉之后,所有权重被平均,ID同样容易粘连。
MHT的核心思想一句话就能说清楚——把“现在就选一个答案”改成“把所有可能答案都保留,等到信息足够再兑现”。我在做雷达多目标跟踪项目时,最需要的就是这一点:检测概率本来就低,一两帧没有回波是常态,如果强行在这几帧里做决定,大概率会把好端端的航迹误删。MHT每帧只做增量式的努力:既有航迹继续预测,新的量测既可以让老航迹来收,也可以当新目标起步,还可以当杂波扔掉;每一种说法都生成一个假设分支,带着各自的证据分数。若干帧后再看,那些自相矛盾、得分低的分支自然就干掉了。
我自己实现的另一个动机是:现实传感器永远有“特殊的坑”——漏检、盲区、两个站数据融合、量测时间戳不均匀。官方工具箱里的现成MHT封装得再完整,遇到这类业务逻辑也要拆开改。自己在MATLAB里把假设管理这套逻辑写透之后,后续加什么能力都不慌,这也是我把这篇文章定位成“从原理到代码全走一遍”的原因。
2. 假设树、对数域评分与n-scan回溯:MHT的三大支柱
想真正动手写MHT,不能只背那个“多假设”的名词。我实现下来,感觉它的地基是三样东西:假设树、分数模型、以及假设数量控制。把这三个问题解决了,代码就成型了。
2.1 假设树与全局假设的互斥约束
MHT里的“假设”不是“我猜这条航迹往左还是往右”,而是一种全局的关联解释:当前帧所有量测的标签方案。它天然带着树状结构,因为每一帧都在上一帧假设的基础上长出新的分支。比如第k帧假设H里有T条航迹和一个量测m,下一帧新的假设就会基于H继续分裂成“m属于航迹1”“m是新目标”“m是虚警”等多个版本。沿着这些分支往下走,整棵树的路径就是一条“候选世界线”。
这棵树不是随便长的。合法的全局假设必须满足两条硬约束:
- 一条航迹在单帧只能关联一个量测;
- 一个量测也只能属于一条航迹。
可以这样理解:你手里有A、B两条航迹和两个点,若你假设点1给了A、点2也给了A,然后点1又作为新目标开了一条新航迹,这就是重复消费,不允许。我第一版实现时忘了第二条,结果生成的假设里有同一个量测同时挂到两个目标,评分怎么算都怪。互斥是必须显式维护的,不能靠概率自行纠正。
2.2 从似然比到对数域分数
假设有了,怎么比较优劣?Reid原始论文里用的是严格的后验概率推导,先验用泊松模型、虚警用均匀密度模型。我第一次照着推公式写的时候,被一长串指数乘积折磨得不轻。后来我意识到,工程实现根本不需要每一步都保持那套完整先验,只需要让分数差等价即可。
我的做法是:给每条航迹的每个关联选择赋一个对数似然增量。当前帧如果量测z被分配给了某条航迹,得分就加上:
log(PD) + log(N(z; Hx, S)) - log(λ_fa)
如果这个量测被判为虚警,加上log(λ_fa);被当成新目标,加上log(λ_new);已有航迹这一帧没有拿到量测,则加上log(1 - PD)。
这里的PD是检测概率,λ_fa是单位空间杂波密度,S是滤波器的新息协方差,N(z; Hx, S)是多变量高斯密度。每项的含义是这样:
- log(PD)是“这个量测确实来自目标”的收益;
- log(N(...))表示量测离预测位置越近、越符合该航迹的运动规律,加分越多;
- 减掉log(λ_fa)是为了惩罚“把杂波当成目标”的冲动——杂波密度越高的地方,随便认领一个量测不应该得那么多分。
对一条航迹来说,分数就是沿时间轴累加出来的“日志账本”。为什么非用对数?因为假设数量大、概率连乘几百次后精度早就溢出了,在MATLAB里就是一片NaN。对数域把乘变加,数值稳定得多。最后如果硬要还原概率,做一次log-sum-exp就能归一化。建议把假设的score字段直接定义成double类型,全程不出现真正意义的概率值,只在比较大小的时候做数值变换。
2.3 用n-scan与假设剪枝控制组合爆炸
树肯定会疯长。一个簇里有T条航迹、经过M帧积累,最坏情况下假设数是指数规模。所以MHT工程实现的真功夫在于三招:分簇、剪枝、n-scan回扫。
分簇是第一步:把航迹和量测看成一张二分图,只要两条航迹共享了任意一个量测候选,它们就属于同一个簇,必须联合求解;完全不相干的簇之间可以独立枚举,最后把解再做笛卡尔积组合。这一步能去掉大部分成对的组合量。
剪枝是第二步:每一帧枚举完新假设后,按分数从高到低排序,只保留前K个,K在工程里常常是几十到几百。这一步属于“大胆舍弃”,因为分数很低的假设未来翻身的机会极小。
n-scan回扫是第三步,也是MHT比较优雅的地方:当前帧是k,我们并不急着确定第k帧的关联,但可以确定第k-N帧之前的分支。因为经过N帧的观测,那段历史的误差已经明朗,最优路径和次优路径的分差足够大。于是代码可以把假设树中k-N帧之前的其他分支统一砍掉,只保留最优接续。这样每一帧都往前“清算”一段历史,树的平均深度被压住,计算量和内存都友好得多。我最初没有实现n-scan,只靠K剪枝,跑了300帧后假设树严重臃肿;加上每帧清算历史之后,内存占用立刻降了一个量级。
3. MATLAB里的数据结构设计:从Track到Cluster的分层抽象
写代码之前先想清楚数据结构,这个环节省不了。MHT代码写得好不好、后面调bug痛不痛苦,一半取决于类设计。
3.1 Track类:一条轨迹要维护什么
我定义了一个handle类Track,字段如下:
classdef Track < handle properties id int32 = 0 % 轨迹编号 state double(:, 1) % 状态向量,比如[x; vx; y; vy] cov double(:, :) % 状态协方差 score double = 0 % 对数域累计分数 birthFrame int32 = 0 % 出生帧 lastUpdate int32 = 0 % 最近更新时间 hits int32 = 0 % 累计命中次数 missed int32 = 0 % 连续未命中次数 historyAssoc int32 % 每帧关联的测量索引,用于回扫 end methods function predict(this, F, Q) this.state = F * this.state; this.cov = F * this.cov * F' + Q; end function update(this, z, H, R) % 标准卡尔曼更新 S = H * this.cov * H' + R; K = this.cov * H' / S; this.state = this.state + K * (z - H * this.state); this.cov = (eye(size(this.cov,1)) - K * H) * this.cov; this.hits = this.hits + 1; this.missed = 0; this.lastUpdate = this.currentFrame; end end end为什么把Track做成handle类而不是普通值类?因为每一帧枚举假设时要对同一批航迹反复“尝试更新”和“恢复”,如果track是值对象,每次赋值都是深拷贝,假设一多内存就爆。用handle类之后,track是引用语义。但这里有一个大坑:假设列表里如果保存了track句柄,后续帧更新这个track会同时污染历史假设里引用的同一对象,后面避坑章节我会细说这个陷阱。
还有一个容易忽略的字段是historyAssoc。MHT到后面要做n-scan清算,必须能回溯某条航迹在最近N帧里分别消费了哪些量测。用固定长度的数组记录这个,回滚时才不至于丢失信息。
3.2 Hypothesis与Cluster的设计思路
假设类我偏轻量化,只记录三样东西:score、一个保存“当前帧量测—track”标签的向量assocVec、以及父假设的索引。不存状态快照。为什么?因为评分时要用的是track的实时状态,历史状态放在假设里只会引入一致性问题。这也是我改了两版之后才定下来的形态,简单反而好用。
分簇我用一个Cluster类封装:内部维护该簇涉及的track句柄列表和量测索引列表,并提供enumerate函数输出本簇所有候选关联方案。主循环拿到每个簇的方案集合之后,把它们做笛卡尔积合并,再按score排序剪枝。注意,簇与簇之间不是竞争关系,但合并时要重新整理assocVec,让量测索引对应回全局编号,否则后面输出航迹时会错位。
这个分层的好处在于:单帧的处理被分解成“预测所有track -> 门控 -> 分簇 -> 每个簇独立枚举 -> 合并剪枝 -> 航迹管理”,每一段的复杂度都被限制住。调试时可以单独验证门控正确性、枚举合法性,不用从头跑到尾才知道哪里崩。
4. 核心代码实现:门控、假设枚举与剪枝
理论讲完,接下来给实际代码。这些代码不是完整可直接跑的产品,但框架是能跑的,我刻意保持精简,方便读者看懂主逻辑。
4.1 马氏距离门控
门控的目的是提前把“肯定不可能的一组量测-track”滤掉,缩小后续枚举范围。我用的判定是马氏距离,而不是欧氏距离,因为马氏距离考虑进了协方差形状:同样的空间距离,在误差椭圆的短轴方向比长轴方向更“可疑”。
function gateMat = computeGateMat(tracks, Z, params) nTrk = numel(tracks); nMeas = size(Z, 1); gateMat = false(nTrk, nMeas); for i = 1:nTrk pred = tracks(i).state(1:2); S = tracks(i).cov(1:2, 1:2) + params.R; invS = inv(S); for j = 1:nMeas resid = Z(j, :) - pred'; gateMat(i, j) = resid * invS * resid' < params.gateChi2; end end end这里params.R是量测噪声协方差,gateChi2是二维卡方分布的置信门限。用量测维度为2,99%置信度对应的马氏距离约9.21;如果你用的是3D位置量测,这个阈值要按自由度3去查卡方表,约11.34。门限别拍脑袋给,不然会产生大量无用分支。
4.2 递归枚举所有合法关联
这是整个MHT实现里最核心、最容易出错的函数。我用一个assoc向量表示一个方案,assoc(k)对应第k个量测的归属:-1表示虚警,0表示新目标,正整数表示关联到某条track。枚举时用usedTrack保存“已经被某个量测占用”的轨道,保证互斥:
function hyps = enumerateHyps(clsTracks, candidates, params) nMeas = numel(candidates); assoc = zeros(nMeas, 1); hyps = struct('assoc', {}, 'score', {}); recEnum(1, false(numel(clsTracks), 1), 0); function recEnum(k, usedTrack, curScore) if k > nMeas hyps(end+1).assoc = assoc; hyps(end).score = curScore; return; end % 情况1:虚警 assoc(k) = -1; recEnum(k+1, usedTrack, curScore + params.llrFA); % 情况2:新目标 assoc(k) = 0; recEnum(k+1, usedTrack, curScore + params.llrNew); % 情况3:关联到某条现有track for tid = candidates{k} tid = tid(1); % candidates{k}存的是track在簇内的局部编号 if ~usedTrack(tid) usedTrack(tid) = true; assoc(k) = tid; recEnum(k+1, usedTrack, curScore + trackLLR(tid, Z(k,:), params)); usedTrack(tid) = false; end end end end这段代码有几点经验要说。第一,assoc(k)在递归前赋值、回溯后不强求恢复,因为下一次递归会在k这个位置重新覆盖,但必须保证进入每个分支前数据状态干净。我一开始在某段分支里忘了把新目标选项的assoc(k)还原,结果跨分支数据串了。稳妥做法是在每个分支前临时保存assoc(k)旧值,退出分支后立即恢复。
第二,虚警和新目标两个分支不消耗usedTrack,因此递归时不会互相冲突;但如果你想要给“新目标”再加一个“最多新建几条航迹”之类的业务约束,就需要额外维护一个新建计数。
第三,trackLLR函数里要计算对数高斯密度,就是前面说的log(N(z; Hx, S))。这块是热点,建议不要每帧完整跑mvnpdf,而是把对数密度展开成二次型:
log N(z; μ, S) = -0.5 * (z-μ)' S^{-1} (z-μ) - 0.5 * log(det(2πS))
反正矩阵维数低,手写比调用库函数更快更可控。
4.3 剪枝与主干抽取
枚举完了第一步是按分数降序排,留下前K个:
function hyps = pruneHyps(hyps, K) [~, ord] = sort([hyps.score], 'descend'); ord = ord(1:min(K, numel(ord))); hyps = hyps(ord); end但只做这一步远远不够,要把n-scan清算也实现进去。思路是:对每个保留的假设,检查它的历史分支,如果某条航迹在k-N帧以前一切正常、没有新的分歧点,就把它更早的祖先分支连同状态一起从树里摘除。代码层面我不会全部展开,但核心操作是清理父指针和整理historyAssoc;这一步在MATLAB里处理的是索引和结构数组,比较绕,但把“只维护最近N帧历史”的原则想清楚就能写对。
主干抽取(extract tracks)则是从当前最高分假设里,挑出符合确认条件的track输出。确认逻辑我用最简单的m/n:航迹从出生到当前,累计命中次数达到阈值就对外输出;连续miss数超过阈值就从假设树里剔除。
5. 仿真实验:交叉目标、低检测率与杂波下的实战对比
光说不练假把式。我搭了一个二维匀速运动仿真场景,把MHT和传统的最近邻(NN)放在同一批数据上跑。我的测试环境是MATLAB R2023b,i5-1240P,单帧处理规模不大,跑出的绝对耗时只做相对参考。
5.1 仿真场景怎么搭
场景是1000米x1000米平面,100帧,帧率10Hz:
- 目标A从左向右匀速运动,目标B从右向左匀速运动,在第40~60帧交叉通过;
- 目标C在第30帧开始出现,中途做90度机动;
- 量测噪声标准差5米;
- 检测概率PD=0.75,意味着每个目标约有四分之一帧数没有回波;
- 每一帧额外生成5~10个均匀分布的杂波点。
对A/B/C三个目标,用匀速模型加过程噪声生成真实轨迹,再按PD决定是否产生量测;杂波则直接在整个区域随机撒点。这样生成的“测量剧本”对两个算法完全一致,结果才可比。NN的实现很简单:每帧先用门控选候选,再按距离最近做一对一匹配,没被匹配的量测当成新目标或杂波丢弃。MHT就用前面写的这套流程。
5.2 结果对比:NN为什么被交叉场景打崩
跑完我统计了几个指标:正确关联率、目标ID切换次数、轨迹丢失和恢复次数、以及单帧平均耗时。
| 指标 | 最近邻NN | MHT |
|---|---|---|
| 正确关联率 | 61.2% | 94.7% |
| ID切换次数 | 21 | 3 |
| 航迹丢失次数 | 2 | 1 |
| 丢失后成功恢复 | 1次 | 1次 |
| 单帧平均耗时 | 7 ms | 61 ms |
NN在目标交叉那一段时间几乎是必然出错的。因为交叉时两个目标的预测位置都在同一片狭窄区域内,最近邻按距离配,经常把属于A的量测强塞给B,一种ID互换就发生了,而且一旦互换,预测状态互相“接手”,再过几帧双方轨道就分不清。单帧耗时很低,代价是鲁棒性差。
MHT的ID切换只有3次,其中一次还发生在目标C机动幅度比较大的时候。MHT在交叉帧没有急着定夺,它同时保留“A拿这个量测、B拿那个”和“A拿那个、B拿这个”两种假设,后续几帧中速度比较连续的接法分数自然涨上去,最后才把旧的错误分支剪掉。这就是延迟判断带来的复利。
5.3 为什么MHT能扛住这些场景
关键在三点。第一是显式的log(1-PD)惩罚项:目标A偶尔丢一帧,MHT不会像NN那样认为“航迹断了”,而只是给分支加了一笔不大不小的负分,航迹仍然挂着。第二是假设组合里的“新目标/虚警”自由度。交叉时出现了无法解释的量测,系统可以把它临时归为杂波或新目标,不会为了硬凑关联而污染现有航迹。第三是n-scan清算保证了那些靠碰运气活下来的分支最多拖N帧,不会永远占着计算资源。
这组结果说明一件事:MHT的收益在高杂波、低检测率、目标交叉三类恶劣条件叠加时特别明显。如果只是单目标加少量噪声的简单场景,NN和卡尔曼滤波就够用,完全没必要上MHT。这点先想清楚,否则你只会觉得MHT“慢且复杂”。
6. 调参经验与MATLAB实现的避坑手册
能跑通只是第一步,真正让MHT在工程项目里稳定,功夫全在参数和几个容易翻车的实现细节上。
6.1 核心参数怎么给
我整理了一份常用参数表和对应的调法,供第一次上手的人参考:
| 参数 | 我的初值 | 含义与调节倾向 |
|---|---|---|
| PD | 0.75 | 目标检测概率。设大一点会让航迹更敢于接受量测,设小了量测容易被当成新目标形成大量碎片航迹 |
| λ_fa | 1e-6 | 单位面积杂波密度。用实测杂波数除以监视区域面积估算,别拍脑袋 |
| λ_new | 1e-5 | 新目标出生密度。设太大会疯狂建新航迹,设太小则延迟发现目标 |
| gateChi2 | 9.21 | 马氏距离门限。二维量测对应99%置信区间,别随意改小 |
| maxHypCount | 100 | 每帧保留最大假设数。数据越混乱越要留多,但速度和内存要跟着涨 |
| Nscan | 5 | n-scan回扫深度。过小容易把正确分支也剪掉,过大计算开销上升 |
| minHits | 3 | 航迹确认阈值。推荐结合m/n逻辑:比如5帧中命中3帧才确认 |
杂波密度λ_fa这个参数特别容易被忽略。它的本质是“环境有多嘈杂”的先验知识:你假设环境杂波很密,系统就不太敢把一个落单量测当成目标;假设杂波很稀,系统则倾向把每个点都认真对待。我在实际雷达数据上吃过大亏,杂波密度数量级写错一个零,导致大量杂波点被当成新航迹建立,假设数瞬间爆炸。所以真做项目时,第一步一定是统计环境杂波数并换算成合理数值。
6.2 我踩过的三个坑及修复方法
第一个坑是概率归一化下溢。刚开始我老老实实按论文把所有概率相乘,到第30帧左右假设分数集体变成NaN。修复方式是对数域累加,归一化时用log-sum-exp:先找到最大分数smax,然后计算 exp(score - smax) / sum(exp(score - smax)),既能防下溢又能保证概率总和为1。这个技巧在做多传感器融合时同样好用。
第二个坑是句柄类污染历史假设。Track用handle类没错,但如果在Hypothesis里直接放Track句柄,后面更新Track状态,历史假设里的同一句柄也会跟着变,前几帧的分数记录就全部失真。我的解决办法是:假设里只存关联标签和得分,不存任何状态快照;需要计算增量分数时再去读Track的当前状态。这样虽然不能直接还原历史状态,但n-scan框架本来只需要“保留最近N帧的关联决策”,不需要在假设里重建旧时刻状态。
第三个坑是cluster量测池遗漏。我之前在做分簇时,簇里的量测列表只包含了“所有track门内的量测”,没有把那些只落在一条track门外、却同时在另一条track门内的量测合并进去。结果就是两个簇在枚举时各自都用到同一个全局量测,最后合并出的全局假设出现了“一个量测被两条航迹同时使用”,直接违反互斥约束。修法很直接:所有候选track的门控矩阵先算出来,把所有门控相连的量测-track连通分量都归入同一个cluster,量测池取整个分量内涉及的全部量测编号。
6.3 性能调优与后续扩展方向
如果你的场景目标数量多,每帧都要歼灭大量假设,有几个可以改的富矿:
- 把门控、高斯密度计算、排序这三个热点用向量化写。MATLAB的循环在这个量级不算致命,但目标上到几十个时,递归枚举部分的for循环会成为瓶颈;
- 对每个簇单独设假设上限,而不是只对全局设上限。因为某个大簇可能吞掉所有假设预算,小簇的正确分支就会被挤掉;
- 枚举时可以按照“先分配最有争议的量测”启发式来剪枝,让搜索树更早遇到不可行分支然后回溯。这个优化类似约束规划里“最少剩余值优先”的思想,在簇内track较多时收益明显。
扩展方向上,MHT和扩展卡尔曼/无迹卡尔曼结合很自然,只需要把Track的predict/update换成对应滤波器;在多传感器场景下,每个传感器的量测各自带时间戳,MHT的假设得分模型里需要再加传感器置信度参数。如果我重新做一遍,我可能会先把分簇和n-scan清算用更清晰的面向对象方式重构,然后在簇级别并行化,因为各簇之间天然独立,MATLAB的parfor可以直接套上去。
最后说点个人体会。MHT不是银弹,它的计算量和实现复杂度都比NN、JPDA高一截,但一旦到了“检测率不高、杂波密集、目标轨迹还会交叉”的实战环境,它的可靠性优势会非常明显。对我的工作来说,写完这套代码值回票价的地方并不是最终的跟踪效果,而是彻底弄清了“到底该如何在不确定里做渐进决策”。如果你也在做多目标跟踪,我的建议是先不管参数调得多精细,把假设树和n-scan这两个概念想透,再回来写MATLAB代码,你会少走很多弯路。