不管赛道是白线还是黄线,只要把车推到走廊的阴影里、或者下午四点斜阳一照,阈值法调了三天三夜的那套参数立刻报废。这个场景我经历过不止一次,也是我最后狠下心把整条视觉链路换成深度学习的原因。这篇就聊聊我在地平线旭日派X3这块板子上,用 ResNet18 搭一台深度学习巡线小车的完整过程:从数据怎么采、光照怎么覆盖、模型怎么改、量化怎么不掉点,一直到最后 ResNet18 怎么在板端跑出 60 帧以上、舵机怎么稳。核心目标是解决一个具体问题:拒绝光照影响。方案本身对算力要求不高,树莓派级别的板子也能借用思路,但用旭日派X3 的话,板载 BPU 能把 ResNet18 压到几毫秒一帧,整套东西的实时性会舒服很多。适合已经跑通基础循迹、被光照折磨过、想上第一版端侧深度学习模型的同学,也适合做嵌入式视觉毕业设计的同学当参考。
1. 为什么传统巡线一换光就废:整体方案怎么定
1.1 阈值法的三个死穴
先说清楚为什么要换方案,不然直接上深度学习容易变成“为了用而用”。常见的巡线做法是把摄像头图像裁下半部分,转到 HSV 空间,取一个白线或者黑线的阈值区间,算出线的质心,再用质心横坐标做 PID。这条链路在实验室地毯上跑得非常好,一旦出到走廊、操场、地下车库,问题就来了。
第一个死穴是全局阈值根本不存在。同一块场地,上午十点和下午五点的画面直方图能差出一倍多的动态范围,一个固定的V > 180在下午根本圈不出线。第二个死穴是阴影和高光,树影投在赛道上,白线局部亮度掉到 80,阈值法直接把这段线认成地面,车就从一个方向拐出去。第三个死穴是反光地面,瓷砖、抛光水泥、亚克力板,灯光一照整片白色高光,比白线还亮,质心被拉走。
有人会用 Otsu 自适应阈值、局部均值归一化、甚至上形态学去修补,这些手段确实能撑一阵,但本质上是在跟一个不断变化的成像条件赛跑,参数越调越多,系统越来越脆。我需要的是一个**从“像素亮度”里跳出来、直接学“线长什么样”**的模型,这就是深度学习的切入点。
1.2 为什么是 ResNet18,不是 ResNet50 也不是 MobileNet
选 ResNet18 有三个理由,都是实际约束倒逼的,不是为了凑热度。
第一是算力预算。旭日派X3 板载的 BPU 是双核架构,INT8 定点算力标称 5 TOPS。这个数字听着大,但它是峰值,实际有效利用率能到 20%~30% 就不错了。ResNet18 在 224×128 输入下的理论算力约 1.1 GFLOPs,放到 BPU 上单帧 3~5ms 是合理区间;换 ResNet34 大概翻倍,换 ResNet50 更是四倍起步,帧率就掉到 20 上下,对巡线的控制周期来说太糙了。
第二是数据量。巡线任务的视觉复杂度其实很低——就是“地面上的高对比度线”这一种语义,类别极其单一。ResNet18 的 11M 参数量对这个任务已经严重过剩,我甚至试过砍掉最后一层 stage 的版本,效果基本不掉。上 ResNet50 除了训练更慢、量化更容易掉点,没有任何收益。
第三是量化友好。这一点是很多人容易忽略的。ResNet 系列的结构非常规整,残差连接、BN 折叠之后就是纯卷积加 ReLU,官方转换工具链对这类结构的支持非常成熟,编译器能识别出所有的算子融合机会。我自己试过一个小型的自定义骨干网络,量化之后掉点掉了将近九个点,排查了两天才发现是某个非标准激活函数在 INT8 下数值范围失控。用现成的 ResNet18,这种坑基本没有。
那么为什么不选 MobileNetV2 这类轻量网络?实测下来,在 BPU 上它的优势没有想象的大,因为瓶颈往往不在卷积计算量,而在内存带宽和数据搬运。而且深度可分离卷积在某些量化配置下精度损失反而比标准卷积更敏感。我不会说 MobileNet 一定不行,但在这个任务上,ResNet18 是“性能够用 + 部署踩坑最少”的平衡点。
1.3 系统数据流:一张图从摄像头到舵机的七个环节
在动手之前,我把整条链路画清楚,后面所有的优化都围绕这个图来。整个系统分七个环节:
- 摄像头采集:MIPI CSI 摄像头,输出 640×480、60fps 的 NV12 原始帧;
- 裁剪与缩放:取画面下半区(比如 640×480 里取 y 从 200 到 460),缩放成 224×128 送进网络;
- 颜色空间处理:NV12 直接送 BPU,不经过 CPU 转 RGB,省一次整帧拷贝;
- BPU 推理:ResNet18 INT8 前向,输出角度 logits 和有效线概率;
- 后处理解码:softmax,取期望值作为连续转角,同时检查有效概率;
- 控制律:转角经过滤波、死区、斜率限制,转成舵机 PWM 占空比;
- 执行器:前轮舵机转向,后轮电机按转角大小动态降速。
这里最关键的一个设计决定是把“视觉 + 控制”解耦。模型只负责输出一个角度,不负责输出速度、不负责输出 PID 参数。这样模型换掉、参数重调都不影响控制层,控制层调好之后也不用反复重训模型。我见过有同学把速度也塞进网络输出,结果训练数据里人手遥控的速度波动全被模型学去,车跑起来忽快忽慢,问题反而更难定位。
1.4 硬件清单与接线要点
我这台车的配置大致是这样:旭日派X3 主板(4GB 内存版)、MIPI CSI 摄像头模组、一块带 TB6612 的电机驱动扩展板、SG90 或更大扭矩的数字舵机、直流减速电机加编码器、7.4V 锂电池加一路 5V/3A 降压给板子供电。
这里有个非常容易被忽略的点:供电。BPU 满载加上电机启动瞬间的电流冲击,如果 5V 那一路的余量不够,板子会随机重启或者摄像头掉流。我最早用一块标称 3A 的降压模块,直线加速时画面直接黑掉,查了半天才定位到是电压跌落。换成功率余量更大、输出电容更足的模块之后彻底解决。这个问题不解决,后面所有调试都会被它干扰,所以放在最前面说。
接线方面,舵机信号接板子的 PWM 输出口(一般是 32 号或者 33 号 GPIO,具体看你的扩展板定义),电机驱动的 PWM 和方向脚另算,摄像头走 CSI 排线。CSI 排线的方向一定核对清楚,金属触点朝向搞反了上电是有风险的,我第一次装的时候反复对了好几遍图才敢插。
2. 数据这一关:光照鲁棒性是在数据集里解决的
2.1 采集设备与录制方案
很多人一上来就调模型结构,其实“拒绝光照影响”这件事,八成以上的功劳在数据。模型只是一个拟合器,你没给它见过阴影下的线,它凭什么在阴影里认出来?
我的采集方案比较土但有效:用一块临时的遥控接收板接在板子的串口上,人拿着遥控器开车,板子把摄像头原始帧和遥控通道值打上同一时间戳记录下来。这样每一帧图像天然带一个“人当时打了多少方向”的标签,业内叫行为克隆。录制的时候尽量开慢一点,别猛打方向,因为人的操作有抖动和滞后,标签噪声会直接传导给模型。
录制分辨率建议直接用 640×480,帧率 30 就够,因为巡线的控制周期通常也就 30~50Hz,录太高只会让数据集膨胀。存盘格式我用的是把 NV12 原始数据直接写二进制文件,附带一个 CSV 记录帧号、时间戳、遥控通道值。不用 mp4 的原因是视频编码会引入色度失真和压缩噪声,训练时模型可能学到编码伪影,反而干扰泛化。
单条赛道录个五到八分钟,跑个十几圈,总共一万帧左右,就是一个不错的起点。我最初只录了两分钟,模型在训练集上准确率 98%,实车一到弯道就冲出去,典型的样本不均衡——直道占了绝大多数,弯道样本严重不足。后面专门补录了大量急弯和连续 S 弯,问题才解决。
2.2 光照覆盖矩阵:把“变光”变成数据
这一段是我认为整篇文章最值钱的部分。光照鲁棒性不是靠一个神奇的数据增强函数变出来的,它是靠有意识地构造光照分布做出来的。我给自己列了一个采集矩阵,每条赛道都要在下面这些条件下各跑几圈:
| 光照条件 | 具体做法 | 模型要学的东西 |
|---|---|---|
| 均匀散射光 | 阴天室外、室内顶灯 | 基础形态,作为基准 |
| 强直射光 | 正午室外,线面发白 | 过曝时线边缘的梯度特征 |
| 逆光 | 摄像头朝向窗户或太阳 | 大面积低对比度下的线轮廓 |
| 侧向长阴影 | 树影、栏杆影横跨赛道 | 局部亮度阶跃不要被误判成线 |
| 斑驳光斑 | 树荫下的碎光 | 高频亮度变化下的连续性判断 |
| 低照度 | 傍晚、地下车库、只开一盏灯 | 暗光噪声下的线可见性 |
| 人工补光 | 车头加 LED 补光 | 近距离过曝与中心亮斑 |
| 反光地面 | 瓷砖、亚克力板、湿地面 | 镜面高光与真实线的区分 |
这八类里,最容易被漏掉的是“侧向长阴影”和“反光地面”。前者会让阈值法瞬间失效,后者会让模型学到“亮 = 线”这个错误捷径。我特意在有强反光的抛光地面上跑了很多圈,让模型学会用“线是连续的细长条”这个形状先验,而不是靠亮度。
还有个细节:采集时摄像头的曝光参数要固定,不要让自动曝光来回跳。自动曝光会让同一段赛道在不同时刻呈现完全不同的亮度,模型很难学到稳定特征。固定曝光、固定白平衡、固定增益,让变化只来自真实光照,这对训练收敛帮助很大。
2.3 标签怎么来:行为克隆与半自动初标
标签来源我试过两条路,各有优劣,最后是混着用的。
第一条是纯行为克隆,就是前面说的遥控器通道值直接当标签。好处是天然覆盖所有光照,人在暗光下也能开车,标签自动就有了;坏处是人的操作有噪声,直道上人会下意识左右微调,这些微调被当成“正确标签”教给模型,车就会画龙。
第二条是半自动初标:在光照良好的条件下,用 HSV 阈值找出线的中心点,对下半画面做直线拟合,反推出一个几何上的期望转角,作为初始标签,然后人工过一遍异常帧。好处是标签平滑、干净;坏处是只能在好光照下生成,暗光段还得靠人。
我最后采用的是混合方案:光照良好的帧用几何初标 + 人工抽检,光照差的帧用行为克隆标签 + 平滑滤波。行为克隆的标签在做平滑之前,先按角度变化率做一个过滤,把那些“相邻帧角度差超过 8 度”的抖动帧剔掉,因为正常人开车不会一帧之内打这么多方向,这基本是手抖或者遥控器回中不准造成的。
还有一个小技巧:把连续角度离散成 41 个 bin,覆盖 -45° 到 +45°,每格 2.25°。但训练时不用 one-hot,用高斯软标签——把目标 bin 附近的几个 bin 也分配一点概率质量,形状像一个小高斯核。推理的时候取 softmax 的期望值,这样既能拿到连续输出,又避免了回归头在暗光下容易出现的输出漂移。这个改动在我的实测里让弯道处的角度抖动明显下降。
2.4 数据集划分的两个坑
第一个坑是随机划分导致的泄漏。如果你把录制的一万帧随机打乱,按 8:1:1 分训练验证测试,那么相邻的两帧很可能一帧在训练集、一帧在测试集,它们几乎一模一样。这样测出来的验证集准确率虚高得离谱,我第一版就遇到了,验证集 97%,实车一塌糊涂。
正确做法是按连续片段划分。比如录了 15 圈,1~11 圈做训练,12~13 圈做验证,14~15 圈做测试。或者按时间段切,前 70% 时间训练,后 30% 里对半分。这样才能真实反映模型在没见过的场景上的表现。
第二个坑是测试集的光照分布要和训练集错开一点。我特意把“傍晚低照度”和“强反光地面”这两类里的一部分数据留出来做测试,而不是全扔进训练集。这样测试集才能告诉你“模型对没见过的光照有多鲁棒”,而不是“模型把它背下来的光照记得多牢”。这个做法让我的第一版模型暴露了真实水平——低照度下准确率只有 62%,我才知道校准集和增强策略都得补。
3. 训练:把 ResNet18 改造成巡线网络
3.1 网络结构改造与输出设计
原始 ResNet18 是为 1000 类 ImageNet 分类设计的,最后一层是全连接 512→1000。我们要改两个地方:输入尺寸和输出头。
输入我定为 224×128(宽×高)。为什么不是正方形?因为巡线任务里横向视野比纵向更重要——你要看到左右两边线跑偏了多少,而纵向只需要看到前方三四米。用宽扁的输入能在同样的算力下容纳更大的横向视场。224 和 128 都能被 32 整除,经过五次下采样后特征图是 7×4,刚好合适。
输出设计成双头:一个 41 维的角度分类头,一个 2 维的有效性分类头(线存在/不存在)。加有效性头的动机很实际——车冲出赛道、被遮挡、或者遇到十字路口时,模型如果强行输出一个角度,车就会乱拐。有了有效性判断,主控可以选择“保持上一次转角”或者“减速停车”。
代码骨架大概是这样:
import torch import torch.nn as nn from torchvision import models class LineNet(nn.Module): def __init__(self, num_bins=41, pretrained=True): super().__init__() weights = models.ResNet18_Weights.DEFAULT if pretrained else None backbone = models.resnet18(weights=weights) # 去掉最后的全局池化和全连接,保留到 layer4 self.stem = nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool, backbone.layer1, backbone.layer2, backbone.layer3, backbone.layer4, ) self.pool = nn.AdaptiveAvgPool2d(1) self.angle_head = nn.Linear(512, num_bins) self.valid_head = nn.Linear(512, 2) def forward(self, x): f = self.pool(self.stem(x)).flatten(1) return self.angle_head(f), self.valid_head(f)预训练权重一定要加载。有人觉得“巡线和 ImageNet 差太远,预训练没用”,我实测过,从零训练在五千帧的数据量下收敛慢一倍,而且最终精度低三个点左右。ImageNet 学到的边缘、纹理、方向性滤波器,对巡线是完全通用的底层特征。
3.2 针对光照的增强管线
这一段是“拒绝光照影响”的核心工序。增强不能只做简单的亮度抖动,要模拟真实世界里光照变化的物理形态。我用了四类增强叠加:
第一类是全局光度变换:亮度、对比度、gamma、饱和度各自随机。gamma 特别重要,因为它模拟的是非线性响应,比线性亮度调整更接近真实相机的过曝和欠曝。
第二类是局部阴影模拟:随机在图上贴一到三个暗色椭圆或者斜向长条,模拟树影、灯杆影、建筑投影。这个增强对模型的鲁棒性提升非常明显,因为它逼着模型不能依赖“整幅图亮度一致”这个假设。
第三类是光源位置模拟:用一个径向渐变叠加,模拟点光源从某个方向打过来造成的明暗渐变。
第四类是噪声与模糊:低照度下图像噪声会显著上升,加一点高斯噪声和轻微运动模糊,能提升暗光下的稳定性。
import random import torchvision.transforms.functional as TF from PIL import Image, ImageDraw, ImageFilter class LightingAug: def __init__(self): pass def __call__(self, img: Image.Image): if random.random() < 0.85: img = TF.adjust_brightness(img, random.uniform(0.45, 1.75)) if random.random() < 0.7: img = TF.adjust_contrast(img, random.uniform(0.55, 1.6)) if random.random() < 0.6: img = TF.adjust_gamma(img, random.uniform(0.6, 1.8)) if random.random() < 0.4: img = TF.adjust_saturation(img, random.uniform(0.5, 1.5)) # 随机阴影 if random.random() < 0.5: w, h = img.size overlay = Image.new("L", (w, h), 0) d = ImageDraw.Draw(overlay) for _ in range(random.randint(1, 3)): cx, cy = random.randint(0, w), random.randint(0, h) rx, ry = random.randint(w // 8, w // 2), random.randint(h // 8, h // 2) d.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=random.randint(90, 180)) overlay = overlay.filter(ImageFilter.GaussianBlur(radius=12)) dark = Image.new("RGB", (w, h), (20, 20, 20)) img = Image.composite(dark, img, overlay.point(lambda v: int(v * 0.8))) # 低照度噪声 if random.random() < 0.3: import numpy as np arr = np.array(img).astype(np.float32) arr += np.random.normal(0, random.uniform(3, 12), arr.shape) img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) return img注意:阴影增强的强度要一点点加上去。我一开始直接把阴影做得很黑很硬,结果模型学会了“有阴影就认为线丢了”,反而更差。后来把阴影的灰度控制在 40~90 之间、边缘做高斯柔化,才真正起到正向作用。
另外一个很重要的操作是在线随机化而不是离线生成。因为增强本身有随机性,每次 epoch 看到的图像都不一样,等效于扩充了数据集。离线生成一次性几十万张图,磁盘和时间都不划算。
3.3 超参与收敛判断
训练配置没什么花哨的:AdamW,学习率 3e-4,weight decay 1e-4,余弦退火,batch size 64,跑 60 个 epoch。损失函数是两项加权,角度头用 KL 散度对高斯软标签,有效性头用交叉熵,权重比 1:0.3。有效性头权重给低一点,因为它太容易学了,权重高了会压制角度头的梯度。
判断收敛不要只看 loss。我的经验是看三个指标的组合:
- 训练 loss 平滑下降,但到后期会出现轻微震荡,这是正常现象,因为增强太强;
- 验证集的角度平均误差(用期望值解码后算的角度差)收敛到 2° 以内,基本就可以实车试了;
- 验证集上“有效线”判断的召回率要特别关注,宁可多报有效也别漏,因为漏报会导致车在没有线的地方强行转弯。
实车上真正影响体验的是角度误差的分布,不是平均值。我在评估时会单独统计“误差大于 8° 的帧占比”,这个数字在弯道处特别关键。第一版模型平均误差 1.9°,看起来不错,但大误差帧占了 6%,实车表现为弯道偶尔冲出。后来补了急弯数据、把软标签的高斯核标准差从 1.0 调到 1.5,大误差帧降到 2.1%,实车表现明显稳定。
3.4 训练结果的离线评估口径
在把模型往板子上搬之前,我习惯做一次完整的离线回放评估:把测试集的图逐帧喂给模型,把输出的角度序列存下来,和标签序列画在一张图上看趋势,再算几个统计量——平均绝对误差、95 分位误差、大误差帧占比、角度符号(左/右)判对率。
这里有个容易被忽视的点:时序连续性。真实驾驶时角度是连续变化的,如果模型在相邻两帧输出 +20° 和 -18°,哪怕每一帧单独看误差都不大,实车也会剧烈摆动。所以我会额外算一个“相邻帧角度差的 95 分位”,这个值最好控制在 5° 以内。如果超标,说明模型输出抖动,需要靠后处理平滑,或者干脆把相邻几帧的图叠成一个多帧输入。
多帧输入是另一个思路:把连续三帧拼成一个 6 通道输入,让模型自己学时序。我试过,效果确实好,但代价是三倍的采集和推理开销,而且量化更麻烦。在 ResNet18 单帧已经够用的情况下,我最终没走这条路,而是在控制层做平滑,性价比更高。
4. 转换与量化:最容易掉点的一步
4.1 ONNX 导出与算子检查
训练完的 PyTorch 模型不能直接上板,要经过 ONNX 再编译成板子能吃的 bin 格式。导出这一步就两个要求:opset 版本别太新、输入输出名字固定好。
import torch model = LineNet(num_bins=41, pretrained=False).eval() dummy = torch.randn(1, 3, 128, 224) # NCHW,注意高在前宽在后 torch.onnx.export( model, dummy, "line_resnet18.onnx", input_names=["input"], output_names=["angle_logits", "valid_logits"], opset_version=11, do_constant_folding=True, )opset 用 11 是稳妥选择,更高的版本在某些工具链里会引入Resize、Gather的变体算子,转换容易报不支持。导出之后我会用一个简单脚本跑一遍,确认 ONNX 模型和 PyTorch 模型在同一批输入上的输出差异小于 1e-4,这就是所谓的“数值对齐检查”。不做这一步,后面量化掉点你根本分不清是导出错了还是量化错了。
导出前记得model.eval(),把 BatchNorm 切到推理模式,不然导出的计算图里 BN 层会带上训练时的统计量计算,行为不对。
4.2 编译配置逐项说明
旭日派X3 用的官方模型转换工具链,核心是一个 yaml 配置文件。这个文件写错一个字段,后面可能要排查半天,所以逐项说一下。
model_parameters: onnx_model: 'line_resnet18.onnx' march: 'bernoulli2' output_model_file_prefix: 'line_resnet18' working_dir: './build' input_parameters: input_name: 'input' input_type_rt: 'nv12' input_layout_rt: 'NHWC' input_type_train: 'rgb' input_layout_train: 'NCHW' norm_type: 'data_mean_and_scale' mean_value: 123.675 116.28 103.53 scale_value: 0.01712475 0.017507 0.01742919 calibration_parameters: cal_data_dir: './calib_data' cal_data_type: 'float32' calibration_type: 'kl' max_percentile: 0.9999 compiler_parameters: compile_mode: 'latency' optimize_level: 'O3' debug: false几个关键字段解释一下。march指定芯片架构,写错了编译能过但跑不起来。input_type_rt填nv12是性能优化的关键——摄像头的原始输出就是 NV12,直接送进 BPU 不用 CPU 转 RGB,省掉一整帧的颜色空间转换和一次内存拷贝。但这里有个必须知道的副作用:NV12 的色度通道是 2×2 下采样,颜色信息的分辨率只有亮度的一半。如果你的赛道线是靠颜色区分的(比如红线、蓝线),精度会损失一些。我的赛道是白线,靠的是亮度和形状,所以影响不大;换成彩色赛道的话,可以把input_type_rt改成rgb用 CPU 转,代价是多两三个毫秒。
norm_type这里要特别注意:data_mean_and_scale是在模型内部做归一化,你要填的是训练时的真实均值和标准差对应的缩放系数。如果填错了,模型在板上表现会莫名其妙——不报错,但精度就是上不去。我第一次填错了 scale 的小数位数,实车转向幅度整体偏小一半,查了一整天才发现是一个数量级的问题。
4.3 校准集挑选:暗光样本必须进
量化是把浮点权重压成 INT8,需要一批真实输入来统计每层激活值的动态范围,这批数据就是校准集。校准集的分布决定了量化后模型的表现分布,这里踩过的坑最多。
我第一版校准集是从训练集里随机抽了 200 张,结果量化后白天精度几乎无损,傍晚低照度下直接崩了——角度输出全挤在 0 附近,车只会直行。原因是随机抽的 200 张里,暗光帧只占不到 10%,大部分层的激活范围被白天的亮图主导,暗光输入进来之后激活值全落在量化区间的下半段,分辨率极低。
正确的做法是按光照条件分层抽样:每种光照条件抽 20~30 张,总共 200 张左右,强制覆盖阴影、逆光、低照度、反光面。改完之后,量化模型在低照度下的角度平均误差从 11° 降到 2.8°,几乎是质变。
还有一个参数值得调:calibration_type。默认的default用的是最大最小值统计,对离群值敏感;kl用的是 KL 散度做阈值搜索,更适合激活分布长尾的情况。我两种都试过,kl在大误差帧占比上略好一点,最终用的就是它。
编译完成之后一定要在板子上跑一次逐层对比,工具链一般会提供这个功能,把量化模型和浮点模型在同一批输入上的输出对比,看每一层的余弦相似度。如果某几层相似度掉到 0.95 以下,基本可以确定是那一层的激活范围被少数样本带偏了,回头去检查校准集。
5. 板端部署:把 60 帧跑稳
5.1 加载模型与前处理
板端推理用的是板子配套的推理库,Python 接口上手快,C++ 接口性能更好。调通阶段我用 Python,定型之后如果帧率不够可以换 C++,但在 X3 上 ResNet18 用 Python 也够用。
from hobot_dnn import pyeasy_dnn as dnn import numpy as np import cv2 models = dnn.load('./line_resnet18.bin') model = models[0] def bgr_to_nv12(bgr): h, w = bgr.shape[:2] yuv_i420 = cv2.cvtColor(bgr, cv2.COLOR_BGR2YUV_I420) yuv_i420 = yuv_i420.reshape(h * 3 // 2, w) nv12 = np.empty((h * 3 // 2, w), dtype=np.uint8) nv12[:h, :] = yuv_i420[:h, :] uv = yuv_i420[h:, :].reshape(2, h // 4, w) nv12[h:, :] = uv.transpose(1, 2, 0).reshape(h // 2, w) return nv12前处理的关键是别在 Python 里做逐像素循环,全部用 numpy 和 OpenCV 的向量化操作。曾经为了“看清楚”写过一个 for 循环版的 NV12 转换,一帧要 40 多毫秒,直接把帧率压到 20 以下,换成向量化之后降到 1ms 以内。
裁剪也要讲究。我的摄像头是 640×480,正前方偏下区域才是有效路面,所以裁剪框取[200:460, 80:560]这样的范围,把上半部分的天花板、远处墙壁都扔了。这一步不仅提升精度——模型不会被无关区域干扰——还减少了缩放的计算量。
5.2 后处理与平滑解码
模型输出两个张量:41 维的角度 logits 和 2 维的有效性 logits。后处理要做三件事。
def softmax(x): e = np.exp(x - x.max()) return e / e.sum() BINS = np.linspace(-45.0, 45.0, 41) def decode(angle_logits, valid_logits): p = softmax(angle_logits.flatten()) angle = float((p * BINS).sum()) valid = float(softmax(valid_logits.flatten())[1]) return angle, valid第一件是取期望值而不是 argmax。argmax 只能输出 41 个离散值,分辨率 2.25°,实车上表现为舵机一顿一顿地跳。取期望相当于做了一次加权平均,输出是连续的,同时还有平滑效果。
第二件是置信度门限。当 valid 概率低于某个阈值(我设的是 0.35),说明模型认为当前画面里没有可靠的线,这时候不应该相信角度输出,而是沿用上一帧的角度并且降低车速。这个机制在车压过十字路口、或者被强光糊住整个画面时特别管用。
第三件事是时序平滑,用一个一阶低通:
steer = 0.65 * steer_prev + 0.35 * angle_raw系数 0.65 是调出来的。太大响应迟钝,过弯跟不上;太小则抖动明显。弯道多的赛道可以适当降低到 0.55,直道为主的可以升到 0.75。
5.3 流水线优化:零拷贝、双缓冲、多线程
单帧推理 3~5ms 听起来很美好,但实际系统里,摄像头采集、颜色转换、缩放、推理、后处理、串口发指令加起来,很容易变成 40ms 一帧。要跑满 60 帧,必须做流水线。
我的做法是生产者-消费者双缓冲:一个线程专门负责取图、裁剪、转 NV12、缩放,把处理好的 buffer 放进队列;另一个线程从队列里取 buffer 送 BPU。两个线程之间用两个固定 buffer 轮换,避免频繁申请释放内存。
中间还做了一次零拷贝的尝试:如果摄像头的输出本来就是 NV12,而且分辨率刚好是模型输入需要的,那么理论上可以直接把摄像头 buffer 的物理地址传给 BPU,连一次内存拷贝都省了。这个需要用到板子提供的图像处理接口,能省下大概 6~8ms。我在 640×480 输入的情况下试过直送,但那样模型输入就变成 640×480 了,算力翻好几倍,得不偿失。最终还是在 CPU 侧缩放,但缩放用cv2.resize的INTER_LINEAR已经足够快。
另外一个优化是推理和控制在不同的线程里跑不同的频率。推理跑 60Hz,控制律跑 100Hz,控制层拿最近一次的推理结果做插值。这样即使推理偶尔卡一帧,控制输出也不会出现明显跳变,车更稳。
5.4 实测数据
我这套配置下的实测数据大致是:
| 环节 | 耗时 |
|---|---|
| 取图 + 裁剪 + 缩放 + 转 NV12 | 5~7 ms |
| BPU 推理(ResNet18,224×128,INT8) | 3.5~4.5 ms |
| 后处理 + 平滑 + 串口写 | 1 ms 以内 |
| 单帧总耗时(流水线并行后) | 约 9~12 ms |
流水线并行之后整体稳定在 70~90 FPS,把控制周期锁在 50Hz,剩下的余量用来做日志和状态上报。这个帧率对巡线来说绰绰有余,甚至可以说配置过剩——真正限制车表现的是数据质量和控制参数,不是帧率。
提示:以上数字是在我的板子、我的模型配置下测的,换摄像头、换分辨率、换量化配置都会变。你的板子上跑出来不一样很正常,重点是先把整条链路的耗时拆开测一遍,找到真正的瓶颈在哪,别一上来就猜是推理慢。
6. 控制闭环:从角度到轮子
6.1 舵机标定
模型输出的是“期望前轮转角”,单位是度,而舵机吃的是 PWM 脉宽。这中间的映射必须自己标定,不能照抄别人的参数。
标定方法很朴素:让舵机在中位脉宽(一般 1500us)时观察前轮是不是正的,如果不正,微调脉宽直到机械中位对齐。然后分别给最小和最大脉宽,量出前轮实际能打到的左右极限角度,记录下对应的脉宽值。假设左极限 1000us 对应 -28°,右极限 2000us 对应 +27°,那映射关系就是线性的。
这里有个要点:机械极限要留余量。不要把脉宽打到舵机的物理极限,容易堵转烧舵机。我一般把有效范围收缩到极限的 90%,剩下的靠控制层限幅。
6.2 滤波、死区、斜率限制
模型输出、平滑、映射这三步之后,还要再过三道保护,这三道保护决定了车“看起来稳不稳”。
死区:角度绝对值小于 1.5° 时直接当 0 处理。为什么?因为模型在直道上的输出会有零点几度的漂移,舵机跟着这个漂移微调,车就会左右轻微摆动,肉眼看起来像画龙。加了死区之后直道明显更稳。
斜率限制:相邻控制周期的角度变化不超过 6°。这是防止模型偶尔抽风输出一个突变值,把车一下子甩出去。斜率限制的代价是响应变慢,但巡线赛道通常不会出现需要瞬间打满方向的场景,这个代价可以接受。
输出限幅:最终脉宽限制在标定范围内,防止任何异常情况把舵机推过头。
顺序上我是这么排的:原始角度 → 有效性检查 → 低通平滑 → 死区 → 斜率限制 → 脉宽映射 → 限幅输出。顺序错了效果会差很多,比如先限幅再平滑,限幅的边界效应会被平滑抹开,反而更容易振荡。
6.3 弯道降速
速度控制不需要模型参与,用角度大小就能做得很自然。我的策略是:角度绝对值越大,速度越低,用一个分段线性映射。
| 转角绝对值 | 目标速度占空比 |
|---|---|
| 0~5° | 70% |
| 5~15° | 55% |
| 15~30° | 40% |
| >30° | 30% |
另外再加两个修正:当有效概率低于阈值时,在基础速度上再乘 0.6;当连续多帧角度符号一致且绝对值持续增大时(说明进弯),提前一点降速,给电机留出响应时间。这个“提前降速”的细节听起来很小,但实车上能明显减少冲出弯道的情况,因为直流减速电机的响应有滞后。
7. 常见问题与排查实录
7.1 光照类问题速查
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 白天好,傍晚完全失效 | 训练集和校准集低照度样本不足 | 按光照分层补采数据,校准集按光照分层抽样 |
| 过阴影时突然转向 | 阴影被误判为线的边界 | 增加侧向长阴影增强,检查标签是否在阴影处有噪声 |
| 反光地面上乱拐 | 模型学到了“亮=线”的捷径 | 增加反光地面数据,加入高光斑增强,降低对比度增强的上限 |
| 逆光时线消失 | 直方图动态范围被高光压缩 | 增加逆光采集,考虑在预处理加一次局部对比度归一化 |
| 补光灯开启后精度下降 | 训练集中没有补光数据 | 补采带补光的场景,或者干脆训练和实车都不开补光 |
我想单独说一下“局部对比度归一化”这个手段。经典的 CLAHE 在板端是有开销的,224×128 的图上大概 2~3ms。如果你的场景光照差异真的非常大,这个开销值得花;但如果数据覆盖够,其实不需要它。我现在的版本就没用 CLAHE,因为光照多样性已经在数据里了,模型自己能处理。加 CLAHE 反而引入了一个额外的超参数要调。
7.2 部署类问题速查
| 现象 | 排查方向 |
|---|---|
| 加载 bin 报错 | march 架构是否与芯片匹配,模型是否完整拷贝 |
| 输出全是 0 或全是 NaN | 归一化的均值和缩放系数填错,或者输入布局 NCHW/NHWC 搞反 |
| 板端精度比 PC 端掉很多 | 校准集分布问题,先做逐层余弦相似度对比定位 |
| 帧率上不去 | 拆开测各环节耗时,重点看颜色转换和缩放是否用了低效写法 |
| 摄像头偶尔黑屏 | 供电余量不足或 CSI 排线接触不良 |
| 推理结果和 PC 差一个固定偏移 | 图像通道顺序问题,BGR 和 RGB 搞反了 |
通道顺序这个坑非常经典。训练时用的是 RGB,板端摄像头出来的是 BGR,如果前处理忘了转,模型能跑但精度会掉一大截,而且掉得很隐蔽——白天可能看起来还行,到了颜色对比明显的场景就崩。我建议在开发阶段画一张对比图:把板端送进模型的图存下来,和训练时的图并排看,颜色不对一眼就能发现。
7.3 横向漂移与画龙
这个问题的成因特别多,我按排查顺序列一下,从便宜到贵:
先看死区,太小的死区会让模型零点漂移直接传导给舵机,加大到 1.5°~2° 试试。再看平滑系数,低通太弱会让噪声穿透,太强会让响应滞后产生振荡,0.6~0.7 之间扫一遍。然后看控制频率是否稳定,如果控制周期抖动很大(比如串口阻塞导致偶尔 60ms 才发一次),PID 类的控制必然振荡,这时候要先把周期性做稳。
如果这些都排除了,那就是模型输出的问题。去看离线回放的“相邻帧角度差 95 分位”,如果超标,说明模型本身输出抖动,需要靠软标签加强、数据增强、或者多帧输入来解决。
还有一种情况容易被误判为画龙:机械间隙。舵机舵盘和转向拉杆之间如果有虚位,模型输出再准,前轮也会有一两度的自由活动空间,低速直线上表现为轻微摆动。这个只能靠机械上收紧来解决,软件补不了。
7.4 几条踩过的坑
第一条:别在训练集上验证自己。我第一版模型在训练集上 98.5%,一度以为自己搞定了,实车一跑全是问题。后来按连续片段重新划分,验证集掉到 79%,那才是真实水平。数据集划分这件事,多花半天时间,能省下后面好几天的迷茫。
第二条:量化后一定要实车测。PC 上跑量化模型的仿真结果和板端实际结果可能有差异,尤其是后处理里如果用了浮点运算,和量化模型的输出配合起来会有细微偏差。我遇到过仿真精度 96%、板端实车表现明显变差的情况,最后发现是校准集里有一批图的分辨率和实际推理分辨率不一致,缩放插值方式不同导致了分布偏移。
第三条:先让车慢下来。调试阶段把速度压到 30%,让车稳稳跑完全程,把视觉链路的问题暴露清楚,再逐步提速。我早期心急,视觉还没调好就全速跑,结果每次冲出赛道都要重新摆车、重新上电,效率极低。
第四条:保留原始数据。录制的时候一定要存原始 NV12 数据和标签 CSV,不要只存处理好的训练图。后面你改了裁剪区域、改了缩放尺寸、改了颜色空间,原始数据还能重新生成训练集;只存了处理后的图,就得重新采集一遍。我就因为这件事白采了一轮数据。
第五条:模型版本和配置要记档。每次训练改了什么增强、什么超参、什么校准集,都记下来。深度学习调参的随机性很大,不加记录的话,跑出一个好模型你都复现不出来。我现在的习惯是每次训练自动把配置、git 版本、最终指标写进一个 json,跟模型文件放一起。
这套东西我从最开始被光照折磨,到后来能在傍晚的地下车库里稳定跑完全程,前后折腾了差不多两个月。真正让效果稳定下来的,不是某个神奇的网络结构,而是数据覆盖的完整度和量化环节的细致程度——模型再好,校准集没覆盖暗光,一样白搭。如果让我重来一次,我会把七成时间花在数据采集和光照覆盖上,剩下三成再分给训练和部署,这个比例我觉得比较接近真实情况。