毫米波雷达人数统计:pplcount点云解析与调优实战
简介:围绕 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(恒虚警率)在距离-多普勒图上滑动窗口,把明显高于噪声底的目标点挑出来,再做角度维 FFT(DOA)算出方位角和俯仰角,最终形成点云。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 位整数字段,再往后是若干个 TLV(Type-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, timeout=0.3) with open(cfg_path, 'r', encoding='utf-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(errors='ignore') if 'Done' not in resp: print(f'[WARN] {cmd} -> {resp!r}') # 没收到 Done 就记下来 ser.close()timeout=0.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, c=ds, cmap='viridis', s=18) # 颜色深浅表示多普勒速度大小 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_frames=2000): 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用生成器而不是一次性返回列表,是为了处理长录制的内存占用——几分钟的数据量在大场景下能到几百兆。
回放验证有个我一直在用的判据:拿同一段数据,用两组不同参数各跑一遍,把人数曲线画在同一张图上。如果两条曲线差异巨大,说明这组参数对数据非常敏感,换个人、换个房间大概率就失效了,宁可取一组曲线平缓的保守参数。稳定比"某一次特别准"重要得多。
从那以后我每次调人数统计,都强制先采一段原始数据落盘再动手,现场只做验证不做调试。这个习惯帮我省掉的返工次数,比任何一组"调好的参数"都值钱。希望帮到你。
本文还有配套的精品资源,点击获取