
不管赛道是白线还是黄线只要把车推到走廊的阴影里、或者下午四点斜阳一照阈值法调了三天三夜的那套参数立刻报废。这个场景我经历过不止一次也是我最后狠下心把整条视觉链路换成深度学习的原因。这篇就聊聊我在地平线旭日派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_bins41, pretrainedTrue): super().__init__() weights models.ResNet18_Weights.DEFAULT if pretrained else None backbone models.resnet18(weightsweights) # 去掉最后的全局池化和全连接保留到 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], fillrandom.randint(90, 180)) overlay overlay.filter(ImageFilter.GaussianBlur(radius12)) 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-4weight 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_bins41, pretrainedFalse).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_version11, do_constant_foldingTrue, )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), dtypenp.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 实测数据我这套配置下的实测数据大致是环节耗时取图 裁剪 缩放 转 NV125~7 msBPU 推理ResNet18224×128INT83.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跟模型文件放一起。这套东西我从最开始被光照折磨到后来能在傍晚的地下车库里稳定跑完全程前后折腾了差不多两个月。真正让效果稳定下来的不是某个神奇的网络结构而是数据覆盖的完整度和量化环节的细致程度——模型再好校准集没覆盖暗光一样白搭。如果让我重来一次我会把七成时间花在数据采集和光照覆盖上剩下三成再分给训练和部署这个比例我觉得比较接近真实情况。