智能车竞赛源码解析:从大津法到模糊PID的C++工程实践
简介基于C打造的贝壳23年智能车校内赛设计源码是一份面向智能车竞赛学习者与C开发者的完整项目参考。资源聚焦自主导航与决策中的关键难题涵盖图像预处理、赛道识别、速度规划及PID控制等模块并保留从v0.0到v1.0的多个迭代版本便于读者对照版本演化理解竞赛代码的调试思路。包体共45个文件以29个C源文件和6个头文件为核心辅以C源文件、Markdown文档、授权文件等整体大小约332KB目录按“分站赛”与功能程序集划分结构清晰。其中图像算法部分引入大津法、局部大津法及八邻域处理控制部分提供模糊PID与经典PID的多种实现并配套说明文档梳理图像处理流程与参数演进适合希望系统学习智能车项目架构、算法落地与版本管理的学生和参赛队借鉴。目前已有92人学习对于想要快速上手竞赛代码或深入实践C工程化的人来说是一份高性价比的参考资料。1. 从贝壳23年校内赛源码包看智能车竞赛的C代码该写多“重”拿到 upload.zip 的第一感觉是这不是课程设计而是一个被分站赛反复锤炼过的真实工程。36 个文件里26 个 C 源文件、5 个头文件、2 个 C 源文件名字从 OSTU大津法、八邻域3 到 模糊PIDVersion2再到 第一次分站赛、第四次分站赛 这样的目录透露出一个关键信息智能车竞赛的源码不是写出来一次就行而是每一轮场地都在改算法、换策略。对正在备战智能车竞赛或者想在 C 工程里看嵌入式图像处理与控制代码的同学这份源码的价值不在某一段代码有多漂亮而在于你能看到一个队伍如何在有限时间内把图像采集、赛道搜索、速度规划、PID 输出捏合成一条流水线。适合带着“边界提取为什么用八邻域”“模糊PID到底有没有必要”这类问题去读比单纯抄代码有意义得多。2. 图像处理源码拆解大津法、局部阈值与八邻域搜索2.1 为什么阈值分割是智能车摄像头处理的第一步智能车摄像头赛道识别的第一件事是把灰度图压缩成“白区域/黑区域”的二值图。贝壳校内赛源码里同时出现了OSTU大津法.cpp和局部大津法.cpp说明作者在全局阈值和局部阈值之间都做过实验。大津法的核心逻辑是遍历 0 到 255 的每个灰度计算把图像分成前景和背景两类时的类间方差方差最大的那个灰度值就是最佳阈值。它的好处是不需要人工标定阈值坏处是一旦赛道路面出现反光、阴影或者全场光照变化全局单阈值容易把整幅图分割碎掉。下面是典型的 C 大津法实现注意代码里用long long累计像素值避免 MCU 上整型溢出// OSTU大津法.cpp 核心逻辑 int otsuThreshold(const unsigned char* gray, int width, int height) { int hist[256] {0}; for (int i 0; i width * height; i) { hist[gray[i]]; } int total width * height; long long sumAll 0; for (int i 0; i 256; i) { sumAll static_castlong long(i) * hist[i]; } long long sumFg 0; int fgCount 0; double maxVariance -1.0; int bestThreshold 0; for (int t 0; t 256; t) { fgCount hist[t]; if (fgCount 0) continue; int bgCount total - fgCount; if (bgCount 0) break; sumFg static_castlong long(t) * hist[t]; double fgMean static_castdouble(sumFg) / fgCount; double bgMean static_castdouble(sumAll - sumFg) / bgCount; double variance static_castdouble(fgCount) * bgCount * (fgMean - bgMean) * (fgMean - bgMean); if (variance maxVariance) { maxVariance variance; bestThreshold t; } } return bestThreshold; }这段代码就是大津法的标准实现直方图统计在后灰度阈值遍历在前。fgCount * bgCount是类间方差的类别权重类别越均衡、均值差越大方差就越大最终选出的阈值越接近两类灰度交界。需要注意的是如果摄像头画面里赛道占比很小大津法可能会把赛道当成背景所以竞赛代码往往会在统计前先对图像做一次 ROI 裁剪只保留路面前方区域。2.2 全局大津法和局部大津法的选择阈值方式计算量抗干扰能力典型问题全局大津法低一次直方图遍历光照突变时容易整体失效反光区域与背景同灰度局部大津法高每个分块各算一次能保留局部对比度分块边界不连续产生块状伪影固定阈值最低依赖场地固定每次换场地都要重新标定从文件名看作者保留了两套阈值实现这印证了智能车算法里“没有放之四海皆准的阈值方案”。局部大津法的常见做法是把一帧灰度图分成 8x6 或 16x12 的格子每格独立计算阈值最后拼接二值图。代价是每个格子都要执行一次直方图统计对于 OV7725 这类摄像头输出的 320x240 灰度图16x12 分块的开销还能接受如果分辨率再高就需要隔行采样来换取处理速度。2.3 八邻域搜索把二值图里的白色赛道边界“串”起来二值图画好之后赛道边界是一堆散点需要按行或按边界顺序连成线。源码里有八邻域.cpp、八邻域2.cpp、八邻域3.cpp和八邻域3.h这是典型的边界跟踪算法。八邻域搜索的核心是从一个已知边界点出发沿当前方向周围找下一个白色像素如果 8 个位置里只有一个符合条件就继续推进。下面是一个简化的八邻域搜索函数方向数组dx/dy按 0 到 7 表示右、右上、上、左上、左、左下、下、右下// 八邻域2.cpp 简化版按上次方向优先搜索下一边界点 const int dx[8] {1, 1, 0, -1, -1, -1, 0, 1}; const int dy[8] {0, -1, -1, -1, 0, 1, 1, 1}; int nextBoundary(const unsigned char* binary, int width, int height, int x, int y, int lastDir) { // 从 lastDir 的斜后方开始优先保持原方向 int start (lastDir 3) 7; for (int i 0; i 8; i) { int dir (start i) 7; int nx x dx[dir]; int ny y dy[dir]; if (nx 0 nx width ny 0 ny height) { if (binary[ny * width nx] 255) { return dir; } } } return -1; // 丢线 }代码里start (lastDir 3) 7表示从上次方向的顺时针 135 度开始搜索防止搜索跑回头路。实际项目里这三个八邻域版本的区别主要在于断线重连策略有的版本遇到nextBoundary返回 -1 就直接放弃边界有的版本会跳到下一行重新寻找起始点。智能车赛场上最常见的失败不是阈值选错而是边界跟踪中途丢线后中线提取把左右两条边界插值到一起导致转向误判。2.4 从 search.h 到 SearchTwice.cpp行扫描与中线提取的接口search.h、search.cpp、SearchTwice.cpp、SearchVerionTwoPointZero.cpp这些文件组成了搜索模块。竞赛级代码不需要复杂的查找表通常会固定扫描若干行比如每隔 4 行扫一次对每个扫描行找左边界和右边界取中点作为该行的赛道中心线。这里给出一个可复用的接口// search.h 简化的搜索模块接口 class LineSearch { public: void init(int width, int height) { imageWidth width; imageHeight height; } // 输入二值图输出每行的左右边界列坐标 bool findEdges(const unsigned char* binary, int left[], int right[], int step 2) { bool found false; for (int row imageHeight - 1; row 0; row - step) { int l -1, r -1; for (int col 0; col imageWidth; col) { if (binary[row * imageWidth col] 255) { l col; break; } } for (int col imageWidth - 1; col 0; --col) { if (binary[row * imageWidth col] 255) { r col; break; } } left[row] l; right[row] r; if (l 0 r 0) found true; } return found; } private: int imageWidth; int imageHeight; };这段代码把“从左右两侧找第一个白色像素”封装成类left[]和right[]数组作为输出缓冲区供上层控制模块直接读取。step 2表示隔行扫描计算量直接减半。实际竞赛里SearchTwice.cpp的“Twice”通常意味着做两遍搜索第一遍用大步长找大致区域第二遍在小范围内精细搜索避免弯道过急时漏掉边界。3. 控制链路拆解从位置式PID到模糊PID查询表3.1 PIDVersion系列为什么先写位置式再写增量式源码里的控制文件很多PIDVersionZeroPointOne.cpp、PIDVersionOnePointZero.cpp、PID2.cpp、PIDNo3FinalDocument.cpp说明控制器的迭代比图像模块更频繁。智能车转向环最常用的就是位置式 PID直接对“目标偏差 - 当前偏差”做比例、积分、微分运算。// PIDVersionOnePointZero.cpp 位置式PID float pidCalc(float target, float current, float kp, float ki, float kd) { static float integral 0.0f; static float lastError 0.0f; float error target - current; integral integral error; // 积分限幅防止长时间丢线导致积分饱和 if (integral 50.0f) integral 50.0f; if (integral -50.0f) integral -50.0f; float diff error - lastError; lastError error; return kp * error ki * integral kd * diff; }这段代码里kp越大转向越激进kd会对误差变化率提前刹车减少过弯时的来回摆动。积分限幅很重要智能车过急弯时会有连续几十毫秒的大偏差不限幅的话积分项会冲得很高出弯后车子还会继续往反方向打。文件名里从v0.1到No3FinalDocument说明作者最后把积分项和微分项都改成了可变参数这是竞赛代码里常见的做法——同一套 PID 代码不同赛场只换参数表。3.2 位置式、增量式与模糊PID的适用边界控制方式输出内容对执行器的要求典型问题位置式PID直接输出占空比/角度简单直接赋值积分饱和增量式PID输出本次变化量需要执行器带锁存积累误差需要额外处理模糊PID在线修整PID系数需要较快的控制周期查询表标定工作量大传统 PID 把三个系数固定后弯道与直道的响应特性会互相妥协。模糊PID 根据当前误差e和误差变化率ec实时修正kp/ki/kd源码里的模糊PIDVersion2.cpp就是干这件事的。它的典型做法是先把e和ec量化到负大到正大的模糊区间再查一个 7x7 的修正表。// 模糊PIDVersion2.cpp 片段7x7模糊查询表修正kp const float kpDelta[7][7] { // ec: NB NM NS ZO PS PM PB // e: NB {1.2f, 0.9f, 0.6f, -0.1f, -0.3f, -0.5f, -0.7f}, // e: NM {0.8f, 0.6f, 0.3f, -0.2f, -0.4f, -0.6f, -0.8f}, // e: NS {0.5f, 0.3f, 0.1f, -0.1f, -0.3f, -0.5f, -0.6f}, // e: ZO {0.2f, 0.1f, 0.0f, -0.1f, -0.2f, -0.3f, -0.4f}, // e: PS {0.0f, -0.1f, -0.2f, -0.3f, -0.4f, -0.5f, -0.6f}, // e: PM {-0.2f, -0.3f, -0.4f, -0.5f, -0.6f, -0.7f, -0.8f}, // e: PB {-0.4f, -0.5f, -0.6f, -0.7f, -0.8f, -0.9f, -1.0f} }; float fuzzyDeltaKp(float e, float ec) { int i quantize(e); // 把误差映射到 -3..3 int j quantize(ec); // 把误差变化率映射到 -3..3 return kpDelta[i 3][j 3]; }这个kpDelta表是示例数据实际比赛时作者会根据场地把每一项按 0.1 的步进标定。使用模糊 PID 的最大收益不是响应更快而是省去在调试器前反复试参数的几个小时。不过要注意模糊表一旦定死对“没见过的路况”反而更保守所以源码里又单独保留了PIDNo3FinalDocument.cpp说明决赛阶段可能又切回了手工调参的位置式 PID。控制器没有绝对优劣只有与当前赛道模型是否匹配。3.3 速度规划v1.0直道加速与弯道减速的逻辑竞赛代码里速度控制与转向控制通常分开。速度规划v1.0这个版本号很直白它根据中线曲率或左右边界间距来限制目标速度。下面是一个简单的速度规划函数// 速度规划v1.0 弯道减速逻辑 int calTargetSpeed(float boundaryDiff, int maxSpeed) { // boundaryDiff 为当前行左右边界间距直道时接近图像宽度 float width boundaryDiff; float speedPercent (width - 30.0f) / 100.0f; // 归一化 if (speedPercent 1.0f) speedPercent 1.0f; if (speedPercent 0.3f) speedPercent 0.3f; return (int)(maxSpeed * speedPercent); }这里的boundaryDiff来自搜索模块输出的左右边界列坐标差值差值越小说明前方越接近急弯目标速度越低。实际的赛道可能没有这么平滑更稳的做法是把最近 5 行的宽度求平均再加一个斜率限制防止速度在直道大油门和弯道重刹之间生硬跳变。这个函数与control.cpp配合将目标速度转换成电机 PWM。注意不要在控制周期里穿插人机交互一行printf就可能让图像处理循环抖动几十毫秒。4. 分站赛版本的模块化演进从 search.c 到 control.cpp4.1 目录快照版本管理在竞赛场景里的真实形态源码里按“第一次分站赛”“第二次分站赛”“第三次分站赛”“第四次分站赛”组织目录这种以“赛场快照”为粒度的版本管理比 git 分支更直观。第一次分站赛里只有Threshold.cpp和Search.cpp说明当时重点在图像处理第二次分站赛出现了search.c和control.c这是 C 语言风格代码可能是在 MCU 工程里直接用 C 写第三次分站赛变成search.h和control.cppC 接口开始成型第四次分站赛把 PID 和速度规划独立成单独文件。一个典型的目录树如下upload.zip ├── 第一次分站赛 │ ├── Threshold.cpp │ └── Search.cpp ├── 第二次分站赛 │ ├── search.c │ └── control.c ├── 第三次分站赛 │ ├── search.h │ └── control.cpp ├── 第四次分站赛 │ ├── PIDVersionOnePointZero.cpp │ └── 速度规划v1.0 └── 图像算法 ├── OSTU大津法.cpp ├── 局部大津法.cpp └── 八邻域3.h这段目录结构是根据压缩包文件名还原的可能与原始工程顶层目录略有出入但可以看出作者习惯把每个分站赛的比赛版本单独沉淀。竞赛代码往往没有完整的 release 分支这种快照方式至少能让你在当天比赛结束后回滚到上一个能跑的版本。很多队伍失败在“新代码调试到一半旧代码已经被覆盖了”快照目录是对抗这种风险的最低成本方案。需要注意的是C 与 C 文件混用意味着编译器需要按 C 标准统一编译C 头文件要加extern C保护否则链接阶段会出现符号找不到的问题。4.2 重复文件不是坏味道是快速原型开发的副产品search2.cpp、search2_0.cpp、search3.h、search3.cpp、SearchVerionTwoPointZero.cpp、SearchTwice.cpp这些名称很相似的文件如果放在商业项目里会立刻被 code review 打回。但在智能车竞赛里它们是边比赛边实验的真实痕迹。每个文件代表一种搜索策略SearchTwice.cpp可能代表两次扫线SearchVerionTwoPointZero.cpp可能代表对拐点的特判。这种文件组织方式最大的问题是调用关系不清晰。如果你要快速读懂这个源码包我一般会先看带Final或最大版本号的文件比如PIDNo3FinalDocument.cpp、SearchVerionTwoPointZero.cpp再回头对比早期版本找出新增了哪些边界条件。例如下面这种在多个search版本里反复出现的双保险写法// SearchVerionTwoPointZero.cpp 中常见的双保险 void processRow(const unsigned char* bin, int row, int left, int right) { left -1; right -1; for (int col 0; col IMG_W; col) { if (bin[row * IMG_W col] 128) { if (left 0) left col; right col; } } // 丢线策略当一侧丢失时沿用上一行边界并外推 if (left 0 lastLeft[row] 0) { left lastLeft[row] - 5; // 向左外推 } if (right 0 lastRight[row] 0) { right lastRight[row] 5; // 向右外推 } }这个函数里lastLeft/lastRight保存上一帧边界丢线时向丢线侧外推让车辆在短暂丢失赛道时保持上一帧的转向状态而不是直接冲出赛道。这种外推只能持续有限帧数如果连续两帧都找不到边界还是要把车降速并恢复默认方向。作者保存了多个search版本实际就是在调整外推系数和连续丢线的帧数阈值。4.3 readme.txt 和 LICENSE竞赛代码里被低估的交付物压缩包里还有readme.txt和支持文件LICENSE 是授权文件。竞赛代码一般不强制开源许可但它体现了分享者对代码使用边界的尊重。对你来说拿到源码包先读 readme 和 LICENSE可以快速知道这些分站赛代码对应哪个硬件平台、哪些引脚定义避免把control.cpp里的 PWM 引脚直接抄到自己的板子上。5. 移植到自建车模前要替换的四个关键参数5.1 图像分辨率与 ROI 裁剪区域原代码是为 320x240 灰度图写的你在自己的摄像头车模上先确认二值化和八邻域搜索的循环范围。如果分辨率上升到 640x480大津法的直方图计算量会变成四倍建议把 ROI 砍到图像下半部分比如只处理height * 3 / 4之后的行只保留车头前方赛道。5.2 扫描步长与八邻域起始方向findEdges里的step参数需要根据摄像头安装高度调整。摄像头装得高、视野远可以用step 4装得低step 2才能保证弯道不漏线。八邻域的lastDir初始值建议设为 6即左下方向因为赛道边界在图像里大部分是斜向下方延伸的。5.3 PID 参数和模糊表PIDVersionOnePointZero.cpp里的kp/ki/kd是作者对场地调出来的直接套到你的车模上大概率会震荡。先把积分和微分项清零只调kp让车辆能走直线然后逐步增加kd抑制摆动。模糊表里的修正量也按同样顺序验证。5.4 速度规划中的边界间距基准值速度规划里的boundaryDiff阈值取决于摄像头视场角。120 度广角镜头直道时的左右边界间距会接近图像宽度60 度窄视角直道间距可能只有图像宽度的 60%必须重新归一化。移植后可以用下面的打印代码快速验证// 串口打印某一行左右边界用于验证移植 printf(row%d left%d right%d diff%d\n, row, left[row], right[row], right[row] - left[row]);在 MCU 上串口打印频率不要超过 10 Hz否则主循环会被阻塞。把大津法的直方图统计改成隔行采样能节省你每次上电调参的近半秒等待时间。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →