简介围绕 TI 毫米波传感器人数统计实验 lab0011-pplcount 展开的完整工程包面向具备一定 C 语言与嵌入式基础、希望上手毫米波雷达开发的初学者和工程师。内容以实时检测并统计场景内人员数量为主线涵盖传感器参数配置、信号处理与目标跟踪算法、上位机图形界面以及快速上手说明可用于理解检测阈值设定、跟踪滤波等常见问题。压缩包共 309 个文件、约 14.67MB主体是 50 个 c 源文件与 45 个 h 头文件配合 cfg 配置文件、PDF 文档、makefile 以及 sln、vcxproj 工程文件另有 png、jpg 截图与界面素材目录按代码、文档、工程配置分块组织便于按模块查阅。目前已有 361 人学习。读者可获得可直接编译运行的实验工程、毫米波配置参数范例、界面交互代码与配套说明文档对照实践能较快建立从雷达数据采集到人数统计输出的完整链路认知。1. 一份 pplcount 实验包能把毫米波人数统计做到什么程度很多做室内感知的项目第一反应是上摄像头真到落地才发现隐私合规、逆光漏检、口罩遮挡这几关都不好过。mmwave毫米波方案的好处是不成像、只看点云和目标轨迹暗光、烟雾、穿着变化都不影响这也是近几年区域人数统计场景里毫米波方案被反复验证的原因。lab0011-pplcount 就是一套把这条链路走完整的东西从 chirp 波形配置、CFAR 检测、点云聚类到目标跟踪和进出计数配置、固件、上位机解析都能对上。它适合做会议室占用、楼层人数概览、工位区域统计这类要个数量、不要人脸的活儿不适合拿来做厘米级定位或者密集人群精确计数。下面按我拆包的顺序讲。2. 拆开 lab0011 的数据链路一次 chirp 怎么变成一帧点云拿到压缩包先别急着烧固件把目录结构和数据流看懂后面调参才不会靠猜。这个实验的核心不是一个 demo而是一条有明确边界的处理链每一层都能单独拿出来验证。2.1 三层处理前端 FFT、检测、跟踪各管什么毫米波雷达前端做的事很朴素发射一组线性调频的 chirp接收回波后和发射信号混频得到中频信号再做 ADC 采样。采样后的原始数据在 DSP 上走两级 FFT——距离维 FFT 把拍频换成距离多普勒维 FFT 把相位变化换成速度。到这一步你得到的还只是一张距离-多普勒的热力图里面全是能量没有人。第二层是检测。CFAR恒虚警率在距离-多普勒图上滑动窗口把明显高于噪声底的目标点挑出来再做角度维 FFTDOA算出方位角和俯仰角最终形成点云。people counting 这个实验里点云输出的是每个点的 x/y/z 坐标加多普勒速度单位是米和米每秒。第三层是跟踪与计数。点云本身是散的一帧可能有几十上百个点需要先聚类同类点归成一个目标簇再把簇的质心喂给跟踪器由跟踪器给每个目标分配 ID、维护速度和加速度并根据目标跨越预设边界线的方向累加进/出人数。这三层是解耦的前端配置错了后面再怎么调聚类也救不回来跟踪参数不对人数就会在边界附近反复横跳。提示判断问题出在哪一层最快的办法是先把点云打到上位机看一眼。点云分布合理但人数乱跳八成是跟踪层点云本身就散成一片那就回到前端和 CFAR 找原因。2.2 cfg 文件逐段拆解哪些参数可以动哪些动不得整个实验的行为由一份纯文本 cfg 配置决定通过 CLI 串口下发。配置文件是一行一条命令顺序敏感——sensorStop打头、flushCfg清场、最后sensorStart收尾中间的顺序基本不能重排。常见做法是把工程自带的默认 cfg 复制一份再改数值不要自己从头写。配置文件大致分四段作用完全不同配置段代表命令主要影响改动风险数据通路dfeDataOutputMode、channelCfg、adcCfg、adcbufCfg采集模式、天线通道、ADC 位宽与采样高改错直接没数据波形profileCfg、chirpCfg、frameCfg距离分辨率、最大探测距离、最大速度、帧率中高牵一发动全身检测cfarCfg、doaCfg、rangeSelCfg灵敏度、虚警率、角度精度、感兴趣距离段中靠实测调应用层clusterCfg、trackingCfg、boundaryCfg 等聚类门限、跟踪器行为、进出边界低可反复试profileCfg是最容易改出问题的一条。它里面同时有起始频率、斜率、空闲时间、ramp 结束时间、ADC 采样起始时间和采样点数这些量互相咬合改一个斜率就会连带改变最大中频带宽和距离分辨率。血泪经验是想改探测距离优先调 frameCfg 的 chirp 循环数和 profile 的采样点数别去动斜率斜率一改前面所有标定过的距离门限全废。channelCfg决定用哪几根收发天线。用满通道能得到更好的角度分辨率和点云密度但数据量和功耗也上去了只开部分通道点数会明显变少人数统计在高密度场景下更容易漏。我一般先按默认通道跑通确认链路没问题再考虑减通道降功耗。2.3 帧格式与 TLV 解析UART 那头到底吐了什么数据串口输出的不是裸点云而是一帧一帧带头的二进制包。每帧开头是一个 8 字节固定魔数后面跟着 7 个 32 位整数字段再往后是若干个 TLVType-Length-Value块。把包头和 TLV 拆对后面画图、存盘、离线回放才有基础。import struct MAGIC b\x02\x01\x04\x03\x06\x05\x08\x07 # 帧头魔数小端解释为 0x0708050603040201 HDR_FMT 8s7I # 魔数 version/totalLen/platform/frameNum/time/numObj/numTLV HDR_LEN struct.calcsize(HDR_FMT) # 8 7*4 36 字节 def read_frame(port): 从串口对象中同步出一帧完整数据返回 (帧号, 时间戳, 点数, TLV 数, 负载) buf b while True: buf port.read(8192) idx buf.find(MAGIC) if idx 0: buf buf[-(len(MAGIC) - 1):] # 只保留可能是半个魔数的尾巴 continue if len(buf) - idx HDR_LEN: continue # 包头还没收全 _, ver, total_len, platform, frame_no, ts, n_obj, n_tlv \ struct.unpack(HDR_FMT, buf[idx:idx HDR_LEN]) if len(buf) - idx total_len: continue # 整帧还没收全继续等 payload buf[idx HDR_LEN: idx total_len] buf buf[idx total_len:] return frame_no, ts, n_obj, n_tlv, payload def split_tlv(payload, n_tlv): 按 TLV 头切分负载返回 {tlv_type: body_bytes} out, off {}, 0 for _ in range(n_tlv): tlv_type, tlv_len struct.unpack_from(2I, payload, off) out[tlv_type] payload[off 8: off 8 tlv_len] off 8 tlv_len return out def parse_points(body): 点云 TLV每个点 4 个 float32 —— x, y, z, doppler n len(body) // 16 return [p for p in struct.iter_unpack(4f, body[:n * 16])]read_frame里最关键的是三段长度校验魔数没对齐就丢缓冲只留尾巴、包头没收全就返回继续读、整帧不足就等下一批。少了任何一段帧率一高就会出现解析错位看起来像数据乱跳其实是自己把字节流切歪了。split_tlv用偏移量递进而不是一次性 unpack是因为不同 TLV 的长度差异很大必须逐块读长度。parse_points里写死 16 字节是因为点云块是 4 个 float32 紧排如果换了工程或 SDK 版本这里要按固件头文件里的结构体重新对一遍。TLV 的 type 编号不要凭记忆写。常用的几个点云、统计信息、跟踪目标列表、目标索引、存在/占据信息编号在工程的头文件里都有定义第一次拆包务必打开对一遍。我吃过一次亏——把统计信息块当点云解画出来是一堆固定坐标的鬼影排查了整整一晚上。翻车原因就是把 type 记串了跟雷达一点关系都没有。3. 让雷达真正跑起来烧录、双串口与最小可视化链路看懂了接下来是把它点亮。这一步的坑集中在版本对齐和串口分工上跟雷达原理关系不大但对新手最劝退。3.1 固件、SDK 与工具箱版本必须对齐这个实验的固件是用某个版本的毫米波 SDK 编译出来的工具箱版本、SDK 版本、芯片的硅版本三者对不上最常见的表现是烧录成功但串口一句配置都不回或者回了sensorStart之后数据口一声不响。判断方法很直接看实验目录里的说明文档写的是哪个 SDK 版本、支持哪几款板子然后确认你手上的板子型号和芯片版本在支持列表里。烧录流程本身不复杂把板子上的 SOP 跳线摆到烧录位用烧录工具加载编译好的 bin烧完把跳线拨回运行位重新上电。SOP 组合记不住就贴个标签在板子背面我见过太多人包括我自己烧完之后忘了拨回来然后对着串口没反应查了两小时。注意烧录前先确认串口没有被别的程序占用。上位机、串口助手、自己写的脚本同时开两个去抢同一个口Windows 下不一定报错但数据会随机丢表现出来像是雷达在抽风。3.2 双串口分工CLI 口下发配置数据口收帧板子枚举出两个串口一个是配置口波特率通常较低一个是数据口波特率高得多。配置口负责发命令、收回应数据口只负责往外吐二进制帧。搞混这两个口是新手最常见的错误配置发到数据口上当然不会有任何反应。下发配置的脚本要处理两件事命令之间的间隔以及最后sensorStart之前的等待。写得太快固件来不及处理中间某条命令被吞掉后面的配置就全乱套了。import serial, time def send_cfg(cli_port, cfg_path): ser serial.Serial(cli_port, 115200, timeout0.3) with open(cfg_path, r, encodingutf-8) as f: for line in f: cmd line.strip() if not cmd or cmd.startswith(%): # 跳过空行和注释行 continue ser.write((cmd \n).encode()) # frameCfg 之后固件要做一次重配置等久一点 time.sleep(0.3 if cmd.startswith(frameCfg) else 0.05) resp ser.read(4096).decode(errorsignore) if Done not in resp: print(f[WARN] {cmd} - {resp!r}) # 没收到 Done 就记下来 ser.close()timeout0.3决定读回应时最多阻塞多久太小会把正常回应当成超时。frameCfg后面单独拉长等待是因为这一条会触发帧定时器的重新配置固件处理时间明显长于普通命令。Done not in resp这个判断看着粗糙但足够用固件对每条成功的命令都会回一个确认串没收到就说明这条大概率没生效及时打出来比事后猜强得多。3.3 用 Python 画一张能看的点云俯视图不用等上位机二十行代码就能确认数据是不是对的。把点云的 x、y 投到平面上散点画出来人站在哪、走动方向、点云聚不聚一眼就看出来。import matplotlib.pyplot as plt fig, ax plt.subplots(figsize(6, 6)) ax.set_xlim(-4, 4); ax.set_ylim(0, 8) # 俯视图范围按实际安装位置改 ax.set_xlabel(X (m)); ax.set_ylabel(Y (m)) def draw(points): ax.clear() ax.set_xlim(-4, 4); ax.set_ylim(0, 8) xs [p[0] for p in points] ys [p[1] for p in points] ds [abs(p[3]) for p in points] ax.scatter(xs, ys, cds, cmapviridis, s18) # 颜色深浅表示多普勒速度大小 plt.pause(0.01)坐标范围要按实际安装高度和朝向改ylim从 0 开始意味着雷达朝正前方、装在墙面或吊顶上。颜色映射用多普勒绝对值静止的人呈现为深色走动的人偏亮这个特征在调聚类门限时特别有用——同一个人走动时会散成好几簇静止时反而聚得更紧。plt.pause(0.01)负责刷新事件循环值太大会卡顿太小会吃满 CPU。4. 参数调优人数准不准取决于这几个可调参数跑通之后才是真正花时间的部分。人数统计的误差几乎全来自三个地方分辨率不够导致两个人被合并、聚类太松导致一个人被拆成多个、跟踪器跟不上导致 ID 频繁切换进而重复计数。4.1 距离、速度、角度分辨率与帧率的相互制约距离分辨率由 chirp 的扫频带宽决定带宽越大分辨率越高最大探测距离由采样率和中频带宽共同决定最大不模糊速度由相邻 chirp 的时间间隔决定。这三个量和帧率之间存在硬性取舍一帧里塞的 chirp 越多速度分辨率越好但单帧时间变长帧率下降快速走动的人在两帧之间位移变大跟踪器更容易丢目标。实际调的时候我按这个顺序决定先定场景需要的最大距离会议室六到八米够用走廊要十几米再定能接受的最低帧率人数统计十帧每秒已经够用低于五帧走动的人就开始丢最后在剩下的预算里分给 chirp 数和采样点数。帧率不是越高越好帧率拉高意味着每帧 chirp 数减少速度分辨率变差慢速移动的人和多普勒展宽会混在一起反而更难分。角度分辨率取决于天线通道数和阵列孔径。人数统计场景里两个人在横向上一米左右就要求能分开这对通道数是有下限要求的。如果发现并排走的两个人始终只出一个目标先别怀疑聚类去看角度谱是不是本来就分辨不开。4.2 CFAR 与聚类一个人被拆成三个点的常见原因CFAR 门限决定了什么算目标。门限低远处微弱回波也能检出但噪点变多门限高噪点干净了但背对雷达、穿着吸波材质衣服的人容易漏。常见做法是先在一个空旷但有人正常走动的环境里把点云打出来看噪点的分布范围然后把门限定在噪点之上、人体回波之下这条带子里。聚类是把散点归并成目标的核心。门限设小了一个人身上的多个反射点头、肩、手、腿会被拆成好几个簇跟踪器就会认为是好几个人门限设大了并排走的两个人会被合成一个。这两个失效模式在人数输出上的表现完全相反前者人数虚高且在一个位置附近反复跳动后者人数偏低但轨迹很平滑。看到人数虚高先怀疑聚类门限看到人数偏低先怀疑 CFAR 或角度分辨率。调聚类门限有个实用技巧让人在雷达前方不同距离上分别站定和走动记录点云里同一个人的点间距。近距离点密、远距离点疏如果聚类用的是固定欧氏距离远距离的人必然被拆散。有些配置里会带按距离缩放的门限打开它比死调一个固定值有效得多。4.3 跟踪器与进出计数边界线怎么画才不抖跟踪器给每个簇分配 ID维护位置、速度、加速度并根据预测位置做门限关联。它的几个关键参数是最大速度、最大加速度、关联门限和目标存活时间。最大速度和加速度设得比人实际运动小快走的人会被判成新目标设得太大两个靠近的人容易互相抢ID。进出计数依赖一条虚拟边界线。这条线的位置和角度决定了计数是否稳定线画在雷达正前方太近的位置人刚进入视场就被计入实际还在门口徘徊线画得和人的运动方向不垂直斜着走过去可能被判成一次进一次出。我的做法是把边界线放在视场中部、和主要人流方向尽量垂直的位置并且给跨越判定加一个最小位移约束——目标必须连续若干帧都在线的另一侧才算一次有效跨越。5. 避坑与排查五条从现场带回来的记录下面这几条都是实际调这个实验时踩过的按现象 → 原因 → 解决整理遇到对得上的直接抄。5.1 现象串口能收到数据但解析出来全是乱码原因是波特率不匹配。数据口和配置口的波特率不一样数据口通常是高波特率用默认的 115200 去读必然是乱码。还有一种情况是读到了正确的字节流但没有做帧同步从任意位置开始解包头对不上。解决方式是先确认数据口波特率看工程文档或上位机设置并确保解析代码里是先搜魔数再解包头而不是上来就按固定长度切。5.2 现象点云正常人数一直是 0 或者一直是 1先看应用层配置有没有下发成功。这类配置行如果被前面的命令挤掉发送太快、固件还在处理上一条跟踪器和边界配置就是空的人数自然不动。排查方法是把配置脚本改成逐条打印回应看哪一条没回 Done。另一个常见原因是边界线的坐标定义和你的安装朝向不一致人实际没跨过判定区计数当然不增加。5.3 现象同一个人走动时人数在 1 和 3 之间反复跳典型的 ID 频繁切换导致的重复计数。根因通常是聚类门限偏小走动时人体点云被拆成多簇跟踪器把每一簇当成新目标。解决顺序是先把聚类门限按距离缩放打开再适度放大关联门限最后才考虑调大目标存活时间——存活时间调得太大会让已经离开的人赖在计数里反而更难排查。5.4 现象烧录成功上电后配置口毫无回应八成是 SOP 跳线还在烧录位。拨回运行位重新上电即可。如果不是这个原因检查是不是把配置下发到了数据口或者串口被上位机占着。还有一种隐蔽情况板子供电不足USB 口带不动表现为上电瞬间有回应、几秒后彻底静默换个供电口或外接电源就好。5.5 现象白天正常晚上或者开空调后虚警明显增多环境变化引起的噪底漂移。温度变化会影响前端噪声空调出风口的空气流动也会被多普勒检测到。解决办法一是重新做一次噪底标定二是把 CFAR 门限适度上调三是物理上避开正对出风口和窗户的安装位。这个坑很玄学因为同一份配置在别的房间可能完全正常别急着改代码。6. 进阶把数据存下来做离线回放比现场调参省一半时间现场调参最痛苦的地方是不可复现——同一个人走两遍点云不一样改完参数你不知道是参数生效了还是这次走得不一样。我的做法是把原始帧落盘之后所有调参都在离线数据上做。落盘很简单就是把read_frame拿到的完整帧含包头原样追加写进一个二进制文件同时把配置一起存一份同名 txt。回放时按文件顺序读走同一套解析和聚类逻辑唯一区别是数据来源从串口换成文件。这样聚类门限、跟踪参数、边界位置全都可以在几分钟内扫一遍不用每次叫人来回走。import struct def dump_frames(port, out_path, max_frames2000): with open(out_path, wb) as f: for _ in range(max_frames): frame_no, ts, n_obj, n_tlv, payload read_frame(port) total 36 len(payload) f.write(struct.pack(HDR_FMT, MAGIC, 1, total, 0, frame_no, ts, n_obj, n_tlv)) f.write(payload) def load_frames(in_path): with open(in_path, rb) as f: data f.read() off 0 while off 36 len(data): _, _, total, _, frame_no, ts, n_obj, n_tlv \ struct.unpack_from(HDR_FMT, data, off) yield frame_no, ts, n_obj, n_tlv, data[off 36: off total] off totaldump_frames里的max_frames决定采多久按十帧每秒算两千帧大约三分多钟够覆盖多种走动路线。回写包头时 version、platform 这些字段填固定值就行因为离线解析只依赖 total 和帧号。load_frames用生成器而不是一次性返回列表是为了处理长录制的内存占用——几分钟的数据量在大场景下能到几百兆。回放验证有个我一直在用的判据拿同一段数据用两组不同参数各跑一遍把人数曲线画在同一张图上。如果两条曲线差异巨大说明这组参数对数据非常敏感换个人、换个房间大概率就失效了宁可取一组曲线平缓的保守参数。稳定比某一次特别准重要得多。从那以后我每次调人数统计都强制先采一段原始数据落盘再动手现场只做验证不做调试。这个习惯帮我省掉的返工次数比任何一组调好的参数都值钱。希望帮到你。本文还有配套的精品资源点击获取