☰
全国大学生智能汽车竞赛完全模型组:从模型训练到板端部署的工程化实践
2026/10/20 17:38:50 网站建设 项目流程

简介:本资源为第十七届全国大学生智能汽车竞赛完全模型组的完整参赛工程包,来自湖北工业大学蓝电YYDS Car队,面向备战智能车竞赛的高校学生、嵌入式与汽车电子方向的学习者。包内包含完整的车辆控制源码、底层驱动与算法实现,可帮助读者理解完全模型组的赛题方案、控制策略与工程组织方式。资源共539个文件,以262个h头文件、150个c源文件为主,另有hpp、cpp、xml、cs等配置与代码文件,以及jpg、png图片、mp4演示视频和少量工程配置脚本,压缩包约69.67MB,目录结构清晰,便于按模块检索。目前已有883人学习下载,适合需要参考真实参赛工程、对照源码梳理控制逻辑与查漏补缺的竞赛选手和嵌入式开发者。

1. 完全模型组到底在比什么:从“蓝电YYDS”这个队名说起

如果你最近在搜“全国大学生智能汽车竞赛 完全模型组”,大概率是被那套“车自己看路、自己决策、自己跑完全程”的设定吸引进来的。完全模型组和传统的电磁、摄像头组最大的区别在于:它不再让你从零焊板子调运放,而是给你一个已经封装好的车模平台和一套视觉推理框架,你要做的是把感知、决策、控制三件事在有限算力上捏成一个能跑完全程的闭环。湖北工业大学那支叫“蓝电YYDS”的队伍,名字听着像玩梗,但背后是一整套工程化流程——数据采集、模型训练、量化部署、赛道元素识别、舵机与电机联调,缺一环就翻车。

这个方向适合谁?适合已经玩过一轮基础组、想往嵌入式 AI 部署走的人,也适合做视觉算法但没碰过实时控制的同学。它解决的核心痛点是:怎么让一个算力只有几 TOPS 级别的小板子,在颠簸、光照突变、赛道元素密集的场地上,稳定输出转向和速度指令。你不需要造车,但你必须懂模型怎么剪、阈值怎么设、丢帧了怎么兜底。接下来我按“先立住理论、再动手复现”的顺序,把这条链路拆开讲。

2. 完全模型组的软硬件链路:从摄像头到舵机的数据流

2.1 为什么完全模型组偏爱“检测+分类”而不是端到端

常见做法是把赛道元素拆成两类任务:一类是目标检测,负责找出锥桶、路障、坡道、斑马线这些离散元素;另一类是语义分割或分类,负责判断当前赛道类型(直道、弯道、十字、环岛)。端到端直接输出转向角听起来很优雅,但在实际比赛里,端到端模型对数据分布极其敏感,换一个场地光照就崩,而且出问题你没法定位是感知错了还是控制错了。检测+分类的组合虽然工程量大,但每一环都有中间结果可以看,调参时有据可依。

我一般会建议把感知输出定义成一个固定长度的向量,比如[左边界偏移, 右边界偏移, 元素类型ID, 元素置信度, 前方可行驶宽度],控制层只吃这个向量。这样模型换版本时,只要向量定义不变,控制代码不用动。这个解耦在后期迭代时能省下大量返工时间。

2.2 一套可复现的目录结构与依赖清单

下面是我在模拟项目X里用过的目录结构,和完全模型组的落地方式基本一致。你不需要照抄名字,但分层逻辑值得参考:

smartcar_ws/ ├── data/ │ ├── raw/ # 原始采集视频与标注 │ ├── train/ # 训练集 │ └── val/ # 验证集 ├── models/ │ ├── yolov5n_custom.pt # 训练权重 │ └── quantized/ # 量化后模型 ├── src/ │ ├── dataset.py # 数据加载与增强 │ ├── train.py # 训练入口 │ ├── export.py # 导出ONNX/量化 │ └── infer.py # 板端推理封装 ├── configs/ │ └── train_config.yaml # 超参与路径 └── deploy/ ├── camera_test.py # 摄像头采集验证 └── control_loop.py # 控制主循环

依赖方面,训练端用 PyTorch + OpenCV + NumPy 就够了,板端推理常见的是 ONNX Runtime 或者厂商自带的推理框架。注意版本对齐:训练用的算子如果板端不支持,导出时就会报错,这个坑后面会细说。

2.3 数据采集的四个硬性要求

完全模型组的赛道元素是固定的,但光照和背景千变万化。采集数据时我踩过的坑是:只在一个时间段采,结果模型在下午逆光时全瞎。后来定了几条规矩:

第一,同一元素至少覆盖三种光照(顺光、逆光、阴影);第二,摄像头高度和俯仰角必须和比赛车模一致,不能手持采集;第三,每段视频保留原始帧率,不要抽帧后再标注,否则运动模糊样本会丢失;第四,标注时把“不确定”的样本单独放一个文件夹,不要硬标,否则噪声会教坏模型。

采集脚本可以很简单,用 OpenCV 读摄像头然后按帧存图:

import cv2 import os save_dir = "data/raw/session_01" os.makedirs(save_dir, exist_ok=True) cap = cv2.VideoCapture(0) # 板端换成对应设备号 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) idx = 0 while True: ret, frame = cap.read() if not ret: break cv2.imshow("capture", frame) key = cv2.waitKey(1) & 0xFF if key == ord('s'): # 按 s 保存当前帧 cv2.imwrite(f"{save_dir}/{idx:06d}.jpg", frame) idx += 1 elif key == ord('q'): # 按 q 退出 break cap.release() cv2.destroyAllWindows()

逻辑说明:这段代码只做一件事——把摄像头画面实时显示,按 s 存图。参数上,分辨率设成 640x480 是因为完全模型组常见板端推理输入就在这个量级,采太高分辨率训练时还要下采样,反而引入额外误差。idx用六位补零是为了后续按文件名排序时不会乱序。注意采集时不要只存图不记录场景标签,建议在目录名里带上“光照_元素类型”这样的前缀,后期做分层采样会方便很多。

3. 模型训练与板端部署:把 PyTorch 权重塞进小算力板子

3.1 选轻量检测网络还是自定义分类头

完全模型组的赛道元素数量通常在 10 到 20 类之间,用 YOLO 系列做检测是常见选择,但直接上大模型肯定跑不动。我的做法是:主干用轻量网络,检测头保留,但把输入分辨率压到 320x320 或 416x416,先保证帧率。如果元素之间区分度很高(比如锥桶和斑马线形状差异大),甚至可以退化成分类网络加滑动窗口,但滑动窗口的耗时不好控,我不推荐在比赛场景用。

训练配置里几个关键参数:学习率用余弦退火,初始 0.01;batch size 根据显存来,一般 16 或 32;数据增强只开 HSV 抖动和随机翻转,不要开马赛克增强,因为赛道元素的空间关系是有意义的,马赛克会破坏上下文。训练轮数看验证集 mAP 曲线,通常 100 到 150 轮就收敛了。

3.2 导出 ONNX 与量化:三个必须检查的算子

训练完的.pt权重不能直接上板子,中间要过 ONNX 和量化。导出命令如下:

python export.py \ --weights models/yolov5n_custom.pt \ --img-size 320 320 \ --batch-size 1 \ --opset 11 \ --include onnx

参数说明:--img-size必须和训练时一致,否则锚框尺寸对不上;--opset 11是兼容性比较好的版本,太高了板端推理框架可能不支持;--batch-size 1是因为板端通常单帧推理,动态 batch 会引入额外开销。

导出后一定要用 Netron 或者脚本检查三个地方:第一,有没有Resize算子用了非整数缩放,板端可能不支持;第二,Sigmoid和Mul有没有被融合,没融合会多一次内存拷贝;第三,输出层是不是三个分支(分类、置信度、框回归),如果被合并成一个,后处理代码要重写。量化时优先用训练后量化(PTQ),校准集从验证集里抽 200 张左右,覆盖所有元素类型。

3.3 板端推理封装与帧率兜底

板端推理代码的核心不是“跑通”,而是“跑不稳时怎么办”。我一般会加一个帧率监控和降级策略:

import time import numpy as np class InferWrapper: def __init__(self, model_path, input_size=320): self.input_size = input_size # 这里换成实际推理框架的加载方式 self.session = load_model(model_path) self.last_result = None self.fps_window = [] def preprocess(self, frame): img = cv2.resize(frame, (self.input_size, self.input_size)) img = img[:, :, ::-1].transpose(2, 0, 1) # BGR->RGB, HWC->CHW img = np.ascontiguousarray(img, dtype=np.float32) / 255.0 return img[None, ...] def infer(self, frame): t0 = time.time() inp = self.preprocess(frame) outputs = self.session.run(None, {self.session.get_inputs()[0].name: inp}) dt = time.time() - t0 self.fps_window.append(1.0 / max(dt, 1e-6)) if len(self.fps_window) > 30: self.fps_window.pop(0) avg_fps = sum(self.fps_window) / len(self.fps_window) if avg_fps < 15: # 帧率过低时复用上一帧结果 return self.last_result result = postprocess(outputs) self.last_result = result return result

逻辑说明:preprocess里做了通道转换和归一化,注意归一化系数要和训练时一致,否则精度掉得莫名其妙。fps_window是一个滑动窗口,用来算平均帧率;当平均帧率低于 15 时,直接返回上一帧结果,避免控制层因为等待推理而卡死。这个兜底策略在比赛里救过我一次——当时赛道边有强反光,模型推理时间突然翻倍,如果没有兜底,车会在弯道中间停住。

3.4 控制层怎么吃感知结果

控制层不要直接拿检测框坐标去算转向,中间要加一层“赛道几何重建”。常见做法是:根据左右边界点拟合两条直线或二次曲线,然后取前方固定距离处的中心点作为预瞄点,再用纯跟踪(Pure Pursuit)算转向角。速度控制则根据曲率来,曲率大就减速,曲率小就加速。这里的关键参数是预瞄距离,太短会震荡,太长会切内弯。我一般从 0.8 米开始试,每次加 0.1 米,看车在直道末端和弯道入口的表现。

4. 避坑与排查:完全模型组最容易翻车的五个地方

4.1 现象:训练时 mAP 很高,上车后元素全认错

原因通常是训练数据和板端摄像头成像差异太大。训练用的是采集卡存的图,板端摄像头有自动曝光和自动白平衡,颜色分布变了。解决方法是:在板端摄像头下重新采一批图,只用来做量化校准和微调最后几层,不要全量重训。另外把板端摄像头的自动曝光关掉,固定曝光值,减少帧间波动。

4.2 现象:模型导出 ONNX 成功,但板端加载报错

先看报错信息里有没有算子名。常见的是Resize的coordinate_transformation_mode不被支持,或者Hardswish激活函数板端没有实现。解决办法是在导出时把Hardswish替换成ReLU或LeakyReLU,重新训练几轮让精度恢复。另一个坑是输入尺寸带了动态维度,板端只认静态 shape,导出时把dynamic_axes去掉。

4.3 现象:车在直道正常,一进弯道就冲出赛道

这往往不是感知问题,而是控制层的预瞄距离没调好,或者舵机响应有延迟。排查步骤:先把车架起来,手动给转向指令,看舵机从收到指令到到位要多少毫秒;如果超过 50 毫秒,就要在控制里做前馈补偿。然后检查预瞄距离,用日志把每帧的曲率和转向角打出来,看是不是曲率计算用了太多噪声点。我一般会对边界点做一次中值滤波再拟合,能压掉不少抖动。

4.4 现象:推理帧率忽高忽低,车一顿一顿的

先确认是不是内存拷贝导致的。板端推理时,如果每帧都重新分配输入张量内存,GC 会拖慢帧率。解决办法是预分配输入输出缓冲区,复用内存。另外检查有没有在推理线程里做图像保存或打印日志,这些 IO 操作在比赛时一定要关掉。如果用了多线程,注意推理线程和控制线程之间的锁粒度,锁太粗也会导致卡顿。

4.5 现象:量化后精度掉得厉害,尤其是小目标

训练后量化对小目标不友好,因为校准集里小目标样本少,量化参数没覆盖到。解决办法有两个:一是校准集里按元素类型分层抽样,保证每类都有足够样本;二是对检测头部分不做量化,只量化主干网络,牺牲一点速度换精度。如果板端支持混合精度,优先用混合精度。

5. 进阶技巧:用日志回放把调参从玄学变成工程

5.1 把每一帧的感知和控制结果落盘

比赛现场调参最怕的是“跑完一圈不知道哪一帧出了问题”。我的习惯是在控制循环里加一个轻量日志模块,把每帧的时间戳、感知向量、转向角、速度指令写成一个二进制文件。不要用文本日志,IO 太慢。格式可以很简单:每帧固定 32 字节,前 8 字节是时间戳,后面按 float 排。跑完之后在电脑上回放,就能精确定位是哪一帧的感知跳变导致了控制震荡。

import struct class FrameLogger: def __init__(self, path): self.fp = open(path, "wb") def log(self, ts, steer, speed, perception_vec): # 格式:double + float + float + 8*float data = struct.pack("<dff8f", ts, steer, speed, *perception_vec) self.fp.write(data) def close(self): self.fp.close()

逻辑说明:struct.pack的格式字符串<dff8f表示小端、一个 double、两个 float、八个 float。这样每帧固定长度,回放时按固定偏移读取即可。参数上,感知向量我固定取 8 维,不够的补零,保证格式统一。这个日志文件在赛后复盘时比任何视频都管用,因为你能同时看到感知输入和控制输出。

5.2 用回放数据做离线参数扫描

有了日志,就可以在电脑上做参数扫描了。比如预瞄距离,你可以写一个脚本,把日志里的感知向量重新喂给不同预瞄距离的纯跟踪控制器,看哪个参数下转向角序列最平滑、且没有滞后。这比在车上一次次试快得多。我一般会扫三组参数:预瞄距离、速度上限、转向增益,每组取 5 个值,交叉跑一遍,选出综合表现最好的组合,再上车微调。

5.3 一个容易被忽略的细节:时间戳对齐

感知和控制如果不在同一个线程,时间戳一定要用同一个时钟源。我见过有队伍用time.time()在感知线程打一次,在控制线程又打一次,结果两个时间戳差了几十毫秒,回放时完全对不上。正确做法是在主循环里统一取时间戳,然后传给感知和控制模块。另外,如果板端有硬件时钟,优先用硬件时钟,软件时钟在负载高时会漂。

5.4 最后说一个习惯

每次改完参数,不管多小的改动,都要跑一次完整日志并回放确认。我吃过亏:有一次只改了一个阈值,觉得肯定没问题,结果那个阈值影响了元素切换的时机,车在环岛入口犹豫了一下直接撞墙。从那以后,我的原则是“改必有日志,日志必回放”。这个习惯看起来费时间,但比在赛场上临时找问题要省得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询