
多目标跟踪里最让人头疼的不是卡尔曼滤波那套状态推导而是每一帧的“量测—航迹关联”。尤其是雷达、多摄像头视觉这类场景虚警一多、检测率又不高两颗目标一旦交叉走过用最近邻匹配的ID几乎必换。MHTMultiple 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是二维卡方分布的置信门限。用量测维度为299%置信度对应的马氏距离约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(end1).assoc assoc; hyps(end).score curScore; return; end % 情况1虚警 assoc(k) -1; recEnum(k1, usedTrack, curScore params.llrFA); % 情况2新目标 assoc(k) 0; recEnum(k1, 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(k1, 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 R2023bi5-1240P单帧处理规模不大跑出的绝对耗时只做相对参考。5.1 仿真场景怎么搭场景是1000米x1000米平面100帧帧率10Hz目标A从左向右匀速运动目标B从右向左匀速运动在第40~60帧交叉通过目标C在第30帧开始出现中途做90度机动量测噪声标准差5米检测概率PD0.75意味着每个目标约有四分之一帧数没有回波每一帧额外生成5~10个均匀分布的杂波点。对A/B/C三个目标用匀速模型加过程噪声生成真实轨迹再按PD决定是否产生量测杂波则直接在整个区域随机撒点。这样生成的“测量剧本”对两个算法完全一致结果才可比。NN的实现很简单每帧先用门控选候选再按距离最近做一对一匹配没被匹配的量测当成新目标或杂波丢弃。MHT就用前面写的这套流程。5.2 结果对比NN为什么被交叉场景打崩跑完我统计了几个指标正确关联率、目标ID切换次数、轨迹丢失和恢复次数、以及单帧平均耗时。指标最近邻NNMHT正确关联率61.2%94.7%ID切换次数213航迹丢失次数21丢失后成功恢复1次1次单帧平均耗时7 ms61 msNN在目标交叉那一段时间几乎是必然出错的。因为交叉时两个目标的预测位置都在同一片狭窄区域内最近邻按距离配经常把属于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 核心参数怎么给我整理了一份常用参数表和对应的调法供第一次上手的人参考参数我的初值含义与调节倾向PD0.75目标检测概率。设大一点会让航迹更敢于接受量测设小了量测容易被当成新目标形成大量碎片航迹λ_fa1e-6单位面积杂波密度。用实测杂波数除以监视区域面积估算别拍脑袋λ_new1e-5新目标出生密度。设太大会疯狂建新航迹设太小则延迟发现目标gateChi29.21马氏距离门限。二维量测对应99%置信区间别随意改小maxHypCount100每帧保留最大假设数。数据越混乱越要留多但速度和内存要跟着涨Nscan5n-scan回扫深度。过小容易把正确分支也剪掉过大计算开销上升minHits3航迹确认阈值。推荐结合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代码你会少走很多弯路。