☰
智能车国二方案复现:感知决策控制三层调参实战
2026/10/22 5:20:51 网站建设 项目流程

简介:这份资源是面向高校学生、教师及科研人员的全国大学生智能汽车大赛百度智慧交通组国二获奖方案完整资料包,适合人工智能、自动化、电子信息、通信工程等专业用于竞赛备赛、课程设计、毕业设计或项目立项演示。压缩包共220个文件,约174.67MB,以66个Python脚本、58个C++头文件、17个模型与17个参数文件为核心,辅以CMake构建脚本、Markdown说明文档、XML配置及Shell运行脚本,覆盖感知、决策、控制等模块的完整工程结构。方案含源码、项目说明与设计文档,代码经过测试可正常运行,能帮助读者理解赛题实现思路、模型部署方式与工程组织逻辑,也可在此基础上修改扩展功能。目前已有43人学习下载,适合希望借鉴国赛级方案、快速上手智慧交通项目的中高级学习者参考。

1. 从零复现一套智能车国二方案:为什么“能跑”和“能拿奖”之间差了三层调参

很多人第一次接触这个赛题,都是被“智慧交通”四个字吸引进来的——听起来像是把自动驾驶那一套搬到缩微赛道上,实际上手才发现,它更像一场在有限算力、有限传感器、有限调试时间下的系统工程考试。你手里可能已经拿到一份往届国二方案的压缩包,里面有源码、有项目说明、有全部资料,但打开之后往往一脸茫然:代码能编译,车却跑不出成绩。问题不在代码本身,而在于这套方案背后的场景约束、参数耦合和调试节奏,文档里通常不会写透。

这篇笔记面向三类人:刚组队、准备从零搭一套能进国赛的智能车系统的同学;已经有一版能跑但成绩不稳定的方案、想系统排查瓶颈的开发者;以及想理解“缩微交通场景下感知—决策—控制”这条链路到底怎么落地的一线工程师。我会按“先立住理论、再动手复现、最后讲坑”的顺序,把一套国二级别方案拆成可执行的步骤。你不需要有现成的车模,但需要一台能跑 Ubuntu 的机器、一个能编译的工程环境,以及愿意反复上车调试的耐心。

2. 智慧交通组的任务拆解:从赛道元素到代码模块的映射关系

2.1 赛题到底在考什么:不是跑得快,而是决策稳

很多人把智能车比赛理解成“调 PID 让车不抖”,这个认知在智慧交通组会直接翻车。这个赛题的核心不是单一控制环,而是一个缩微的交通场景:赛道上有红绿灯、有斑马线、有障碍物、有会车区域,车需要在规定时间内完成一圈,同时正确处理这些交通元素。换句话说,它考的是“感知—决策—控制”三层链路的协同,而不是某一层的极致优化。

从历年赛题来看,任务可以拆成四类:第一类是循迹,也就是沿着赛道中心线走;第二类是交通标志识别,包括红绿灯、限速牌、停止线;第三类是障碍物避让,通常是锥桶或模拟车辆;第四类是特殊区域处理,比如斑马线减速、会车区让行。这四类任务在代码里对应四个模块,但它们不是独立的——红绿灯识别结果会影响决策层的速度规划,障碍物避让会临时改变循迹的目标点,斑马线减速又和全局速度规划耦合。

我一般会把整个系统画成一条流水线:摄像头采集图像 → 感知层做车道线拟合和标志识别 → 决策层根据当前场景选择行为模式 → 控制层把目标速度和目标转角转成电机和舵机的 PWM 信号。这条流水线上任何一个环节的延迟或误判,都会在下一圈被放大。所以复现一套方案时,不要一上来就调 PID,先把每个模块的输入输出定义清楚。

2.2 硬件选型的最小可行配置:算力、传感器、执行器怎么配

一套能进国赛的配置,不需要堆最贵的硬件,但需要每个环节都不拖后腿。下面这张表是我在实际调试中总结的最小可行配置,以及每个部件的选型理由。

部件常见选型选型理由容易踩的坑
主控树莓派 4B 或 Jetson Nano能跑轻量神经网络,接口丰富树莓派 USB 供电不足会导致摄像头掉线
摄像头全局快门摄像头,分辨率 640×480全局快门避免运动模糊卷帘快门在高速下车道线会扭曲
舵机数字舵机,扭矩 15kg·cm 以上响应快,回中准模拟舵机在频繁打角后发热漂移
电机直流减速电机带编码器编码器能闭环测速不带编码器只能开环,速度控制玄学
电源2S 或 3S 锂电,配稳压模块主控和电机分开供电共地没做好会导致图像有横纹

这张表里最容易被忽视的是电源。很多队伍的车在静止时图像正常,一跑起来就出现横纹或摄像头掉线,根源就是电机启动瞬间把电压拉低,主控复位。常见做法是主控和电机分别用独立的稳压模块,并且两个模块的 GND 必须共地。这一点在项目说明里通常不会强调,但它是“能跑”和“稳定跑”之间的第一道分水岭。

2.3 软件栈的分层:感知、决策、控制各自负责什么

软件层面,我习惯把代码分成三个目录:perception、decision、control。每个目录下再按功能分子模块。这样做的好处是,当你发现车在某个弯道冲出赛道时,可以快速定位是感知层车道线拟合错了,还是决策层速度给高了,还是控制层参数不对。

感知层的输入是摄像头图像,输出是车道线方程、交通标志类别、障碍物位置。决策层的输入是感知结果和当前车速,输出是目标速度和目标转角。控制层的输入是目标值和编码器反馈,输出是 PWM 占空比。三层之间通过一个共享内存或消息队列传递数据,避免直接函数调用导致的耦合。

常见做法是用 ROS 做消息传递,但对于算力有限的平台,直接用一个全局结构体加时间戳也能跑。关键是每层的数据都要带时间戳,这样出现问题时可以回放日志,看是哪一层的延迟导致了控制滞后。这个习惯在后期调参时能省下大量“盲调”的时间。

3. 把源码跑起来:环境搭建、编译与第一圈手动推车验证

3.1 环境搭建:依赖安装与交叉编译的取舍

拿到源码包后,第一步不是急着编译,而是先看项目说明里的依赖列表。常见的依赖包括 OpenCV、NumPy、串口库(如pyserial或wiringPi),以及深度学习推理框架(如 NCNN 或 TensorRT)。如果主控是树莓派,建议直接在树莓派上编译,避免交叉编译带来的库版本不一致问题。

下面是一段在树莓派上安装基础依赖的脚本,我一般会把它保存成setup.sh,每次重装系统后直接跑一遍。

#!/bin/bash # 智能车主控基础环境安装脚本 # 适用:树莓派 4B,Ubuntu 20.04 或 Raspberry Pi OS sudo apt update sudo apt install -y python3-pip python3-opencv python3-numpy sudo apt install -y libopencv-dev cmake build-essential sudo apt install -y i2c-tools libi2c-dev # 串口库和图像处理常用库 pip3 install pyserial pip3 install imutils # 如果方案里用了 NCNN 推理,需要单独编译 # 这里只装依赖,不编译 NCNN 本身 sudo apt install -y libprotobuf-dev protobuf-compiler echo "基础依赖安装完成,接下来检查摄像头和串口权限"

这段脚本的逻辑说明:前两行更新软件源并安装 OpenCV 和 NumPy,这是图像处理的基础。libopencv-dev和cmake是为了编译 C++ 版本的感知代码。i2c-tools用于调试舵机驱动板。pyserial是 Python 串口通信库,用来和底层单片机通信。最后检查摄像头和串口权限,是因为树莓派的/dev/ttyUSB0默认需要dialout组权限,否则代码会报“Permission denied”。

参数说明:如果你的方案用的是 Jetson Nano,把python3-opencv换成 JetPack 自带的 OpenCV 即可,不要重复安装。如果用的是 NCNN,编译时注意开启-DCMAKE_TOOLCHAIN_FILE指定交叉编译工具链,但更推荐直接在目标板上编译。

3.2 编译与烧录:从源码到可执行文件的最小命令集

源码包通常包含一个CMakeLists.txt或Makefile。我一般先看有没有build目录,如果没有就手动创建。下面是一套通用的编译命令,适用于大多数 CMake 工程。

# 在项目根目录下执行 mkdir -p build cd build cmake .. make -j4 # 如果编译报错找不到 OpenCV,手动指定路径 # cmake -DOpenCV_DIR=/usr/lib/aarch64-linux-gnu/cmake/opencv4 ..

逻辑说明:mkdir -p build创建编译目录,避免污染源码目录。cmake ..生成 Makefile,make -j4用四核并行编译,加快速度。如果报错找不到 OpenCV,说明 CMake 没有自动找到 OpenCV 的配置文件,需要手动指定OpenCV_DIR。这个路径在不同系统上不一样,树莓派 4B 的 64 位系统通常在/usr/lib/aarch64-linux-gnu/cmake/opencv4。

编译完成后,可执行文件通常在build目录下。先不要急着上车,用./your_program --help看看有没有参数说明。很多方案会支持--video参数,用来读取本地视频文件而不是摄像头,这个功能在调试感知层时非常有用。

3.3 第一圈手动推车:用日志验证感知层是否正常

车能编译、能启动,不代表感知层是对的。我习惯在正式跑之前,先用手推着车走一圈,同时记录日志。具体做法是:把车放在赛道起点,打开程序,然后用手匀速推着车沿赛道走,观察终端输出的车道线参数和标志识别结果。

# 感知层日志输出示例(伪代码,实际方案里通常是 C++) import time def log_perception(frame_id, lane_params, sign_result): # 每 10 帧打印一次,避免刷屏 if frame_id % 10 == 0: timestamp = time.time() print(f"[{timestamp:.3f}] frame={frame_id} " f"lane_left={lane_params['left']:.2f} " f"lane_right={lane_params['right']:.2f} " f"sign={sign_result}")

逻辑说明:这段代码不是让你直接抄,而是说明日志应该包含哪些字段。frame_id用来对齐图像帧和日志,lane_params是左右车道线的拟合参数,sign_result是当前识别到的标志类别。时间戳用来计算感知延迟。手动推车时,如果日志里的车道线参数随着车的移动连续变化,说明感知层正常;如果参数跳变或不变,说明图像采集或拟合有问题。

参数说明:日志频率不要太高,每 10 帧一次足够。如果发现日志里sign_result一直是None,先检查摄像头曝光是否合适,再检查标志识别模型的输入尺寸是否和训练时一致。这个步骤能帮你排除掉大部分“代码没问题但车不动”的情况。

4. 感知层调参:车道线拟合与交通标志识别的四个关键参数

4.1 车道线拟合:为什么你的车在弯道会“画龙”

车道线拟合是感知层最核心的部分,也是“画龙”现象的根源。所谓画龙,就是车在直道上左右摆动,在弯道上冲出赛道。很多人第一反应是调 PID,但实际问题往往出在车道线拟合的参数上。

常见的车道线拟合方法有两种:一种是基于边缘检测加霍夫变换,另一种是基于颜色阈值加多项式拟合。前者对光照变化敏感,后者对赛道颜色敏感。我一般会先用颜色阈值做粗定位,再用多项式拟合做平滑。关键参数有三个:ROI 区域的高度比例、颜色阈值的上下界、拟合多项式的阶数。

ROI 区域的高度比例决定了你只看图像的下半部分还是包括中间部分。比例太小,弯道信息不足;比例太大,天空和远处干扰太多。我一般从 0.4 开始试,也就是只看图像下方 40% 的区域。颜色阈值需要根据赛道实际颜色调整,常见做法是在 HSV 空间里取白色或黑色的范围。多项式阶数一般用 2 阶,也就是抛物线,足够描述大多数弯道。

下面是一段车道线拟合的参数配置示例,用 YAML 格式保存,方便修改。

# lane_config.yaml roi: height_ratio: 0.4 # 只看图像下方 40% width_margin: 0.1 # 左右各留 10% 边距,避免边界干扰 color_threshold: h_min: 0 h_max: 180 s_min: 0 s_max: 60 v_min: 200 v_max: 255 poly_fit: order: 2 # 二次多项式 min_points: 50 # 至少 50 个像素点才拟合

逻辑说明:height_ratio控制 ROI 高度,width_margin避免图像边缘的畸变影响。颜色阈值在 HSV 空间里取低饱和度、高亮度的区域,对应白色赛道。poly_fit里的order是多项式阶数,min_points是拟合所需的最小像素点数,低于这个值就沿用上一帧的结果,避免噪声导致拟合跳变。

参数说明:如果你的赛道是黑色,把v_min调低、v_max调高,覆盖暗区域。如果弯道经常拟合失败,先把min_points降到 30,再检查 ROI 是否切掉了弯道内侧。这个配置文件的好处是不用重新编译,改完直接重启程序即可。

4.2 交通标志识别:轻量模型选型与推理加速

交通标志识别通常用轻量卷积神经网络,比如 MobileNet 或 SqueezeNet 的变体。选型的核心指标不是准确率,而是推理延迟。在树莓派 4B 上,一个 224×224 输入的 MobileNetV2 大约需要 30 到 50 毫秒,如果加上预处理和后处理,单帧延迟可能超过 80 毫秒。对于时速 1.5 米每秒的车,80 毫秒意味着车已经走了 12 厘米,这个延迟在红绿灯前停车时可能刚好压线。

我一般会做三件事来加速:第一,把输入分辨率降到 128×128,准确率掉 2 到 3 个百分点,但延迟减半;第二,用 NCNN 或 TensorRT 做推理加速,树莓派上 NCNN 比 OpenCV DNN 快 2 到 3 倍;第三,用跳帧策略,每 3 帧做一次标志识别,中间帧沿用上一次结果。

# 跳帧推理示例 import cv2 import numpy as np class SignDetector: def __init__(self, model_path, input_size=128, skip_frames=3): self.net = cv2.dnn.readNetFromONNX(model_path) self.input_size = input_size self.skip_frames = skip_frames self.frame_count = 0 self.last_result = None def detect(self, frame): self.frame_count += 1 # 跳帧:不是每帧都推理 if self.frame_count % self.skip_frames != 0: return self.last_result blob = cv2.dnn.blobFromImage( frame, 1/255.0, (self.input_size, self.input_size), swapRB=True, crop=False ) self.net.setInput(blob) output = self.net.forward() self.last_result = np.argmax(output) return self.last_result

逻辑说明:skip_frames=3表示每 3 帧推理一次,中间帧直接返回上一次结果。blobFromImage把图像缩放到 128×128,并归一化到 0 到 1。swapRB=True是因为 OpenCV 读进来是 BGR,而模型训练时通常用 RGB。np.argmax取最大概率的类别作为结果。

参数说明:input_size根据模型训练时的输入尺寸设置,不要随意改。skip_frames根据车速调整,车速快就设小一点,比如 2;车速慢可以设 4。如果发现标志识别在远处就触发,说明模型对背景过拟合,需要增加负样本重新训练。

4.3 感知层的时间同步:图像时间戳与决策层对齐

感知层的输出必须带时间戳,否则决策层拿到的是“过期”数据。我见过很多方案,图像采集线程和决策线程各自跑,中间用一个全局变量传数据,结果决策层用的可能是 100 毫秒前的图像。在直道上问题不大,在弯道上就是灾难。

常见做法是用一个环形缓冲区,感知层每处理完一帧就把结果和时间戳写进去,决策层从缓冲区里取最新的一帧,并检查时间戳是否超过阈值。如果超过 50 毫秒,就降速或停车。

import time from collections import deque class PerceptionBuffer: def __init__(self, maxlen=5, timeout_ms=50): self.buffer = deque(maxlen=maxlen) self.timeout_ms = timeout_ms def push(self, result): self.buffer.append((time.time(), result)) def get_latest(self): if not self.buffer: return None ts, result = self.buffer[-1] if (time.time() - ts) * 1000 > self.timeout_ms: return None # 数据过期 return result

逻辑说明:deque是一个固定长度的队列,maxlen=5表示最多存 5 帧。push写入时间戳和结果,get_latest取最新一帧并检查是否超时。如果超时,返回None,决策层收到None就应该进入安全模式,比如减速停车。

参数说明:timeout_ms根据系统延迟设置,树莓派上一般 50 到 80 毫秒。如果经常触发超时,先检查图像采集线程是否被阻塞,再检查 CPU 占用率是否过高。

5. 决策与控制:速度规划、舵机打角和串口通信的避坑清单

5.1 速度规划:不是越快越好,而是“该快的地方快”

速度规划的核心是根据赛道曲率动态调整目标速度。直道可以跑满,弯道必须降速。我一般用车道线的二次项系数来估计曲率,系数越大,曲率越大,目标速度越低。

def compute_target_speed(curvature, max_speed=1.5, min_speed=0.6): # curvature 是二次项系数,范围大约 0 到 0.01 # 曲率越大,速度越低 speed = max_speed - (max_speed - min_speed) * min(curvature / 0.01, 1.0) return max(speed, min_speed)

逻辑说明:curvature来自车道线拟合的二次项系数,归一化到 0 到 1 之间,然后线性映射到min_speed和max_speed。min和max保证速度不超出范围。

参数说明:max_speed和min_speed需要根据车模和赛道实测调整。我一般先用低速跑一圈,记录每个弯道的曲率,再反推合适的min_speed。不要一上来就设很高的max_speed,否则第一个弯道就会冲出赛道。

5.2 舵机打角:PD 控制比 PID 更常用,为什么

舵机控制通常用 PD 而不是 PID,因为积分项在频繁打角时容易累积,导致回中不准。P 项负责响应偏差,D 项负责抑制震荡。我一般先调 P,再调 D,比例大概是 1 比 0.1 到 0.3。

class SteeringController: def __init__(self, kp=0.8, kd=0.15, max_angle=30): self.kp = kp self.kd = kd self.max_angle = max_angle self.last_error = 0 def compute(self, error, dt=0.02): derivative = (error - self.last_error) / dt output = self.kp * error + self.kd * derivative self.last_error = error # 限幅 output = max(-self.max_angle, min(self.max_angle, output)) return output

逻辑说明:error是车道线中心与图像中心的偏差,dt是控制周期。derivative是误差变化率,用来抑制超调。max_angle限制舵机最大打角,保护舵机。

参数说明:kp从 0.5 开始试,如果车反应迟钝就加大,如果震荡就减小。kd从 0.1 开始,如果车在直道上左右摆动,加大kd。dt要和实际控制周期一致,不要用估计值。

5.3 串口通信:丢包、乱码和延迟的排查顺序

串口通信是底层和主控之间的桥梁,也是最容易出问题的地方。常见现象有三种:丢包、乱码、延迟。排查顺序是:先看波特率是否一致,再看数据帧格式是否匹配,最后看缓冲区是否溢出。

import serial import struct class SerialSender: def __init__(self, port='/dev/ttyUSB0', baudrate=115200): self.ser = serial.Serial(port, baudrate, timeout=0.01) def send(self, speed, angle): # 帧头 0xAA 0xBB,数据为两个 float,帧尾 0xCC 0xDD frame = struct.pack('<2sff2s', b'\xAA\xBB', speed, angle, b'\xCC\xDD') self.ser.write(frame)

逻辑说明:struct.pack把速度、角度打包成二进制帧,帧头帧尾用于接收端对齐。<表示小端模式,2s是两个字节的字符串,f是浮点数。

参数说明:波特率必须和底层单片机一致,常见 115200 或 9600。如果丢包,先降低波特率试试。如果乱码,检查struct格式是否和接收端一致。如果延迟大,把timeout设小,并减少发送频率。

5.4 避坑清单:五条血泪经验

现象一:车在直道上左右摆动。原因:舵机 D 项太小,或者车道线拟合跳变。解决:先加大kd,如果无效,检查车道线拟合的min_points是否太低。

现象二:红绿灯识别在远处就触发。原因:模型对背景过拟合,或者 ROI 区域太大。解决:增加负样本重新训练,或者缩小 ROI 只看图像中间部分。

现象三:车在弯道冲出赛道。原因:速度规划没有及时降速,或者舵机打角限幅太小。解决:检查曲率计算是否正确,把max_angle从 30 调到 35 试试。

现象四:串口通信偶尔丢包。原因:波特率太高或缓冲区溢出。解决:降到 9600,或者在发送前清空缓冲区。

现象五:摄像头跑一段时间后掉线。原因:USB 供电不足。解决:给摄像头单独供电,或者换一个带外部供电的 USB Hub。

6. 从国二到国一:用日志回放和参数扫描把调参变成可复现的工程

6.1 日志回放:把赛道“搬”到桌面上

调参最大的痛苦是每次都要上车跑,跑一圈几分钟,改一个参数又要重新跑。我后来养成了一个习惯:把所有感知和决策数据写成日志文件,然后在桌面上用脚本回放。这样改参数只需要重新跑回放脚本,几秒钟就能看到结果。

日志格式我一般用 CSV,每行包含时间戳、图像帧号、车道线参数、标志识别结果、目标速度、实际速度、舵机打角。回放脚本读取 CSV,模拟决策和控制逻辑,输出如果换成新参数,车会怎么走。

import pandas as pd def replay(csv_path, kp, kd): df = pd.read_csv(csv_path) last_error = 0 results = [] for _, row in df.iterrows(): error = row['lane_center'] - row['image_center'] derivative = error - last_error angle = kp * error + kd * derivative results.append(angle) last_error = error return results

逻辑说明:pd.read_csv读取日志,逐行模拟 PD 控制。lane_center和image_center来自日志,kp和kd是待扫描的参数。返回的results是每个时刻的舵机打角,可以用来画图对比。

参数说明:日志里的lane_center必须是感知层原始输出,不能是已经经过控制器的值。回放时只改控制参数,不改感知参数,这样才能分离问题。

6.2 参数扫描:用网格搜索代替“凭感觉调”

有了回放脚本,就可以做参数扫描。我一般用网格搜索,kp从 0.4 到 1.2,步长 0.1;kd从 0.05 到 0.3,步长 0.05。每个组合跑一遍回放,计算打角序列的方差和最大偏差,选方差小且最大偏差小的组合。

kpkd打角方差最大偏差评价
0.60.102.312.5偏软
0.80.151.89.2较优
1.00.202.110.8偏硬
1.20.253.515.3震荡

这张表是示意,实际数据要根据你的日志跑出来。选kp=0.8, kd=0.15这一组,然后上车实测,如果直道还有轻微摆动,再把kd加到 0.18。

6.3 一个具体技巧:用“影子模式”验证新参数

上车实测新参数时,不要直接替换旧参数。我一般用“影子模式”:旧参数控制车,新参数同时计算但不输出,把两者的打角值都记下来。跑完一圈后对比,如果新参数在关键弯道的打角更平滑,再切换。

# 影子模式示例 old_angle = old_controller.compute(error) new_angle = new_controller.compute(error) log.write(f"{time.time()},{old_angle},{new_angle}\n") # 实际输出 old_angle motor.set_angle(old_angle)

逻辑说明:old_controller和new_controller用不同的参数初始化,同时计算。日志里记录两个打角值,实际只输出旧值。跑完后用脚本对比两条曲线,如果新参数在弯道入口的打角提前量更合理,再切换。

这个习惯让我避免了很多次“改完参数车直接冲出赛道”的翻车。调参不是赌博,是可以用数据验证的工程。我现在的做法是,任何参数改动都先回放,再影子模式,最后才正式切换。希望帮到你。

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

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

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

立即咨询