1. 项目缘起与整体设计思路
室内人群检测和人流进出统计,听起来像是商场、写字楼才用得上的东西,但实际上,只要你在做智能家居、小型办公空间管理,甚至只是想统计一下自家店铺每天进出了多少人,这套东西就非常实用。我这次做的项目,是在ARM嵌入式平台上从零写一套室内人群检测驱动,再加上人流进出检测驱动,最后配一个上层应用把数据用起来。整套东西跑在资源有限的ARM板子上,不依赖云端,本地就能完成检测和统计。
先说清楚这个项目到底解决什么问题。传统的客流统计方案,要么用红外对射,只能数一个方向、容易漏计;要么用摄像头加PC端算法,成本高、功耗大、还得联网。我想要的是一块ARM开发板,接上合适的传感器,本地跑轻量算法,实时输出“当前室内有几个人”“今天进来了多少人、出去了多少人”。这个需求在小型零售、共享办公、智能楼宇的局部区域管理里非常常见。
为什么选ARM平台?因为ARM芯片功耗低、成本可控、接口丰富,GPIO、I2C、SPI、UART这些外设一应俱全,特别适合做常驻运行的边缘设备。你不可能在每个房间放一台工控机,但放一块几十块钱的ARM板子完全合理。而且ARM上跑Linux或者裸机RTOS都很成熟,驱动开发和应用开发的生态也够用。
整套方案我拆成三层:底层是传感器数据采集和驱动层,中间是检测算法和状态机,上层是应用层做数据展示和上报。驱动层负责跟硬件打交道,把原始信号变成可处理的数字量;算法层做人群存在判断和进出方向识别;应用层负责统计、存储、展示。这样分层的好处是,换传感器只需要改驱动,换算法只需要改中间层,应用层基本不动。
注意:分层设计不是为了好看,而是为了调试方便。我在实际调试时,经常需要单独验证某一层的数据,分层之后可以直接打印中间结果,不用每次都跑完整流程。
传感器选型上,我对比了几种常见方案。红外对射便宜但只能检测遮挡,无法区分人和物;超声波测距范围有限,多目标场景下容易混乱;ToF传感器精度高但成本上去了;毫米波雷达能检测微动,穿透性好,但开发门槛不低。综合考虑成本和可实现性,我最终选了“红外对射+被动红外(PIR)”的组合方案,门口两侧各放一对红外对射管,配合PIR做辅助确认。这个组合的好处是:红外对射能精确捕捉进出动作的先后顺序,PIR能确认是活体经过,两者结合能大幅降低误判。
2. 硬件选型与驱动层核心细节
2.1 ARM开发板与传感器接口规划
我用的是一块常见的ARM Cortex-A系列开发板,跑Linux系统,GPIO口引出方便。选板子的时候重点看三个东西:GPIO数量和中断支持、I2C/SPI接口是否够用、功耗和散热是否适合长期运行。有些板子GPIO虽然多,但中断响应慢,做实时检测就会丢事件,这个坑我踩过。
红外对射管我选的是普通对射式红外传感器,发射端和接收端分开,输出是数字信号,遮挡时电平翻转。接线方式很简单:发射端接3.3V和GND,接收端输出接GPIO,配置成中断模式,下降沿和上升沿都触发。PIR传感器输出也是数字信号,接另一个GPIO,同样用中断。两个传感器在物理布局上要错开一定距离,比如门口内外各一组,这样才能判断方向。
GPIO配置这块,Linux下用libgpiod或者sysfs都可以。我推荐libgpiod,因为sysfs的GPIO接口在新内核里逐渐被弃用,而且libgpiod的事件监听机制更可靠。配置的时候要注意:中断触发方式选“双边沿触发”(BOTH_EDGES),因为你需要知道什么时候被遮挡、什么时候恢复。如果只配单边沿,方向判断就做不了。
// 使用libgpiod监听GPIO事件的简化示例 struct gpiod_line_request_config config; config.consumer = "people_counter"; config.request_type = GPIOD_LINE_REQUEST_EVENT_BOTH_EDGES; config.flags = 0; int ret = gpiod_line_request(line, &config, 0); if (ret < 0) { perror("GPIO request failed"); return -1; } // 事件循环 struct gpiod_line_event event; while (1) { ret = gpiod_line_event_wait(line, NULL); if (ret > 0) { gpiod_line_event_read(line, &event); // 根据event.ts和event.event_type做后续处理 } }这段代码的关键在于事件时间戳。两个传感器的事件时间差是判断方向的核心依据。比如A传感器先触发、B传感器后触发,说明有人从A侧走向B侧;反过来就是反向。时间差太短可能是误触,太长可能是有人在门口徘徊,这些边界情况都要在算法层处理。
2.2 驱动层的数据结构与中断处理
驱动层我设计了一个环形缓冲区来存放传感器事件,每个事件包含时间戳、传感器编号、触发类型(进入遮挡/离开遮挡)。为什么用环形缓冲区?因为中断处理函数里不能做太耗时的操作,必须快速记录事件然后返回,后续处理交给工作队列或者用户态线程。缓冲区大小我设了256个事件,实测在正常人流速度下完全够用,极端情况比如一群人同时挤过,可能会溢出,但那种场景本身统计意义就不大。
中断处理函数里只做三件事:读时间戳、记录事件、唤醒等待队列。时间戳用clock_gettime(CLOCK_MONOTONIC)获取,精度到纳秒,足够区分先后顺序。这里有个细节:不要用gettimeofday,因为系统时间可能被NTP调整,导致时间戳回跳,方向判断就乱了。单调时钟不受系统时间调整影响,是正确选择。
// 中断处理中的时间戳获取 struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); uint64_t timestamp_ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec; // 写入环形缓冲区 pthread_mutex_lock(&buf_lock); ringbuf_write(&event_buf, sensor_id, event_type, timestamp_ns); pthread_mutex_unlock(&buf_lock); // 唤醒处理线程 pthread_cond_signal(&event_cond);驱动层还有一个重要工作是去抖动。红外对射管在遮挡边缘可能会有多次电平跳变,如果不处理,一个动作会产生多个事件。我的做法是在驱动层加一个简单的时间窗口过滤:同一个传感器在50ms内的重复触发只记一次。这个阈值是实测出来的,人的正常行走速度下,遮挡持续时间至少100ms以上,50ms的过滤窗口不会误杀真实事件。
实操心得:去抖动窗口不要设太大,否则快速连续进出的人会被漏掉。我试过200ms的窗口,结果两个人紧跟着进门时只记了一个,后来改成50ms就正常了。
3. 人流进出检测算法与状态机设计
3.1 方向判断的核心逻辑
方向判断是整个项目里最核心也最容易出错的部分。我的方案是在门口内外各放一组红外对射,形成两个检测点A和B。当有人从室外走向室内时,必然先遮挡A再遮挡B;从室内走向室外时,先遮挡B再遮挡A。所以方向判断就变成了判断两个传感器触发事件的先后顺序。
但实际场景比这个复杂。有人可能在门口停下来、有人可能走到一半退回去、还有人可能侧身挤过只遮挡一个传感器。这些情况都需要状态机来处理。我设计的状态机有五个状态:空闲、A触发、B触发、A到B确认、B到A确认。状态转移的条件是传感器事件和时间窗口。
具体来说,当A传感器触发时,状态从空闲转到A触发,同时启动一个超时定时器,比如500ms。如果在超时前B传感器也触发了,就确认是“进入”方向,计数器加一,状态回到空闲。如果超时了B还没触发,状态回到空闲,这次A触发被丢弃。反过来B先触发就是“离开”方向。
typedef enum { STATE_IDLE, STATE_A_TRIGGERED, STATE_B_TRIGGERED, STATE_CONFIRM_IN, STATE_CONFIRM_OUT } detect_state_t; // 状态机核心逻辑(简化) void handle_event(sensor_id_t id, uint64_t ts) { switch (state) { case STATE_IDLE: if (id == SENSOR_A) { state = STATE_A_TRIGGERED; last_trigger_ts = ts; } else if (id == SENSOR_B) { state = STATE_B_TRIGGERED; last_trigger_ts = ts; } break; case STATE_A_TRIGGERED: if (id == SENSOR_B && (ts - last_trigger_ts) < TIMEOUT_NS) { people_in++; state = STATE_IDLE; } else if ((ts - last_trigger_ts) >= TIMEOUT_NS) { state = STATE_IDLE; } break; // STATE_B_TRIGGERED 对称处理 } }这个状态机的关键在于超时阈值的设定。太小了,正常行走的人可能因为步速慢而被误判为无效;太大了,两个人反向而行可能被误判为同向。我实测下来,500ms对大多数场景都合适,但如果你门口特别宽,或者人流量特别大,可能需要调整到300ms左右。
3.2 室内人数统计与边界处理
室内人数统计是在进出计数的基础上做加减。初始人数可以手动设定,比如早上开门时设为0,之后每确认一次“进入”就加一,每确认一次“离开”就减一。听起来简单,但实际运行中会出现各种边界情况导致计数漂移。
最常见的问题是“只进不出”或“只出不进”的误判。比如有人站在门口聊天,反复遮挡传感器,可能被误判为多次进出。我的处理方法是加一个“最小间隔”限制:同一个人完成一次进出后,至少2秒内不再计入新的进出事件。这个2秒是经验值,正常人不会在2秒内连续进出两次。
另一个问题是多人同时进出。两个人并排走,可能只触发一次事件序列,导致少计。这个问题在硬件层面很难完全解决,我的做法是在应用层加一个“人数估算”逻辑:如果一次事件序列中,传感器遮挡持续时间明显长于单人通过时间,就按比例估算人数。比如单人通过平均遮挡200ms,这次遮挡了450ms,就估算为2人。这个估算不精确,但比直接少计要好。
| 边界场景 | 表现 | 处理策略 |
|---|---|---|
| 门口徘徊 | 反复触发单传感器 | 最小间隔过滤+超时丢弃 |
| 多人并排 | 遮挡时间延长 | 按持续时间比例估算 |
| 快速跑过 | 事件间隔极短 | 降低去抖动窗口至30ms |
| 侧身挤过 | 只触发一个传感器 | 结合PIR确认活体,单传感器触发也计入 |
| 反向折返 | A触发后未到B就返回 | 超时机制自动丢弃 |
注意:人数估算只是辅助,不能作为精确数据使用。如果场景对精度要求高,建议增加传感器组数或者换用更高精度的方案。
4. 应用层实现与数据展示
4.1 应用层架构与进程间通信
应用层我拆成两个进程:一个采集进程负责跟驱动打交道,读取事件、跑状态机、更新计数;另一个展示进程负责把数据可视化,可以通过本地屏幕显示,也可以通过串口或网络发送出去。两个进程之间用共享内存加信号量的方式通信,因为数据量很小,共享内存足够快,没必要上消息队列。
共享内存里放一个结构体,包含当前室内人数、今日进入总数、今日离开总数、最后更新时间戳。采集进程每次更新计数后,写共享内存并释放一个信号量;展示进程定时等待信号量,读到新数据就刷新显示。这种设计的好处是采集进程不会被展示逻辑阻塞,即使展示进程卡住了,计数依然正常进行。
// 共享内存数据结构 typedef struct { volatile int people_inside; volatile int total_in; volatile int total_out; volatile uint64_t last_update_ns; sem_t update_sem; } shared_data_t; // 采集进程更新 sem_wait(&data->update_sem);>// 原子写入计数文件 void save_counts(const char *path, int in, int out, int inside) { char tmp_path[256]; snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); FILE *fp = fopen(tmp_path, "w"); if (!fp) return; fprintf(fp, "%d %d %d\n", in, out, inside); fflush(fp); fsync(fileno(fp)); fclose(fp); rename(tmp_path, path); // 原子替换 }异常恢复还包括传感器故障的处理。如果某个传感器长时间没有事件,可能是坏了或者被挡住了。我在采集进程里加了一个心跳检测:如果超过10分钟没有任何事件,就打印警告日志,并在应用层显示“传感器可能异常”。这个功能在实际部署中救过我好几次,有一次是红外管被灰尘遮住了,系统及时报警,清理后就恢复了。
实操心得:持久化频率不要太高,否则频繁写flash会缩短存储寿命。5分钟一次对大多数场景都够用,最多丢5分钟数据,可以接受。
5. 常见问题与排查技巧实录
5.1 传感器层面的典型故障
红外对射管最常见的问题是误触发和漏触发。误触发通常是环境光干扰导致的,比如阳光直射接收端,或者附近有强红外光源。解决办法是给接收端加遮光罩,或者在驱动层加一个信号质量检查:如果触发持续时间极短(小于10ms),就认为是噪声丢弃。
漏触发一般是遮挡不充分导致的。比如人走得太靠边,没有完全挡住红外光束。这种情况需要调整传感器的安装位置和角度,确保检测区域覆盖整个门口宽度。我试过把传感器装在门框两侧,结果中间有盲区,后来改成装在门框内侧、稍微向内倾斜,覆盖就好多了。
PIR传感器的问题是受温度影响大。夏天环境温度接近人体温度时,PIR的灵敏度会下降,可能检测不到人。我的应对方法是把PIR的触发阈值调低,同时结合红外对射做双重确认——即使PIR没触发,只要红外对射的事件序列符合方向判断,也计入统计。PIR只作为辅助确认,不作为必要条件。
| 故障现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 频繁误触发 | 环境光干扰 | 遮挡接收端观察是否恢复 | 加遮光罩,驱动层过滤短脉冲 |
| 漏计 | 遮挡不充分 | 检查安装位置和角度 | 调整传感器位置,扩大覆盖 |
| 方向判断错误 | 传感器顺序接反 | 交换A/B传感器接线测试 | 修正接线或软件交换编号 |
| 计数漂移 | 边界情况累积 | 对比人工计数和系统计数 | 调整超时阈值和最小间隔 |
| 系统卡死 | 中断风暴 | 查看CPU占用和中断计数 | 增加去抖动,优化中断处理 |
5.2 软件层面的调试技巧
调试这套系统的时候,最有用的一招是“事件日志回放”。我把所有传感器事件带时间戳写入日志文件,然后写了一个回放工具,可以按原始时间间隔重新播放事件,观察状态机的输出。这样可以在不依赖硬件的情况下反复测试算法逻辑,大大加快了调试速度。
另一个技巧是在状态机里加“调试状态输出”。每次状态转移都打印当前状态、触发事件、时间差,这样一眼就能看出是哪个环节出了问题。比如你发现“进入”计数偏多,看日志就知道是不是超时阈值太小导致反向事件被误判。
# 事件日志格式示例 # timestamp_ns sensor_id event_type 1234567890 A 1 1234567950 B 1 1234568100 A 0 1234568200 B 0回放工具读取这个日志,按时间戳差值sleep,然后调用相同的状态机处理函数。这样你可以在PC上调试算法,调好了再放到ARM板上跑,效率高很多。
注意:日志文件会不断增长,记得加滚动策略,比如按天分割或者限制总大小。我一开始没注意,跑了几天日志把存储写满了,系统直接挂了。
6. 性能优化与长期运行稳定性
6.1 资源占用与实时性保障
ARM板子的资源有限,特别是内存和CPU。我的采集进程常驻内存大约2MB,CPU占用在空闲时接近0%,有事件时峰值也就5%左右,对大多数ARM板子来说都很轻松。但如果你的板子还要跑其他服务,就需要关注优先级设置。我把采集进程的调度策略设成了SCHED_FIFO,优先级设成中等,确保传感器事件不会被其他任务延迟处理。
// 设置实时调度优先级 struct sched_param param; param.sched_priority = 50; pthread_setschedparam(pthread_self(), SCHED_FIFO, ¶m);内存方面,环形缓冲区是固定分配的,不会动态增长。共享内存也是固定大小。整个系统没有动态内存分配的热路径,避免了内存碎片和分配延迟。这一点在长期运行的嵌入式系统里非常重要,我见过太多因为频繁malloc/free导致运行几天后变慢甚至崩溃的案例。
6.2 长期运行的老化与维护
这套系统我连续跑了三个月做稳定性测试,中间遇到过两次问题。一次是日志文件写满存储,前面提过了。另一次是传感器连接线松动,导致间歇性断连。后来我在驱动层加了连接状态检测,如果GPIO持续读到高电平超过一定时间(正常应该有跳变),就判定为断连并报警。
长期运行还需要考虑温度影响。夏天板子温度升高,晶振频率可能有微小漂移,影响时间戳精度。不过对于人流统计来说,毫秒级的漂移完全可以忽略,不需要额外补偿。但如果你的应用对时间精度要求更高,就需要选温补晶振或者定期校准。
维护方面,我建议加一个远程重启功能。虽然系统很稳定,但万一卡死了,能远程重启比跑现场强。实现方式很简单:应用层监听一个网络端口或者串口命令,收到重启指令就执行reboot。当然要做好权限控制,别让随便什么人都能重启。
7. 方案扩展与场景适配
这套基础方案做出来之后,可以扩展的方向很多。比如增加WiFi或4G模块,把数据上传到服务器做多区域汇总;或者加一个本地存储卡,保存更长时间的历史数据;还可以接一个继电器,根据室内人数自动控制空调或照明。
场景适配方面,如果门口特别宽,可以增加传感器组数,用多组对射做更精确的方向判断。如果环境光变化剧烈,可以换用调制红外传感器,抗干扰能力更强。如果预算充足,直接上毫米波雷达,能检测静止人员,适合需要统计“室内是否有人”而不只是“进出人数”的场景。
我个人觉得这套方案最大的价值在于它的可裁剪性。你可以只用驱动层做原始数据采集,也可以只用算法层做方向判断,还可以把应用层换成你自己的展示逻辑。每一层都是独立的,改哪里都不会影响其他部分。这种灵活性在实际项目中非常宝贵,因为需求总是在变,能快速适配的方案才是好方案。
最后分享一个小技巧:如果你也在做类似的项目,建议先用面包板搭原型,验证传感器布局和算法逻辑,确认没问题了再画PCB或者做正式安装。我一开始直接焊在洞洞板上,结果调整传感器位置的时候拆焊了好几次,浪费时间不说,还容易损坏焊盘。面包板虽然看起来简陋,但调试阶段真的方便太多了。