前言
本文主要介绍我们参加2026年全国大学生电子设计竞赛H题时的设计思路,包括赛题分析、系统总体方案、小车底盘、机械结构,以及钢珠视觉识别程序的具体实现。
这道题表面上看是一个钢珠平衡问题,但真正开始调试以后就会发现,最终效果并不只取决于平衡控制算法。
小车启动和制动是否平稳、摆杆有没有机械间隙、舵机连杆是否存在空程、摄像头曝光是否稳定,都会直接影响钢珠的位置控制精度。
因此,我们的整体思路并不是一上来就堆复杂算法,而是先让底盘、机械结构和视觉测量尽量稳定,再利用视觉反馈和闭环控制进一步提高系统精度。
图1 钢珠平衡小车整体实物图
一、赛题任务与核心难点
本题是在智能小车运动控制的基础上,增加钢珠位置识别与动态平衡任务。
系统不仅需要控制小车完成规定路线,还需要实时检测凹槽内钢珠的位置,并通过摆杆机构让钢珠移动到指定区域。
实际调试时,我们把整个问题分成了四个部分。
1.1 小车运行平稳性
小车启动、制动和转向时,如果左右轮速度变化过快,车身就会产生明显的加速度和振动,钢珠也会随之发生较大位移。
因此,小车控制不能只追求响应速度,还需要限制加速度和转向修正量,使整个运行过程尽量平滑。
1.2 机械结构一致性
摆杆、合页和舵机连杆需要尽量减小间隙,并保证左右两个方向的运动阻力基本一致。
如果一侧阻力大、另一侧阻力小,控制器就很难使用同一组参数获得对称的响应效果。
1.3 动态视觉识别
视觉系统不仅要在静止状态下识别钢珠,还要适应以下情况:
车体振动;
摆杆倾斜;
钢珠快速运动;
钢珠表面反光;
现场光照变化;
画面中的支架、端件和阴影干扰。
视觉部分很容易出现一个误区:静止时能够框到钢珠,就认为识别程序已经调好了。
真正运行起来以后,钢珠快速运动会产生拖影和反光;摆杆倾斜后,固定识别区域可能偏离凹槽;小车振动还会让摄像头画面产生轻微位移。
1.4 视觉数据稳定性
视觉程序不能把某一帧的误识别结果直接交给后续控制程序。
除了正确识别钢珠,还需要通过物理范围限制、上一帧位置约束和目标丢失判断,减少坐标跳变。
因此,我们最终采用的不是简单的“在整幅图中找圆”,而是一套结合摆杆结构、钢珠尺寸和运动范围设计的动态识别方案。
二、系统总体设计
2.1 系统总体框架
整个系统主要由以下四个部分组成:
小车运动控制模块;
摆杆机械执行模块;
钢珠视觉识别模块;
任务控制与数据通信模块。
Linux开发板通过摄像头获取摆杆和钢珠图像,运行C++和OpenCV视觉程序,完成图像解码、ROI裁剪、摆杆中心线跟踪、钢珠位置提取和像素坐标标定。
MSPM0G3519负责读取循迹传感器和编码器数据,同时完成小车运动控制及舵机控制。
整个系统的数据关系可以概括为:
摄像头采集图像 ↓ Linux开发板运行视觉程序 ↓ 计算钢珠实际位置 ↓ 任务模块或主控读取位置结果 ↓ 控制舵机调整摆杆角度图2 系统总体结构框图
2.2 小车底盘方案
常见的小车底盘主要包括三轮差速结构和四轮结构。
四轮底盘可以采用差速转向或舵机转向,而三轮底盘通常使用左右驱动轮完成差速转向,再通过万向轮提供支撑。
综合考虑转向灵活性、机械复杂度和低速运行稳定性,我们最终选择了三轮差速底盘。
三轮结构的转向阻力相对较小,对电机扭矩的要求不高,也比较适合本题低速、平稳运行的需求。
小车运动控制采用速度闭环和转向闭环相结合的方式。编码器负责反馈左右轮的实际速度,循迹传感器负责计算小车相对于赛道中心线的位置偏差。
左右电机的控制量可以表示为:
$$
PWM_L=PWM_{speed}+PWM_{turn}
$$
$$
PWM_R=PWM_{speed}-PWM_{turn}
$$
其中:
$PWM_{speed}$ 为速度环输出;
$PWM_{turn}$ 为转向修正量;
$PWM_L$ 和 $PWM_R$ 分别为左右电机的最终控制量。
通过两部分叠加,可以在维持运行速度基本稳定的同时完成方向调整。
这里需要注意,本题中的小车控制不能只追求“转得快”或者“响应灵敏”。小车动作越平滑,钢珠平衡部分的控制压力就越小。
2.3 机械结构方案
机械结构对钢珠平衡效果的影响非常明显。
如果摆杆存在较大间隙、转轴摩擦不均匀或者整体刚度不足,即使后续使用比较复杂的PID控制,也很难得到稳定效果。
根据题目要求,摆杆一端需要使用合页、链条或类似结构连接,另一端可以使用舵机或步进电机驱动。
考虑到步进电机的体积和重量相对较大,而舵机的速度、扭矩和控制精度已经能够满足需求,因此我们最终采用了“自制合页+舵机连杆”的机械结构。
机械部分调试时,建议优先检查下面几个问题:
合页或转轴能否顺畅转动;
摆杆向左右两侧运动时,阻力是否基本一致;
舵机摇臂和连杆之间是否存在明显空程;
连杆与摆杆的连接点是否会松动;
舵机动作时,摄像头支架是否会随之振动;
摆杆运动到极限位置时,是否仍处于摄像头画面内。
这些问题如果没有处理好,摄像头画面和摆杆中心位置都会发生变化。此时无论怎样修改视觉阈值,都很难获得真正稳定的识别结果。
图3 摆杆与舵机连杆机械结构
2.4 主要硬件组成
本系统使用的主要硬件如下:
| 硬件 | 主要作用 |
|---|---|
| MSPM0G3519主控板 | 小车运动控制、编码器采集和舵机控制 |
| Linux视觉开发板 | 运行C++与OpenCV视觉程序 |
| USB或CSI摄像头 | 采集摆杆及钢珠画面 |
| 直流减速电机 | 驱动小车运动 |
| 编码器 | 测量左右轮实际速度 |
| 电机驱动模块 | 驱动左右电机 |
| 循迹或灰度传感器 | 检测赛道位置偏差 |
| 舵机 | 调整摆杆角度 |
| 自制合页与连杆 | 构成摆杆执行机构 |
| 电池与电源模块 | 为主控、开发板和执行机构供电 |
主控最终选择MSPM0G3519,主要是因为它的片上资源可以满足双电机编码器采集、电机PWM控制、循迹传感器读取、舵机控制和通信等需求。
结合实际接口分配情况,MSPM0G3519比MSPM0G3507更加适合完成双编码器闭环控制。
三、视觉识别方案
3.1 方案选择
钢珠识别常见的方案主要有YOLO目标检测和OpenCV传统图像处理两种。
YOLO具有较强的环境适应能力,但需要提前采集、标注和训练数据,对视觉处理平台的算力也有一定要求。
本题中的钢珠始终位于一条已知摆杆附近,钢珠尺寸和活动范围相对固定。因此,我们最终选择了更加轻量的OpenCV灰度图像处理方案。
这套方案的核心思路可以概括为一句话:
先找到摆杆,再沿着摆杆寻找钢珠。
这样做主要有两个优势:
可以大幅缩小搜索范围,减少底盘、舵机、支架和背景对识别结果的影响;
当摆杆发生倾斜时,钢珠检测区域可以跟随摆杆移动,不需要依赖完全固定的矩形ROI。
3.2 视觉程序整体流程
这套视觉工程使用C++和OpenCV实现,与视觉识别直接相关的文件主要包括:
| 文件 | 主要作用 |
|---|---|
vision.cpp / vision.h | 摄像头初始化、MJPEG采集、灰度解码、ROI裁剪和曝光调整 |
ball_detect.cpp / ball_detect.h | 摆杆中心线跟踪、钢珠特征提取、目标筛选和位置标定 |
image_server.cpp / image_server.h | 发送调试图像和识别元数据 |
intrrupt.cpp | 启动视觉线程、注册回调和刷新调试画面 |
视觉程序的主要流程如下:
摄像头采集MJPEG图像 ↓ 解码为灰度图 ↓ 裁剪摆杆活动区域 ↓ 跟踪摆杆中心线 ↓ 沿中心线建立动态ROI ↓ 计算局部背景亮度 ↓ 提取钢珠暗度特征 ↓ 使用球径匹配滤波 ↓ 筛选候选目标 ↓ 计算钢珠亚像素中心 ↓ 转换为实际厘米位置为了避免文章被大量工程代码占满,下面只保留几段最能体现视觉思路的核心代码。边界检查、日志输出和调试统计等辅助部分不再全部展示。
四、视觉核心代码讲解
4.1 图像采集与ROI裁剪
摄像头以MJPEG格式输出图像。
程序获得JPEG数据以后,直接使用OpenCV将其解码为单通道灰度图。后续算法主要利用摆杆和钢珠之间的亮度差异,因此没有必要一直保留彩色图像。
核心代码如下:
const uint8_t *jpg = nullptr; size_t jlen = 0; uint64_t ts = 0; if (!cam.grab(&jpg, &jlen, &ts, 500)) { return; } cv::Mat raw( 1, (int)jlen, CV_8UC1, const_cast<uint8_t *>(jpg) ); detect_gray = cv::imdecode( raw, DETECT_IMREAD_FLAG ); int cy0 = DETECT_CROP_TOP / DETECT_REDUCE; int cy1 = detect_gray.rows - DETECT_CROP_BOTTOM / DETECT_REDUCE; int cx0 = DETECT_CROP_LEFT / DETECT_REDUCE; int cx1 = detect_gray.cols - DETECT_CROP_RIGHT / DETECT_REDUCE; cv::Mat strip = detect_gray( cv::Rect( cx0, cy0, cx1 - cx0, cy1 - cy0 ) );这里真正需要注意的不是cv::imdecode(),而是后面的ROI裁剪。
摄像头原始画面中通常会包含底盘、桌面、支架、铰链座和舵机连杆。这些物体有时比钢珠更黑,如果直接处理整幅图像,很容易形成错误目标。
因此,我们只保留摆杆可能出现的区域。
裁剪以后,一方面可以减少背景干扰,另一方面也能降低后续逐像素扫描的运算量。
图4 摄像头采集到的摆杆与钢珠原始画面
除了裁剪范围,曝光时间也非常重要。
如果曝光时间太长,画面确实会变亮,但快速运动的钢珠会出现明显拖影;如果曝光时间太短,图像噪声又会增加。
所以调试曝光时,不要只看画面够不够亮,还需要观察运动中的钢珠有没有被拉长,以及钢珠和管体之间是否仍然具有稳定的灰度差异。
4.2 摆杆中心线跟踪
如果使用完全固定的ROI,摆杆一旦发生倾斜,检测区域就可能偏离凹槽。
因此,程序会先对摆杆位置进行跟踪。
具体做法是逐列扫描图像,在每一列中寻找最长的一段连续亮像素,再把这段亮区域的中心作为当前列的摆杆中心点。
核心代码如下:
const int thr = (cmax * BALL_RUN_KEEP_Q8) >> 8; int bestLen = 0; int bestStart = 0; int curLen = 0; int curStart = 0; for (int y = y0; y <= y1; ++y, p += stride) { if (*p >= thr) { if (curLen == 0) { curStart = y; } if (++curLen > bestLen) { bestLen = curLen; bestStart = curStart; } } else { curLen = 0; } } s_trkY[n] = (float)bestStart + 0.5f * (float)(bestLen - 1);这里没有直接寻找“最亮的一个像素”,而是寻找“最长的一段亮像素”。
这是因为钢珠表面可能存在很亮的反光点。如果只寻找最大灰度值,钢珠上的高光就可能把摆杆中心位置带偏。
白色摆杆在每一列中通常会形成一段连续亮区域,因此使用最长亮段的中心会更加可靠。
获得多列中心点以后,程序使用二次曲线拟合摆杆中心线:
$$
y=At^2+Bt+C
$$
其中,$t$ 为归一化后的横坐标。
使用二次曲线而不是简单直线,主要是为了适应广角摄像头产生的桶形畸变。原本笔直的摆杆在画面中可能呈现出轻微弯曲,二次曲线能够更好地贴合实际图像。
拟合过程中还可以使用绝对偏差中位数,也就是MAD,剔除少量离群点,避免钢珠、支架和局部反光把整条中心线拉偏。
4.3 沿中心线建立动态ROI
得到摆杆中心线以后,程序不会继续处理整个裁剪区域,而是沿拟合曲线建立一条窄带,作为钢珠检测区域。
int roiH = (int)lroundf( s_band * BALL_ROI_FILL ) | 1; roiH = clampi( roiH, 3, BD_MAX_H );检测区域的高度根据摆杆亮带的实际厚度动态计算。
ROI并不是越大越好。
如果ROI太高,管道外沿、黑色底盘和刻度线都会被包含进来;如果ROI太低,又可能只能看到钢珠的一部分。
比较合适的范围是覆盖凹槽内部,同时尽量避开水管外沿和其他机械结构。
由于ROI沿着拟合后的中心线建立,所以无论摆杆处于水平状态还是倾斜状态,检测区域都可以跟着摆杆移动。
4.4 使用局部中位数提取钢珠特征
钢珠通常比白色凹槽更暗,但不能简单使用一个固定灰度阈值。
例如,在较亮环境中,白色管体灰度可能接近180,钢珠灰度可能为60;换到较暗环境后,管体可能下降到100,钢珠也可能下降到40。
如果一直使用固定阈值,阴影和管壁都有可能被错误识别成钢珠。
因此,程序会计算当前位置附近的局部中位数,并将它作为背景亮度参考。
钢珠的暗度特征可以表示为:
$$
f(x,k)=\max\left(0,\alpha m(x,k)-I(x,k)\right)
$$
其中:
$m(x,k)$ 表示当前位置附近的局部中位数;
$I(x,k)$ 表示当前像素灰度;
$\alpha$ 表示背景亮度比例参数。
对应的核心代码如下:
const int32_t d = ((med * BALL_DARK_Q8) >> 8) - (int32_t)s_row[i]; if (d > 0) { ++s_cnt[i]; s_feat[i] += (float)d; }这里使用中位数而不是均值,是一个比较关键的细节。
当窗口经过阴影边缘时,均值很容易落在明暗区域之间,导致阴影附近的一大片像素都被判断为暗目标。
中位数通常更接近明区或暗区中的一侧,不容易在阴影边缘形成假的钢珠特征。
另外,钢珠只占局部窗口中的一小部分,对中位数的影响相对较小。如果使用均值,钢珠自身反而可能把背景参考亮度拉低。
为了提高实时性,程序使用直方图维护滑动中位数。窗口每向右移动一列,只需要加入一个像素、移出一个像素,不需要重新对整个窗口排序。
4.5 球径匹配与候选目标筛选
钢珠表面经常会出现高光。
因此,钢珠在灰度图中的暗区不一定是一个完整的实心圆,有时会被中间的高光分成左右两部分。
如果程序只寻找最暗的一列,检测坐标就容易在钢珠左右两侧来回跳动。
我们的处理方法是使用一个接近钢珠直径的窗口,将窗口内的暗度特征累加起来。
匹配得分可以表示为:
$$
S(i)=
\sum_{j=i-D/2}^{i+D/2}
\sum_k f(j,k)
$$
其中,$D$ 表示钢珠直径对应的像素宽度。
这个操作可以理解成一个一维匹配滤波。
孤立噪点在球径窗口内无法积累出足够高的得分,而被高光分开的两部分暗区仍然会落在同一个窗口中,因此钢珠整体仍然能够形成比较明显的峰值。
程序还会结合以下条件筛选候选目标:
候选位置不能超出摆杆的实际量程;
暗像素填充率不能太低;
填充率不能接近整片全黑;
目标宽度需要与钢珠尺寸基本一致;
目标与周围背景之间需要具有足够的亮度差异;
连续跟踪时,优先检查上一帧位置附近的候选目标;
偏离上一帧过远的目标不能立即替代原目标。
这里还有一个比较实用的处理方式。
如果当前得分最高的目标不满足条件,程序只淘汰当前候选,然后继续检查其他目标,而不是直接把整帧判断为识别失败。
这样可以避免铰链、舵机端件或者阴影获得最高得分以后,让真正的钢珠失去被检查的机会。
图5 钢珠视觉识别与位置计算结果
4.6 亚像素定位与厘米标定
找到最佳候选区域以后,程序不会直接使用整数峰值位置,而是在峰值附近计算暗度加权质心。
核心代码如下:
const int lo = std::max(0, iPeak - D); const int hi = std::min(nAll - 1, iPeak + D); double sw = 0.0; double swx = 0.0; for (int i = lo; i <= hi; ++i) { sw += s_feat[i]; swx += (double)s_feat[i] * i; } const float xBall = (float)x0 + (float)(swx / sw);这里将质心计算限制在匹配峰值附近,而不是对整个ROI计算全局质心。
如果使用全局质心,摆杆另一端只要出现一个较暗的杂点,就可能把最终坐标拉偏。
限制计算范围以后,既可以获得浮点数形式的亚像素坐标,也能减少远处干扰。
得到钢珠像素坐标后,还需要将其转换成摆杆上的实际厘米位置。
程序首先将像素坐标归一化:
$$
u=\frac{x}{W}-u_0
$$
然后使用二次多项式完成标定:
$$
p=C_2u^2+C_1u+C_0
$$
对应代码如下:
static inline float uToCm(float u) { return BALL_PX2CM_C2 * u * u + BALL_PX2CM_C1 * u + BALL_PX2CM_C0; }这里保留二次项,是因为摄像头可能存在广角畸变和斜视问题。
画面左侧的1 cm和画面右侧的1 cm,不一定对应完全相同的像素数量。使用多点标定和二次拟合,可以减小这种非线性误差。
标定时可以按照下面的方法进行:
将钢珠依次放在摆杆上的多个已知刻度位置;
记录每个位置对应的像素坐标;
对像素坐标进行归一化;
使用最小二乘法拟合二次多项式;
使用没有参与拟合的位置检查实际误差。
需要特别注意,ROI裁剪范围和标定参数是相互关联的。
只要修改了左右裁剪范围、图像缩放比例或者摄像头安装位置,就应该重新检查像素—厘米标定参数。
否则可能出现一种比较隐蔽的问题:检测框看起来始终套在钢珠上,但输出的厘米位置存在整体偏移或比例误差。
4.7 时序约束与识别失败处理
动态画面中偶尔漏掉一帧并不可怕,真正危险的是把远处的伪影突然当成钢珠。
因此,程序会保存上一帧的钢珠位置。
正常跟踪时,程序优先在上一帧位置附近寻找候选目标;如果连续几帧没有找到可信目标,再逐渐扩大搜索范围。
连续丢失达到设定次数以后,程序会清除旧轨迹并重新进行全局搜索。
这种方式既可以减少坐标突然跳变,也能避免钢珠真正移动到其他位置以后,程序一直被旧坐标锁住。
这里还需要明确区分两个状态:
误差为0:表示钢珠已经到达目标位置;
识别失败:表示当前没有可靠的视觉测量结果。
两者在后续程序中不能采用相同的处理方式。
识别失败时,更合理的做法是设置独立的无效标志,并采用短时保持、安全回中或者暂时停止更新控制量等策略。
五、系统调试与日志分析
5.1 推荐调试顺序
视觉系统建议按照下面的顺序逐步调试:
固定摄像头和机械结构;
确认摆杆在极限角度时仍然位于裁剪区域内;
检查摆杆中心线能否稳定跟踪;
观察动态ROI是否能够跟随摆杆移动;
固定钢珠,调整局部暗度比例和候选门限;
排除端件、阴影和刻度线产生的假目标;
缓慢移动钢珠,检查输出坐标是否连续;
完成像素—厘米多点标定;
使用直尺复查没有参与拟合的位置;
让钢珠快速运动,调整曝光时间和识别门限;
最后完成视觉、舵机和小车运动的联合调试。
调试时不要只看检测框有没有套住钢珠,还应该同时记录以下数据:
ROI平均亮度;
管体参考亮度;
钢珠匹配峰值;
暗像素填充率;
连续丢失帧数;
单帧解码时间;
单帧扫描时间;
实际识别帧率。
只有知道某一帧为什么识别成功、为什么识别失败,参数调整才有依据。
5.2 日志曲线分析
为了观察钢珠在实际运行过程中的位置变化,我们记录了目标位置、钢珠实测位置、运动速度、PWM输出以及不同运行阶段的状态。
图6 调参阶段目标位置与钢珠实测位置对比
通过这张曲线,可以重点观察以下几个问题:
实测位置能否跟随目标位置变化;
钢珠到达目标附近时是否存在明显超调;
系统是否发生持续振荡;
最终位置能否进入允许误差范围;
不同运动阶段的响应速度是否一致。
需要说明的是,这组日志属于调参阶段记录,不能直接作为最终达标结果。
在该次测试结束时,目标位置约为 $-5.0\text{ cm}$,钢珠实测位置约为 $-3.45\text{ cm}$,仍然存在约 $1.55\text{ cm}$ 的误差,日志最终事件也标记为verification_fail。
不过,这张曲线依然具有较大的分析价值。
它说明视觉测量能够连续反映钢珠的位置变化,但最终控制效果还会受到舵机响应、机械摩擦、连杆间隙以及控制参数的影响。
换句话说,视觉曲线连续只能说明测量链路基本正常,并不代表整个系统已经完成调试。
5.3 常见问题总结
1. 修改裁剪范围后没有重新标定
这种情况下,检测框可能仍然能够准确跟随钢珠,但厘米坐标会出现整体偏移或比例误差。
2. 曝光时间只看画面亮度,不看运动拖影
画面虽然更亮,但快速运动的钢珠会被拉长,匹配滤波得分反而可能下降。
3. 使用完全固定的灰度阈值
现场光照发生变化后,管体和钢珠的灰度会一起变化,固定阈值很容易失效。
4. 只保留全局最强峰
铰链、舵机端件或阴影可能比钢珠更黑。正确做法是先判断候选目标是否符合钢珠特征,再比较候选得分。
5. 把识别失败当成误差为0
这会让系统误以为钢珠已经到达目标位置。识别失败必须保留独立的无效状态。
6. 只调整控制参数,不检查机械结构
如果摆杆摩擦不均匀或者连杆存在明显空程,控制器就需要不断补偿这些机械误差,最终效果很难稳定。
六、总结
本系统采用三轮差速底盘,以MSPM0G3519作为运动控制核心,机械部分采用合页、摆杆和舵机构成钢珠位置调整机构。
视觉程序运行在Linux开发板上,通过OpenCV完成图像采集、摆杆跟踪、钢珠检测和位置标定。
这套视觉算法最核心的地方,并不是使用了某一个特别复杂的图像处理算子,而是充分利用了题目本身的结构特点:
钢珠始终位于摆杆凹槽附近;
摆杆主体通常比周围背景更亮;
钢珠又比局部管体更暗;
钢珠直径和运动范围是已知的;
连续两帧之间的位置变化存在物理限制。
程序首先通过最长亮段和二次曲线拟合跟踪摆杆,然后沿摆杆中心线建立动态ROI。
接着利用局部中位数抵消光照变化,通过球径匹配滤波累积钢珠暗度特征,并结合物理量程、目标形态和时序约束排除干扰。
最后通过亚像素质心计算钢珠中心坐标,再利用二次多项式将像素位置转换为实际厘米位置。
相比固定阈值或者直接在整幅图中找圆,这种方法更加适合装车后的动态环境。
整个项目调试下来,我们最大的体会是:视觉、机械和控制并不是三个互相独立的部分。
视觉测量稳定以后,控制器才有可靠输入;机械结构足够顺畅以后,控制器才不需要不断补偿摩擦和间隙;小车运行足够平稳以后,钢珠平衡的难度也会明显降低。
只有将底盘、机械结构、视觉识别和控制系统放在一起联合调试,才能真正提高钢珠的位置控制精度和整个系统的环境适应能力。