☰
电赛E题视觉方案:红点定位、靶框识别与云台闭环
2026/10/26 3:17:14 网站建设 项目流程

简介:面向参加全国大学生电子设计竞赛E题、准备机器视觉赛题原型的团队与指导教师,这套工程代码围绕K210视觉模块、云台执行机构与STC32G主控的协同控制展开。视觉端基于sipeed Maix Bit开发板,主控采用STC32G12K128,包内包含视觉代码、主机云台与从机云台工程,便于按模块理解整套方案并对照硬件联调。压缩包共352个文件、约19.47MB,涵盖C/H源码、Python脚本、Keil工程文件(uvproj/uvopt)、hex烧录文件及pdf、docx、md说明文档,兼顾算法实现、嵌入式工程配置与调试记录。代码覆盖图像采集、预处理、特征提取、目标检测与跟踪,并提供颜色识别、物体分类、距离测量等应用案例,可作为赛题调试、方案复盘和二次开发参考。已有619人学习下载,适合需要完整视觉框架、工程组织方式与排错思路的参赛者和自学者。

1. 从一颗打偏的光点说起:电赛 E 题视觉部分到底要交出什么

场地灯一开,调了一下午的阈值全废了。红点从画面里消失,云台开始在原地画圈,屏幕上的绿色十字准星还稳稳停在上一次的位置不动。这是很多人拿到电赛 E 题视觉部分代码之后遇见的第一次翻车,问题并不在算法有多难,而在于没人告诉你这套代码究竟该对外交出什么。

E 题的视觉部分,本质上是三件事:把红色光点从背景里稳定抠出来并给出亚像素级坐标;把靶纸的矩形边框认出来并排序成四个角点;把像素坐标换算成云台该转的角度,并且在光点丢失、被遮挡、跑出视野时有一套能自恢复的状态逻辑。这三件事任何一件没做扎实,现场演示就会变成“能跑但打不准”。

这套方案对应的是一份可直接参考的视觉代码工程,适合正在准备电子设计竞赛控制类題目的人,也适合把它当作机器视觉 + 云台闭环的课设或毕设骨架来改。下面按“需求拆解、算法流水线、控制通信、避坑、验收”的顺序,把我在这类题目上真正会走的流程摊开讲。

2. 需求拆解与硬件选型:三道视觉任务与一套光轴几何

动手写第一行代码之前,我一般先在纸上把“看见什么”和“打到哪里”这两件事分开。视觉部分最容易犯的错,是把识别精度和落点精度混为一谈:图上算得再准,只要摄像头的轴和激光笔的轴不平行,打出去的光点就永远差那么一两厘米,而且这个偏差随距离变化,靠调 PID 是调不回来的。

2.1 三道视觉任务:红点、绿点、靶框各自的精度预算

第一道任务是红色光点定位。它是追踪系统的唯一输入,输出必须是一个稳定的、带时间戳的像素坐标。这里的关键词是“稳定”:单帧算得准不难,难的是连续 200 帧不跳变。我通常要求同一个静止光点在 3 秒内的坐标抖动标准差小于 1 个像素,否则后面不管怎么调 PID,云台都会持续微抖。

第二道任务是绿色光点自检。追踪系统自己打出去的绿点,其实是一个免费的闭环验证信号:把绿点的像素位置和红点的像素位置画在同一张图上,两者重合度直接反映映射矩阵标得准不准。很多队伍跳过这一步,结果现场只能凭肉眼判断“差不多对准了”,一旦评委要求报偏差就说不出来。

第三道任务是靶框识别。矩形四点不只是用来“画边框”,它更重要的作用是建立像素坐标到物理平面的映射基准。有了四个角点,就能算出靶纸的实际边长对应多少像素,从而把“偏差 15 像素”翻译成“偏差 6 毫米”,这句话在答辩时非常值钱。

精度预算要提前算清楚。假设摄像头视野覆盖 1.2 m 宽度,输出 640 像素宽,那么每像素约 1.875 mm;此时要求 2 mm 的落点精度,理论上就已经不够用了。换到 1280 像素宽,每像素 0.94 mm,留出一倍余量才合理。如果算力跟不上,与其全图 1280,不如 640 全图做粗定位、再在目标附近裁 160×160 的 ROI 做精定位,等效分辨率直接翻四倍,这个技巧在嵌入式平台上比换摄像头划算得多。

2.2 摄像头选型:分辨率、帧率、曝光三者的取舍

三条主流路线的差别,其实不在“谁更强”,而在“谁的延迟更可控”。

路线典型输出延迟可控性适合场景
一体化视觉模组(自带脚本环境)320×240 至 640×480,30~60 fps好,图像不出板只要红点坐标、不想碰驱动
单板计算机 + USB 摄像头640×480 至 1280×720,30~60 fps中,受 USB 与驱动缓冲影响需要同时跑识别与串口调度
工控机 / 笔记本 + 工业相机1280×1024 以上,全局快门差,链路最长桌面调试、算法验证

选型上我有一个很固执的偏好:宁可分辨率低一点,也要把帧率拉到 60 fps 并且把曝光锁死。原因在于控制环的延迟预算里,舵机本身就是大头——普通舵机从收到指令到到位,一百多毫秒是常态;如果图像端再贡献 66 ms(30 fps 的一帧周期)加上自动曝光带来的抖动,整个环路的相位裕度就被吃光了。暴露在自动模式下还有个隐蔽的坑:红点进入视野时画面整体亮度被拉低,红点的饱和度跟着掉,阈值分割当场失效。手动曝光 + 手动增益 + 手动白平衡,是这套代码默认的前提条件。

快门类型也值得多说一句。卷帘快门在光点快速横扫时会拉出一条斜线,二值化之后这不是一个圆点,而是一段月牙,质心位置自然偏了。如果预算允许,全局快门是首选;只能用卷帘快门时,就得靠后面 3.2 节讲的亮度加权质心来补偿,同时尽量缩短曝光时间、提高增益,把拖影压到可接受范围。

2.3 光轴几何:为什么“看到的不等于打到的”

摄像头和激光笔装在同一块云台上,但它们的轴线几乎不可能重合。假设两者水平间距 3 cm,目标在 1 m 外,这个几何关系带来的视差本身不大;真正麻烦的是激光笔有一定发散角,近距离时打出的光斑直径可能达到 6~8 mm,而质心算法给出的是光斑中心,两者在斜射到白板上时会呈现椭圆,长轴方向随入射角变化,于是“看到的中心”和“人眼认为的中心”又差了一点。

机械上能做的优化是尽量缩短两轴间距,把激光笔贴着镜头边缘安装,并且让激光笔的出射方向与镜头光轴大致平行(可以用远处墙面做粗调:在 3 m 外用一张纸同时承接镜头中心和光点,反复微调支架)。但机械只能做到“大致平行”,剩下的偏差必须交给软件标定。

我一般的标定流程是五步。第一步,把靶纸固定好,摄像头也固定,之后全程不再碰支架。第二步,让云台在两个轴上各走固定的几个角度(比如水平 -10°、-5°、0°、5°、10°,垂直同理),组成九宫格。第三步,每个角度位置停留 500 ms 等云台稳住,记录下当时的绿色光点像素坐标——注意不是红点,是自己打出去的绿点,因为标定的是“指令角度到落点像素”的映射。第四步,用这九组数据拟合一个二次多项式或者 3×3 单应矩阵,把残差控制在 2 像素以内。第五步,把矩阵固化到配置里,跑一遍验证:随机给十个角度,看绿点落到哪里,误差超标的点单独记录,通常是因为该区域落到了镜头畸变较大的边缘。

提示:标定数据一定要存盘成文本并随代码一起保留。现场换个场地、换张靶纸,很多时候只需要重跑一次标定,而不是重调所有参数。

3. 图像处理流水线:从一帧画面到一组角度增量

流水线的结构我习惯分成四段:取帧、分割、特征提取、坐标解算。每一段的输出都是下一段的输入,段与段之间用最简单的数据结构传递(掩膜、坐标元组、角度元组),这样任何一段出问题都能单独打印出来看,不至于变成一个黑匣子。

3.1 颜色空间选择与阈值分割:HSV 为什么比 RGB 稳

激光光点的最大特点是亮度极高、饱和度极高,而白色靶纸和场地灯光在亮度上会随环境剧烈变化。RGB 三个通道都会被环境光整体抬升或压低,阈值一改就得全改;HSV 把色相 H 单独拎出来,环境光变化主要影响 V,色相基本不动,这就是它更稳的根本原因。

import cv2 import numpy as np # 红色在 HSV 色环上横跨 0 度,必须拆成两段阈值再合并 LOWER_RED_A = np.array([0, 120, 200]) UPPER_RED_A = np.array([10, 255, 255]) LOWER_RED_B = np.array([170, 120, 200]) UPPER_RED_B = np.array([180, 255, 255]) def red_mask(bgr): hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) m1 = cv2.inRange(hsv, LOWER_RED_A, UPPER_RED_A) m2 = cv2.inRange(hsv, LOWER_RED_B, UPPER_RED_B) mask = cv2.bitwise_or(m1, m2) k = np.ones((3, 3), np.uint8) # 先开后闭:开运算去掉孤立噪点,闭运算把光斑补成一个实心块 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k) return mask

三个参数的含义和调法:S 下限设 120,是为了把白纸、白墙这类低饱和高亮区域排除掉;V 下限设 200,只保留接近饱和的高亮像素,因为激光光斑中心一定是过曝的;H 拆成 0~10 和 170~180 两段,是因为纯红的色相在 0 附近,卷绕之后单段阈值必然漏掉一半光斑。开闭运算的核用 3×3 就够,核太大在光点快速移动时会把拖影当成光斑主体,反而放大误差。

改阈值的正确姿势是打印中间图,而不是凭感觉改数字。我通常会在调试模式下把掩膜叠加回原图,一半原画面、一半掩膜,光斑有没有被吃掉、噪点有没有被连通,一眼就能看出来。

3.2 光点质心提取与抗拖影处理

二值化之后直接取轮廓中心,是大多数人写的第一版,也是精度最差的一版。原因在于光斑边缘在二值化时被切得参差不齐,而光斑中心是过曝的、亮度梯度最大的地方。用亮度做权重算加权质心,相当于让中心像素说话,边缘像素闭嘴,位置自然更靠近真实中心。

def spot_centroid(bgr, mask): hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) # 用 V 通道当权重,越亮权重越高 w = cv2.bitwise_and(hsv[:, :, 2], mask).astype(np.float64) total = w.sum() if total < 1e-6: return None ys, xs = np.nonzero(mask) cx = float((w[ys, xs] * xs).sum() / total) cy = float((w[ys, xs] * ys).sum() / total) area = len(xs) # 面积窗口过滤:太小是反光碎点,太大是灯光或白色反光板 if area < 6 or area > 2000: return None return cx, cy, area

这段代码有三个可调的地方。权重通道用 V 是最省事的做法,如果发现光斑边缘有严重红色溢出,可以换成 S 通道,效果会更接近几何中心。面积窗口的上下限必须结合自己的分辨率和场地来定:640×480 下,1 m 外的一个激光点大约 10~40 像素面积,窗口设 6~2000 是很宽松的;如果场地里有白色反光板,上限要往下压到 300 左右。返回值带 area 不是为了显示,而是给状态机判断用的——面积突然翻倍往往意味着视场里进来了另一束光或者镜头被正对光源照到了。

拖影的处理在这里体现得很直接。光点静止时 iterative 结果非常稳,一旦快速移动,掩膜的形心会落后于光斑头部,形成系统性滞后。缓解办法不是加滤波,而是把曝光时间压到 4~8 ms,让单帧内的位移尽可能小,同时允许面积窗口适当放宽,因为短曝光会让光斑变小。

3.3 靶框识别:轮廓筛选与四点排序

靶框的作用是提供物理尺度基准,所以稳定性要求比光点低,但绝对不能认错。认错一次,整个坐标系就翻了,后面的角度全错,而且这种错误从云台动作上看不出来——它照样在动,只是越动越偏。

def find_target_quad(bgr, min_area=3000): gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) edges = cv2.Canny(blur, 50, 150) # 膨胀一次,把断裂的边线接上,否则四边形拟合会失败 edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1) cnts, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best = None for c in cnts: area = cv2.contourArea(c) if area < min_area: continue peri = cv2.arcLength(c, True) # 0.02 是经验系数,太大拟合不出四边,太小会被噪点干扰 approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) != 4 or not cv2.isContourConvex(approx): continue if best is None or area > best[0]: best = (area, approx.reshape(4, 2)) return None if best is None else order_quad(best[1]) def order_quad(pts): # 左上角 x+y 最小,右下角 x+y 最大,右上角 x-y 最大,左下角 x-y 最小 s = pts.sum(axis=1) d = pts[:, 0] - pts[:, 1] return np.array([pts[np.argmin(s)], pts[np.argmax(d)], pts[np.argmax(s)], pts[np.argmin(d)]], dtype=np.float32)

这里最容易翻车的是 approxPolyDP 的系数和面积下限。系数 0.02 是相对周长而言的,意味着允许边线上有 2% 的锯齿;如果靶纸边框被光点压住一角,轮廓会缺一块,拟合出来的就不是四边形而是五边形,这段逻辑会直接返回 None,状态机需要据此进入“靶框丢失”分支而不是报错退出。面积下限 3000 对应的是“靶纸在画面里至少占这么大”,如果摄像头拉得很远、靶纸只占画面一小块,这个值要按实测调小,否则每次上电都要等半天才认出框。

四点的顺序必须固定,否则后续算映射矩阵时角点错配,算出来的矩阵看上去数值正常,实际把画面翻了个个儿。上面这个 sum/diff 排序法对轻微旋转的矩形都成立,只有在矩形旋转超过 45 度时才会失效——场地里靶纸是贴平的,不用担心。

3.4 像素坐标到云台角度:一套能说清楚的映射

这一步是视觉与控制的分界线,也是答辩时最容易被追问的地方。原理上是个针孔模型:像素偏移除以焦距,取反正切就是角度。听起来简单,但直接套用会有一个系统性偏差——摄像头和激光笔不同轴,映射的中心并不是图像中心。

import math def pixel_to_angle(px, py, cx, cy, fx, fy): # 针孔模型:偏移量 / 焦距 = tan(角度) yaw_err = math.degrees(math.atan2(px - cx, fx)) pitch_err = math.degrees(math.atan2(py - cy, fy)) return yaw_err, pitch_err def angle_with_bias(px, py, cfg): # 在针孔模型基础上叠加标定得到的零点偏置与一阶修正 y = (px - cfg['cx']) / cfg['fx'] p = (py - cfg['cy']) / cfg['fy'] yaw = math.degrees(math.atan(y)) * cfg['kx'] + cfg['bx'] pitch = math.degrees(math.atan(p)) * cfg['ky'] + cfg['by'] return yaw, pitch

fx、fy 是焦距的像素表示,标定方法很朴素:把靶纸放在已知距离,量出靶纸实际宽度 W 毫米,再数出它在图像里占多少像素 w,那么 fx ≈ w × 距离 / W。cx、cy 不是图像中心,而是“激光笔光轴指向的那一点”,通过 2.3 节的九宫格标定反推出来,通常偏离图像中心几十个像素,这一步省不得。kx、ky 是标定后残余误差的一阶修正系数,一般落在 0.95~1.05 之间;如果超出这个范围,说明机械安装有问题,应该回去调支架而不是靠系数硬掰。

注意:角度误差在小角度范围内近似线性,超过 ±15 度之后 atan 的非线性就明显了。如果云台行程较大,必须保留 atan,不能偷懒用线性比例替代,否则边缘区域会持续偏。

4. 云台控制与上下位机通信:把角度变成光点的落点

视觉算出的是一组角度增量,真正让云台动起来的是下位机。这一段的设计目标只有两个:指令别丢、时序别乱。听起来平淡,但现场演示时云台突然抽风抽搐,九成问题出在这。

4.1 双环结构:视觉环给目标,舵机环给输出

我的做法是把控制拆成两层。外环是视觉环,跑在视觉端,输出的是“云台应该指向的角度”;内环是舵机环,跑在下位机,负责把角度变成 PWM 并保证舵机平滑到位。外环频率低(15~30 Hz,跟着摄像头帧率走),内环频率高(50~100 Hz),两者用串口解耦。

为什么外环不用高频?因为摄像头就那么多帧,视觉环跑再快也只是在重复计算同一帧。反而是在外环里加一个死区很有用:当角度误差小于 0.3 度时直接输出零,让舵机别再修正。这一步能把可见的抖动压掉一大半,很多人调不好 PID 其实是缺了死区。

class GimbalTracker: def __init__(self, cfg): self.cfg = cfg self.kp, self.ki, self.kd = 0.85, 0.05, 0.08 self.integ_yaw = self.integ_pitch = 0.0 self.prev_yaw = self.prev_pitch = 0.0 self.lost_frames = 0 self.DEADBAND_DEG = 0.3 def update(self, spot, dt): if spot is None: self.lost_frames += 1 # 丢帧时清积分,避免重新捕获瞬间积分项爆炸 self.integ_yaw = self.integ_pitch = 0.0 return None self.lost_frames = 0 yaw_err, pitch_err = pixel_to_angle( spot[0], spot[1], self.cfg['cx'], self.cfg['cy'], self.cfg['fx'], self.cfg['fy']) if abs(yaw_err) < self.DEADBAND_DEG and \ abs(pitch_err) < self.DEADBAND_DEG: return 0.0, 0.0 self.integ_yaw += yaw_err * dt self.integ_pitch += pitch_err * dt d_yaw = (yaw_err - self.prev_yaw) / dt if dt > 0 else 0.0 d_pitch = (pitch_err - self.prev_pitch) / dt if dt > 0 else 0.0 self.prev_yaw, self.prev_pitch = yaw_err, pitch_err out_yaw = self.kp * yaw_err + self.ki * self.integ_yaw \ + self.kd * d_yaw out_pitch = self.kp * pitch_err + self.ki * self.integ_pitch \ + self.kd * d_pitch return out_yaw, out_pitch

三个参数的作用要分清楚:kp 决定跟随速度,太大就会过冲然后来回摆;ki 用来消除静态偏差,但在有死区的情况下容易积攒,所以丢帧时必须清零;kd 抑制超调,但它对图像噪声极其敏感,噪声大的时候宁可把 kd 降到 0.03 以下。我一般的调参顺序是先只开 kp 到刚好不振荡,再把 kp 降到 70%,然后加 ki 到静态偏差消失,最后用 kd 收拾残余的超调。

4.2 串口协议:定长帧 + 异或校验

协议设计上我踩过最大的坑是“用换行符分隔的文本协议”。调试时看着舒服,实际跑起来一旦有半帧丢失或者粘包,解析端就会拿着半截数据去控制云台,动作看起来像抽风。定长二进制帧虽然不好看,但可靠得多。

import struct import serial FRAME_HEAD = b'\xAA\x55' def pack_cmd(cmd, yaw_deg, pitch_deg, laser_on): # 角度乘 100 转成 int16,省带宽也避免浮点精度歧义 yaw = max(-32768, min(32767, int(round(yaw_deg * 100)))) pitch = max(-32768, min(32767, int(round(pitch_deg * 100)))) body = struct.pack('<BhhB', cmd, yaw, pitch, 1 if laser_on else 0) chk = 0 for b in body: chk ^= b return FRAME_HEAD + body + bytes([chk]) def open_port(name='/dev/ttyUSB0', baud=115200): return serial.Serial(name, baud, timeout=0.02)

帧结构是:2 字节帧头 AA 55,1 字节命令字,2 字节偏航角(小端 int16,单位 0.01 度),2 字节俯仰角,1 字节激光开关,1 字节异或校验,一共 9 字节。下位机按字节喂状态机解析:

typedef enum { CMD_IDLE = 0, CMD_SET_ANGLE = 1, CMD_LASER = 2, CMD_RESET = 3 } cmd_t; static uint8_t buf[9]; static uint8_t idx = 0; static uint8_t in_frame = 0; void uart_rx_byte(uint8_t b) { if (!in_frame) { if (b == 0xAA) { buf[0] = b; idx = 1; in_frame = 1; } return; } buf[idx++] = b; if (idx == 2 && buf[1] != 0x55) { in_frame = 0; idx = 0; return; } if (idx < 9) return; uint8_t sum = 0; for (uint8_t i = 2; i < 8; i++) sum ^= buf[i]; if (sum == buf[8]) { int16_t yaw = (int16_t)((buf[4] << 8) | buf[3]); int16_t pitch = (int16_t)((buf[6] << 8) | buf[5]); gimbal_set_angle(yaw / 100.0f, pitch / 100.0f); gimbal_set_laser(buf[7]); } in_frame = 0; idx = 0; }

解析端的两个细节值得说。一是第二个帧头字节校验失败时必须把 idx 清零而不是回退一位,否则遇到连续 0xAA 会陷入死循环;二是校验通过之后立刻复位状态,不要在里面做耗时操作,比如直接驱动舵机走完整段行程,那样下一帧就来不及收了。舵机指令我一般写进一个全局目标值,由定时器中断去平滑输出,串口中断只负责解析和赋值。

提示:波特率先上 115200,如果发现丢帧严重,先查线材和共地,再考虑降波特率。很多丢帧是地线没接好导致的电平漂移,跟波特率没关系。

4.3 状态机:搜索、锁定、复位、丢失重捕

视觉代码看起来是流水线,实际运行时是个状态机。我一般定义四个状态:SEARCH(没找到红点,云台按预设路径扫)、TRACK(锁定,正常闭环)、LOST(连续丢帧但还在尝试)、RESET(收到复位指令,回零点并关激光)。状态迁移条件写死,不要用一堆 if 散在代码里。

关键是 LOST 和 SEARCH 的边界。连续丢 3 帧以内,我倾向继续在 TRACK 里保持上一帧的指令不动,因为激光点在靶纸上偶尔会被手指或者工具挡住,一两帧就恢复了,此时乱动反而丢了目标。连续丢超过 8 帧才切到 LOST,让云台回到最后一次有效位置附近小范围搜索。这个阈值的确定方法是实测:让人用手快速划过光点 10 次,统计从遮挡到恢复的帧数分布,取 95 分位往上一点。

RESET 状态要特别注意时序——先关激光、再回零、最后等 300 ms 稳定,这三步不能并行。我见过有人把回零和关激光同时发,结果云台回零过程中激光还在画线,在靶纸上拖出一道长长的划痕,评委看得一清二楚。

5. 避坑与排查:调了一整晚还是打不中的五个原因

这一节是全篇最该先看的部分。下面五条,每一条我都在不同场合见过至少两次,而且都不是“代码写错了”这么简单,靠读代码是找不出来的。

5.1 现象:白天调好的阈值,晚上一开灯就失效

原因几乎总是自动曝光和自动白平衡没关。摄像头看到画面整体变亮,会自动压低曝光时间,红点在图像里从过曝的白心红边变成一个暗淡的小红点,V 通道掉到 200 以下,直接被阈值切掉。白天在窗边调参时这个机制是隐形的,因为环境光稳定,摄像头不需要调整。

解决办法是把曝光、增益、白平衡、伽马全部设为手动,写在代码初始化里,而不是靠摄像头的默认行为。初始化之后打印一次实际生效的曝光值,如果它跟你设的不一致,说明驱动层覆盖了你的设置,需要换 API 或者换设备。另外建议在阈值之外加一道保险:如果连续 10 帧检测不到任何候选点,且掩膜总面积异常大,就判定为“环境突变”,主动把 V 下限临时下调 20 再试,这个自适应逻辑救过我一次外场演示。

5.2 现象:云台一直在小幅抖动,静止时尤其明显

这通常不是 PID 参数的问题,而是死区没设或者死区太小。图像坐标是整数像素,光点静止时质心也会在 ±0.5 像素之间游走,换算成角度大概 0.05 度,看起来微不足道,但 PID 会把这个误差放大并持续输出修正量,舵机就在两个相邻位置之间来回跳。

解决办法是引入角度死区,误差小于 0.3 度直接输出零,同时把 kd 降下来,因为 kd 对噪声的放大最厉害。还有一个常被忽略的点:舵机的机械回差。便宜舵机在目标位置附近有 0.5~1 度的空程,你让它走 0.1 度它根本不动,但指令一直在发,下一个周期误差还在,积分项就越积越大,最后突然跳一下。所以积分限幅一定要加,我一般限制在输出量的 30% 以内。

5.3 现象:换个场地就得重调所有参数

原因在于阈值是绝对阈值,而不同场地的环境光色温差别很大。日光灯偏绿、白炽灯偏黄,靶纸的白色在不同光源下的 HSV 值能差出一大截。用固定阈值去适配所有场地,注定要重调。

解决办法有两个层次。浅层的做法是把所有阈值、焦距、中心点、死区全部外置到配置文件里,现场改文件不改代码,改完重启进程即可。深层的做法是在开机时做一次快速标定:让云台把绿点打到靶框四个角,用这四组数据现场重算映射矩阵,同时统计靶纸区域的 V 通道均值,用它来动态调整光点检测的 V 下限。这个自动标定流程跑一次大概 8 秒,换来的是换场地不用改代码。我一般会把它做成一个独立的标定模式,通过串口命令触发,而不是每次上电都跑,免得演示时多等十秒。

5.4 现象:中间准,边缘偏,越靠边越离谱

这是典型的映射非线性或者镜头畸变问题。要么是 3.4 节里的线性比例替代了 atan,要么是镜头本身畸变没校正。分辨方法是:把光点打到九个均匀分布的位置,记下每个位置的指令角度和实际落点像素,反算像素到角度的关系。如果偏差随位置单调增大且方向一致,是模型问题;如果四角偏差大、中间小、方向各异,是畸变问题。

模型问题就老老实实用 atan 加标定系数;畸变问题需要做一次相机标定拿内参和畸变系数,然后用 undistort 校正。很多人嫌相机标定麻烦想跳过,但如果靶纸在画面里只占中间一小块区域,畸变影响确实可以忽略——所以我的建议是:能用中心区域就别用边缘,把云台的机械零位调到让工作区域落在画面中央,比事后校正省事得多。

5.5 现象:串口通着,但云台每隔几秒抽一下

原因通常是粘包或者半帧。视觉端以 30 Hz 发,下位机如果解析不及时,缓冲区里会堆两三帧,某一帧正好被截断,解析出来的角度是个随机值,云台就抽一下。另一个来源是视觉端的多线程写串口没加锁,两帧数据交错写入,粘在一起。

解决办法有三条一起上:串口中断里只做解析不做业务,业务放到主循环;解析端严格按帧长度滑动,校验失败也必须把整个 9 字节跳过,不能只跳 1 字节;视觉端发数据的线程和算数据的线程之间用队列,队列长度限制为 1,新帧覆盖旧帧——反正旧的角度指令已经没有意义了,宁可丢也不要排队。

注意:调试期间建议在视觉端记录每一帧的发送时间戳和角度,下位机记录接收时间戳和校验结果,两边日志一对,是发的问题还是收的问题,五分钟就能定位。

6. 验收与答辩:用一份自测记录证明视觉部分真的能用

到了最后一步,很多人卡在“我知道它能用,但我不知道怎么证明”上。答辩场上评委不会看你的代码写得多漂亮,他们只问三个问题:精度多少、延迟多少、丢了怎么办。这三个问题都有办法用数据回答,前提是你提前测了。

我的做法是准备一张自测表,在正式演示前一天跑一遍,结果直接打印出来贴在旁边。测试项不用多,五项足够,每一项都要有可复现的操作步骤和明确的判据。

测试项操作方法合格判据
静态定位精度光点静止在靶框四个角和中点,各采 100 帧坐标标准差小于 1 像素
落点偏差给定九个角度,量取实际落点与理论位置距离最大偏差小于靶纸边长的 2%
跟踪延迟光点做阶跃移动,统计视觉输出响应帧数小于 5 帧(60 fps 下约 83 ms)
丢失恢复手动遮挡光点 200 ms 后松开,重复 10 次10 次全部重新捕获,平均恢复时间小于 0.5 s
连续运行闭环跟踪 5 分钟不干预无状态机异常跳转、无串口丢帧报警

测完之后把数据整理成一句话:“静态抖动 0.7 像素,最大落点偏差 4.1 毫米,阶跃延迟 4 帧,十次遮挡全部恢复。”这句话在答辩时比任何架构图都管用,因为它把抽象的能力变成了可核对的数字。

演示顺序也有讲究。我一般先做复位,让所有人看到系统能回到确定状态;再慢速走一圈靶框,让人看清光点是贴着边框走的;最后才做快速跟踪和遮挡恢复。反过来的顺序风险很大——一上来就高速跟踪,万一第一下没锁上,后面所有演示都在解释“刚才那个是特殊情况”。

从那以后我每次做这类带视觉闭环的项目,都会强制走一遍“先测数据、再排演示顺序、最后才碰参数”的流程,哪怕时间再紧也不跳步。参数是最后才调的东西,一旦在演示前十分钟改了阈值又没记下来,出了问题是查不出来的。希望这份流程能帮到你,把视觉部分从“能跑”做到“敢演示”。

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

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

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

立即咨询