毫米波雷达数据处理:峰值搜索在距离-多普勒图上提取目标的工程实战
简介这是一份面向雷达信号处理与数据解析的MATLAB工具包共两个m文件readDCA1000.m负责读取DCA1000采集板生成的雷达I/Q数据完成文件解析、采样时刻换算、I/Q分量拆分等必要步骤findAllPeak.m则聚焦峰值检测通过滤波去噪、峰谷条件判断、导数寻峰等手段提取信号峰值并设置验证规则减少虚警。两者组合后得以实现从原始雷达数据读入到目标特征峰值输出的完整处理链路可直接用于目标探测、姿态感知等课题研究或工程调试。压缩包共两个文件均为m脚本大小仅2KB轻巧易用便于对照源码理解处理流程。已有363人学习查看适合雷达工程、信号处理等方向的本科生、研究生及科研人员作为参考模板可显著缩短前期编码和调试时间将更多精力投入核心算法改进。1. 一根天线扫出的不是距离是一串峰值findAllPeak 在雷达数据处理里的真正用处做毫米波雷达数据处理时最常被低估的一步不是 FFT 也不是 CFAR而是“找到目标对应的那个峰”。很多人把 CFAR 跑完就拿到目标列表了但实际工程里CFAR 输出的只是“疑似有目标的检测点”真正把距离、速度、角度算出来还要靠一次精确的峰值搜索。findAllPeak 这个名字像是随手起的工具函数但它背后就是雷达信号处理里最核心的“峰值提取”环节从幅度谱里把目标的局部极大值挑出来再映射成物理量。这篇文章不聊泛泛的原理直接讲我在雷达数据处理里用 findAllPeak 这类函数时的完整落地路径包括数据怎么准备、参数怎么设、哪些边界坑让我返工过最多次以及最后怎么把它封装成能用的目标检测小工具。适合正在写毫米波雷达数据处理代码、被 RD 图上杂散峰搞到头疼的人也适合刚接手雷达信号处理板、需要快速看明白目标是怎么从 ADC 数据里“冒出来”的新手。2. 峰值搜索前的雷达信号准备从 RD 图到一维序列的常见处理链2.1 为什么不能直接对 RD 图用 findAllPeak雷达信号经过距离 FFT 和多普勒 FFT 之后得到一个二维矩阵行是距离门列是多普勒门幅度代表该距离-速度格点的回波强度。这个时候直接对整个二维矩阵调用 find_peaks或者自己写 findAllPeak是能跑但结果基本没法用。原因有三个第一二维峰值搜索要考虑邻域窗口单纯按“比身边的8个点都大”来判断会把同一个目标在距离和速度维的旁瓣都当成独立峰值一个目标变成几十个点第二CFAR 检测通常已经给出的目标位置比如第 15 个距离门、第 -20 个多普勒门如果你在二维矩阵上再做一次峰值搜索会重复劳动且容易把强目标的副瓣当成新目标第三RD 图上每个格点的幅度是复数模值不同距离、不同速度的杂波底噪差异很大不做归一化直接设一个固定阈值弱目标会被漏掉。所以我的处理顺序一定是先做检测目标在哪个距离、哪个速度再做峰值提取在这个检测点附近精确算出峰的位置和幅度。检测这一步用 CA-CFAR 或者 OS-CFAR 都行峰位提取这一步才轮到 findAllPeak。注意检测输出的位置是离散的格点索引比如距离门 15、多普勒门 -20而实际目标的精确距离可能不是 15 对应的那个距离门中心特别是在低信噪比时能量会泄漏到相邻格点。峰值搜索在这里的价值就是对检测位置附近做插值或精细搜索把离散索引变成亚格点精度。2.2 把二维 RD 图压成一维序列的两种常见做法既然 findAllPeak 大多实现为一维峰值搜索就需要把二维 RD 图降维。常见做法有两种我分别用在不同的场景里。第一种是按距离向处理固定每一个多普勒门沿距离维取一维幅度序列然后在这个序列上找峰。也就是说第 k 个多普勒门对应一条一维曲线曲线横坐标是距离门索引纵坐标是幅度。我们在这个曲线上找局部极大值得到“该多普勒频率下有哪些距离上存在目标”。做距离向峰值搜索时峰值位置对应目标距离峰值幅度反映目标反射强度。FMCW 毫米波雷达做单目标测距时用这种方式就够了。第二种是按多普勒向处理固定距离门沿多普勒维取一维序列找峰得到目标的速度。实际中常把这两种做法组合先用 CFAR 拿到若干个“距离-多普勒对”然后对每个距离门取该距离门对应的整行多普勒幅度序列用 findAllPeak 在该行上找主峰和次峰。组合起来的逻辑是CFAR 负责筛选可疑格点findAllPeak 负责把每个可疑格点附近真正的主峰找出来去掉 CFAR 由于栅栏效应产生的偏移。还有第三种更工程化直接对 CFAR 输出的检测点集合做聚类比如 DBSCAN每个聚类簇内部用质心法求峰位等于跳过了一维峰值搜索。这种方式的好处是抗噪但代价是实时性差而且参数聚类半径要和距离/多普勒分辨率强相关。我一般只在后处理分析时用聚类实时运行时仍然用一维 findAllPeak因为计算量小、可解释性强。2.3 最小可复现的峰值搜索代码先跑通再谈参数为了让后面所有讨论都落在实处先给一段最简实现。这里用一个合成的 FMCW 中频信号做一维距离 FFT然后对幅度谱调用基于 scipy.signal.find_peaks 封装的 findAllPeak。scipy 里的 find_peaks 就是典型的峰值搜索函数它返回峰值索引和属性峰值高度、左右边缘等。在雷达数据处理里我通常在此基础上加一层封装把索引转换成距离。import numpy as np from scipy.signal import find_peaks # 模拟中频采样256个采样点采样率10MHzFMCW斜率50MHz/ms fs 10e6 N 256 slope 50e12 # Hz/s ts np.arange(N) / fs # 两个目标距离对应高频分量幅度不同 f_targets [200e3, 450e3] # 中频频率 200kHz, 450kHz r_targets [25.0, 56.25] # 对应距离单位m用 c*T*f/(2*B) 换算 if_sig np.zeros(N) for f, a in zip(f_targets, [1.0, 0.6]): if_sig a * np.exp(2j * np.pi * f * ts) # 距离FFT r_fft np.fft.rfft(if_sig) r_mag np.abs(r_fft) freq_bins np.fft.rfftfreq(N, d1/fs) # 峰值搜索最小高度0.1最小间距3个采样点 peaks, properties find_peaks(r_mag, height0.1, distance3) # 把峰值索引转为距离 c 3e8 dist_per_bin c / (2 * slope * (N / fs)) # 每格对应距离 distances peaks * dist_per_bin print(检出峰值台数:, len(peaks)) print(峰索引:, peaks) print(对应距离:, distances)这段代码里最关键的是distance3和height0.1两个参数。distance表示两个峰之间至少间隔 3 个采样点目的是避免同一个目标的旁瓣被重复算成两个峰。雷达数据里的旁瓣间隔通常和窗函数相关如果用了 Hamming 窗第一旁瓣大概在主瓣外主瓣宽度的两倍位置distance至少要大于主瓣宽度的采样点数。height是绝对幅度阈值这里设 0.1 是因为我模拟信号的峰值约 1.0 左右。实际雷达数据里绝对幅度会和 ADC 增益、FFT 点数强相关所以更稳的做法是先把幅度序列做归一化比如除以整条序列的最大值再设height0.2这类相对值。归一化能避免不同雷达设备、不同距离门之间的判决不一致。代码跑完之后你应该能看到两个峰分别对应 25 米和 56.25 米的目标。如果distance设成 1很可能会在真实峰旁边多出一两个小峰特别是当你加了窗函数之后。这就是峰值搜索最需要直接观察的“黑匣子”现象同样的数据参数不同结果差一倍。3. 用 findAllPeak 提取距离 / 速度 / 角度参数怎么设才不丢目标3.1 距离向峰值提取每一个距离门的谱线怎么处理在 2D FFT 得到 RD 图之后距离向的峰值提取通常按“每一个多普勒门对应的距离谱线”来做。为什么要按多普勒门拆开因为同一个目标在距离维上只有一个峰但在不同多普勒门里这个峰的能量会分散——目标径向速度会跨越多个多普勒格而且强目标会有多普勒泄漏。如果只看 CFAR 给出的那一个多普勒门很可能只看到目标峰的半边幅度偏低峰值索引也偏到相邻格点上。常见处理是对 CFAR 检测到的每个多普勒索引 k取出 RDM[:, k] 这一列沿距离维做峰值搜索。但这里有一个细节CFAR 检测的点可能不止一个多个检测点可能属于同一个目标目标在 RD 图上占据多个连续格点。所以我会先对检测点做简单合并如果两个检测点的距离差小于 3 个距离门且多普勒差小于 3 个多普勒门就把它们视为同一个目标簇然后只对这个簇的联合区域做峰值搜索。具体做法是在两个维度的邻域里取最大幅度点作为粗峰再用二维抛物线插值得到亚格点距离。对于一维搜索findAllPeak 的prominence峰显著性参数要比height重要。prominence衡量一个峰相对周围低点的突出程度。雷达回波里底噪不平远距离段噪声底近距离段有耦合波绝对幅度阈值很难统一。用prominence可以解决只要峰相对于它的左右最低谷高出设定值就算峰这比一刀切的幅度阈值合理得多。我在实际工程中height只用来排除零频耦合通常设在噪声底之上一点prominence才是判断目标是否存在的主力。# 假设rdm是距离-多普勒矩阵cfar_idx 是CFAR检测到的[距离门, 多普勒门]列表 import numpy as np from scipy.signal import find_peaks def peak_on_range_dim(rdm, doppler_bin): # 取指定多普勒门下的距离幅度序列 range_mag np.abs(rdm[:, doppler_bin]) # 归一化防止不同距离段的噪声起伏影响阈值 range_mag_norm range_mag / np.max(range_mag) peaks, props find_peaks( range_mag_norm, height0.3, # 相对幅度阈值排除低幅度噪声 distance5, # 距离门间隔5防止旁瓣重复计数 prominence0.15, # 峰显著性允许近距和远距不同底噪 rel_height0.5 # 用于计算峰值宽度后面可用来估计主瓣范围 ) return peaks, props[peak_heights]这里distance5的理由是雷达距离分辨率如果按c/(2B)计算加 Hamming 窗后主瓣宽度大约是 2~3 个距离门。两个目标要能被分辨至少需要相隔一个主瓣宽度。如果你设distance2很可能同一个目标的距离旁瓣被算成两个目标。rel_height0.5可以用来计算峰宽峰宽能告诉你这个峰是尖锐目标还是被噪声拖宽的疑似峰——我一般用峰宽上限来排除强杂波反射造成的宽峰。3.2 速度维峰值提取多普勒模糊、零速杂波和微多普勒速度维的峰值提取比距离维更敏感因为零速杂波静止物体反射和地杂波经常在零多普勒附近形成一个非常高的峰。这个峰会严重影响findPeaks的判定因为它会把整条多普勒序列的底噪抬高导致弱目标峰被按下“阈值线”下面。处理顺序建议是先对整个距离门的多普勒谱做均值滤波把零速附近的尖峰记下来然后对这个距离门选峰时把零速附近的 ±2 个多普勒门直接屏蔽掉。另一个坑是多普勒模糊。FMCW 雷达的 PRF 决定了最大不模糊速度当目标速度超过这个值它的多普勒峰会被折返到序列的另一端造成“虚假低速”目标。遇到这种情况仅凭峰值搜索无法分辨必须用双 chirp 或调频斜率不同的帧数据来解模糊。峰值搜索在这里能做的就是保证在折返峰附近只取一个峰不要把原位置和折返位置的旁瓣都算上。为此在速度维搜索时distance通常要设为 4 以上——多普勒主瓣在加窗后大约 3 个格点宽。对速度维做 findAllPeak 的另一个场景是提取微多普勒特征。人员在走动时身体反射会产生一个主峰四肢摆动会产生围绕主峰的一串小峰。这些小峰幅度低、距离主峰近很容易被distance参数过滤掉。如果你想保留微多普勒信息就不能用distance5这种粗过滤而是先检测主峰然后在主峰左右一段范围内用height0.05的低阈值再搜一次。这一步通常用在 4D 毫米波雷达的人体识别数据里作为点云前的特征补充。3.3 角度估计时的峰值配对一个目标对应多个峰怎么选MIMO 毫米波雷达做角度估计时先对虚拟天线阵的每一维做 FFT或 MUSIC/ESPRIT。用 FFT 时角度谱也是一个峰值搜索问题目标角度对应角度谱上的峰。但多目标场景下角度谱上会有多个峰且幅度和目标的反射面积相关。findAllPeak 在这里的作用是把角度谱的峰值和前面得到的距离-速度对配对起来。常见的配对逻辑是对每个距离-速度对在那个距离门和速度门下的天线维做角度 FFT得到一个角度谱然后在角度谱上找峰。理想情况是一个目标一个峰实际会出现同一距离-速度处的两个峰来自两个不同角度的目标——这需要让 findAllPeak 同时返回多个峰而不是只取最高峰。我会设置distance3角度谱的主瓣宽度大概在半个波束宽度并把height设在噪声底上二分之一处然后把所有过阈值的峰全部输出再通过聚类把这些峰归到目标点云。这里的常见失误是只取主峰导致两个靠近的目标在角度维被合并成一个点云数量变少。另一个失误是把角度谱的旁瓣当成第二个目标。我后来用了一个“峰有效性判定”主峰旁边的次级峰如果它的位置与主峰的角度差小于天线的半波束宽度且幅度低于主峰 3dB就视为旁瓣直接丢弃不参与配对。这个逻辑写在 findAllPeak 的封装函数里而不是在调用之后才做能少写很多事后补救的代码。4. 雷达峰值搜索的避坑指南那些让目标“凭空消失”的细节4.1 距离门两端的目标被截断现象目标明明就在雷达正前方很近小于最小距离或刚好在最大检测距离附近CFAR 能检测到但 findAllPeak 返回的峰值索引偏到边界距离值明显不对。原因FFT 之后的距离谱在索引 0 附近有直流耦合在索引 N/2 附近有镜像能量。距离门两端的目标峰会被这些“结构性过高”的边界值淹没或者峰的另一半超出序列范围导致prominence计算时找不到完整的左右谷底。解决搜索前先裁剪序列比如把距离维索引限制在 1 到 N/2-2 之间对于接近边界的峰单独用抛物线插值从相邻三个点插值出峰位不要再调用完整的 find_peaks。边界目标我建议直接交给 CFAR 的“多普勒折叠”处理逻辑不要依赖峰值搜索的精度。4.2 旁瓣重复峰最小间距设个数字就完事现象同一个强目标在距离维返回了三个相距 2~3 个距离门的峰和目标个数完全对不上。原因distance参数设置得太小。这通常是因为只按主瓣宽度估算却忽略了加窗后旁瓣的间隔可能恰好是主瓣宽度的一半。有些窗函数比如 Blackman-Harris旁瓣衰减快但旁瓣位置离主瓣近。解决用数值法直接量一下典型目标峰的主瓣宽度。可以先用弱目标接近噪声底做一次搜索测出主瓣两个零值点之间的格点数再把distance设为该格点数的 1.5 倍。我在代码里会写一个estimate_mainlobe_width()函数离线跑一遍把值打到配置里而不是拍脑袋设 3 或 5。4.3 强目标分裂成两个峰峰值搜索赢了检测输了现象一个金属角反射器目标在距离谱上出现两个高度差不多的相邻峰各自距离差约一个距离门。findAllPeak 把它们当成两个目标导致目标数翻倍。原因目标反射占据了多个距离门且幅度在相邻门之间波动形成局部极小值。这常见于目标距离正好在距离门边界一半处能量平均分配到两个格点。解决先用峰宽度属性widths判断如果两个相邻峰之间的距离小于主瓣宽度且两个峰之间的谷底和峰顶的比例高于 0.8那么合并为一个目标峰位取两个峰的幅度加权平均。具体实现时我把这个判定放在 find_peaks 返回之后而不是调用前因为只有看到峰属性才能做这个决策。4.4 噪声底不平固定 height 失效现象相同的目标在近距比如 5 米能被检测在远距比如 50 米却搜不到峰但 CFAR 明确告诉你 50 米处检测到了。原因距离平方反比导致远距回波显著变小近距残留噪声和天线耦合反而大。固定的height阈值在近距噪声底之上在远距却比噪声还高。解决不直接对幅度谱用 height而是对“CFAR 输出的噪声归一化幅度谱”用 height。即spectrum / noise_floor_est然后height1.5含义就是超过噪声底 1.5 倍算峰。这个方法比设置绝对幅度可靠但需要预处理把每个距离门的噪声底存下来。如果你没有噪声底可以退而求其次用prominence替代height因为 prominence 本质上是相对值。4.5 雷达模式差异FMCW 和 FSK 的峰值含义不能互换现象同一段代码从 FMCW 模式切到 FSK 模式目标距离和速度全乱了。原因FMCW 通过差频频率测距和测速耦合而 FSK 通过相邻 chirp 的相位差测速距离则靠多频相位解。findAllPeak 找的峰在两种模式下代表的物理量完全不同——FMCW 的峰是“中频频率”FSK 的峰可能是“角度谱或相位谱”。解决在封装 findAllPeak 时把“峰索引到物理量”的换算函数作为参数传入不同雷达模式传入不同的映射函数。我吃过一次亏把 FSK 模式下角度谱上的峰当成距离峰输出了一组负距离后来才意识到模式切换时换算关系没有更新。现在我的建议是峰值搜索代码不做模式假设只返回峰索引和峰高后续物理量换算单独写避免把两个模式耦合在一起。5. 进阶把 findAllPeak 封装成雷达目标检测的验证工具5.1 峰到目标peak_to_target的封装与输出把 findAllPeak 从单纯的数学函数变成雷达目标列表关键是输出结构。我习惯用一个 dataclass 保存目标距离米、速度米/秒、角度度、信噪比dB、峰宽格点数。封装好的函数应当接受一个“谱数据容器”和一组峰值搜索参数返回一个目标列表。下面的代码展示这种封装的核心部分包含了归一化、边界裁剪和亚格点插值。import numpy as np from scipy.signal import find_peaks from dataclasses import dataclass dataclass class RadarTarget: range_m: float velocity_ms: float azimuth_deg: float snr_db: float peak_width: int def peak_to_target(range_spectrum, dist_per_bin, search_params): # 裁剪边界避免直流和镜像干扰 start 1 end len(range_spectrum) - 2 spec np.abs(range_spectrum[start:end]) spec spec / np.max(spec) # 归一化到相对幅度 peaks, props find_peaks( spec, heightsearch_params[height], distancesearch_params[distance], prominencesearch_params[prominence] ) targets [] for p, h in zip(peaks, props[peak_heights]): # 用峰值及其相邻两点做抛物线插值得到亚格点峰位 if p 0 or p len(spec) - 1: idx p else: y0, y1, y2 spec[p-1], spec[p], spec[p1] delta 0.5 * (y0 - y2) / (y0 y2 - 2*y1) idx p delta # 还原到原始索引 range_idx idx start dist range_idx * dist_per_bin # 信噪比近似峰高 / 该序列中值 noise_level np.median(spec) snr 10 * np.log10(h / max(noise_level, 1e-9)) targets.append(RadarTarget( range_mdist, velocity_ms0.0, # 由多普勒维搜索填入 azimuth_deg0.0, # 由角度维搜索填入 snr_dbsnr, peak_widthprops[widths][list(peaks).index(p)] if widths in props else 0 )) return targets这个封装里最值得注意的不是峰值搜索本身而是峰位插值。因为离散 FFT 的距离门分辨率有限目标落在两个距离门中间时不插值会引入至少半个距离门的系统误差。抛物线插值能把误差降到 1/10 格左右。信噪比的计算也是实际中比较好用的近似用中值估计噪声底比均值更抗强目标影响。5.2 和 CFAR 联动峰值搜索不是用来替代检测的有的同学尝试用 findAllPeak 替代 CFAR 做目标检测。我的观点是对单目标高信噪比场景这样做没问题但雷达数据处理进入多目标、杂波环境后峰值搜索的假警率会高到没法用。CFAR 的价值在于提供了自适应阈值随距离、多普勒门的噪声底变化而调整findAllPeak 的价值在于精确定位和峰属性分析。两者结合的正确姿势是先用 CFAR 得到一组检测门位置。然后以每个检测门为中心取一个 3×3 或 5×5 的距离-多普勒邻域在邻域内用峰值搜索找最大峰再用这个最大峰的索引替换 CFAR 的粗糙索引。这样做不会增加太多计算量但能明显提升目标位置的精度。更重要的是当 CFAR 因为栅栏效应漏检时峰值搜索还能在相邻门发现能量峰作为“疑似目标”补回来。我在实际项目里还让 findAllPeak 的输出反向校验 CFAR如果峰值搜索找到的峰距离 CFAR 检测点太远超过 2 个距离门我就要怀疑 CFAR 检测点是不是杂波虚警或者峰值搜索是否被旁瓣干扰。这个互相校验的逻辑比任何单一算法都更能帮助排查问题。5.3 用仿真验证峰值搜索的正确性三种波形各写一段回归用例做雷达信号处理板驱动时没有真实天线也能先验证峰值搜索逻辑。我通常用三组仿真数据来做回归单目标回波、双目标等幅回波、双目标幅度相差 10 倍的回波。单目标验证基本峰位精度双目标等幅验证距离分辨率双目标强/弱验证prominence筛选能力。import numpy as np from scipy import signal def synth_ifc(sample_rate, num_samples, distances, amps): t np.arange(num_samples) / sample_rate sig np.zeros(num_samples, dtypecomplex) # FMCW 斜率 slope 50e12 c 3e8 for d, a in zip(distances, amps): f_if 2 * slope * d / c sig a * np.exp(2j * np.pi * f_if * t) return sig # 用例近距强目标 10m (1.0)远距弱目标 30m (0.3) sig synth_ifc(10e6, 256, [10, 30], [1.0, 0.3]) r_fft np.fft.rfft(sig) r_mag np.abs(r_fft) # 找峰 peaks, props signal.find_peaks(r_mag, height0.2, distance5, prominence0.1) print(峰值索引:, peaks, 对应距离:, peaks * (3e8/(2*50e12*(256/10e6))))这段仿真里距离 10m 和 30m 的对应中频频率分别是 200kHz 和 600kHzFFT 分辨率是 39kHz 左右两个峰相隔超过 10 个格点distance5足够分开。但如果你把距离改成 10m 和 10.5m相差 0.5m对应中频频率差 10kHzFFT 分辨不出distance设置也没用。这说明峰值搜索的上限受限于 FFT 分辨率不是调参数能突破的。想提高距离分辨率得提高 FMCW 带宽或增加采样点数而不是在峰值搜索里做文章。仿真回归测试跑不过基本都是参数设置和分辨率不匹配造成的。我会把每一组仿真对应的预期峰位写成一个 yaml 配置每次改代码后跑一遍只要有一个用例的峰位偏差超过半个距离门就直接报警。这个习惯帮我拦截了至少三次因为 FFT 补零方式改变导致的距离索引偏移问题——那些问题在人眼观察时很难发现但回归测试一跑就现原形。一个多年留在我心里的踩坑记忆是有一版峰值搜索代码为了提高处理速度把距离维序列做了降采样结果近距目标峰被平均掉目标彻底消失。当时我在车里测试了整整一天最后发现是降采样把目标的峰宽抹平了。从那以后我给自己定了个规矩峰值搜索前的任何降采样、去均值、归一化操作都必须保留峰宽大于降采样窗口宽度的保证。现在写每一版雷达数据处理代码我几乎闭眼都能说出 findAllPeak 该设什么距离、该在什么时候用 prominence 而不是 height但每次遇到新雷达芯片我还是会先跑一遍仿真回归再拿真值验证。希望这些用真金白银换来的经验能帮你省下一点调试时间。本文还有配套的精品资源点击获取