☰
电赛视觉节点设计:从HSV目标提取到稳定脱靶量输出
2026/10/26 19:20:39 网站建设 项目流程

简介:这份资源是2023年全国大学生电子设计竞赛E题视觉部分的完整代码包,面向备战电赛视觉赛题的高校参赛队与指导教师,也适合想入门嵌入式视觉的开发者参考。方案基于Sipeed Maix Bit(K210)开发板实现图像采集、预处理、特征提取与目标检测跟踪等视觉功能,并配套主从云台控制代码,主控采用STC32G12K128,形成视觉与运动控制协同的完整框架。压缩包共352个文件、约19.47MB,以C与H源码、Keil工程文件(uvproj、uvopt)、编译中间文件(obj、lst、crf、map)及hex烧录文件为主,另有少量Python脚本、lib库、asm汇编、PDF与md说明文档,可直接在Keil环境下打开编译。目前已有619人学习下载。读者可从中获得赛题视觉部分的工程组织方式、K210与STC32G的通信与控制分工、云台步进电机与MPU6050姿态模块的驱动代码,以及可复用的图像处理函数,便于快速搭建稳定的视觉系统并在其基础上做二次开发与赛前调试。

1. 需求先立住:视觉部分要交付的是稳定脱靶量,不是一张识别截图

拿到某届电子设计竞赛 E 题视觉部分代码这个题目,很多人第一反应是先把摄像头点亮、把目标框出来,截一张识别成功的图就算交差。真正上云台一跑,问题立刻暴露:光斑在画面里跳、云台来回摆、串口数据断断续续。E 题视觉部分要交付的不是一张识别截图,而是一串稳定、有坐标系、有时序约束的脱靶量。适合谁?正在做电子设计竞赛视觉题、课程设计里要做目标跟踪、毕设需要搭一套摄像头到云台执行链路的人。关键词里写的是软件/插件,实际这套代码更像独立视觉节点:它不替主控做控制决策,只负责把目标位置、置信状态和帧序号讲清楚。先把需求立住,后面的方案才不会变成调参玄学。

2. 总体方案设计:采集、识别、解算、通信四层链路怎么拆

视觉部分最容易写成一坨:取图、阈值、找轮廓、算中心、发串口全塞在一个while True里。调参时看着能跑,换一个光照或者换一个云台就翻车。我一般把电赛视觉题拆成四层,每层只回答一个问题:采集层回答“画面是否稳定且低延迟”,识别层回答“目标在哪里、可不可信”,解算层回答“像素偏差对应云台要转多少”,通信层回答“主控什么时候拿到、拿到的数据是否完整”。四层拆开之后,参数有归属,问题有边界,答辩时也能把链路讲清楚。

2.1 视觉链路拆分:四个层各自只做一件事

采集层不参与识别,只负责相机索引、分辨率、帧率、曝光和增益。竞赛现场常见的问题是自动曝光把光斑压暗,或者自动白平衡让颜色漂移,所以相机参数通常要手动锁定。分辨率不是越高越好,640×480 在 60 帧下往往比 1280×720 在 30 帧下更适合跟踪,因为控制环需要的是低延迟,而不是一张好看的图。采集层还要记录帧时间戳,后面做丢帧判断和延迟统计都靠它。

识别层只输出候选目标列表,不直接决定最终目标。候选列表里包含中心点、面积、外接圆半径、圆形度、置信分数。最终目标选择放在解算层,因为选择逻辑会用到上一帧位置和跟踪状态。这样拆的好处是,识别层可以单独拿静态图片回放测试,不用接云台;解算层可以拿录好的像素坐标离线跑,不用反复上电。

解算层负责坐标映射、死区、限幅和丢帧保持。它不直接驱动舵机,只输出角度增量或者归一化偏差。通信层负责帧头、长度、校验、序号和发送频率。视觉节点只发“偏差+状态”,不背控制锅,主控愿意用 PID 还是比例控制,是主控的事。职责一混,出现振荡时就会互相甩锅:视觉说主控参数太猛,主控说视觉数据太跳。把接口定死,排查才有黑匣子。

目录上我习惯分成camera/、detect/、solve/、comm/、config/五块,配置文件单独放,现场改阈值不用翻代码。下面这份参数表是视觉节点的起点,不同场地要改的是 HSV 和曝光,不是整个结构。

camera: index: 0 width: 640 height: 480 fps: 60 exposure: -6 # 手动曝光,现场按光斑亮度微调 gain: 8 # 增益过高会放大噪声 target: hsv_lower: [0, 120, 80] hsv_upper: [10, 255, 255] min_area: 80 max_area: 8000 min_circularity: 0.55 blur_ksize: 5 track: kp_x: 0.08 kp_y: 0.08 deadband_px: 3 lost_hold_frames: 5 serial: port: /dev/ttyUSB0 baud: 115200 frame_head: 0xAA55

这段配置把相机、目标、跟踪、串口分开。exposure和gain需要现场锁定,自动模式在光斑移动时会反复调整,导致阈值跟着漂。min_area和max_area不是拍脑袋写的,先用静态靶在目标距离上量出光斑像素面积,再取 0.5 倍到 3 倍作为范围。deadband_px用来抑制中心点抖动,太小会让云台追着噪声走,太大又会出现明显滞后。lost_hold_frames是丢帧保持帧数,目标短暂被遮挡时不要立刻发丢失,否则主控会急停再启动,机械结构容易抖。

2.2 坐标系对齐:图像中心、光轴中心和云台零位不能混

视觉题翻车最多的地方不是识别,而是坐标系。图像中心是(width/2, height/2),光轴中心是相机镜头实际瞄准的点,云台零位是舵机或电机安装后的机械零位。这三个点不重合时,画面里目标在中心,云台却偏一边。我的做法是先做静态对中:把靶点固定在远处,手动把云台零位对准,再读图像中心偏差,把这个偏差记成offset_x、offset_y,后面所有脱靶量都先减掉它。

像素到角度的映射有两种常用做法。小孔模型适合快速起步:已知水平视场角HFOV,焦距像素近似为f = (width/2) / tan(HFOV/2),像素偏差dx对应角度theta = atan(dx / f)。但镜头畸变和安装误差会让边缘不准,所以正式验收前通常要做一张标定表:在画面横向和纵向各取 5 个点,记录像素偏差和云台实际角度,用分段线性或者二次拟合修正。标定表不用很复杂,但一定要有,否则中心准、边缘飘,追踪快速目标时就会画圈。

提示:先把云台零位、相机光轴、图像中心三件事在同一张静态图上对齐,再谈跟踪算法。顺序反了,后面调参全是玄学。

2.3 通信帧设计:视觉节点输出偏差、状态和序号

视觉节点和主控之间最常见的问题是“数据看起来发了,主控解析出来是乱码”。原因通常不是串口坏了,而是帧边界不清楚:只发浮点数,没有帧头,没有长度,没有校验,主控一上电从中间开始读,就把半个浮点当成帧头。我一般用固定帧结构:帧头两字节、长度一字节、两个浮点偏差、一个状态字节、一个序号字节、一个校验字节。浮点用小端打包,主控和视觉端约定死。

import struct def pack_frame(dx, dy, state, seq): # 帧头固定 0xAA55,长度表示 payload 字节数 head = b"\xAA\x55" # <ffBB 表示小端:两个 float、两个 unsigned char payload = struct.pack("<ffBB", dx, dy, state, seq) length = len(payload) checksum = sum(payload) & 0xFF return head + bytes([length]) + payload + bytes([checksum])

这段代码只做打包,不负责发送。dx、dy是解算后的像素偏差或归一化偏差,单位要在协议里写清楚;state用位标志表示“目标有效、目标丢失、帧超时、饱和”;seq每帧加一,主控可以用它判断有没有丢帧。checksum用简单累加和就够,竞赛现场干扰没有大到必须上 CRC,但帧头和长度必须有。发送频率通常跟图像帧率一致,50 到 60 帧足够,主控控制环再快也不能拿旧帧硬补。

3. 目标提取实现:HSV 阈值、轮廓筛选与中心点稳定的三组参数

目标提取是视觉部分最直观的一层,也是最容易被“能识别”骗过去的一层。静态图上阈值一拉,光斑清清楚楚;现场灯光一打,白墙反光、地面高光、队友衣服上的颜色全跑进来。HSV 比 RGB 稳,是因为色调 H 和亮度 V 分开,光照变化主要影响 V,颜色类别主要看 H 和 S。但 HSV 不是万能药,阈值范围给太宽会把背景吃进来,给太窄目标稍微暗一点就丢。我的习惯是先用固定曝光拍几张现场图,再用滑动条调 H、S、V 上下限,最后把范围收窄到刚好包住目标,而不是包住所有可能。

3.1 颜色空间选择:HSV 阈值要按现场光而不是屏幕光调

调阈值时不要只看摄像头预览窗口,那个窗口经过显示器和环境光,和实际保存下来的帧不是一回事。我一般先抓一帧原图存成 PNG,用离线脚本读图调参,调完再把参数写回配置文件。H 通道在 OpenCV 里是 0 到 179,红色通常跨 0 和 179 两端,所以红色目标往往要做两段掩膜再合并;绿色和蓝色一般一段就够。S 下限用来压掉白色和灰色背景,V 下限用来压掉暗部噪声。若现场有强反光,V 上限也可以适当限制,避免高光区域被当成目标。

阈值不是一劳永逸的。比赛现场从上午到下午,窗边自然光会变,LED 补光灯角度也会变。我的做法是准备两套阈值:一套“保守阈值”用于正式跟踪,宁可丢几帧也不要乱框;一套“宽松阈值”只用于调试和找目标。运行中如果连续多帧丢失,再短暂切到宽松阈值搜目标,搜到后切回保守阈值。这样比全程宽松稳,也比全程保守不容易丢。

3.2 轮廓筛选:面积、长宽比、圆形度三板斧

二值化之后直接取最大轮廓,是另一个常见翻车点。现场最大的白色区域可能是反光板,不是目标。轮廓筛选我一般看三个量:面积、外接矩形长宽比、圆形度。面积用来排除噪点和过大背景;长宽比用来排除长条形反光;圆形度4πA/P²用来排除不规则亮斑。光斑通常是近似圆形,圆形度在 0.5 以上比较合理。三个条件不必同时卡死,可以做成评分:面积越接近期望值、圆形度越高、离上一帧位置越近,分数越高。

import cv2 import numpy as np def extract_target(frame, cfg): hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) lower = np.array(cfg["hsv_lower"], dtype=np.uint8) upper = np.array(cfg["hsv_upper"], dtype=np.uint8) mask = cv2.inRange(hsv, lower, upper) # 先开后闭:去掉孤立噪点,再把断裂的光斑连起来 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best = None for c in cnts: area = cv2.contourArea(c) if area < cfg["min_area"] or area > cfg["max_area"]: continue perimeter = cv2.arcLength(c, True) if perimeter < 1e-6: continue circularity = 4 * np.pi * area / (perimeter * perimeter) if circularity < cfg["min_circularity"]: continue (cx, cy), radius = cv2.minEnclosingCircle(c) if radius < 3: continue score = area * circularity if best is None or score > best["score"]: best = { "x": float(cx), "y": float(cy), "r": float(radius), "area": float(area), "score": float(score), } return best, mask

这段函数只返回一个最佳候选和掩膜,方便调试时把掩膜显示出来看。min_area和max_area决定哪些轮廓进入评分;min_circularity卡掉不规则亮斑;score = area * circularity是简单评分,实际项目里还可以加上“离上一帧距离”的权重,避免两个相似光斑之间来回跳。注意minEnclosingCircle给的是外接圆中心,不是质心,光斑被部分遮挡时外接圆中心更稳,但形状被拉长时会偏,所以后面还要滤波。

3.3 中心点解算与滤波:滑动平均不能替代丢帧保护

中心点解算常用质心、外接圆中心、最小二乘拟合圆心。质心对形状敏感,外接圆对遮挡敏感,拟合圆心计算量大一些。竞赛场景里目标通常是圆形光斑,我一般用外接圆中心加滑动平均起步,够用且计算量小。滤波窗口不要太大,5 帧左右,窗口越大越平滑,但相位滞后越明显,云台追快速目标时会画弧。更好的做法是自适应滤波:目标静止时窗口大一点,目标快速移动时窗口小一点,用中心点位移速度判断。

丢帧保护比滤波更重要。目标短暂被遮挡时,如果立刻发“丢失”,主控会急停,再出现时又猛追,机械结构容易抖。我一般设lost_hold_frames = 5,丢失后的前 5 帧继续发上一帧偏差,但状态位标记“预测/保持”,主控可以选择降速跟踪。超过 5 帧再发丢失,主控进入搜索模式。这个数字不是固定的,云台惯量大就设大一点,惯量小就设小一点。滤波和保持都要在解算层做,不要塞进识别函数,否则离线回放时很难复现。

4. 坐标映射与云台接口:像素偏差到角度指令的闭环设计

识别到目标只是半成品,真正决定追踪效果的是映射和控制接口。像素偏差 100 在画面中心可能对应 5 度,在边缘可能对应 7 度,镜头畸变越大差异越明显。云台控制环的输入如果是“像素偏差”,主控就要自己猜比例;如果视觉节点直接发“角度增量”,主控的 PID 更容易调。我一般让视觉节点输出归一化偏差或者角度增量,单位在协议里写死,主控只做闭环。这样视觉部分可以单独用录制的像素序列回放,不用每次都接云台。

4.1 相机标定与像素-角度映射:小孔模型起步,分段标定收尾

小孔模型适合快速搭链路。已知相机水平视场角,焦距像素f = (width / 2) / tan(HFOV / 2),像素偏差dx对应的角度近似atan(dx / f)。这个公式在画面中心附近准,边缘会受畸变影响。如果镜头是广角,边缘误差可能大到让云台追偏。我的做法是先用小孔模型跑通,再在画面横向取 -240、-120、0、120、240 五个像素点,记录云台实际角度,做分段线性修正。纵向同理。标定表不用很密,五点足够覆盖竞赛场地。

标定时要注意云台角度读数和图像采集的时间同步。如果云台还在动就读数,标定表会带滞后。正确做法是云台转到位置后停 200 毫秒,等画面稳定再采图,记录当前角度和光斑中心。标定完成后,把offset_x、offset_y和分段斜率写进配置,运行时先减偏移再查表。若现场换了镜头或者相机安装角度,标定表必须重做,不能沿用旧参数。

4.2 串口协议实现:帧头、长度、校验、浮点打包

串口协议在第 2 章已经给了打包函数,这里补接收端和状态位。主控解析时先找0xAA55,再读长度,再按长度收 payload,最后校验。校验不过直接丢帧,不要尝试修复。浮点数用struct.unpack("<ffBB", payload)解析,字节序必须和发送端一致。状态位建议定义清楚:bit0 目标有效,bit1 目标丢失,bit2 帧超时,bit3 输出饱和。主控看到 bit3 时应该限幅,而不是继续加大控制量。

import struct def pack_frame(dx, dy, state, seq): # dx/dy 单位为角度增量,保留浮点;state 为位标志 head = b"\xAA\x55" payload = struct.pack("<ffBB", dx, dy, state, seq) checksum = sum(payload) & 0xFF return head + bytes([len(payload)]) + payload + bytes([checksum]) def unpack_frame(buf): # buf 至少包含帧头+长度+payload+校验 if len(buf) < 4 or buf[0] != 0xAA or buf[1] != 0x55: return None length = buf[2] if len(buf) < 3 + length + 1: return None payload = buf[3:3 + length] checksum = buf[3 + length] if (sum(payload) & 0xFF) != checksum: return None if length != 10: return None dx, dy, state, seq = struct.unpack("<ffBB", payload) return {"dx": dx, "dy": dy, "state": state, "seq": seq}

发送端每帧调用pack_frame,接收端把串口缓冲喂给unpack_frame。length != 10是防御性判断,防止协议版本不一致时解析错位。实际项目里还可以加时间戳,但竞赛现场主控和视觉端通常共用一个电源,时间戳同步要求不高,序号足够判断丢帧。若串口出现连续校验失败,先查波特率和地线,再查发送频率是否超过主控解析能力。

4.3 跟踪策略:比例控制、死区和丢帧保护

视觉节点不直接做 PID,但可以给主控一个建议控制量。比例控制u = kp * error简单有效,kp太大云台会来回摆,太小追不上快速目标。死区deadband_px用来忽略中心附近的小抖动,通常 2 到 5 像素。输出限幅必须有,否则目标突然出现在画面边缘时,云台会猛冲,机械结构容易撞限位。丢帧保护前面说过,保持帧数内继续发上一帧偏差并标记预测状态,超过后发丢失。

调参顺序我一般这样走:先把云台断开,只看视觉输出的偏差曲线,确认目标静止时偏差不跳;再接云台,给很小的kp,让云台慢慢追,确认方向对;再逐步加大kp,直到出现轻微过冲,然后回调 20%。不要一上来就上大kp,云台撞限位一次,齿轮间隙可能就变了,后面调参全是血泪经验。死区也不要设太大,否则目标在中心附近移动时云台不动,看起来像“识别丢了”,其实是控制没响应。

5. 避坑与排查:视觉部分最容易翻车的五个现场问题

视觉代码在实验室跑通,到现场翻车,通常不是算法不够先进,而是几个工程细节没兜住。下面五条是电赛视觉题里最常见的现场问题,每条按“现象、原因、解决”写,照着排查能省很多时间。

5.1 光照一变阈值全废:现象是上午能跟,下午丢目标

现象:同一套 HSV 阈值,上午在窗边能稳定跟踪,下午太阳角度变了,目标要么丢,要么背景反光被当成目标。原因:自动曝光和自动白平衡没有锁定,环境光变化让 H、S、V 整体漂移;阈值范围又给得太宽,背景一亮就进入掩膜。解决:相机曝光、增益、白平衡全部手动锁定,现场先用静态靶抓图,按实际帧调阈值;准备保守和宽松两套阈值,丢失时短暂切宽松搜索,搜到后切回保守跟踪。阈值范围收窄到刚好包住目标,不要追求“所有光照都能识别”。

5.2 反光与同色背景导致轮廓乱跳:现象是目标框在两个亮斑之间跳

现象:画面里目标明明只有一个,识别框却在两个位置之间来回跳,云台跟着左右摆。原因:现场有同色反光或者高光区域,面积和圆形度都接近目标,评分函数只按面积选最大,谁大选谁。解决:在评分里加入“离上一帧中心距离”的权重,近距离候选优先;用跟踪门限限制候选范围,超过上一帧一定像素半径的候选直接排除;实在分不开时,用形状和亮度分布做二次筛选。不要只靠面积,面积最大的不一定是目标。

5.3 坐标没对齐导致越追越偏:现象是目标在画面中心,云台却往一边跑

现象:静态看目标已经在画面中心,云台零位也调过,但一追动态目标就慢慢偏出画面。原因:图像中心、光轴中心、云台零位没有完全对齐,或者像素角度映射只用了中心点比例,边缘误差被放大。解决:先做静态对中,记录offset_x、offset_y;再用五点标定做分段修正;运行时先减偏移再映射。若换了相机安装角度,标定表必须重做。这个坑很隐蔽,因为静态图看起来一切正常。

5.4 分辨率太高帧率掉:现象是识别准但云台反应慢半拍

现象:高分辨率下识别很准,但云台总是慢半拍,快速移动目标追不上。原因:图像分辨率和帧率、处理耗时是矛盾的,大图找轮廓耗时增加,控制环拿到的是旧帧。解决:跟踪阶段降到 640×480 或 320×240,先保证 50 到 60 帧;识别只在 ROI 里做,ROI 根据上一帧中心外扩;关闭不必要的显示窗口和调试输出。帧率比单帧精度更重要,控制环吃的是延迟。

5.5 串口丢包与浮点解析错:现象是数据偶尔飞点,云台猛抖一下

现象:大部分时间正常,偶尔云台猛抖,日志里出现一个非常大的偏差值。原因:串口帧没有帧头或者校验,接收端从中间开始读;浮点字节序不一致;发送频率超过主控解析能力。解决:固定帧头、长度、校验和序号;接收端先找帧头再收长度;校验失败直接丢帧;浮点统一小端;发送频率和主控解析频率匹配。出现飞点时先看seq是否连续,再看校验失败计数,不要先怀疑算法。

6. 验收与答辩:用五组可复现测试讲清视觉部分的边界

视觉部分做完,验收不要只说“能跟踪”。我一般准备五组可复现测试,每组都有操作和通过标准,答辩时直接拿数据说话。静态靶看中心误差和抖动,阶跃看响应和过冲,匀速看跟踪滞后,遮挡看丢帧保持和恢复,光照变化看阈值鲁棒性。测试前把相机曝光锁定,把云台零位对齐,把串口日志打开,记录帧序号和偏差序列。没有日志的验收,评委一问细节就只能凭感觉答。

测试项操作通过标准
静态靶目标固定在画面中心附近 30 秒中心误差小于 5 像素,偏差峰峰值小于 3 像素
阶跃目标从中心快速移到画面一侧云台 1 秒内跟上,过冲小于 10%,无持续振荡
匀速目标沿画面横向匀速移动跟踪滞后稳定,不出现周期性画圈
遮挡手动遮挡目标 0.5 秒保持帧内不报丢失,恢复后 0.5 秒内重新锁定
光照改变补光灯角度和亮度保守阈值下不误框背景,丢失后能切宽松阈值找回

答辩讲法我建议按“约束、链路、数据、边界”四段走。先讲现场约束:光照不可控、云台有惯量、串口有延迟;再讲链路:采集、识别、解算、通信各做什么;然后拿表格里的数据讲静态误差、阶跃响应和丢帧恢复;最后主动讲失败边界,比如强反光下会误判、快速目标在画面边缘会有滞后。主动讲边界比被评委问出来更加分。代码不用逐行念,挑extract_target的筛选逻辑和pack_frame的协议设计讲清楚,足够说明你懂工程。

从那以后我每次交付视觉代码,都强制先跑一遍静态靶和阶跃测试,再谈追踪效果。没有这两组数据,调参就是碰运气。希望帮到你。

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

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

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

立即咨询