C++ 方案搞定 2026 年电赛 H 题视觉与图传

一、问题描述与探讨
在 2026 年全国大学生电子设计竞赛 H 题 —— 车载平衡滚球运动控制系统中,要求在一根水平放置的槽中放置小钢球,通过传动装置控制槽的水平使得小钢球在小车行使的过程中保持相对位置的平衡,同时还要将车辆运行时的图像实时传输到一个接收端中。从中我们可以提取出两个跟视觉检测相关的子命题:
| 命题 | 核心问题 | 评价维度 |
|---|---|---|
| 视觉 | 如何从摄像头画面中找到小球,输出可靠的坐标? | 精度、实时性 |
| 图传 | 如何把带标注的实时画面传给远处的调试人员? | 延迟、兼容性 |
两个问题有个共同的特点就是需要获取图像输入数据,因此,我决定将这两个问题合并起来,设计一个统一的架构,并使用现有的 Raspberry Pi 4b 来实现这个系统。
二、整体架构设计
2.1 技术栈的选取
最开始,我曾尝试使用 Python 来实现这套系统,但在实际实现过程中,发现树莓派 4b 的性能实在有限,使用 Python 会遇到性能瓶颈,难以满足视觉检测的实时性要求(也有可能是我的 Python 功底不足写的太烂)。因此,我决定使用 C++ 来实现这套系统。
在 C++ 中,获取图像的工作毫无疑问是使用 OpenCV 来实现的,对于传输与通信,我选择使用 Qt Framework ,它不仅提供了方便的的前端界面设计框架,还可以很方便的使用 QtNetwork 以及 QSerialPort 来实现网络图传和串口通信。
2.2 系统架构的设计
我将整体任务切分为两条独立线程:
主线程 (Qt Event Loop)├── UI 渲染:30fps QTimer 定时刷新 QLabel├── HTTP 服务:QTcpServer 事件驱动,接受浏览器连接└── MJPEG 推送:20ms QTimer 向所有客户端推送最新帧
采集线程 (QThread)├── 摄像头读取:OpenCV VideoCapture├── 小球检测:灰度波峰 + 加权质心├── 卡尔曼滤波:一维匀速模型└── 串口通信:向主控发送检测结果两条线程分工明确,主线程主要负责显示相关的任务,包括 UI 渲染、HTTP 服务、MJPEG 推送等任务;采集线程主要负责采集与检测相关的任务,包括摄像头读取、小球位置检测、卡尔曼滤波、串口通信等任务。
为什么要使用两条线程?
- 视频采集是一个持续性的 IO 密集任务,每帧需要完成:摄像头读取 → 图像处理 → JPEG 编码 → 串口收发。如果全部放在 Qt 主线程,任何一步的阻塞都可能会导致界面卡死。
- 树莓派 4b 与 MSPM0 这种单核的 MCU 不同,它拥有 4 核心的处理器,具有并行处理的能力,从采集效率上看,分线程可以利用多核处理器的优势,分离采集与显示的任务,提高采集效率。
- 主控需要尽可能地实时获取小球的位置信息,以控制槽的水平保持小球在槽中平衡。因此,采集线程需要及时将检测结果发送给主控,也就是需要一个较高的采集帧率,而受限于网络带宽/屏幕刷新率,图传任务无法负载采集线程的输出。将二者进程分离,采集检测与图传显示可以运行在不同的刷新率中,从而使得整套系统流畅运行。
2.2 线程间的数据共享
在主线程中,图像的显示与 MJPEG 推送都需要从采集线程中获取图像数据,而由于两线程所需刷新率的不同,主线程没必要获取采集线程的每一帧图像,因此,我使用了“缓存最新帧”的方式来实现数据共享。同时使用 Qt 的 QMutex 来加锁保护数据的线程安全。
QMutex m_frameMutex;QByteArray m_latestJpeg;
// 采集线程:写入最新帧(captureLoop 中调用)void CameraWorker::captureLoop(int cameraIndex) { cv::Mat frame; while (m_running.load()) { cap >> frame; // ... 检测 + 标注 ... std::vector<uchar> encoded; // OpenCV 编码为 JPEG 格式 cv::imencode(".jpg", frame, encoded, {cv::IMWRITE_JPEG_QUALITY, 40}); // 将标准库的 vector 转换为 QByteArray // 此处加上花括号以规定锁的持有范围 { QMutexLocker locker(&m_frameMutex); m_latestJpeg = QByteArray( reinterpret_cast<const char*>(encoded.data()), static_cast<qsizetype>(encoded.size())); } // 其余代码 }}
// 主线程:读取最新帧(UI 刷新 + MJPEG 推送都调用)QByteArray CameraWorker::getLatestFrame() { QMutexLocker locker(&m_frameMutex); return m_latestJpeg;}2.3 摄像头参数对检测的影响
一个容易被忽略但决定性影响检测效果的因素是摄像头的参数选择选择。我所使用的 USB 摄像头支持 YUYV 和 MJPEG 两种像素格式,但默认是 YUYV。
| 格式 | 原理 | 640×480 下的帧率 |
|---|---|---|
| YUYV | 未压缩的 YUV 4:2<2>2>,每像素 2 字节,带宽 ~7.7 MB/帧 | 5–10 fps |
| MJPEG | 帧内 JPEG 压缩,带宽 ~15–30 KB/帧 | 30–60 fps |
在实际使用中,我们发现如果使用默认的摄像头参数,帧率会过低,导致检测结果的实时性下降。因此需要在采集线程中显式指定摄像头参数,以获得较高的帧率。
在我们的实际使用中,以下参数选择使得检测帧率达到了 60 fps 的硬件上限
void CameraWorker::captureLoop(int cameraIndex) { cv::VideoCapture cap(cameraIndex, cv::CAP_V4L2); if (!cap.isOpened()) { std::cerr << "[CameraWorker] 无法打开摄像头" << std::endl; m_running.store(false); return; }
// 强制 MJPEG 格式 cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G')); cap.set(cv::CAP_PROP_FRAME_WIDTH, 640); // 640 cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480); // 480 cap.set(cv::CAP_PROP_FPS, 60);
// 后续的采集/检测/滤波/串口通信代码
cap.release();}摄像头的参数选择要根据自己的实际硬件进行调整,以获得最佳检测效果。
三、基于灰度峰值法的视觉检测
视觉检测小球位置是整个系统的核心。在 640×480 的画面中找到一个小球的位置,看似是图像识别问题,很多人第一时间会想到用Canny + 霍夫圆检测 或者 Yolo 深度学习这类方法。然而,在实际使用中,我发现由于小钢球球面的反光以及场景光源的变化,Canny 边缘检测方法在小钢球上效果一般,而 Yolo 需要较高性能,放在我们组现有的树莓派/k230设备上帧率较低。
在认真研究题目后我发现,H 题的检测任务场景提供了有效的约束:
- 小球在纯色的 PPR 管凹槽中, 管内壁是白色的
- 小钢球是暗色的
- 小球只在管内做直线运动
- 摄像头与小车平板/导轨凹槽的位置相对固定
于是,我想到了一个准确且高效的位置检测方法——灰度峰值法。
灰度波峰法的核心优势在于它直接利用了问题域的物理约束(背景均匀 + 物体暗色 + 运动方向在图像中近似水平),用最简单的数学工具得到最可靠的解。它不是最”高级”的方法,但在这个场景下,是最合理的方法。
3.1 ROI 选取:缩小问题空间
全帧搜索既不必要也不高效,小球只可能在凹槽区域运动,因此直接预先划定一个矩形 ROI(感兴趣区域)对齐凹槽区域:

ROI 边界的选择来自对实际装置的观察:小球在平板上滚动的轨迹是一条水平线(平板只做俯仰运动,左右倾斜),因此 ROI 只需要刚好覆盖凹槽区域即可。信号无关区域被完全排除,显著降低了误检概率。此外,较小的 ROI 也大大降低了视觉需要处理的图像数据量,提高了检测效率。
3.2 列灰度累加求均值:一维信号的构造
由于 PPR 管内壁凹槽是白色的,而小球是灰色暗色的,我们可以对其构建一个一维的灰度信号:在 ROI 内的其他区域中,颜色较亮,信号强度较低,而小球区域的颜色较暗,信号强度较高。这一步的数学表达如下:
其中 是像素灰度值(0–255), 是 ROI 高度。这里做了两件事:
- 垂直求均值将二维信息压缩为一维,维度从 降为 ,后续所有操作都在一维数组上进行。
- 灰度反转(255 - 均值)将暗色小球映射为信号峰值,方便后续的寻峰算法。
C++ 代码实现如下:
for (int x = 0; x < roiW; ++x) { int sum = 0; for (int y = 0; y < roiH; ++y) sum += grayRoi.at<uchar>(y, x); signal[x] = 255 - sum / roiH;}这一步有一个重要的物理前提:PPR管壁背景在 ROI 区域内是近似均匀的——没有突变纹理、没有强烈阴影、没有高反射,若测评场景过暗可在摄像头支架上加装 LED 进行补光。
3.3 加权平滑与峰值定位
三点加权平均平滑:
冲激响应为 ,中心权重加倍。核大小设为 3 以免更大的平滑核把信号峰抹平。
随后两步是关键——找到 的最大值(peakValue)和最小值(baseline),计算峰强度 peakStrength = peakValue - baseline,再做双阈值过滤(步骤 5-6):
任何一个阈值未通过,当前帧不输出坐标,保持上一次有效位置不变。这避免了”球丢了”时输出随机坐标。
半峰宽加权求质心
半峰宽加权求质心是对”直接用峰值坐标”的关键改进。找到信号值高于 baseline + peakStrength/2 的区间 :
其中权重是”高于基线的灰度值”——越暗(信号越高)的像素对位置贡献越大,加权后质心自动偏向中心。对示例图片按上述流程进行检测,结果如下:

3.4 卡尔曼滤波:轨迹平滑
检测得到的原始坐标 存在 2–5 像素的帧间抖动,直接发送下位机会被 PID 放大为电机振颤。将小球运动建模为一维匀速模型,状态向量包含位置和速度:
观测只能看到位置,速度通过过程模型估计。
代码实现:
class Kalman1D {public: Kalman1D(double dt = 1.0, double measureNoise = 50.0) : R_(measureNoise) { x_ = cv::Mat::zeros(2, 1, CV_64F); F_ = (cv::Mat_<double>(2, 2) << 1.0, dt, 0.0, 1.0); H_ = (cv::Mat_<double>(1, 2) << 1.0, 0.0); P_ = (cv::Mat_<double>(2, 2) << 100.0, 0.0, 0.0, 10.0); Q_ = (cv::Mat_<double>(2, 2) << 0.01, 0.0, 0.0, 0.1); }
double filter(double z) { x_ = F_ * x_; // 状态预测 P_ = F_ * P_ * F_.t() + Q_; // 协方差预测 double s = (H_ * P_ * H_.t()).at<double>(0, 0) + R_; cv::Mat K = (P_ * H_.t()) / s; // 卡尔曼增益 double y = z - (H_ * x_).at<double>(0, 0); // 测量残差 x_ = x_ + K * y; // 状态修正 P_ = (cv::Mat::eye(2, 2, CV_64F) - K * H_) * P_; return x_.at<double>(0, 0); }
void reset() { x_.setTo(cv::Scalar(0)); P_ = (cv::Mat_<double>(2, 2) << 100.0, 0.0, 0.0, 10.0); }
private: cv::Mat x_, F_, H_, P_, Q_; double R_;};调用方式简洁——每帧只需一行:
const int kalX = m_kalmanEnabled ? static_cast<int>(m_kalman.filter(static_cast<double>(pinballX))) : pinballX;kalmanEnabled 开关允许一键禁用滤波对比效果,reset() 在每次重启摄像头时归零状态。
是唯一可调参数:太小(R=1)几乎不做平滑,抖动依然存在;太大(R=1000)响应迟钝,位置明显滞后。经验值 能在平滑高频抖动的同时保留低频运动响应。
3.5 参数运行时可调
前面几节描述的检测管线中有好几个”魔法数字”——平滑核大小、过滤阈值、卡尔曼噪声——它们在默认值下能工作,但在不同光照、不同小球颜色、不同摄像头角度下,默认值不一定最优。因此我设计了一个设置页面,允许用户在运行时调整这些参数,以获得最佳检测效果。
可调参数定义在 AppConfig 结构体中,通过程序目录下的 config 文件持久化(单行空格分隔,例:50.0 40 60 /dev/ttyUSB0 115200 1):
struct AppConfig { double measureNoise = 50.0; // 卡尔曼测量噪声 R int minPeakValue = 40; // 波峰最小峰值 (0–255) int minPeakStrength = 60; // 波峰最小峰强度 (0–255) bool kalmanEnabled = true; // 卡尔曼滤波开关};minPeakValue 和 minPeakStrength 在 3.3 节的双阈值过滤中使用,kalmanEnabled 在 3.4 节控制滤波开关,不再赘述。measureNoise 在 CameraWorker::start() 时注入滤波器:
m_kalman.setMeasureNoise(m_config.measureNoise);m_kalman.reset(); // 状态归零,新旧参数不产生状态污染kalmanEnabled 是 config 文件的可选字段,缺失时回退默认值 true,兼容旧版配置文件。文件缺失或解析失败时静默回退到默认构造值。
设置页
设置页面 SettingsPage 是一个独立窗口,包含上述可调参数的控件。保存时从控件读取值写入 AppConfig 并调用 save(),成功后底部显示”已保存,下次开启摄像头时生效”。

调参方法
针对不同现场现象的调整建议:
| 现象 | 调整方向 |
|---|---|
| 检测不到球(准线不出现) | 降低 minPeakValue 或 minPeakStrength |
| 噪声被误检为球(准线跳到无关区域) | 提高 minPeakStrength |
| 准线围绕小球剧烈抖动 | 增大 measureNoise(如从 50 调到 100) |
| 准线明显滞后于小球真实位置 | 减小 measureNoise(如从 100 调到 30) |
| 不确定是检测问题还是滤波问题 | 关闭 kalmanEnabled,观察原始检测值 |
四、HTTP MJPEG 图传方案设计
赛题要求图传发送模块须稳固安装在小车上,接收模块可连接显示存储装置并整体置于环形线路以外,要求能稳定实时显示钢球在凹槽中滚动的画面,并完整记录每次测试时钢球运动的视频且能按要求回放。
综合考虑后,我选择使用 HTTP MJPEG 来实现图传方案:浏览器原生支持、实现简单、任何设备打开 URL 就能看到画面,零安装零配置,测评时随便一台设备都能正常访问。

4.1 MJPEG 协议原理
MJPEG 流的核心是 HTTP 响应的 multipart/x-mixed-replace 内容类型。浏览器向 /stream.mjpg 发起 GET 请求,服务器返回一个永不断开的 HTTP 响应,body 由无限序列的 JPEG 帧组成。
响应头格式如下:
QByteArray header;header += "HTTP/1.1 200 OK\r\n";header += "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n";header += "Cache-Control: no-cache\r\n";header += "Pragma: no-cache\r\n";header += "Connection: close\r\n\r\n";boundary 设为 frame,此后每帧都用 --frame 作为分隔符。这里没有 Content-Length——这是一个不定长的流式响应。
每帧的线上格式:
--frame\r\nContent-Type: image/jpeg\r\nContent-Length: 28456\r\n\r\n<JPEG 二进制数据>\r\n浏览器检测到新的 --frame 边界,就用新的图像替换 <img> 中当前显示的图像。从浏览器视角看,它只是在加载一张「不断变化的图片」。
4.2 推送策略
采集线程以摄像头原生帧率(大约60fps)运行,MJPEG 推送定时器设为 20ms(约 50fps 上限),每拍只取当前最新的 JPEG 帧推送,不缓存历史帧。这样采集线程一帧没写完时自动跳过,也从不积压队列。
推送图像时,向客户端的 socket 写入当前帧块:
void HttpVideoServer::pushLatestFrame() { const QByteArray jpeg = m_worker->getLatestFrame(); // 从采集线程获取最新帧 if (jpeg.isEmpty()) return; // 帧未就绪则跳过本轮
QByteArray part; part += "--frame\r\n"; // multipart 帧 part += "Content-Type: image/jpeg\r\n"; part += "Content-Length: " + QByteArray::number(jpeg.size()) + "\r\n\r\n"; part += jpeg; // JPEG 原始数据 part += "\r\n";
for (auto *client : m_clients) { if (client->bytesToWrite() > 2 * 1024 * 1024) continue; client->write(part); }}2MB 的背压阈值用来保护慢速客户端不拖垮服务端:正常局域网环境下缓冲区从未超过 200KB,2MB 是充裕的安全上限。
4.3 JPEG 压缩质量
图像压缩使用 cv::imencode 的 JPEG 编码器,质量参数设为 40/100:
| 质量 | 单帧大小 (800×480) | 带宽 @30fps | 视觉观感 |
|---|---|---|---|
| 95 | ~80 KB | 19.2 Mbps | 几乎无损 |
| 60 | ~30 KB | 7.2 Mbps | 轻微色块,完全可用 |
| 40 | ~20 KB | 4.8 Mbps | 有压缩痕迹,标注清晰可见 |
| 20 | ~10 KB | 2.4 Mbps | 严重块效应 |
质量 40 下带宽仅 5Mbps,坐标标注和 ROI 矩形框仍清晰可辨。调试需要更清晰图像时可以提高质量,或直接在程序界面上查看未压缩的原始画面。
4.4 网页端设计
浏览器页面遵循「零交互、全画面」原则:
<style> html, body { margin: 0; height: 100%; background: #111; } body { display: grid; place-items: center; } img { max-width: 100vw; max-height: 100vh; object-fit: contain; }</style><img src="/stream.mjpg" alt="Camera stream">纯黑背景保证最大对比度,object-fit: contain 保证不裁剪不变形,max-width/max-height: 100v* 自动适配横竖屏,没有任何 JS 交互逻辑——比赛现场不需要额外的操作负担。
五、串口通信方案
5.1 帧协议
上位机与下位机之间采用自定义二进制帧协议:
帧头(2B) + 长度(1B) + 命令码(1B) + Payload(N B) + CRC8(1B) + 帧尾(2B)帧头 0xAA 0x55、帧尾 0x0D 0x0A 提供帧边界;CRC8 覆盖长度、命令码和 Payload,提供传输可靠性。协议本身不是本文重点,不再展开。
对于协议帧以及相关基础设施的实现可以前往以下开源仓库中查看
5.2 收发实现
接收——每帧调用一次,以非阻塞方式读取所有可用数据:
为了实现非阻塞式接收数据,程序需要维护一个流式解码器,逐字节喂入数据,自动定界并输出完整帧,无需等待完整帧到达,其实现不是本文重点,不再展开。
void MsgManager::processRxData() { if (!m_serial || !m_serial->isOpen()) return;
// waitForReadyRead(0):超时 0ms,无数据立即返回 —— 等价于 select(timeout=0) if (!m_serial->waitForReadyRead(0)) return;
const QByteArray data = m_serial->readAll(); if (data.isEmpty()) return;
// 逐字节送入流式解码器 for (int i = 0; i < data.size(); ++i) { try { auto frame = m_decoder.feed(static_cast<uint8_t>(data.at(i))); if (frame.has_value()) { handleFrame(frame.value()); // 收到完整帧,分发处理 } } catch (const FrameProtocol::ProtocolException &e) { std::cerr << "[MsgManager] 协议错误: " << e.what() << std::endl; } }}readAll() 返回的可能是多帧拼接或半帧数据,因此不能假设一次读取恰好对上一帧。解码器内部维护一个状态机,逐字节喂入:遇到 0xAA 0x55 开始收帧,根据长度字段收取 payload,校验 CRC8 和帧尾后输出完整帧。无论数据如何分片到达,状态机都能自动定界,无需上层关心字节对齐问题。
收到完整帧后按命令码分发:
- 启动检测:设置
m_needSendParam = true(std::atomic<bool>,跨线程安全),回复确认帧 - 停止检测:设置
m_needSendParam = false,回复确认帧 - 标记原点:将当前绝对坐标记录为原点,后续发送的相对位置 = 绝对坐标 − 原点
发送——采用 write() + flush() 组合:
void MsgManager::sendParam() { const int relPos = relativePosition(); // = 绝对位置 - 原点 auto data = FrameProtocol::Protocol::encodeString( kCmdParamRsp, std::to_string(relPos));
if (m_serial && m_serial->isOpen()) { m_serial->write(reinterpret_cast<const char *>(data.data()), static_cast<qint64>(data.size())); m_serial->flush(); // 同步刷出,不依赖事件循环 }}flush() 是关键——绕过 Qt 输出缓冲区直接写入内核设备文件,实现实时同步输出。
5.3 命令交互流程
MSPM0 单片机与 Raspberry Pi 的握手交互流程如下:
下位机 上位机 │ │ │──── 0x01 启动检测 ──────────▶│→ needSend = true,回复确认 │◀──── 0x03 启动回复 ──────── │ │ │ │◀──── 0x05 位置数据 ──────── │ │◀──── 0x05 位置数据 ──────── │ · · · · · · │ │──── 0x06 标记原点 ──────────▶│→ 记录原点,此后位置 = 当前值 - 原点 │ │ │◀──── 0x05 位置数据 ──────── │→ (每帧一次,payload 为相对位置) │◀──── 0x05 位置数据 ──────── │ │ · · · · · · │ │ │ │──── 0x02 停止检测 ──────────▶│→ needSend = false,回复确认 │◀──── 0x04 停止回复 ──────── │六、闭环总结
系统主要由两条并行流水线构成,分别运行在采集线程和主线程中。
流水线一:图像输入与检测(采集线程,每帧约 30ms)
VideoCapture >> frame → ROI 裁剪 → 灰度化 → 列累加 S(x) = 255 - ΣI(x,y)/H → 边缘衰减(15%) → 三点平滑 → 峰值检测 → 半峰宽质心 → 卡尔曼滤波 → 绘制标注 → if needSend: sendParam(绝对位置 - 原点) → 串口发送位置数据 → imencode JPEG → QMutex 共享流水线二:图传与显示(主线程)
HttpVideoServer: 20ms 定时器推送最新帧 → multipart JPEG 流 → 浏览器MainWindow: 30fps QTimer 取最新帧 → QLabel 渲染 → 树莓派屏幕两条流水线通过 QMutex + 「缓存最新帧」模式解耦:采集线程只写当前帧,主线程只读最新帧,各自按自己的节奏运行,互不阻塞。

灰度波峰法利用了场景物理约束取代 Yolo/Canny,HTTP MJPEG 省去了 RTSP/WebRTC 的服务端依赖,自定义二进制帧 + 流式解码器替代了复杂协议栈。在树莓派 4b 上组合成一套完整的视觉定位、视频图传与串口交互方案。
电赛四天三夜的窗口、手头凑的旧硬件、没有公网和服务器——在这些约束下,简洁不是妥协,而是最适合的答案。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

