简介:这是一套面向TI IWR系列毫米波传感器评估模块的Python 3工具箱,可作为TI官方mmWave Demo Visualizer的轻量替代方案,重点解决雷达数据捕获、信号处理与可视化流程中的环境搭建问题。资源兼容IWR1443、IWR1843、IWR6843等多款常见评估板,覆盖从ES2.0到ES3.0、从SDK 1.1到3.5的多个版本,同时支持高精度14xx与4K FFT拼接等工业工具箱示例场景。包内共57个文件,核心为22个Python脚本与19个配置文件,另有文档、示例图与备份文件,整体仅221KB。这些脚本覆盖距离-多普勒热图、距离-方位角热图、简单CFAR聚类、活动监测、范围剖面绘制等任务,配置文件则针对不同硬件参数与实验场景,方便直接修改复用。目前已有50人学习下载,适合正在使用TI毫米波平台的开发者和算法研究人员,下载后结合随包说明可快速掌握目录结构与各模块调用方式,轻松开展数据采集与分析实验。
1. TI IWR 雷达传感器和 Python 毫米波工具箱:第一步不是调板子
插上一块 IWR1443BOOST 或 IWR6843ISK,电脑上多出两个 COM 口:一个发配置命令,一个吐点云。真正的坑不在硬件,而在拿到手的原始字节——不定长 TLV、帧与帧拼接、字节序,把数据拉成 numpy 数组之前,一切都是偏移量。TI 的 mmWave SDK 官方 demo 主要面向 C/CCS,想用 Python 做数据记录、算法原型和自动化测试,就需要一套顺手的 Python 毫米波工具箱,把配置下发、串口采集、点云解析和可视化固定成可复用的代码文件。
下面这条路径我实际走下来的顺序是:先读懂帧与 TLV 结构,再用 pyserial 收一帧点云,接着接 CFAR 和聚类算法做后处理。整套方案在本地即可复现,不需要 TI 的 IDE,Python 3.8 以上就能跑。适合那些不想把时间耗在 demo 上、想尽早用自己的算法处理 IWR 雷达数据的人。
2. UART 帧结构与 TLV 解析:毫米波点云是怎么排布的
2.1 两条串口链路:CLI 控制口与数据口的配合方式
几乎所有 IWR 评估板都会通过板载仿真器虚拟出两个串口:CLI 口用于写文本命令,数据口用于输出二进制帧。Windows 上常见是COM7/COM8,Linux 上通常是/dev/ttyUSB0和/dev/ttyUSB1,名字可能不同,但逻辑固定。
| 链路 | 常见设备名 | 默认波特率 | 干什么用 |
|---|---|---|---|
| CLI 控制口 | COM7//dev/ttyUSB0 | 115200 | 发sensorStop、channelCfg、frameCfg、sensorStart |
| 数据口 | COM8//dev/ttyUSB1 | 921600 | 输出帧头 + TLV 形式的点云、目标信息 |
CLI 口发的是 ASCII 文本,一条命令以换行结尾;数据口则是纯二进制流,没有任何行分隔。把两个角色搞混是最常见的起步错误——往数据口敲了命令,然后在 CLI 口等半天看不到点云。
2.2 帧头 40 字节:magic、totalPacketLen、numDetectedObj
IWR 系列数据帧以 8 字节 magic 开头,十六进制是02 01 04 03 06 05 08 07。后续是一段固定结构的头部字段:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0–7 | magicCode | 同步用固定字节,用于找帧 |
| 8–11 | version | SDK 版本信息,解析时可忽略 |
| 12–15 | totalPacketLen | 整帧长度,含 magic 和 TLV |
| 16–19 | platform | 传感器型号信息 |
| 20–23 | frameNumber | 帧计数,用于查丢帧 |
| 24–27 | timeCpuCycles | 时间戳 |
| 28–31 | numDetectedObj | 当前帧检测到的目标点数 |
| 32–35 | numTLVs | 本帧里 TLV 条目数 |
| 36–39 | subFrameNumber | 子帧号,部分 demo 才有 |
帧头固定 40 字节,之后是若干 TLV。每个 TLV 由 4 字节 type、4 字节 length、变长的 payload 组成。pointCloud TLV 的 type 通常约定为 1,但不同 SDK 版本可能存在差异,不要认为这个值永远不会变。
2.3 用 Python 对 magic 与 TLV 做通用解析
先写一个通用的同步函数,从一段字节流里定位 magic 并切出完整一帧:
import struct import numpy as np MAGIC = b"\x02\x01\x04\x03\x06\x05\x08\x07" HEADER_STRUCT = struct.Struct("<IIIIIIIIII") # magic 两段 + 8 个字段 def sync_and_extract(buf): """从缓冲区里找帧头,切出完整一帧;找不到或没读满返回 None""" idx = buf.find(MAGIC) if idx < 0: return None if len(buf) - idx < 40: return None hdr = HEADER_STRUCT.unpack_from(buf, idx) total_len = hdr[3] if len(buf) - idx < total_len: return None return buf[idx:idx + total_len]这个函数用find做滑动匹配,省去逐字节对齐的麻烦。拿到完整帧后,再按 TLV 逐条解析点云:
def decode_points_from_frame(frame): hdr = HEADER_STRUCT.unpack_from(frame, 0) num_tlvs = hdr[8] offset = 40 for _ in range(num_tlvs): tlv_type, tlv_len = struct.unpack_from("<II", frame, offset) body = frame[offset + 8: offset + 8 + tlv_len] if tlv_type == 1: # 常见约定 1 = pointCloud n = struct.unpack_from("<I", body, 0)[0] if n == 0: return np.empty((0, 3)), np.empty((0,)) raw = body[4:] psize = len(raw) // n pt = np.frombuffer(raw, dtype=np.uint8, count=n * psize) pt = pt.reshape(n, psize) xyz = np.ascontiguousarray(pt[:, :12]).view("<f4").reshape(n, 3) doppler = np.ascontiguousarray(pt[:, 12:16]).view("<f4").reshape(-1) return xyz, doppler offset += 8 + tlv_len return np.empty((0, 3)), np.empty((0,))注意psize是用 payload 长度除以点数计算出来的。原因很直接:不同 SDK 和不同 demo 里,每个点的字节数可能是 16、20、24 甚至更大,多的字段可能是 SNR、noise、quality 之类。按总数反推,解析器就不会因为版本差异崩掉。前 12 字节基本稳定是 x/y/z 的 float32,这一点在大多数工程里都成立。
2.4 需要原始 ADC 时:DCA1000 与 LVDS 数据路径
如果只用点云 TLV,板载 DSP 已经把 CFAR 和角度估计做完了。很多做算法研究的人会嫌这个处理链太“黑盒”,想自己改窗函数、换波束成形方式,这时就要走 DCA1000 采集卡,把 LVDS 接口上的 ADC 原始数据抓成文件。
这条路径输出的是连续 IQ 数据,不再有 TLV 结构,解析时按采样点数、chirp 数和收发通道数去 reshape。Python 工具箱里通常同时保留两条入口:一条是 UART 点云,低延迟适合实时验证;另一条是adc_data.bin离线回放,适合在算法层面做迭代。后面 CFAR 部分会基于原始 ADC 路径展开。
3. Python 控制 IWR 雷达:配置命令、串口同步和第一帧点云
3.1 搭建工具箱的 Python 环境
首先准备一个干净的 conda 环境,避免把系统 Python 弄乱:
conda create -n mmwave python=3.8 -y conda activate mmwave pip install pyserial numpy scipy scikit-learn matplotlib open3d这里pyserial负责串口读写,numpy做字节解析与 FFT,scikit-learn主要用于后面 DBSCAN 聚类,open3d做点云可视化。如果你用的是 window 或 vscode,Python 安装完别忘把解释器地址切到 conda 环境里;缺哪个包时 pip 会提示,按提示安装即可,不必一次装完。
3.2 三组最常用的 CLI 配置命令
IWR 传感器启动后不会主动出数据,必须先通过 CLI 口下发一组配置。不同的 SDK 版本命令格式略有差异,但下面三条是绕不开的基础:
| 命令 | 典型参数 | 作用 |
|---|---|---|
channelCfg | 15 7 0 | 启用 RX 通道 0–3、TX 通道 0–2,第三个参数是低功耗采样模式开关 |
frameCfg | 0 2 32 0 100 1 | 定义 chirp 序列 0–2、每帧 32 次循环、帧周期 100ms |
sensorStart | 无 | 当前配置写入硬件后启动帧输出 |
channelCfg第一个参数按位表示 4 个 RX 通道,15 的二进制是1111;第二个参数 7 表示使能 3 个 TX。frameCfg中间的 32 越多,单帧点数上限越高,但 UART 带宽有限,调太高会丢帧。
3.3 同时打开两个串口,完成第一帧数据读取
下面是连接 IWR 板并读取一帧点云的完整脚本:
import serial import time CLI_PORT = "/dev/ttyUSB0" DATA_PORT = "/dev/ttyUSB1" cli = serial.Serial(CLI_PORT, 115200, timeout=1) data = serial.Serial(DATA_PORT, 921600, timeout=1) config = """\ sensorStop channelCfg 15 7 0 frameCfg 0 2 32 0 100 1 dataFmtCfg 3 1 0 sensorStart """ cli.write(config.encode()) time.sleep(0.5) # 等传感器启动,避免读到半包 def read_one_frame(): """逐字节找 magic,找到后按 totalPacketLen 读齐整帧""" while True: head = data.read(1) if head != b"\x02": continue head += data.read(7) if head != MAGIC: continue rest = data.read(32) hdr = HEADER_STRUCT.unpack_from(head + rest, 0) total = hdr[3] - 40 if total <= 0: continue body = data.read(total) return head + rest + body这里先开 CLI 口写命令,再开数据口等帧。sensorStop放在第一行是为了让传感器进入可重配状态;如果你之前跑过别的配置,不先 stop 直接发新参数,可能得到的是新旧混合的异常数据。
read_one_frame选择先读 1 字节试探头,是照顾到串口缓冲区里可能残留旧帧的碎片。一旦确认前 8 字节是 magic,剩下的读取都按已知长度走,不会因为数据不齐而卡死。整帧数据到手后,交给第 2 章的decode_points_from_frame就能得到点云坐标。
3.4 丢帧、残留缓冲与帧号校验
UART 在 921600 波特率下,如果应用层处理慢于帧产生速度,缓冲区溢出是必然的。判断丢帧不要靠模糊的“数据变少了”,直接用帧号最可靠:
last_frame_id = -1 def check_frame(frame): global last_frame_id frame_id = struct.unpack_from("<I", frame, 20)[0] if last_frame_id != -1 and frame_id != (last_frame_id + 1) & 0xFFFFFFFF: print(f"检测到丢帧: {last_frame_id} -> {frame_id}") last_frame_id = frame_id另外注意sensorStart后不要立刻清缓冲区。我一般会 sleep 200–500ms,再调用两三次reset_input_buffer()把启动噪声清掉。每次读新帧前也不要盲目清缓冲,否则正好把完整帧头清掉,反而制造“假丢帧”。
4. 从原始数据到目标级结果:Range-Doppler、CFAR 与聚类
4.1 先分清点云和原始 ADC 两条路径
第 2、3 章拿到的是已经过板载处理后的点云。如果要做 CFAR 和更自由的算法,必须先拿到原始 ADC。读取 DCA1000 落盘的adc_data.bin时,通常按chirp * sample * channel的组合做重排:
def load_adc_bin(path, num_chirps=128, num_samples=256): raw = np.fromfile(path, dtype=np.int16) # 常见存储顺序按 chirp 主序排列,I/Q 交错 total = num_chirps * num_samples * 2 raw = raw[: total * 8] # 8 通道示意,按实际通道数调整 iq = raw[0::2] + 1j * raw[1::2] return iq.reshape(num_chirps, num_samples)4.2 Range-Doppler 二维变换的 numpy 实现
拿到单帧 ADC 后,先在距离维做 FFT,再多普勒维做 FFT:
def range_doppler_map(adc_data): # adc_data shape: (num_chirps, num_samples) rng = np.fft.fft(adc_data, axis=-1) # 距离维 dop = np.fft.fftshift( np.fft.fft(rng, axis=-2), axes=-2 ) # 速度维 return 20 * np.log10(np.abs(dop) + 1e-12)距离维 FFT 把中频信号变成距离谱;多普勒维 FFT 把同一距离门在不同 chirp 上的相位变化变成速度。fftshift是为了把零多普勒谱线移到中心,方便后面用同一套 CFAR 门限处理正负速度。
4.3 一维 CA-CFAR 的参考实现
CFAR 的核心是给每个待检测单元估计一个局部噪声底,再乘系数形成自适应门限。写一个不依赖外部库的版本:
def cfar_1d(x, guard=4, window=8, alpha=18): n = len(x) flag = np.zeros(n, dtype=bool) for i in range(n): cell = x[i] l0 = max(0, i - window - guard) l1 = max(0, i - guard) r0 = min(n, i + guard + 1) r1 = min(n, i + guard + 1 + window) ref = np.concatenate([x[l0:l1], x[r0:r1]]) if ref.size == 0: continue noise = ref.mean() if noise <= 1e-12: noise = 1e-12 flag[i] = cell > alpha * noise return flagguard是保护单元,防止宽目标脉冲把噪声底抬得太高;window是参考窗长度,决定噪声估计的平滑度;alpha相当于门限倍率。实际里先固定guard=4,再根据虚警数量调alpha,比来回改窗口更有效。
4.4 检测点聚类成目标:DBSCAN 与参数参考
CFAR 输出的是多个“过门限”的点,不等于目标个数。同一目标可能有多个谱峰,要用聚类把它们合成一个目标。常用 DBSCAN,因为它不需要预先指定类别数:
from sklearn.cluster import DBSCAN def cluster_targets(points, eps=0.6, min_samples=3): if len(points) == 0: return np.array([]) labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(points) return labels调整参数时可以参考这张对照表:
| 参数 | 常用起步值 | 调参方向 |
|---|---|---|
guard | 3–4 | 目标在距离维占据多个点时增大 |
window | 8–12 | 杂波不平稳时增大,稳定噪声底 |
alpha | 12–18 | 虚警多就提高,漏检多就降低 |
eps | 0.5–0.8m | 目标尺寸大、点位稀就增大 |
min_samples | 3–5 | 点数密度低时减小 |
点数少的时候直接用层次聚类也行,但层次聚类需要额外设置连接尺度参数,实际迭代中不如 DBSCAN 可控。我一般把 DBSCAN 作为默认,只在目标太密、粘连严重时才换层次聚类。
5. 更好用的验证方式:用录制回放调通整条链路
5.1 把 UART 数据录成文件再离线回放
调试串口程序最怕问题只在实时运行时出现。我通常先录一段原始帧,再让解析器跑离线文件,这样问题可复现,也能拿同一份数据对比不同解析器的结果。
with open("frame_dump.bin", "wb") as f: f.write(frame) # 回放时直接读文件当串口用 with open("frame_dump.bin", "rb") as f: buf = f.read() frame = sync_and_extract(buf) xyz, doppler = decode_points_from_frame(frame)5.2 用断言验证 TLV 长度是否自洽
解析器写完后,先不要急着可视化。加一个长度自洽检查:
def validate_frame(frame): hdr = HEADER_STRUCT.unpack_from(frame, 0) total_len = hdr[3] num_tlvs = hdr[8] offset = 40 payload = 0 for _ in range(num_tlvs): tlv_type, tlv_len = struct.unpack_from("<II", frame, offset) payload += 8 + tlv_len offset += 8 + tlv_len assert 40 + payload == total_len, "TLV 长度与帧头不符,解析有误"这个断言能快速发现两种问题:一是 SDK 版本变了,帧头字段偏移不对;二是自己写的 TLV 类型判断跳错了长度。任何长度不匹配,基本都意味着后续的坐标全部不可信。
5.3 给可视化留一个实时开关
算法调试到最后总要输出画面。我习惯把可视化封装成独立的plot_point_cloud,实时与回放共用同一套代码。用 matplotlib 调试单帧,换 open3d 做连续帧,Python 毫米波工具箱的整体结构就是:配置、采集、解析、算法四层解耦,每层都能单独验证。这样 IWR 板有没有问题、解析器有没有问题、算法有没有问题,一次运行就能分清。
本文还有配套的精品资源,点击获取