1. 先把问题定义清楚:巡线到底难在哪
光照这件事,是巡线小车绕不过去的一道坎。我在室内把阈值调得漂漂亮亮,一辆车跑得笔直;搬到走廊拐角,窗外的光斜着打进来,车道线一半过曝一半压暗,车立刻开始画龙,运气不好直接冲出去。这类现象不是调参能解决的,它属于输入分布发生了漂移——你写死的 HSV 上下界,本质是对某一种光照条件下像素分布的硬编码,光照一变,分布整体平移,硬编码就失效。
我后来换的思路很直接:别再教程序“车道线长什么样”,改成让模型自己去学“什么特征在光照变化下依然稳定”。在旭日派 X3 这块板子上,我最终用 ResNet18 做端到端的车道状态分类,配合 BPU 的 INT8 推理,做到了 30 帧左右的稳定运行,晴天、逆光、树荫、室内白光、地面反光这几类场景都能扛住。这篇就是把这套东西从数据采集到轮子转动的全过程摊开讲一遍。
需要说明的是,这套方案不追求在赛道上刷极限圈速,它解决的是鲁棒性问题:同一套权重,不换参数,挪到不同光照环境下还能跑。适合正在做智能车、机器人课程项目、或者想入门边缘端深度学习部署的朋友。如果你现在还在用大津法加形态学,被光照折磨得很难受,那这篇基本可以对症。前置要求不算高:会写一点 Python,能在 Linux 下敲命令,知道卷积网络大概是什么就行;板端部署部分我会把命令和配置文件都贴出来,照着改就行。
2. 方案整体设计与关键选型
2.1 阈值法在光照下的三种典型失效
先把对手的问题说透,后面选型才有依据。我用传统方法跑过很长一段时间,遇到的失效基本归成三类。
第一类是全局亮度漂移。自动曝光一动作,整幅图的灰度分布就整体右移或左移,固定阈值直接失效。你以为调好了,其实只是刚好卡在某一个曝光点上。
第二类是局部高光与阴影。阳光直射地面会形成一块高亮斑,过曝后车道线和地面的灰度差被压平,二值化出来是一片空白;树荫边缘则是另一种情况,阴影里的车道线整体变暗,跟深色地面的对比度掉到个位数,噪声占比反而上升。
第三类是色温变化。室外偏冷、室内偏暖,白平衡一飘,HSV 里的 H 通道整段偏移,你按蓝色车道线设的区间可能直接落到别的颜色上。这三类问题叠加起来,靠调参是调不完的——每换一个场地就得重来一遍,这在工程上没法接受。
2.2 为什么是 ResNet18,而不是更大的骨干或者检测网络
选 ResNet18 有三层考虑,我逐个说。
第一层是任务本身不难。巡线本质上是个低维决策问题:车相对车道中心偏左还是偏右、偏多少、前面是不是路口。这个任务的判别信息高度集中在中低层特征上——边缘、方向、局部对比度。ResNet18 的 1.8 GFLOPs 完全够用,上 ResNet50 或者更大的骨干,参数量翻几倍,精度提升可能就一两个点,但在边缘端的延迟和内存占用是实打实的代价。
第二层是量化友好。这一点很多人会忽略。网络越深、结构越复杂,INT8 量化后的精度损失越难控制。ResNet18 结构规整,全是标准卷积加残差连接,没有那种对量化极敏感的特殊算子,做 KL 散度校准之后掉点通常在 1 个点以内。我实测过几个更激进的轻量骨干,浮点精度确实高,但转成 INT8 之后反而崩了,最后还得回来用 ResNet18。
第三层是生态成熟。这个模型在各大框架里的实现都验证过无数遍,改输入通道、换分类头、加预处理,几乎不会踩到实现层面的坑。对于一个要快速落地的项目来说,这种“确定性”比那一点点精度更值钱。
至于为什么不用检测网络(比如 YOLO 系列):检测框的输出对控制环来说是冗余的。我要的是转向量,不是车道线的像素坐标。用分类头直接输出状态,链路更短,后处理几乎为零,延迟也更可控。当然如果你后续要做避障、识别路标,那确实该上检测,但那是另一个阶段的事。
2.3 X3 板端的算力分工与整体链路
旭日派 X3 这块板子的芯片里,CPU 是四核 A53,另有一颗专门做神经网络推理的 BPU,标称算力 5 TOPS(INT8)。这个架构决定了整条链路的划分方式:
| 环节 | 执行单元 | 说明 |
|---|---|---|
| 摄像头取帧 | ISP + V4L2 | 输出 NV12,分辨率 640x480 起步 |
| 图像缩放到 224x224 | BPU 内置硬件 | 配置成 NV12 输入后由硬件完成,不占 CPU |
| 前向推理 | BPU | ResNet18 INT8,单帧几毫秒量级 |
| 后处理与决策 | CPU | softmax、状态映射、PID |
| 电机输出 | CPU + PWM | 通过串口或 GPIO 下发 |
这个分工里最关键的一点是缩放放在 BPU 做。早期我在 CPU 上用 OpenCV 做 resize,一帧 640x480 缩到 224x224 要花掉七八毫秒,还占一个核,整个控制周期被拖到 50ms 以上,车跑快了就抖。后来把模型的输入类型直接配成 NV12,摄像头原始帧喂进去,硬件自己完成缩放和格式转换,CPU 这边只剩一次拷贝,延迟立刻下来了。
整体数据流是这样:摄像头出 NV12 帧 → 直接喂给 BPU → 拿到 5 维分类 logits → softmax → 映射成目标转角 → PID 输出 PWM → 电机。整条链路上没有任何一处依赖固定的颜色阈值,鲁棒性全部由模型权重承担。
3. 数据这一关:把光照当成正式变量
3.1 采集清单与场景矩阵
做这个项目,我把七成时间花在了数据上。模型结构是现成的,训练脚本是模板改的,真正决定成败的是你喂进去的图覆盖了多宽的光照分布。
我的做法是先列一张场景矩阵,按两个维度交叉:光照类型 × 地面材质。光照维度包括:正午直射、侧逆光、树荫斑驳、阴天散射、室内白光灯、走廊尽头强逆光、夜间补光。地面材质包括:浅色瓷砖、深色沥青、木地板、带反光的地胶、有接缝的水泥地。两个维度交叉出来三十多种组合,每种至少采两分钟视频,不同日期分几次采。
采集的时候有个细节必须注意:车要真的在跑,不要拿在手上晃。手持采集的视角高度、俯仰角、运动模糊都跟实际装车不一样,训出来的模型上车就废。我一般是把车装好,用遥控或者简单的定速程序让它沿着线跑,同时录像,这样采到的帧天然带有转向时的视角变化和运动模糊,跟推理时的分布一致。
另外一个容易忽略的点是摄像头参数要固定。自动曝光、自动白平衡、自动增益这些功能在采集和推理时必须保持一致,否则你采的是 A 分布,跑的是 B 分布。我一般在程序启动时把这几项手动锁死,曝光值选一个中间档,宁可暗一点也别让它自己跳。
3.2 标签体系:分类还是回归,粒度怎么定
标签设计上我试过两种方案,最后选了分类。
回归方案是直接让网络输出一个转向角,损失用 L1 或者 smooth L1。理论上更精细,但实际训练很不稳——标注本身就是个问题,你很难给每一帧标出一个精确的角度值。用人工标或者用传统算法生成伪标签,都会引入噪声,模型学到最后输出一直在均值附近徘徊,反应迟钝。
分类方案是把转向离散成若干档。我最后定的是 5 类:急左、缓左、直行、缓右、急右,对应目标转角 -0.5、-0.25、0、0.25、0.5 弧度,中间值靠 PID 的连续性去补。5 类的粒度对于巡线足够了,因为控制环是闭环的,模型只需要告诉车“往哪边偏、偏得厉害不厉害”,不需要精确到小数点后两位。
如果你跑的是急弯很多的赛道,可以加到 7 类甚至 9 类;如果场地很规整,3 类也能跑。判据是:相邻两类的角度差,不要让 PID 产生明显的阶跃。角度差太大,车会在两个状态之间反复横跳,表现出来就是车身左右摆。
除了转向,我还额外加了两个类别:路口和停止线。这两个不参与转向控制,但可以让程序做一些状态机切换。加起来一共 7 类,输出的分类头改成 7 维。
数据组织上,我按类别名/场景名_序号.jpg的目录结构存,这样后面做分层抽样和划分数据集都方便。整个数据集最后攒到大概 1.2 万张,7 类分布尽量均衡,路口类偏少,用重复采样补了一点。
3.3 专门针对光照的增强策略
数据增强这块,通用的一套(随机裁剪、翻转、缩放)是基础,但对这个任务来说,真正起作用的是光照相关的增强。我用了下面几类,效果比较明显:
- 随机 Gamma 校正:gamma 在 0.6 到 1.6 之间随机取,模拟曝光不足和过曝。这一项对逆光场景的提升最直接。
- 随机亮度与对比度抖动:亮度 ±30%,对比度 0.7 到 1.3,模拟自动曝光的漂移。
- 局部高光模拟:随机在图上叠一个高斯亮斑,模拟阳光直射形成的过曝区域。这个是我自己加的,加上之后模型在强光斑场景下的误判率降了不少。
- 色温偏移:在 RGB 三个通道上做小幅度的独立增益,模拟白平衡漂移。
- 随机擦除:随机挖掉一块矩形区域填黑,逼模型别只依赖图像的某一个局部。
有两点必须提醒。第一,左右翻转要同步换标签。你翻转了图像但没把“急左”改成“急右”,模型就学废了。这个坑我第一次就踩了,训练 loss 一直降不下去,查了半天才发现是标签没跟着翻。
第二,增强不要上得太狠。我一开始把亮度抖动开到 ±50%,结果模型把“很暗”和“很亮”都当成了同一种状态的噪声,反而对正常光照的判断变迟钝了。后来收到 ±30% 就稳了。增强的目的是让分布变宽,不是让分布变形。
4. 训练与量化:精度主要丢在这一步
4.1 训练配置与几个关键超参
训练跑在带独显的机器上,框架用 PyTorch。骨干直接拿 ImageNet 预训练权重,把第一层和最后的全连接换掉。输入尺寸我选的是 224x224——这个尺寸是 16 的整数倍,后面 BPU 缩放对齐不会有问题,同时信息量也够。
核心配置大概是这样的:
model = resnet18(pretrained=True) model.fc = nn.Linear(512, 7) # 7 类输出 criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)几个参数值得说一下为什么这么定。
标签平滑 0.1:巡线的相邻类别之间本身就有模糊地带(一帧图到底是“缓左”还是“直行”,人看也有争议),硬标签会逼模型过度自信,量化之后这种过度自信特别容易变成错误。加一点平滑,输出分布更软,量化损失也小。
学习率 3e-4 加余弦退火:骨干是预训练的,学习率太大会把预训练特征冲掉,太小收敛慢。3e-4 这个档我试下来最稳,配合余弦退火跑 30 个 epoch,最后几个 epoch 基本收敛。
划分方式要按场景切,不能按帧随机切。这是最容易犯的错。同一段视频的连续帧高度相似,随机切分会让验证集里混进大量跟训练集几乎一样的图,准确率虚高到 99%,上车直接傻眼。我的做法是按“场景_日期”整体划分,比如“树荫_A日”整段进验证集,训练集里完全不出现这个场景的任何一帧。这样验证集准确率虽然只有 92% 左右,但这个数字是可信的。
训练完导出 ONNX:
torch.onnx.export( model, dummy_input, "resnet18_lane.onnx", input_names=["input"], output_names=["output"], opset_version=11, do_constant_folding=True )opset 用 11 比较保险,太新的版本工具链不一定支持。
4.2 量化校准集怎么选,这一步决定成败
模型转 INT8 需要一批校准数据来统计激活值的分布。这一步是整个流程里最容易翻车的地方,也是光照鲁棒性能不能保住的关键。
校准集的作用是让工具链知道每一层输出的数值范围,从而确定量化系数。如果你的校准集全是正午阳光下的亮图,那模型学到的“暗光”特征在量化时就会被压掉——因为统计范围里根本没覆盖到那一块。表现出来就是:浮点模型在暗光下正常,量化后暗光全错。
我的校准集选法是:按场景分层抽样,每个场景抽 20 到 30 张,总量控制在 200 张左右。七个类别、七八种光照条件,尽量均匀覆盖。特别要把最亮和最暗的那几张挑进去,宁可让范围宽一点,也不要漏掉极端值。
另外有一件事值得单独说:校准集必须来自训练集,不能来自验证集。用验证集做校准,等于把测试数据的信息泄漏进模型,评估结果会失真。这种泄漏在数字上很好看,在实际使用中毫无意义。
4.3 模型转换的配置文件与踩坑点
转换用的是随板子配套的模型工具链,跑在 Docker 里。核心是一份 YAML 配置:
model_parameters: onnx_model: './resnet18_lane.onnx' march: 'bernoulli2' output_model_file_prefix: 'resnet18_lane' layer_out_dump: False input_parameters: input_name: 'input' input_type_rt: 'nv12' input_layout_rt: 'NHWC' input_type_train: 'rgb' input_layout_train: 'NCHW' input_shape: '1x3x224x224' norm_type: 'data_mean_and_scale' mean_value: '123.675 116.28 103.53' scale_value: '0.01712475 0.017507 0.017429' calibration_parameters: cal_data_dir: './calib_data' calibration_type: 'kl' max_percentile: 0.99996 compiler_parameters: compile_mode: 'latency' optimize_level: 'O3'几个参数容易配错,我逐个解释。
input_type_rt: 'nv12'是这套方案延迟低的关键。它告诉工具链:板端喂进来的是 NV12 格式,请自动插入格式转换和缩放节点。配成 NV12 之后,板端就不需要在 CPU 上做cvtColor和resize了。
input_layout_rt: 'NHWC'跟 NV12 是配套的。NV12 本身是平面加交织的排布,板端传入时按 NHWC 处理。这里配错,模型能转成功但推理结果全是乱的,输出恒定某一类,这个现象特别有迷惑性。
mean_value和scale_value必须跟你训练时的归一化一致。我用的是 ImageNet 的均值和标准差换算出来的 scale:1/(255*std)。如果训练时用的是ToTensor()加Normalize(mean, std),这里就要对应上。差一点点精度掉几个点,差得多直接全错。
calibration_type: 'kl'是 KL 散度校准,对分类任务一般是最优选择。max_percentile设到 0.99996 左右,能把极少数离群值排除在量化范围外,减少它们对整体精度的影响。
转换命令大概是这样:
hb_mapper makertbin --config resnet18_lane_config.yaml --model-type onnx跑完之后目录下会生成resnet18_lane.bin,这个就是板端加载的文件。
转换过程中如果报校验错误,通常是 ONNX 里有工具链不支持的算子(比如某些自适应池化的变体)。解决方法是把自适应池化换成固定尺寸的AvgPool2d,因为输入尺寸固定,固定池化完全等价。
还有一个必须核对的东西:模型精度比对报告。工具链会输出每一层的量化误差,重点看最后几层和分类头的 cosine 相似度,一般要求 0.99 以上。如果某一层掉到 0.95 以下,多半是那一层的激活值分布太分散,得回头检查校准集。
5. 板端落地:从摄像头帧到轮子转动
5.1 环境确认与依赖
系统烧好之后,先确认几件事:BPU 驱动版本、Python 环境、推理库是否可用。我一般先跑一句导入测试:
python3 -c "from hobot_dnn import pyeasy_dnn as dnn; print('ok')"能打印 ok 说明推理库就位。如果报找不到模块,检查一下是不是用了虚拟环境——板子自带的推理库装在系统 Python 里,切到 conda 环境反而找不到。
摄像头方面,如果是 USB 摄像头,用v4l2-ctl --list-devices确认设备号;如果用板载 MIPI 摄像头,走的是另一套采集接口。我这边用的是 USB 摄像头,输出 NV12,640x480,30 帧。
有一点要提前确认:摄像头能不能直接输出 NV12。有些摄像头只支持 MJPG 或 YUYV,这时候要么在 CPU 上转一次格式(多花几毫秒),要么换摄像头。买之前查一下规格,能省很多事。
5.2 推理代码骨架
完整的推理脚本不长,核心就三步:加载模型、取帧、前向。
import cv2 import numpy as np from hobot_dnn import pyeasy_dnn as dnn # 1. 加载 bin 模型 models = dnn.load('./resnet18_lane.bin') model = models[0] print("输入张量:", model.inputs[0].properties) print("输出张量:", model.outputs[0].properties) # 2. 打开摄像头,强制 NV12 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'NV12')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) def to_nv12(frame): # VideoCapture 读出来是 (h*3/2, w) 的二维数组 return frame.reshape(-1) while True: ret, frame = cap.read() if not ret: continue nv12 = to_nv12(frame) # 3. 前向,缩放和格式转换由 BPU 完成 outputs = model.forward(nv12) logits = outputs[0].buffer.reshape(-1) # softmax e = np.exp(logits - logits.max()) prob = e / e.sum() cls = int(prob.argmax()) # 映射到目标转角 target_angle = [-0.5, -0.25, 0.0, 0.25, 0.5, 0.0, 0.0][cls] # cls 5、6 分别是路口和停止线,走状态机这里有几个值得展开的细节。
第一,frame的形状。用 OpenCV 读 NV12,返回的是(h*1.5, w)的二维数组,因为 NV12 是 Y 平面加 UV 交织平面拼起来的。直接reshape(-1)展平成一维字节流喂给模型,不要试图把它当成 BGR 图去处理。
第二,输入尺寸跟模型不匹配也没关系。模型训练时是 224x224,摄像头给的是 640x480,工具链在转换时已经插入了缩放节点,BPU 硬件会自动处理。这也是为什么前面强调要配 NV12——如果配成 RGB 输入,板端就得自己做 resize,那几毫秒就白省了。
第三,outputs[0].buffer的类型。不同版本的推理库返回的形状描述略有差异,model.outputs[0].properties打印出来看一眼,确认是[1, 7]还是别的形状,再决定 reshape 的参数。
第四,不要把日志写进主循环。我一开始在循环里print每一帧的类别和置信度,CPU 占用直接从 20% 涨到 60%,控制周期从 33ms 抖到 60ms。后来改成每 30 帧打一次,问题消失。串口输出、文件写入这些操作在主循环里都是性能杀手。
5.3 控制闭环与延迟补偿
拿到目标转角之后是 PID。这部分看起来简单,实际上跟视觉延迟的配合是重点。
我的控制环大概是:
Kp, Ki, Kd = 1.2, 0.05, 0.15 err_sum = 0 last_err = 0 while True: target = infer() # 目标转角 err = target - current_angle # 当前转角由编码器或者舵机反馈 err_sum = max(-50, min(50, err_sum + err)) out = Kp * err + Ki * err_sum + Kd * (err - last_err) last_err = err set_steering(clip(out, -1, 1))关键问题是视觉链路有延迟。摄像头取帧、BPU 推理、后处理,整条链路实测在 25 到 35ms 之间。车在 0.8 m/s 的时候,30ms 就是 2.4 cm 的位移。速度再快一点,这个延迟就会表现为过弯时的滞后,车会往弯道外侧冲。
补偿方式有两种。一种是相位提前:用上一帧和当前帧的目标转角做线性外推,给一个提前量。实现上就是target = cur + 0.5 * (cur - prev),系数根据速度调。另一种是降低微分项增益,用更保守的 D 值减少超调。我两个一起用,速度低的时候靠前者,速度快的时候两者结合。
另外还有几个控制层面的坑:
- 死区。模型输出的类别是离散的,直行类持续输出 0,但车实际上有轻微的机械偏差,会慢慢偏出去。我加了一个 ±0.02 弧度的死区补偿,让直行时有个向中心的微调。
- 积分饱和。连续弯道的时候积分项容易积起来,出弯之后车会反向修正一段。必须做限幅,上面那个
max(-50, min(50, ...))就是干这个的。 - 控制频率与视觉频率解耦。视觉跑 30 帧,电机控制的 PWM 更新可以跑 100Hz,中间用最近一次视觉结果保持。这样电机的响应更细腻,不会有阶梯感。
6. 现场调试:故障排查与经验沉淀
6.1 光照相关故障速查表
下面这张表是我在实际调试中整理出来的,基本覆盖了九成以上的光照相关故障:
| 现象 | 最可能的原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 强光下输出恒为同一类 | 量化后该场景激活值溢出 | 用浮点模型跑同一张图对比 | 补充强光样本进校准集,重新转换 |
| 暗光下精度骤降 | 校准集亮度分布偏亮 | 统计校准集与实测图的亮度直方图 | 校准集按光照分层抽样 |
| 色温变化后误判 | 训练时未做色温增强 | 用冷暖两种白光各跑一遍 | 加通道增益抖动增强,重训 |
| 阴影边缘反复横跳 | 相邻类别决策边界模糊 | 打印连续帧的 softmax 分布 | 调大标签平滑,或加时序滤波 |
| 白天正常夜间全错 | 夜间未采集数据 | 看每类样本的采集时段 | 补夜间补光场景数据 |
| 摄像头换位置后失效 | 视角分布偏移 | 对比安装高度与角度 | 重新采一批该视角的训练数据 |
表里最后一条特别值得说。我遇到过装车之后精度掉了十几个点的情况,查了半天不是光照问题,是摄像头俯仰角跟采集时差了大概 8 度。视角一变,车道线在图像里的形状、透视全变了,模型认不出来。所以采集之前一定把机械结构固定死,采集和上车用同一个安装位置。
6.2 时序滤波:一个几乎零成本的提升
分类模型逐帧独立推理,会遇到单帧误判。表现出来就是车突然抽一下。这个问题的解很简单:对 softmax 概率做滑动平均。
prob_hist = deque(maxlen=5) prob_hist.append(prob) smooth = np.mean(prob_hist, axis=0) cls = int(smooth.argmax())窗口取 3 到 5 帧。太小起不到滤波作用,太大响应变慢,过弯会迟滞。我实测 5 帧在 30fps 下大概对应 160ms 的平滑窗口,对巡线来说正好,能把单帧抖动压掉,又不至于让转向明显滞后。
如果车跑得很快,可以只用 3 帧,或者改用加权平均,最近帧权重高一点。
6.3 我踩过的几个坑
第一个坑:scale_value抄错了。从别人的配置里复制过来,均值对了,scale 用的是1/255而不是1/(255*std)。转换过程没有任何报错,模型也能跑,但准确率只有 30% 左右,输出几乎随机。排查的时候对着训练脚本一行一行核对才找出来。经验是:转换前先用浮点 ONNX 模型在板端跑几张图,跟训练时的预测结果比对,一致了再转 INT8。这一步能提前拦掉绝大部分配置错误。
第二个坑:Docker 里的路径映射。校准数据放在宿主机目录,Docker 里没挂载进去,工具链读不到,直接用了默认的空目录,量化的结果惨不忍睹。这个错误的隐蔽之处在于工具链不一定会报错,只是精度悄悄掉下去。建议每次转换完都看一眼日志里的“校准图片数量”那一行。
第三个坑:模型文件和板端固件版本不匹配。我在开发机上用新版本工具链转的模型,部署到系统版本较旧的板子上,加载直接失败。后来把工具链版本对齐到跟板子系统固件匹配的那一版才通。板子到手第一件事是记下系统镜像版本号,后面所有工具链操作都以这个版本为准。
第四个坑:以为通道数可以直接改。训练时把 ResNet18 第一层改成单通道灰度输入,想着能省点算力,结果迁移学习的效果一塌糊涂——预训练权重的前三个通道被平均压缩,纹理特征损失很大。后来还是老老实实用三通道。灰度确实能减少光照中的色温影响,但代价是丢掉了颜色信息,如果你的车道线有颜色区分(比如红蓝双线),千万不要走灰度这条路。
第五个坑:忽略了散热。连续跑二十分钟之后,板子温度上来,CPU 触发降频,控制周期从 33ms 抖到 50ms 以上,车开始画龙。加了个小风扇之后稳住了。这类问题在实验室里短时间测试根本发现不了,一定要做长时间连续跑测试。
6.4 后续可以继续往下做的方向
这套东西跑通之后,我后来又加了两个小改动,成本很低但效果明显。
一个是多尺度输入。训练时随机在 192 和 256 之间切换输入尺寸,让模型对缩放不敏感,这样换摄像头分辨率的时候不用重训。另一个是置信度门限。当 softmax 的最大概率低于 0.6 时,判定为“不确定”,保持上一帧的转向输出并减速,而不是盲目相信一次低置信度的预测。这个简单的保护逻辑,在实车上避免了好几次冲出赛道。
至于更远的方向,比如把分类头换成多任务(转向 + 路标 + 障碍),或者做模型的在线自适应,那是另一个量级的工程,暂时没往那边走。当前这套方案的价值在于:结构足够简单,每一环都能自己掌控,出了问题能定位到具体是哪一步。在边缘端做项目,可控性往往比先进程度更重要。
最后分享一个很实用的小习惯:每次调完参数、换完模型,我都用同一段固定的测试视频跑一遍,把每帧的预测类别和置信度存成 CSV,跟上一版对比。这样改动的效果是变好还是变差,一目了然,不会出现“感觉好像好了一点”这种模糊判断。这个习惯帮我省了很多反复折腾的时间。