1. 项目缘起与整体方案设计
1.1 为什么要在嵌入式环境跑MobileNet-SSD
手头有一个实时目标检测的需求,硬件平台是一块算力有限的嵌入式开发板,摄像头用的是Intel RealSense D435。目标很明确:在有限算力下做到拿起来就能跑的实时检测,帧率不能太难看,精度够用就行。选来选去,MobileNet-SSD成了最合适的落地方案。
MobileNet-SSD本质上是把SSD检测头挂在MobileNet这个轻量骨干网络上。MobileNet用深度可分离卷积把标准卷积拆成逐通道卷积加逐点卷积,参数量和计算量都压得很低,特别适合嵌入式这种内存和算力都吃紧的场景。SSD本身是多尺度特征图直接出框,不需要像两阶段检测那样先出候选区域再分类,单阶段推理速度快,工程上也好落地。这两个东西组合在一起,就是典型的“精度换速度”的务实选择。
RealSense D435这边,它输出的是深度图加RGB图,我们做二维目标检测主要用它的RGB流,深度流可以留着做后续的距离估计或者辅助过滤。D435通过USB输出,在嵌入式平台上驱动和取流是第一个要啃的硬骨头。
1.2 整体技术路线拆解
整个项目可以拆成四层:底层是嵌入式系统的环境准备,包括系统烧录、依赖库安装、USB权限配置;中间层是RealSense SDK的编译与取流验证;上层是Caffe框架的编译以及MobileNet-SSD模型的加载推理;最上层是把取流和推理串起来,做成一个实时检测的循环。
选Caffe而不是其他框架,原因很实际:MobileNet-SSD的原始权重和deploy文件大多是Caffe格式的,社区里现成的模型资源多,改起来也直接。虽然Caffe现在不算主流,但在嵌入式上它的依赖相对可控,编译出来体积也不大。当然代价是Caffe的编译坑不少,尤其是protobuf版本、boost版本这些依赖,后面会详细说。
方案设计上我遵循一个原则:先让每一层单独跑通,再往上叠。不要一上来就把取流、推理、显示全写在一起,出了问题根本不知道是哪一层的事。所以整个流程是分阶段验证的,每一步都有明确的验收标准。
1.3 硬件与软件环境选型考量
硬件平台选的是一块带ARM Cortex-A系列核心的嵌入式板子,具体型号这里不点名,反正就是那种算力在几个GFLOPS级别、内存1到2GB的常见开发板。RealSense D435对USB带宽有要求,最好接USB 3.0口,接2.0口虽然能出图但帧率和分辨率会打折。
软件环境上,系统用的是Ubuntu系的嵌入式发行版,内核版本要能支持UVC和RealSense需要的USB协议。Caffe选的是BVLC版本加上SSD分支的合并,MobileNet-SSD的模型文件用的是社区常见的预训练权重。这里要提醒一句,模型文件来源要可靠,deploy.prototxt里的输入尺寸、类别数要和权重严格对应,否则加载时直接报维度不匹配。
提示:嵌入式环境搭建最忌讳“一步到位”的心态。先把系统跑起来,再装SDK,再编框架,最后跑模型,每一层都验证过再往上走,能省掉大量排查时间。
2. 嵌入式系统环境准备与依赖梳理
2.1 系统基础环境确认
拿到板子第一件事不是急着装东西,而是把系统底子摸清楚。用uname -a看内核版本,用lsb_release -a看发行版,用free -h看内存,用df -h看存储余量。这几个信息决定了后面能装什么版本的库。比如内存只有1GB的话,编译Caffe时最好加swap,不然链接阶段很容易被OOM杀掉。
存储空间也要留意,Caffe编译加上RealSense SDK,中间产物动辄几个GB,根分区如果只剩两三个G,编到一半空间不足会非常难受。我一般会先挂一块外置存储或者扩展分区,把编译目录放过去。
网络方面,嵌入式板子最好能有稳定的有线网络,装依赖和拉源码都靠它。无线在编译大项目时偶尔会断,断了之后apt锁住还得手动清理,很烦。
2.2 依赖库安装的取舍
依赖安装这块,核心是几个:cmake、git、build-essential、libprotobuf-dev、protobuf-compiler、libboost-all-dev、libgflags-dev、libgoogle-glog-dev、libhdf5-dev、libopencv-dev。这些是Caffe编译的常规依赖,RealSense SDK还需要libusb、libglfw3-dev、libgtk-3-dev这些。
这里有个关键取舍:protobuf的版本。Caffe对protobuf版本比较敏感,太新的版本(比如3.6以上)有时候会和Caffe的代码不兼容,报一些奇怪的符号错误。我的经验是锁定在3.0到3.5之间比较稳。如果系统源里的版本太新,可以考虑源码编译一个指定版本,装到独立前缀下,编译Caffe时通过CMAKE_PREFIX_PATH指过去。
boost也是类似的情况,Caffe用到的boost组件不多,但版本太新偶尔会有API变动。一般系统自带的版本就够用,不用特意折腾。
OpenCV这边,嵌入式上建议用系统源里的版本,自己编OpenCV在板子上耗时太长,而且容易缺依赖。如果源里的OpenCV版本够用(3.x或4.x都行),直接用就好。RealSense SDK对OpenCV有依赖,但主要是用于示例程序,我们自己的检测程序可以只用RealSense的取流接口加自己的图像处理。
2.3 USB权限与内核模块配置
RealSense D435插上之后,默认情况下普通用户是没有权限访问的,需要配置udev规则。RealSense SDK的源码里带了一个99-realsense-libusb.rules文件,把它拷到/etc/udev/rules.d/下,然后sudo udevadm control --reload-rules && sudo udevadm trigger,重新插拔设备就能生效。
内核模块方面,要确认uvcvideo模块加载正常,lsmod | grep uvc能看到。有些嵌入式内核裁剪得比较狠,UVC支持可能没编进去,那就得重新配内核。另外USB 3.0的控制器驱动也要正常,lsusb -t能看到设备挂在5000M的速率上,说明是USB 3.0协商成功了。
注意:udev规则改完一定要重新插拔设备,光reload有时候不生效。如果还是权限不够,检查一下当前用户是否在
plugdev组里,不在的话sudo usermod -aG plugdev $USER然后重新登录。
2.4 编译环境的内存与swap优化
前面提到内存问题,这里展开说。Caffe编译时,尤其是链接阶段,单个编译单元可能吃掉几百MB到1GB内存。板子内存小的话,开多个并行编译任务(make -jN)很容易把内存打满。我的做法是:先看nproc有几个核,然后make -j的N设成核数的一半甚至更少,宁可慢一点也别OOM。
如果实在内存紧张,加swap是最直接的办法。创建一个2GB的swap文件:
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile然后加到/etc/fstab里做持久化。swap放在SD卡上会慢,但总比编译被杀掉强。编译完成后可以关掉swap释放空间。
3. RealSense D435取流验证与Caffe编译实战
3.1 RealSense SDK编译与取流测试
RealSense SDK的编译流程是标准的cmake那一套。先git clone源码,然后建build目录,cmake ..,make -jN,sudo make install。编译选项上,如果不需要CUDA和Python绑定,可以在cmake时关掉,能省不少编译时间。嵌入式上一般也没有NVIDIA GPU,CUDA直接关。
编译完成后,用rs-enumerate-devices命令确认设备能被识别,能看到D435的型号、固件版本、支持的流配置。然后用SDK自带的示例程序测试取流,比如rs-capture,能看到RGB和深度图窗口弹出,说明取流链路通了。
如果rs-enumerate-devices报错找不到设备,先查lsusb有没有Intel的设备,再查udev规则,再查USB线是不是3.0的。这几个点排查完基本能定位问题。
取流测试通过后,可以写一个最小的取流程序,只开RGB流,把帧数据拿出来打印分辨率,确认我们能拿到数据。这一步是为后面和Caffe对接做准备。
3.2 Caffe编译的关键配置与踩坑
Caffe编译是整个流程里最容易出问题的环节。首先是Makefile.config的配置,要改的地方包括:去掉CPU_ONLY的注释(嵌入式没有GPU),指定BLAS为OpenBLAS或者Atlas,指定Python相关路径(如果不需要Python接口可以关掉),指定protobuf、boost、OpenCV的路径。
BLAS选择上,OpenBLAS在ARM上性能比Atlas好,但需要先装libopenblas-dev。如果源里有就直接装,没有就源码编。BLAS主要影响矩阵运算速度,对推理帧率有直接影响,值得花时间配好。
protobuf版本问题前面提过,如果系统版本太新导致编译报错,常见错误是undefined reference to google::protobuf::...,这时候要么降级protobuf,要么在Caffe代码里打补丁。我一般选择降级,因为打补丁容易引入新问题。
编译时用make all -jN,N根据内存调整。如果报错,先看第一个错误,后面的错误往往是连锁反应。常见的错误还有fatal error: hdf5.h: No such file,这是hdf5头文件路径没配好,在Makefile.config里把INCLUDE_DIRS加上hdf5的include路径。
编译完成后,make test跑一下单元测试,虽然嵌入式上跑全量测试很慢,但至少确认核心库能正常链接。然后make pycaffe如果需要Python接口。
3.3 MobileNet-SSD模型文件准备与校验
模型文件需要三个:deploy.prototxt(网络结构定义)、权重文件(.caffemodel)、以及可选的labelmap文件(类别名映射)。deploy.prototxt里要确认输入层的input_shape,MobileNet-SSD一般是300x300或者512x512,这个尺寸要和训练时一致。类别数也要对,比如VOC是21类(20类加背景),COCO是81类。
权重文件下载后要校验完整性,用md5或者sha256对一下官方给的哈希值。文件不完整的话加载时会报解析错误,这种错误有时候很隐晦,不如提前校验。
labelmap文件是纯文本,每行一个类别,格式是类别ID 类别名。这个文件在推理后处理时用来把类别ID映射成可读的名字。
提示:deploy.prototxt里的
input_shape和权重文件的维度必须严格对应。如果输入尺寸改了,权重里的全连接层(如果有)维度就对不上。MobileNet-SSD是全卷积结构,改输入尺寸理论上可行,但精度会受影响,建议保持和训练时一致。
3.4 取流与推理的对接设计
取流和推理对接的核心是数据格式转换。RealSense SDK拿到的RGB帧是rs2::video_frame,数据格式可能是BGR8或者RGB8,需要转成Caffe需要的格式。Caffe的输入是NCHW布局的float数组,值域一般是0到255或者归一化到0到1,具体看训练时的预处理。
MobileNet-SSD的常见预处理是:减均值(比如[127.5, 127.5, 127.5]),然后乘以scale(比如0.007843,即1/127.5)。这个预处理要和训练时一致,不一致的话检测效果会明显下降。
数据转换的流程是:从video_frame拿到数据指针,用OpenCV的Mat包一层(注意步长和格式),然后resize到网络输入尺寸,减均值乘scale,最后把数据拷到Caffe的Blob里。这一步的耗时主要在resize和格式转换上,嵌入式上可以用OpenCV的cv::resize,如果嫌慢可以考虑用neon优化或者自己写简单的双线性插值。
推理完成后,输出是检测框、类别、置信度。后处理包括:按置信度阈值过滤,做NMS(非极大值抑制)去掉重叠框,然后把框的坐标从网络输入尺寸映射回原始图像尺寸。这些步骤在Caffe的SSD示例代码里都有参考实现,拿来改改就能用。
4. 实时检测循环实现与性能调优
4.1 主循环的架构设计
主循环的结构很直接:取一帧,预处理,推理,后处理,画框,显示。但要做成实时,就不能串行阻塞。我的做法是用一个简单的双缓冲或者队列:取流线程负责不断拿帧放进队列,推理线程从队列取帧处理。这样取流和推理可以重叠,不至于取流的时候推理闲着。
不过嵌入式上线程多了调度开销也大,如果单线程能跑到可接受的帧率,单线程反而更简单稳定。我先用单线程跑通,测出基线帧率,再决定要不要上多线程。
显示这块,嵌入式上如果有HDMI输出可以直接用OpenCV的imshow,如果没有就用framebuffer或者干脆不显示,只打印检测结果。调试阶段显示很重要,能直观看到检测效果。
4.2 预处理与后处理的耗时分析
实测下来,预处理和后处理的耗时占比不低。resize一张1080p的图到300x300,在嵌入式CPU上可能要十几毫秒。后处理的NMS如果框多的话也要几毫秒。推理本身反而可能只占几十毫秒。
优化的思路:预处理可以用OpenCV的INTER_NEAREST代替INTER_LINEAR,速度快但质量差一点,对检测影响不大。或者用cv::resize的INTER_AREA在缩小图像时效果和速度比较平衡。后处理方面,置信度阈值设高一点能减少进入NMS的框数量,比如0.5以上,能明显减少后处理时间。
还有一个技巧是固定输入尺寸,不要每帧都动态resize。如果摄像头输出分辨率固定,可以预先算好缩放比例,用查表或者定点运算加速。
4.3 帧率瓶颈定位与优化手段
帧率上不去,先定位瓶颈。用std::chrono在取流、预处理、推理、后处理、显示各段打时间戳,看哪一段最耗时。常见情况是推理最耗时,那就得从模型和框架层面优化。
模型层面,可以尝试量化。Caffe本身对量化的支持不如一些新框架,但可以手动把权重和激活量化到int8,然后用定点运算推理。这个工作量大,收益也大,帧率可能翻倍。如果不想动模型,可以试试减小输入尺寸,比如从300x300降到224x224,推理量按面积比下降,帧率能提升不少,代价是小目标检测效果变差。
框架层面,Caffe的CPU推理可以用OpenBLAS的多线程,设置OMP_NUM_THREADS环境变量控制线程数。线程数不是越多越好,超过物理核数反而会因为调度开销变慢。一般设成物理核数或者核数减一。
还有编译优化,Caffe编译时加上-O3 -mfpu=neon这些标志,能利用ARM的NEON指令集加速。Makefile.config里可以改CXXFLAGS和CPU_ONLY相关的编译选项。
4.4 内存与温度监控
嵌入式跑久了容易遇到内存泄漏或者温度过高降频的问题。内存方面,主循环里如果有动态分配,要注意及时释放,或者用内存池预分配。Caffe的Blob如果每帧都重建,开销很大,应该复用。
温度方面,cat /sys/class/thermal/thermal_zone0/temp能看到温度。如果温度超过阈值,CPU会降频,帧率就掉下来了。散热要做好,加个散热片或者小风扇。如果没法加散热,就限制推理频率,比如每两帧推理一次,中间帧复用上一帧结果,虽然检测有延迟但至少不降频。
注意:长时间跑检测,建议加一个看门狗或者定时重启逻辑,防止内存碎片或者偶发死锁导致程序挂掉。嵌入式上稳定性比峰值性能重要。
5. 常见问题排查与避坑经验实录
5.1 设备识别与取流类问题
问题一:rs-enumerate-devices找不到设备。
排查顺序:先lsusb看有没有Intel设备,没有的话检查USB线和接口,换线换口试试。有设备但SDK找不到,检查udev规则是否生效,ls -l /dev/bus/usb/看权限。还不行就看内核日志dmesg | tail,有没有USB枚举失败的报错。
问题二:取流时报Frame didn't arrive within 5000。
这是超时错误,常见原因是USB带宽不够。D435如果同时开深度和RGB,带宽需求大,接USB 2.0口就容易超时。解决办法是接USB 3.0口,或者降低分辨率/帧率,或者只开RGB流。
问题三:取流一段时间后画面卡住。
可能是USB控制器过热或者驱动bug。先摸一下板子USB口附近烫不烫,烫的话加散热。软件上可以加一个取流超时重启逻辑,超时后重新初始化pipeline。
5.2 Caffe编译与运行类问题
问题一:编译报undefined reference to google::protobuf。
protobuf版本不匹配。protoc --version看版本,和编译Caffe时链接的库版本对一下。不一致就统一版本,要么降protobuf,要么重新编Caffe指定protobuf路径。
问题二:运行时报Check failed: shape[i] == ...。
输入维度不匹配。检查deploy.prototxt的input_shape和实际传入的数据维度。常见错误是通道顺序搞错,Caffe要NCHW,如果传了NHWC就会报维度错误。
问题三:检测结果全是背景或者框位置离谱。
预处理不一致。检查减均值和scale是否和训练时一致。另外检查图像通道顺序,RealSense给的是RGB还是BGR,Caffe训练时用的是RGB还是BGR,不一致的话颜色通道就反了,检测效果会很差。
问题四:推理速度远低于预期。
先确认是不是用了Debug模式编译,Debug模式没有优化,速度可能差好几倍。然后确认BLAS是不是用了OpenBLAS,用Atlas或者参考BLAS会慢很多。再确认线程数设置,OMP_NUM_THREADS设了没有。
5.3 实时性与稳定性类问题
问题一:帧率波动大。
可能是系统里有其他进程抢CPU。用top看一下,把不必要的后台服务关掉。另外CPU调频策略也会影响,cpufreq-set把governor设成performance,让CPU一直跑最高频,帧率会稳一些,代价是功耗和温度上升。
问题二:跑一段时间后程序崩溃。
先看是不是内存泄漏,用valgrind或者简单的内存监控。Caffe的Blob和OpenCV的Mat如果管理不当容易泄漏。另外检查有没有数组越界,后处理里映射坐标时容易越界。
问题三:检测框闪烁。
这是帧间不一致导致的。可以在后处理加一点平滑,比如对同一目标的框做加权平均,或者用跟踪算法补帧。简单点的做法是提高置信度阈值,减少低置信度的抖动框。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 设备找不到 | USB权限/线缆/内核模块 | lsusb、dmesg、udev规则 | 配udev、换线、加载模块 |
| 取流超时 | USB带宽不足 | 看USB速率、降配置测试 | 接3.0口、降分辨率 |
| 编译报protobuf错误 | 版本不匹配 | protoc --version对比 | 统一版本 |
| 推理维度错误 | 输入shape不对 | 检查prototxt和传入数据 | 修正shape和布局 |
| 检测效果差 | 预处理不一致 | 对比训练预处理 | 统一均值scale通道 |
| 帧率低 | Debug编译/线程数/BLAS | 看编译选项、OMP设置 | 开O3、设线程、换BLAS |
| 运行崩溃 | 内存泄漏/越界 | valgrind、加日志 | 修泄漏、加边界检查 |
| 帧率波动 | CPU抢占/调频 | top、cpufreq | 关服务、设performance |
6. 实操心得与后续扩展方向
6.1 几个让我少走弯路的经验
第一个经验是版本锁定。嵌入式环境搭建最怕版本漂移,今天装的库和明天装的可能就不一样。我的做法是把所有关键依赖的版本号记下来,写成一个安装脚本,下次换板子直接跑脚本,省得重新踩坑。protobuf、boost、OpenCV、Caffe的版本都要锁。
第二个经验是先跑通再优化。不要一上来就想着多线程、量化、NEON优化,先把单线程版本跑通,确认功能正确,再逐步优化。优化的时候每改一处就测一次帧率和精度,确保没有引入回归。
第三个经验是日志要够。嵌入式上调试不方便,没有图形界面的时候只能靠日志。在关键路径上加时间戳和状态打印,出问题的时候能快速定位。但日志也别太多,多了影响性能,用条件编译控制日志级别。
第四个经验是散热别省。板子跑满负载温度上升很快,降频之后帧率直接掉一半。一个几块钱的散热片就能明显改善,这笔投入很值。
6.2 精度与速度的进一步平衡
如果当前帧率还不够,可以考虑几个方向。一是换更轻的骨干网络,比如MobileNetV2或者ShuffleNet,但需要重新训练或者找现成权重。二是剪枝,把MobileNet-SSD里贡献小的通道剪掉,模型变小推理变快,精度损失可控。三是知识蒸馏,用大模型教小模型,但训练成本高。
如果精度不够,可以试试输入尺寸加大,比如从300加到512,小目标检测会好一些,但帧率会降。或者用多尺度测试,对同一帧跑多个尺寸然后融合结果,精度提升但速度成倍下降,嵌入式上不太现实。
6.3 从二维检测到三维定位的扩展
RealSense D435有深度流,检测到二维框之后,可以把框内的深度值取出来做统计,得到目标到相机的距离。这样就从二维检测扩展到了三维定位。实现上就是在后处理阶段,把检测框映射到深度图坐标,取框中心区域深度的中位数或者均值作为距离。
这个扩展在机器人避障、抓取引导这些场景很有用。但要注意深度图和RGB图的对齐,D435有对齐接口,可以把深度对齐到RGB视角,这样框和深度就一一对应了。对齐会引入一点误差,但工程上够用。
6.4 模型更新与远程部署的考虑
实际部署后,模型可能需要更新。嵌入式设备如果分散在各处,手动更新很麻烦。可以考虑做一个简单的远程更新机制,比如从指定服务器拉取新的模型文件,校验后替换,然后重启推理进程。这个机制不用太复杂,一个shell脚本加定时任务就能实现。
远程更新要注意版本管理和回滚。新模型如果效果不好,要能快速回滚到旧版本。所以模型文件要带版本号,旧版本保留一份,更新失败自动回滚。
提示:远程更新涉及网络传输,模型文件要校验完整性,防止传输损坏导致加载失败。另外更新时机要选好,别在业务高峰期更新。
6.5 关于这套方案适用边界的个人看法
MobileNet-SSD加RealSense D435这套组合,适合的是对精度要求不是极致、但对实时性和成本敏感的嵌入式场景。如果场景里目标很小或者很密集,MobileNet-SSD可能力不从心,得考虑更重的模型或者更高分辨率的输入。如果算力更充裕,也可以考虑YOLO系列的一些轻量版本,精度和速度的平衡点不太一样。
Caffe作为推理框架,优点是模型资源多、依赖相对简单,缺点是生态不如新框架活跃,一些新算子支持不好。如果项目周期长,可以考虑把模型转到ONNX或者NCNN这些更适合嵌入式的框架上,推理效率可能更好。但转换过程有风险,需要仔细验证精度。
这套流程我前后搭过几次,每次都会遇到新问题,但整体框架是稳的。把每一层单独验证、版本锁定、日志到位、散热做好,基本就能跑起来。剩下的就是根据具体场景调参和优化了。