简介:一份面向智能车竞赛与机器人操作系统(ROS)学习者的参赛源码包,来自第十八届全国大学生智能汽车竞赛讯飞组别,最终获得全国三等奖。包内共1065个文件,压缩包整体仅10.16MB,主要涵盖C++源码与头文件、Python辅助脚本、launch启动文件、yaml参数配置、rviz可视化文件以及msg/srv自定义通信接口,覆盖TF坐标变换、激光雷达驱动、串口通信等核心模块;同时辅以HTML、PNG、TXT等说明文档和项目图表,便于对照结构阅读。目前已有七百二十一人学习下载。整套代码可直接运行,目录分类清晰,既适合计算机、自动化、电子信息等专业的课程设计、期末大作业或毕业设计参考,也适合打算再次参赛的同学研究国三方案的整体架构、算法细节与调试思路,在熟悉ROS基础的前提下自行修改、调试和扩展功能。
1. 讯飞组别拿国三,源码和说明到底该沉淀什么
收到一个全国大学生智能汽车竞赛讯飞组别参赛源码+项目说明(国三).zip,第一反应不应该是“又一个比赛代码包”,而应该先想清楚:一个国三项目,真正的交付物到底是什么。讯飞组别和传统电磁、摄像头组最大的区别在于,它跑的是嵌入式 Linux 平台,视觉识别、状态决策、底盘控制各自独立,代码量和工作量都远超单片机工程。国三意味着整个系统能稳定跑完比赛流程,识别偶尔误判但可控,控制粗糙但有冗余,项目说明能让人复现但不一定顺畅。这篇博文就用这个标题,把五个问题讲透:平台特性、源码结构、视觉实现、控制链路、文档写法,读者是准备参赛的队伍,以及需要接手这类工程的嵌入式开发者。
2. 讯飞组别的平台选型与工程骨架:先弄清跑在什么上
2.1 开发板、摄像头和下位机:一条怎样的异构链路
讯飞组别的基本硬件组成是固定的:一块讯飞标配的嵌入式开发板跑 Linux,挂 USB 摄像头采集图像,板子通过串口连接一块单片机(最常见的是 STM32F4 或同类 MCU)作下位机,下位机再输出 PWM 给舵机和电机。这套异构链路决定了源码的组织方式——不可能像纯单片机项目那样把图像处理与控制写在一起,Linux 侧承担视觉和决策,MCU 侧承担执行。
选择这个平台的第一理由不是性能,而是开发效率。Linux 上可以直接用 OpenCV、摄像头驱动、Python 或 C++ 调试脚本,拿不到实时视频流的痛苦比单片机小一个量级。但代价是链路变长,帧率、串口波特率、MCU 中断响应都成了瓶颈。我一般会在动手写识别算法前,先打通三个最小验证:摄像头出图稳定在 25FPS、串口收发不丢字节、下位机能按协议驱动舵机。这一步没做,后面所有识别代码都只是在纸面上正确。
工程骨架要能支撑这两种角色协作,所以源码目录从一开始就不能按“文件类型”分,而要按“运行环境”分,这是讯飞组别和普通嵌入式代码库最大的差异点。
2.2 工程目录设计:源码、模型、脚本、文档各归其位
一个能传三代、能让下一届学弟三天入门的讯飞组别工程,目录至少要有下面这些模块。以下是我推荐的布局,来自竞赛源码打包的常见惯例:
| 目录/文件 | 作用 | 打包时是否保留 |
|---|---|---|
src/vision/ | 图像采集、预处理、锥桶与标志物识别 | 保留 |
src/decision/ | 赛道元素判定、状态机、速度决策 | 保留 |
src/serial/ | 串口组帧、解析、校验、日志上报 | 保留 |
src/mcu/ | 下位机源码,单独编译烧录 | 保留 |
scripts/calib/ | HSV 阈值标定、PID 调参脚本 | 保留 |
scripts/analyze/ | 日志回放、CSV 曲线绘制脚本 | 保留 |
models/ | 分类器或神经网络模型文件(如有) | 保留 |
config/ | 相机参数、HSV 阈值、PID 初值等配置文件 | 保留 |
docs/ | 项目说明、硬件接线表、串口协议文档 | 保留 |
build/ | 编译产物、中间文件 | 删除,勿打包 |
record/ | 比赛现场视频、日志 | 删除,体积大且无用 |
打包时只保留源码、配置和文档,build 目录和录像是典型垃圾,评审看的是代码结构和可复现能力,不是你的编译缓存。config/这个目录尤其容易被忽略,但 HSV 阈值、PID 初值、相机曝光这些参数全部外置到配置文件,是“源码可复现”的关键一步——没有它,换了机器就得改代码。
提示:如果你拿到一个 zip,先看根目录有没有 README,再看 config 目录是否独立,这两样缺一样,复现成本至少翻倍。
3. 视觉识别源码的核心设计:从帧率到元素判定的取舍
3.1 图像预处理:为什么坚持 640x480 而不是更高分辨率
讯飞组别识别对象是赛道上的锥桶和标志物,比赛场地光照可控,但赛道周围环境复杂度不可控。图像分辨率的选择直接影响帧率下限,1080p 在高分辨率下识别精度看起来更高,但嵌入式平台的编解码和颜色转换开销会让帧率掉到 15FPS 以下。我最终采用的是 640x480 输出,再经过一次缩放或直接按 ROI 裁剪,理由只有一个:识别结果需要实时性,而锥桶尺寸在画面里通常只占几十个像素,720p 带来的边缘锐度收益远小于帧率损失。
预处理阶段的固定化处理也是关键。自动曝光和自动白平衡在比赛现场是灾难,阳光从侧面打进来时颜色会整体漂移,HSV 阈值全部失效。代码里必须固定曝光时间和白平衡增益,或者至少在 OpenCV 初始化后显式关闭CAP_PROP_AUTO_EXPOSURE,转手动设定。这个操作写在源码里只有三行,但它决定了后续 HSV 阈值能稳定多久。
cv::VideoCapture cap(0); cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); cap.set(cv::CAP_PROP_AUTO_EXPOSURE, 0.25); // 关闭自动曝光 cap.set(cv::CAP_PROP_EXPOSURE, 120); // 手动曝光值,需现场标定 cv::Mat frame, hsv, mask; cap.read(frame); cv::cvtColor(frame, hsv, cv::COLOR_BGR2HSV); cv::inRange(hsv, cv::Scalar(low_h, low_s, low_v), cv::Scalar(high_h, high_s, high_v), mask); cv::morphologyEx(mask, mask, cv::MORPH_OPEN, cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3)));这里的逻辑:先用inRange按 HSV 范围做阈值分割,再用开运算(先腐蚀后膨胀)去掉孤立噪点。MORPH_OPEN的核大小决定去噪强度,核太大锥桶边缘会缩小,太小则噪点残留。曝光值 120 只是示例,实际要根据场地亮度从 50 到 200 之间二分试到锥桶边缘不溶、背景不飘为止。阈值low_*和high_*从配置文件读取,不要写死在代码里。
3.2 锥桶与标志物识别:颜色阈值、轮廓筛选与帧间连续
比赛赛道最常见的元素是锥桶,通常有红、蓝两种颜色,偶尔出现其他标志物。分割出 mask 之后,下一步是找轮廓并用几何属性过滤,我一般按面积、宽高比、圆度三个条件串行筛选:
std::vector<std::vector<cv::Point>> contours; cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); float min_area = 300.0f, max_area = 20000.0f; for (auto& c : contours) { float area = cv::contourArea(c); if (area < min_area || area > max_area) continue; cv::RotatedRect box = cv::minAreaRect(c); float ratio = box.size.width / std::max(box.size.height, 1e-5f); if (ratio < 0.3f || ratio > 1.8f) continue; double circleness = 4.0 * CV_PI * area / std::max(cv::arcLength(c, true) * cv::arcLength(c, true), 1e-5f); if (circleness < 0.5f) continue; // 通过筛选,记录类型、中心点、宽度 TrackedCone cone = { box.center, area, ratio }; push_to_track_list(cone); }面积下限 300 能把远处芝麻大的噪点直接排除,上限 20000 防止反光板或大面积背景误报;宽高比在 0.3 到 1.8 之间,能容忍锥桶因视角倾斜产生的形变,但能把横在赛道上的长条物体滤掉;圆度circleness越高越接近圆形,低于 0.5 的轮廓大概率不是锥桶而是胶带边或阴影。
筛选逻辑单独作文档意义不大,关键在后面:单帧识别结果不能直接拿来用。我会维护一个“连续 N 帧确认”的状态机——同一位置的锥桶至少被连续 5 帧检测到,才认为它是有效目标;连续消失超过 8 帧,再从跟踪列表里移除。这个设计能在误识别抖动时保持决策稳定,代价是引入约 200ms 延迟,对于最高 3m/s 的车速是完全可以接受的。
3.3 识别结果的输出协议:结构化数据比字符串可靠
识别结果要传给决策模块,跨模块传数据时,常见做法是定义一棵简单的结构体,避免用日志字符串或全局变量传递。全局变量在竞速场景里最危险,决策线程和识别线程如果交错读写,会出现“看起来识别到了但决策逻辑拿不到”的诡异 bug。
typedef struct { uint8_t elem_type; // 0x01 红锥桶, 0x02 蓝锥桶, 0x03 标志物 uint8_t confidence; // 0~100 int16_t position_x; // 图像坐标系下的 x int16_t position_y; // 图像坐标系下的 y uint16_t width_px; // 目标宽度 uint32_t timestamp_ms; // 识别时间戳 } VisionResult; typedef struct { VisionResult results[8]; uint8_t count; uint8_t frame_id; // 帧序号,用于丢帧检测 } VisionFrame;frame_id很容易被当成无关紧要的字段,实际上它是排查丢帧和时序错乱的核心线索。决策模块拿到VisionFrame后,检查frame_id是否连续,不连续说明处理链路有阻塞,必须降低识别耗时或增大队列。timestamp_ms在回放现场日志时能与下位机日志对齐,判断“识别到锥桶后 50ms 才发出转向指令,这个延迟到底耗在哪”。
4. 国三源码里的控制链路:参数怎么调、日志怎么看
4.1 串口协议与 PID 参数表
识别结果通过串口发往下位机,协议设计必须短、定长、带校验。我见过很多队伍用printf("cone:%d,%d")发字符串,下位机再用sscanf解析,调试时确实直观,但串口一旦繁忙就会出现黏包,解析错一帧后面全错。竞赛链路里最可靠的还是固定帧头 + 长度 + 数据 + 校验码:
// 上位机发送,C 侧伪代码 uint8_t tx_buf[16]; tx_buf[0] = 0xAA; // 帧头 tx_buf[1] = 0x55; // 帧头第二字节 tx_buf[2] = 5; // 数据长度 tx_buf[3] = elem_type; // 目标类型 tx_buf[4] = conf; // 置信度 tx_buf[5] = pos_x >> 8; // 高字节在前 tx_buf[6] = pos_x & 0xFF; tx_buf[7] = pos_y >> 8; tx_buf[8] = pos_y & 0xFF; uint8_t sum = 0; for (int i = 0; i < 9; i++) sum += tx_buf[i]; tx_buf[9] = sum; // 累加和校验 serial_write(tx_buf, 10);累加和校验比 CRC16 弱,但计算开销小、实现简单,在波特率 115200 的短帧场景下足够用。帧头用 0xAA 0x55 双字节是为了降低数据段里恰好出现 0xAA 导致的误判概率。
下位机收到数据后做 PID 输出,关键是把上层的“识别到目标”转成执行动作。讯飞组别通常有两套 PID:速度环和转向环。转向环的 P 值过大会左右甩头,过小会冲出赛道,速度环加急了会在弯道外侧推出赛道。下表是常见的初值范围和现场调法:
| 控制环 | P 初值 | I 初值 | D 初值 | 典型问题与调法 |
|---|---|---|---|---|
| 转向环 | 0.8~1.5 | 0 | 0.1~0.3 | P 太小切弯不够;D 太大会高频抖动 |
| 速度环 | 40~80 | 5~20 | 10~30 | I 用于消除稳速误差,D 大则起步猛冲 |
| 直道加速 | 30~50 | 2~8 | 5~15 | 响应要快,允许超调但不可震荡 |
| 入弯减速 | 20~40 | 5~10 | 15~25 | 入弯前 2 米就要给减速量,别等压线再动 |
PID 调参的顺序是先定转向后定速度,因为转向是安全边界。先把车固定在中速 1.5m/s,调转向环到入弯不抖、直道不偏;再接速度环,小步增加直道目标车速,每轮加 0.2m/s,看哪个弯道开始hold不住,对应回调转向 P 值或 D 值。这个过程必须记录,每次改参数后把时间戳、P/I/D 值、现象写进调参表,否则隔天就忘。
4.2 日志与回放:现场排错的第一手证据
比赛现场最怕“平时好好的,上去就翻车”。这种间歇性问题靠眼睛盯在线视频基本无解,因为现场你根本没时间盯屏幕。我给自己的要求是把所有关键节点做结构化日志,并落盘到 SD 卡或通过无线同步到调试机:
# scripts/analyze/log_parser.py 摘要 def parse_line(line): # 日志格式: [timestamp_ms] [level] [module] message ts, level, module, msg = line.split(" ", 3) return {"ts": int(ts), "level": level, "module": module, "msg": msg} def main(log_path): with open(log_path) as f: for line in f: if "DETECT" in line or "PID" in line: entry = parse_line(line.strip()) if entry["level"] in ("WARN", "ERROR"): print(f"{entry['ts']} {entry['module']} {entry['msg']}")日志里至少要有四类信息:识别到锥桶的类型与坐标、发送到下位机的原始帧、下位机回传的当前速度与舵机脉宽、以及每帧处理耗时。回放时把日志拉到电脑上,按时间戳对齐绘制曲线,就能看到“识别到了但控制没跟上”是发生在视觉侧还是串口侧。
提示:日志模块输出级别不要全局打
DEBUG,现场只留WARN和ERROR,否则串口带宽会被日志吃掉。
5. 项目说明文档的写法:提交前最后的 20 分
5.1 README 结构:环境、编译、运行、已知缺陷
竞赛评审时“项目说明”和源码并列,很多队伍代码写得不错,说明文档却只有一段“这是我们的项目”,评审看完根本不知道从哪下手。我写项目说明时固定五段结构,缺一不可:
# 讯飞组别参赛项目说明 ## 1. 环境依赖 - 开发板: 讯飞 i.MX6ULL 核心板(或同类平台) - 系统: Linux 4.14 / Ubuntu 18.04 - 依赖库: OpenCV 3.4.4, gcc 7.5, CMake 3.10 ## 2. 编译与烧录 `mkdir build && cd build && cmake .. && make -j4` 下位机程序见 src/mcu/,用 STM32CubeIDE 打开后烧录。 ## 3. 运行 连接摄像头与串口后,运行: `./bin/main --config config/vision.ini --port /dev/ttyS0` ## 4. 配置文件说明 config/vision.ini 中 hsv_low/hsv_high 为锥桶颜色阈值, pid_kp/pid_ki/pid_kd 为速度环参数,修改后重启生效。 ## 5. 已知问题与后续优化 - 逆光场景下红色锥桶识别率下降,需现场重新标定曝光 - 连续减速弯后速度环存在 0.3s 延迟,建议增加前馈项这段 README 最有价值的部分是“已知问题”,因为评审最想看到的是“作者知道自己的系统哪里会崩”,这比单纯的成功展示更可信。同时也是给下一届队伍踩坑指南——让他们知道这个代码不是完美的,进一步的方向在哪。
5.2 打包规范:zip 里的文件怎么摆
标题里的 zip 就是最后的交付物,里面文件怎么摆,反映了一个队的工程素养。我的打包规范是:根目录只放 README.md、LICENSE(尽量选宽松协议)、src/、config/、docs/,其余一律不打包。绝对不要放build/和record/,前者是编译垃圾,后者体积大且无意义。打包前做一次“从零复现”测试:在另一台没有配置过环境的电脑上,按 README 从克隆到跑通全流程,计时超过三小时就说明文档还欠火候。
提示:用
zip -r project.zip README.md src config docs -x "*/build/*"排除目录,比手动右键压缩可靠。
5.3 最后留给下一届的一个技巧
带一个scripts/calib/hsv_calib.py交互式标定脚本,打开摄像头画面后用滑条调 HSV 阈值,按空格保存到config/vision.ini。这个脚本只有几十行,调参效率是从“改代码重编译”的 10 分钟级别降到 30 秒级别,是源码之外最值得花时间的二十行代码。
本文还有配套的精品资源,点击获取