1. 从一个真实需求说起:为什么要在ARM上自己写人流检测驱动
去年帮一个做智能楼宇的朋友处理过一件事:他们在一栋写字楼的几个主要出入口装了摄像头,想统计每天进出的人数,用来优化空调和新风系统的运行策略。一开始他们买的是市面上现成的客流统计盒子,用了一段时间发现两个问题——一是数据只能在他们自己的云平台上查,没法直接对接楼宇自控系统;二是每个门口都要单独布一根网线加一个盒子,成本高不说,后期维护也麻烦。
后来他们的想法就变成了:能不能把摄像头直接接到一块ARM开发板上,在板子上跑检测算法,然后通过RS485或者Modbus把人数数据直接给到楼宇控制器。这样一来,每个门口只需要一块板子加一个摄像头,数据本地处理完直接输出,不依赖外部网络,也不用担心数据隐私问题。
这个需求其实很有代表性。现在做边缘计算的人越来越多,ARM板子的算力也上来了,把视觉检测放到端侧跑完全可行。但问题在于,市面上讲算法训练和模型部署的内容很多,讲“驱动层怎么把摄像头数据接进来、怎么把检测结果送出去”的内容却很少。很多人卡在驱动这一层,不知道从哪下手。
这篇文章就围绕“在ARM平台上自己写室内人群检测驱动和人流进出检测驱动”这件事,把从硬件选型、驱动架构设计、数据流打通到应用层对接的完整链路讲清楚。不管你是做嵌入式开发的,还是做智能楼宇、安防、零售分析的,只要涉及到端侧人流统计,这篇内容都能给你一个可参考的框架。
2. 先搞清楚要做什么:室内人群检测与人流进出检测的区别
很多人把这两个概念混在一起,觉得都是数人头,没什么区别。实际上它们在驱动层和应用层的设计思路上差别很大,如果一开始没分清楚,后面代码写到一半发现架构不对,返工成本很高。
2.1 室内人群检测到底在检测什么
室内人群检测的核心目标是回答“现在这个空间里有多少人”。它关注的是当前时刻的静态人数,不关心这些人是从哪来的、往哪去的。典型的应用场景包括:会议室人数统计、商场某个区域的热力分析、图书馆座位占用率监测等。
从驱动层的角度看,室内人群检测对数据的要求是“全帧检测”。也就是说,每一帧图像进来,你都需要对整幅画面做一次推理,输出所有人的位置框,然后统计数量。这种模式下,驱动需要保证的是帧率的稳定性和推理的实时性。如果帧率波动太大,人数统计就会出现跳变,比如上一秒显示15人,下一秒变成8人,再下一秒又变成14人,这种数据给到上层应用是没法用的。
另外,室内人群检测通常不需要很高的时间分辨率。每秒处理1到2帧就足够了,因为室内人数变化不会那么快。这就给驱动设计留出了优化空间——你可以用较低的帧率换取更高的推理精度,或者用更大的模型来提升检测效果。
2.2 人流进出检测的核心是“方向”和“计数”
人流进出检测要回答的问题完全不同:“有多少人进来了,有多少人出去了”。它关注的是动态的穿越事件,而不是静态的人数。这就意味着驱动层不仅要检测到人,还要跟踪人的运动轨迹,判断运动方向,并且在人跨越某条虚拟线的时候触发计数事件。
这个“虚拟线”在行业里通常叫“绊线”或者“计数线”。驱动需要维护每条绊线的状态,记录每个人在连续帧中的位置变化,当检测到某个人从线的一侧移动到另一侧时,就产生一个进出事件。
这里有个很容易踩的坑:如果只是简单地判断“人出现在线的上方还是下方”,那当一个人在线附近来回走动的时候,就会产生大量的误计数。正确的做法是需要做轨迹跟踪,确认这个人确实是“穿过”了线,而不是“徘徊”在线附近。这就涉及到跟踪算法的选择和多帧状态机的设计,后面会详细讲。
2.3 两种模式对驱动架构的不同要求
把这两种需求放在一起对比,驱动层的设计差异就很明显了:
| 对比维度 | 室内人群检测 | 人流进出检测 |
|---|---|---|
| 核心输出 | 当前人数 | 进出事件计数 |
| 数据处理方式 | 单帧独立推理 | 多帧跟踪+状态判断 |
| 帧率要求 | 低(1-2fps即可) | 较高(至少5fps,保证跟踪连续性) |
| 驱动状态维护 | 无状态或简单缓存 | 需要维护跟踪器和绊线状态 |
| 对延迟的敏感度 | 低 | 高(延迟会导致轨迹断裂) |
| 典型输出接口 | 定时上报人数 | 事件触发上报 |
实际项目中,这两种模式经常需要同时存在。比如一个门口的设备,既要统计当前门口区域有多少人等待,又要统计进出人数。所以驱动架构最好从一开始就设计成支持两种模式的,而不是后面再打补丁。
3. ARM平台上的驱动架构怎么搭
在ARM上写驱动,跟在x86上写应用有本质区别。ARM平台的资源受限,外设接口也跟PC不一样,很多在PC上理所当然的事情,在ARM上需要重新考虑。
3.1 硬件选型:摄像头接口和算力怎么平衡
先说摄像头接口。ARM开发板上常见的摄像头接口有几种:MIPI CSI、USB、DVP并口。MIPI CSI是首选,因为它是专门为图像传输设计的,带宽高、延迟低、CPU占用少。USB摄像头虽然方便,但USB协议栈的开销比较大,而且带宽共享,多路摄像头的时候容易出问题。DVP并口现在用得少了,主要是引脚太多,布线麻烦。
选摄像头的时候要注意一个参数:输出格式。有些摄像头只输出MJPEG或者H.264压缩流,这种适合做视频录制,但做视觉检测需要先解码,会增加CPU负担。最好选支持RAW Bayer或者YUV输出的摄像头,驱动拿到数据后可以直接送给NPU或者GPU做推理,省去解码步骤。
算力方面,现在主流的ARM板子都会带一个NPU或者GPU。NPU适合跑量化后的检测模型,功耗低、效率高。GPU通用性更强,但功耗也更高。如果只是做人流检测这种相对简单的任务,一块带1TOPS左右算力的NPU就够用了。模型方面,YOLO系列的小模型或者MobileNet-SSD都是不错的选择,量化到INT8之后在NPU上跑,单帧推理时间可以控制在20毫秒以内。
内存也很关键。1080P的RGB图像一帧就是6MB左右,如果驱动里要做多帧缓存和跟踪,至少需要预留几十MB的内存空间。有些板子内存只有512MB,跑Linux系统加上应用之后剩不了多少,这种就要谨慎设计缓存策略。
3.2 驱动分层:从V4L2到应用接口的完整链路
在Linux系统上,摄像头驱动通常走V4L2框架。V4L2是Linux内核里的一套视频采集API,它把摄像头的操作抽象成了标准的ioctl调用。应用层通过打开/dev/videoX设备节点,设置格式、申请缓冲区、启动流,然后就可以从缓冲区里读帧数据了。
但V4L2只是最底层的数据获取。对于人流检测这个场景,我们需要在V4L2之上再封装一层,把原始帧数据变成检测结果。这一层我通常叫它“检测驱动层”,它负责的事情包括:
- 从V4L2缓冲区取帧,做必要的预处理(缩放、格式转换、去噪)
- 把预处理后的帧送给推理引擎,获取检测框
- 对检测框做后处理(NMS、置信度过滤)
- 如果是人流进出检测,还要把检测框喂给跟踪器,维护轨迹
- 把结果通过一个统一的接口暴露给应用层
这个分层的好处是,应用层不需要关心底层用的是哪个摄像头、哪个推理引擎,只需要调用“获取当前人数”或者“注册进出事件回调”这样的接口就行了。后面如果要换摄像头或者换模型,只需要改检测驱动层,应用层代码不用动。
3.3 数据流设计:帧缓冲、推理队列和结果回调
数据流的设计直接决定了系统的稳定性和实时性。我见过不少项目,算法本身没问题,但数据流没设计好,跑一段时间就卡死或者丢帧。
一个比较稳妥的数据流是这样的:
摄像头通过DMA把帧数据写入内核缓冲区,V4L2驱动维护一个缓冲区队列。检测驱动层从队列里取帧,取到之后立即把缓冲区重新入队,这样摄像头可以继续写下一帧,不会被阻塞。取到的帧数据拷贝到用户空间的帧池里,帧池是一个环形缓冲区,大小根据内存情况设定,一般8到16帧就够了。
推理线程从帧池里取帧,送给NPU做推理。推理是异步的,提交任务之后不等待结果,而是继续取下一帧。NPU完成推理后会通过中断或者轮询的方式通知结果就绪,这时候再从结果队列里取检测框。
这里有个细节:如果推理速度跟不上摄像头帧率,帧池会慢慢被填满。这时候有两种策略——一种是丢帧,直接丢弃最旧的帧,保证处理的永远是最新数据;另一种是降帧率,通过V4L2的帧率控制让摄像头少出帧。对于人流检测来说,丢帧是可以接受的,因为人的移动速度不会那么快,丢几帧不影响跟踪。但如果是做高速运动分析,就不能随便丢帧了。
结果回调这块,我建议用观察者模式。应用层注册回调函数,检测驱动层在产生结果后调用回调。回调里不要做耗时操作,否则会阻塞检测线程。如果需要做复杂处理,可以在回调里把数据扔到另一个队列,由应用层自己的线程去消费。
4. 人流进出检测的核心:跟踪与绊线逻辑
人流进出检测的难点不在检测本身,而在跟踪和绊线判断。检测模型只能告诉你每一帧里人在哪,但不知道谁是谁,也不知道运动方向。这部分逻辑需要驱动层自己实现。
4.1 为什么不能只用检测框做计数
最简单的想法是:在画面中间画一条水平线,检测框的中心点从线上方变到线下方,就计一次“进入”。这个逻辑听起来没问题,但实际跑起来会发现误计数非常多。
原因有几个:第一,人在线附近走动的时候,检测框的中心点可能会反复穿越线,导致一次真实穿越被计成多次。第二,如果两个人并排走,检测框可能会合并或者交换,导致计数混乱。第三,检测本身有抖动,同一帧里同一个人的框可能忽大忽小,中心点位置也会跳变。
所以必须引入跟踪。跟踪的作用是给每个人分配一个唯一的ID,并且在连续帧中维护这个ID的位置历史。有了轨迹之后,再判断轨迹是否穿越了绊线,就可靠多了。
4.2 轻量级跟踪算法的选择与调参
在ARM上跑跟踪,不能选太重的算法。像DeepSORT这种带ReID特征的跟踪器,虽然效果好,但计算量太大,在端侧跑不动。比较合适的是基于运动预测的轻量跟踪,比如SORT或者ByteTrack的简化版。
SORT的核心是卡尔曼滤波加匈牙利匹配。卡尔曼滤波根据上一帧的位置和速度预测当前帧的位置,匈牙利算法把预测位置和当前帧的检测框做匹配。匹配上的就更新轨迹,没匹配上的检测框新建轨迹,连续多帧没匹配上的轨迹就删除。
在ARM上实现的时候,有几个参数需要仔细调:
- 最大丢失帧数:一个轨迹连续多少帧没有匹配到检测框就删除。设太小了,人一被遮挡轨迹就断了;设太大了,人已经离开了画面轨迹还留着,会产生幽灵计数。一般设5到10帧比较合适。
- IOU匹配阈值:预测框和检测框的重叠度超过多少才算匹配。设太高了容易匹配失败,设太低了容易匹配错。0.3左右是个比较平衡的值。
- 卡尔曼滤波的过程噪声和观测噪声:这两个参数决定了滤波器对预测和观测的信任程度。人的运动不是匀速的,过程噪声要设大一点,让滤波器更信任观测值。
如果算力允许,可以在SORT的基础上加一个简单的外观特征匹配,比如用颜色直方图。这样在两个人交叉的时候,不容易把ID搞混。颜色直方图的计算量很小,在ARM上完全可以承受。
4.3 绊线状态机的设计细节
有了轨迹之后,绊线判断就变成了一个状态机的问题。每条绊线对每个轨迹维护一个状态,状态包括:在线的哪一侧、是否正在穿越、穿越方向等。
我通常会用这样的状态设计:
- 状态A:轨迹在线的正侧。如果轨迹的上一帧位置在正侧,当前帧位置在负侧,并且位移超过一个最小阈值,就触发“正向穿越”事件。
- 状态B:轨迹在线的负侧。同理,反向穿越触发“负向穿越”事件。
- 状态C:轨迹在线附近。如果轨迹位置在线的缓冲区内(比如线上下各20像素),不触发事件,但记录轨迹的移动方向。
这个最小阈值很重要。如果没有它,人在线附近轻微晃动就会触发计数。阈值的大小要根据画面分辨率和人的移动速度来定。1080P画面下,20到30像素是个比较合理的值。
还有一个细节:绊线不一定是水平或垂直的,可能是斜线。这时候判断“在线的哪一侧”就需要用向量叉积来计算。假设绊线由点P1和P2定义,轨迹点为Q,那么叉积(P2-P1)×(Q-P1)的符号就表示Q在线的哪一侧。这个方法对任意方向的线都适用。
4.4 多人交叉和遮挡的处理经验
多人交叉是人流检测里最头疼的问题。两个人迎面走来,在画面里重叠在一起,检测框合并成一个,跟踪器就懵了——到底是同一个人还是两个人?
我的经验是,在检测后处理阶段就要做判断。如果检测框的宽高比突然变大(比如从0.4变成0.8),很可能是两个人合并了。这时候可以根据框的宽度和之前轨迹的宽度做对比,如果宽度明显增加,就尝试把框拆成两个。拆分的位置可以根据轨迹的预测位置来定。
遮挡的处理更复杂。如果一个人被另一个人完全挡住,检测框会消失,跟踪器只能靠预测来维持轨迹。这时候卡尔曼滤波的预测就很重要了。如果遮挡时间短(几帧之内),预测位置还能跟得上,轨迹不会断。如果遮挡时间长,预测位置会漂移,等目标重新出现的时候可能匹配不上。
一个实用的技巧是:在遮挡期间,不要更新卡尔曼滤波器的观测值,只做预测。同时把轨迹的“丢失计数”加一,但不要立即删除。等目标重新出现后,用较大的匹配阈值去关联,提高重新匹配的成功率。
5. 从驱动到应用:接口设计与数据上报
驱动写好了,最终是要给应用用的。接口设计得好不好,直接决定了应用开发的效率和系统的可维护性。
5.1 驱动对应用暴露哪些接口
我一般会暴露三类接口:
第一类是配置接口。应用可以通过这些接口设置检测区域、绊线位置、灵敏度等参数。比如set_detection_roi(x, y, w, h)用来限定只检测画面中的某个区域,add_tripwire(id, x1, y1, x2, y2)用来添加一条绊线。
第二类是查询接口。应用可以随时查询当前人数、历史计数等。比如get_current_count()返回当前画面中的人数,get_flow_count(direction)返回指定方向的累计计数。
第三类是事件接口。应用注册回调函数,当有进出事件发生时,驱动调用回调通知应用。回调的参数包括事件类型(进入/离开)、时间戳、轨迹ID等。
这些接口最好用C语言定义,因为C接口最通用,上层用C++、Python或者Go都能调用。如果驱动是跑在Linux上,可以把接口封装成字符设备或者sysfs节点,应用通过文件操作来调用。也可以做成共享库,应用直接链接调用。两种方式各有优劣:字符设备更符合Linux驱动规范,但调试麻烦;共享库调用方便,但耦合度高。我一般会做成共享库加一个可选的字符设备接口,灵活一点。
5.2 数据上报协议的选择:MQTT、Modbus还是自定义
数据上报协议的选择取决于上层系统是什么。如果是对接楼宇自控系统,Modbus RTU或者Modbus TCP是最常见的。Modbus的优点是简单、稳定、几乎所有楼宇控制器都支持。缺点是他只能传寄存器数据,表达能力有限。可以把人数放在保持寄存器里,应用层轮询读取。
如果是对接云平台或者本地服务器,MQTT更合适。MQTT是发布订阅模式,驱动把检测结果发布到某个主题,服务器订阅这个主题就能收到数据。MQTT还支持QoS等级,可以保证消息不丢。在ARM上跑MQTT客户端,用mosquitto或者paho-mqtt库都很方便。
如果上层系统是自定义的,那也可以直接用TCP socket传JSON。这种方式最灵活,但需要自己处理粘包、断线重连等问题。我一般会定义一个简单的协议:4字节长度头加JSON体,这样解析起来不容易出错。
不管选哪种协议,都建议在驱动层做一个抽象。定义一个report_interface结构体,里面包含init、send、deinit三个函数指针。具体用Modbus还是MQTT,在初始化的时候指定就行了。这样换协议的时候不用改驱动核心代码。
5.3 本地缓存与断网续传的实现
在实际部署中,网络不是永远可靠的。如果网络断了,检测数据不能丢,需要先缓存在本地,等网络恢复后再补传。
本地缓存可以用SQLite或者简单的文件队列。SQLite的好处是支持事务,写入可靠,查询方便。缺点是写入速度相对慢一点,但对于人流检测这种低频写入(每秒最多几条记录)来说完全够用。
缓存的策略是:检测到事件后,先写入本地数据库,然后尝试上报。上报成功就标记为已上报,上报失败就留在数据库里。后台起一个线程,定期扫描未上报的记录,尝试重新上报。数据库不能无限增长,需要设置一个上限,比如最多保留7天的数据,超过就删除最旧的。
这里有个细节:时间戳一定要用UTC时间,并且记录时区偏移。否则设备部署在不同时区的时候,数据对不上。另外,设备重启后要能恢复未上报的数据,所以数据库要存在非易失性存储上,不能放在tmpfs里。
6. 实测中遇到的坑和优化技巧
理论和架构讲完了,下面说几个实际调试中遇到的问题和解决办法。这些内容在官方文档里基本找不到,都是踩坑踩出来的。
6.1 摄像头曝光和帧率不稳导致检测抖动
最开始调试的时候发现,人在画面里走动时,检测框会忽大忽小,有时候还会闪烁。排查了很久才发现是摄像头自动曝光的问题。室内光线变化的时候,摄像头会自动调整曝光时间,导致图像亮度突变,检测模型对这种突变很敏感,输出的框就不稳定。
解决办法是关闭自动曝光,改成手动曝光。如果光线变化确实很大,可以用自动曝光但限制曝光时间的范围,不要让它变化太剧烈。另外,帧率也要固定,不要用自动帧率。有些摄像头在光线暗的时候会自动降帧,导致跟踪器的时间间隔不均匀,卡尔曼滤波的预测就会出错。
6.2 NPU推理延迟与帧率不匹配的解决
NPU推理是异步的,提交任务后需要等结果。如果推理时间比帧间隔还长,就会出现帧堆积。比如摄像头出帧是30fps,但NPU推理一帧需要50毫秒,那每秒只能处理20帧,剩下的10帧就堆在队列里。
短时间堆积没关系,但长时间堆积会导致延迟越来越大,最后实时性完全丧失。解决办法是限制队列长度,队列满了就丢最旧的帧。同时可以降低摄像头帧率,比如降到15fps,让推理和采集匹配上。如果还是跟不上,就要考虑换更小的模型或者降低输入分辨率。
我实测下来,YOLOv5n量化到INT8,输入320x320,在1TOPS的NPU上跑,单帧推理大概15毫秒,加上前后处理总共25毫秒左右。这个速度处理15fps的摄像头绰绰有余。
6.3 内存泄漏的排查过程
跑长时间测试的时候发现,系统内存会慢慢减少,跑一天下来少了100多MB。这种内存泄漏在嵌入式系统上很致命,时间长了系统就会OOM。
排查内存泄漏可以用valgrind,但在ARM上跑valgrind比较慢。更实用的方法是在代码里加内存统计,定期打印各个模块的内存使用量。我一般会在帧池、轨迹列表、结果队列这几个地方加统计点,看哪个模块的内存在增长。
那次最后发现是轨迹删除的时候没有释放轨迹关联的外观特征缓冲区。每次新建轨迹都会分配一块内存存颜色直方图,但删除轨迹的时候忘了释放。这种问题很隐蔽,因为轨迹数量不多的时候看不出来,只有长时间运行、轨迹频繁创建删除的时候才会暴露。
6.4 长时间运行的稳定性优化
除了内存泄漏,长时间运行还会遇到其他稳定性问题。比如文件描述符泄漏、线程死锁、看门狗复位等。
文件描述符泄漏通常是因为打开了设备或者文件没有关闭。在Linux上可以用ls /proc/<pid>/fd来查看进程打开了多少文件描述符,如果数量持续增长就有问题。
线程死锁一般是因为多个线程竞争同一把锁,或者锁的顺序不一致。避免死锁的方法是尽量用无锁数据结构,或者确保所有线程按相同的顺序获取锁。在检测驱动里,帧池和结果队列可以用无锁环形缓冲区来实现,避免锁竞争。
看门狗复位是因为主线程卡住了,没有及时喂狗。可以在主循环里加一个心跳检测,如果某个线程超过一定时间没有响应,就记录日志并尝试恢复。如果恢复不了,就让看门狗复位系统,至少能自动恢复。
7. 这套方案还能怎么扩展
这套驱动架构不只可以用在人流检测上,稍微改一改就能支持其他场景。
比如做区域入侵检测,只需要把绊线逻辑换成区域判断——检测框中心点是否进入某个多边形区域。做停留时间分析,可以在跟踪器里记录每个轨迹的存活时间,超过阈值就触发事件。做人群密度估计,可以把检测框的数量和画面面积做比值,输出密度等级。
如果算力允许,还可以在检测的基础上加属性识别,比如判断人的性别、年龄、是否戴帽子等。这些属性可以通过一个轻量级的分类网络来实现,在检测框的基础上裁剪出人体区域,送给分类网络推理。属性数据对于零售分析、安防布控等场景很有价值。
另外,多设备协同也是一个方向。多个门口的检测驱动可以通过局域网互相通信,做跨镜头的跟踪。比如一个人在A门口进入,在B门口离开,系统可以关联这两条记录,得到更完整的轨迹。这需要在驱动层之上再加一层协同层,实现设备间的数据同步和轨迹关联。
我在实际项目里还遇到过一个需求:把检测结果实时叠加到视频画面上,输出一路带标注的视频流。这个用GStreamer或者FFmpeg都可以实现,在驱动层输出检测框的同时,把框画到帧数据上,然后编码输出RTSP流。这样用户在监控画面上就能直接看到检测效果,调试的时候特别方便。
最后说一个个人体会:在ARM上做视觉检测驱动,最难的不是算法,而是资源管理和稳定性。算法可以调参、可以换模型,但内存泄漏、线程死锁、帧率不稳这些问题,一旦出现就很难排查。所以从一开始就要把资源管理做好,该加的统计要加,该做的测试要做。我一般会在开发阶段就跑72小时连续测试,确保没有内存增长和性能下降,然后再部署到现场。现场环境比实验室复杂得多,温度、供电、网络都可能出问题,只有驱动本身足够稳定,才能扛得住这些外部因素。