电赛E题视觉链路:红色光斑坐标稳定与云台跟踪调参

发布时间:2026/10/26 11:37:13
电赛E题视觉链路:红色光斑坐标稳定与云台跟踪调参

简介:2023年全国大学生电子设计竞赛E题视觉部分代码包面向参加电赛的参赛队伍与学生,围绕K210视觉识别与双云台协同控制,给出可复用的工程实现。视觉端基于sipeed Maix Bit开发板,涵盖图像采集、预处理、特征提取与目标检测跟踪等关键函数,并配有颜色识别、物体分类、距离测量等实战案例;控制侧提供主机云台、从机云台代码,主控采用STC32G12K128,便于快速搭建稳定的视觉系统,并在此基础上进行改造和创新。压缩包共352个文件、约19.47MB,以C/H源码、Python脚本、Keil工程文件(uvproj、uvopt、lst、obj等)及hex固件、pdf说明与md文档为主,视觉端与单片机端并存,目录按模块划分便于检索。已有619人学习,适合视觉新手与有基础的学生对照工程结构理解赛题方案、复用算法函数并梳理调试思路。

1. 电赛 E 题视觉部分:先让光斑坐标可信,再谈云台跟得快

2023 年电赛 E 题视觉部分代码包,最容易让人误判的一点是:它看起来在“找红色光斑”,真正决定成绩的却是坐标输出是否稳定。摄像头画面里红点一闪一闪,阈值稍微一改就丢目标,后面 PID 再漂亮也只能跟着抖。这份资源适合正在复现电赛 E 题视觉链路、手里有 OpenMV 或 OpenCV 摄像头加二维云台的同学,也适合想把颜色跟踪做成可调参软件模块的开发者。它解决的是从图像采集、阈值分割、轮廓筛选到串口帧输出这一段,不是完整云台工程;拿到后先跑最小闭环,再逐项换参数,比一上来改控制环靠谱。

2. 拆开视觉代码包:从图像采集到串口帧的六段链路

拿到这份视觉代码,先别从 main 一路往下读。颜色跟踪类项目的入口往往藏在“坐标出口”附近:谁把 cx、cy 变成串口字节,谁就定义了整条链路的节奏。先找这个出口,再反推检测和采集,阅读成本会低很多。

2.1 目录结构与模块职责:先找“坐标出口”

这类代码包常见命名不一定统一,但职责大体固定。下面这张表按职责列,不按文件名列;你手里的包叫 detector.py 还是 vision.py 都不重要,能对上输入输出就行。

模块职责常见输入常见输出先检查什么
参数配置无阈值、ROI、串口参数参数是否集中,能否不改代码
图像采集摄像头索引帧、时间戳是否锁曝光、分辨率是否固定
目标检测单帧图像候选轮廓、质心面积和圆度筛选是否合理
坐标解算质心、ROI归一化偏差死区、限幅、丢失状态
串口发送偏差、状态字节帧帧头、长度、校验
离线回放视频、日志复算结果能不能复现现场问题

坐标出口通常是一个打包函数。串口帧不要直接发浮点像素坐标,下位机还得再做一次映射,调试时两边容易打架。常见做法是上位机先归一化到固定整数范围,下位机只做比例缩放。下面这段是可直接抄的帧格式示例,字段长度和校验方式可以改,但思路建议保留。

import struct FRAME_HEAD = 0xAA55 def pack_frame(x: int, y: int, valid: bool, seq: int) -> bytes: # x/y 先归一化到 [-1000, 1000],下位机按云台角度范围缩放 x = max(-1000, min(1000, int(x))) y = max(-1000, min(1000, int(y))) payload = struct.pack( "<hhBBH", x, # int16,水平偏差 y, # int16,垂直偏差 1 if valid else 0, # uint8,本帧是否有效 0, # uint8,预留状态位 seq & 0xFFFF # uint16,帧序号,排查丢帧用 ) body = struct.pack("<H", FRAME_HEAD) + bytes([len(payload)]) + payload checksum = 0 for b in body: checksum ^= b return body + bytes([checksum])

逻辑说明:帧头占 2 字节,长度占 1 字节,payload 固定 6 字节,最后 1 字节异或校验。x、y 用 int16 而不是 float,是为了避免上下位机浮点解析差异;valid 单独占一字节,丢目标时下位机能立刻进入保持或回中策略,不用靠坐标 0,0 猜。seq 每帧自增,联调时看日志就能判断是视觉没出帧,还是串口丢了帧。参数上唯一容易翻车的是字节序:<表示小端,下位机如果是大端解析,收到负数会变成很大的正数,云台会直接打偏。

2.2 颜色阈值 + 轮廓筛选:为什么不是简单的 inRange

红色在 HSV 里跨了 0 度边界,只写一段inRange很容易漏掉偏紫的红光斑。常见做法是低 H 段和高 H 段各做一次掩膜,再按位或。掩膜出来之后也不能直接取最大轮廓,赛场上红色衣服、指示灯、反光贴都可能进来,必须加面积、圆度、长宽比这类几何筛选。

import cv2 import numpy as np def red_mask(hsv): # 红色跨 0 度,低段和高段都要保留 lower1 = np.array([0, 80, 60]) upper1 = np.array([10, 255, 255]) lower2 = np.array([170, 80, 60]) upper2 = np.array([180, 255, 255]) m1 = cv2.inRange(hsv, lower1, upper1) m2 = cv2.inRange(hsv, lower2, upper2) mask = cv2.bitwise_or(m1, m2) kernel = np.ones((3, 3), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return mask def find_spot(mask, min_area=30, max_area=2000): contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) best = None for c in contours: area = cv2.contourArea(c) if not (min_area <= area <= max_area): continue (cx, cy), r = cv2.minEnclosingCircle(c) if r <= 0: continue circularity = area / (3.1415926 * r * r) if circularity < 0.35: continue if best is None or area > best[0]: best = (area, cx, cy, r) return best

逻辑说明:red_mask只负责把红色区域留下,find_spot负责从候选里挑最像光斑的那个。min_area 和 max_area 跟分辨率强相关,QVGA 下 30 到 2000 像素是个起点,换到 640×480 要按面积比例放大。circularity 用面积除以最小外接圆面积,光斑越圆越接近 1;阈值 0.35 偏宽松,现场误检多就往上抬到 0.5 左右。形态学开运算去小噪点,闭运算补光斑内部空洞,核大小别超过 5×5,否则小光斑会被吃掉。这里不要把检测函数写死成只返回一个坐标,最好同时返回面积和圆度,后面排查误检时就是黑匣子数据。

2.3 坐标解算与帧格式:像素坐标怎么变成云台增量

像素坐标直接送下位机不是不行,但下位机要知道当前分辨率和 ROI 偏移,一旦视觉端改窗口,控制端就得跟着改。更稳的做法是在视觉端做归一化:以 ROI 中心为原点,把水平、垂直偏差压到 [-1,1],再乘 1000 转成整数。死区也要在视觉端先做掉,否则光斑在中心附近轻微抖动,云台会一直来回修正。

def pixel_to_norm(cx, cy, roi, dead_zone=4): x0, y0, w, h = roi center_x = x0 + w / 2 center_y = y0 + h / 2 ex = (cx - center_x) / (w / 2) ey = (cy - center_y) / (h / 2) if abs(cx - center_x) < dead_zone: ex = 0.0 if abs(cy - center_y) < dead_zone: ey = 0.0 ex = max(-1.0, min(1.0, ex)) ey = max(-1.0, min(1.0, ey)) return int(ex * 1000), int(ey * 1000)

逻辑说明:roi 传的是当前裁剪窗口的(x, y, w, h),不是整幅图像。dead_zone 单位是像素,4 像素在 320 宽画面里大约对应 1% 出头,太小没效果,太大中心会出现明显停顿。归一化后乘 1000 只是定标,不是物理角度;真正的角度映射要么在下位机做,要么在视觉端用四点标定拟合。帧格式里的 valid 要跟这个函数配合:找不到轮廓时不要发 (0,0),要发 valid=0,并保持上一帧或回中,具体策略看云台机械结构。血泪经验是,很多“云台抖”不是 PID 参数问题,而是丢目标瞬间坐标突然归零,控制器以为目标跳到中心,实际是视觉在撒谎。

3. 把代码跑起来:OpenCV 桌面调试与 OpenMV 移植的两套环境

同一套视觉逻辑,桌面端和嵌入式端要分开跑。桌面端用 OpenCV 方便看掩膜、拖阈值、录视频;OpenMV 或 K210 端负责最终上机,算力和内存都紧。两边保留同样的接口,调参结果才能搬过去。

3.1 桌面端最小可运行环境:依赖、摄像头索引与首帧检查

先建独立环境,避免和系统里的 OpenCV 打架。依赖不多,opencv-python、numpy、pyserial 够用。摄像头索引 0 和 1 经常被笔记本内置摄像头占掉,插上 USB 摄像头后先枚举一遍,别默认 0 就是外置。

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install opencv-python numpy pyserial

装完后先跑一个只读帧的小脚本,确认画面、分辨率、帧率都正常,再谈检测。下面这段设置分辨率后打印 fps,按 Esc 退出。

import cv2 import time cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) # Linux 可去掉 CAP_DSHOW,必要时换 CAP_V4L2 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 60) if not cap.isOpened(): raise RuntimeError("摄像头没打开,先换 index 或检查占用") t0 = time.time() n = 0 while True: ok, frame = cap.read() if not ok: break n += 1 if n % 30 == 0: print("fps", round(n / (time.time() - t0), 2), frame.shape) cv2.imshow("raw", frame) if cv2.waitKey(1) == 27: break cap.release() cv2.destroyAllWindows()

逻辑说明:CAP_DSHOW在 Windows 下对 USB 摄像头兼容性通常更好,Linux 下用 V4L2 或默认后端。分辨率设置不一定生效,打印frame.shape确认实际值;有些摄像头只支持 640×480,设 1280×720 会静默回退。fps 打印用于判断采集是不是瓶颈,如果显示 15 帧但设置 60,后面调 PID 前先换线或换 USB 口。首帧检查还要看画面有没有过曝,红色光斑过曝后 HSV 的 S 会掉下去,阈值再宽也救不回来。

3.2 从视频流到调参面板:用 Trackbar 做阈值粗调

阈值不要靠猜。做一个最小 Trackbar 面板,只调 H 下限、H 上限、S 下限、V 下限,实时看掩膜。先固定曝光和白平衡,再拖阈值;自动曝光开着的时候,同一组参数在画面明暗变化后就会漂。

import cv2 import numpy as np def nothing(x): pass cap = cv2.VideoCapture(0) cv2.namedWindow("tune") for name, val, maxv in [ ("hmin", 0, 180), ("hmax", 10, 180), ("smin", 80, 255), ("vmin", 60, 255) ]: cv2.createTrackbar(name, "tune", val, maxv, nothing) while True: ok, frame = cap.read() if not ok: break hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) hmin = cv2.getTrackbarPos("hmin", "tune") hmax = cv2.getTrackbarPos("hmax", "tune") smin = cv2.getTrackbarPos("smin", "tune") vmin = cv2.getTrackbarPos("vmin", "tune") mask = cv2.inRange( hsv, np.array([hmin, smin, vmin]), np.array([hmax, 255, 255]) ) cv2.imshow("raw", frame) cv2.imshow("mask", mask) if cv2.waitKey(1) == 27: break

逻辑说明:这个面板只调红色低 H 段,高 H 段固定 170 到 180 先看效果;如果光斑偏紫,把 hmax 往上抬,如果偏橙,把 hmin 往下压。S 下限压白墙和灰色物体,V 下限压暗处反光。调的时候把光斑移到靶面四个角各看一次,取四角都能稳定出掩膜的交集,不要只盯中心。参数记到配置表里,别只留在 Trackbar 窗口。窗口缩小到 320×240 看掩膜更清楚,但调参用原分辨率,否则小光斑会被缩放吃掉。

3.3 移植到 OpenMV/K210 时保留的三个接口

桌面端调好后,移植不要重写整套逻辑。保留三个接口:取帧、检测目标、发坐标。OpenMV 上用 MicroPython,颜色空间和 OpenCV 不同,阈值一般是 LAB 六元组,不是 HSV 三元组。下面这段是 OpenMV 找红色色块的骨架,重点是锁自动增益和白平衡,否则现场灯一变阈值就废。

import sensor import image import time sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) red = (30, 100, 15, 127, 15, 127) # LAB 阈值,按现场光重调 clock = time.clock() while True: clock.tick() img = sensor.snapshot() blobs = img.find_blobs( [red], pixels_threshold=30, area_threshold=30, merge=True ) if blobs: b = max(blobs, key=lambda x: x.pixels()) img.draw_cross(b.cx(), b.cy()) # 坐标出口保持独立,串口打包不要塞进检测循环 print(b.cx(), b.cy(), clock.fps())

逻辑说明:pixels_threshold和area_threshold是 OpenMV 侧的面积门槛,和桌面端的 min_area 对应但不能直接照搬,QVGA 下 30 像素起步。merge=True会把相邻色块合并,光斑碎裂时有用,但两个红色目标靠得近时也会粘在一起,现场要关掉对比。set_auto_gain(False)和set_auto_whitebal(False)是必须的,不锁的话画面会自己找平衡,阈值调了等于没调。串口发送建议单独开一个函数,按固定周期发最新坐标,不要每检测到一帧就写串口,否则帧率一高,下位机串口缓冲会积压。

4. 调参不是玄学:阈值、ROI、曝光与坐标标定的可复现步骤

视觉调参最容易变成“看起来差不多”。可复现的做法是每次只改一类参数,改完记录画面和日志。阈值、ROI、曝光、坐标映射四类分开调,云台控制环先别动,等坐标输出稳定了再进 PID。

4.1 阈值怎么设:HSV 三通道的边界与光照余量

HSV 阈值不是越窄越准。窄阈值抗干扰,但光斑稍微偏色就丢;宽阈值不容易丢,但误检多。建议先用宽阈值保召回,再用轮廓筛选压误检。下面这张表是桌面端红色光斑的起点,不是万能值,换摄像头和光源都要重调。

参数起点值现场调整方向作用
H 低段0 到 10偏橙往下压,偏紫往上抬覆盖红色低色相
H 高段170 到 180与低段合并看掩膜覆盖红色高色相
S 下限80误检白墙就抬高压低饱和背景
V 下限60暗处反光多就抬高压暗部噪声
面积下限30 像素按分辨率比例缩放去小噪点
圆度下限0.35误检多抬到 0.5保留圆形光斑

调阈值时先把摄像头曝光锁死,再把光斑打在靶面中心、四角、最远点各看一次。四角都能稳定出掩膜,才算过了光照余量这一关。赛场灯和实验室灯色温不一样,S 和 V 的下限要留出至少 20 的余量,不要卡在临界值上。红色激光点在摄像头里容易过曝,过曝区域会变成白色,H 还在但 S 掉到很低,这时候要么降曝光,要么加 ND 片,不要硬调 S 下限。

4.2 ROI 与分辨率:把算力花在靶面而不是整幅画面

整幅 640×480 做 HSV 转换和轮廓查找,桌面端无所谓,OpenMV 上帧率会掉得很难看。靶面通常只占画面中间一块,先固定 ROI,再考虑动态 ROI。固定 ROI 简单可靠,动态 ROI 跟踪上一帧坐标,能进一步省算力,但丢目标时容易把窗口带偏。

def get_roi(frame, last_xy=None, base=(80, 60, 480, 360)): h, w = frame.shape[:2] x, y, rw, rh = base if last_xy is not None: cx, cy = last_xy rw, rh = 240, 180 x = max(0, min(w - rw, int(cx - rw / 2))) y = max(0, min(h - rh, int(cy - rh / 2))) return frame[y:y + rh, x:x + rw], (x, y, rw, rh)

逻辑说明:base是固定 ROI,起点取画面中间偏下,给云台运动留边。last_xy有值时切到 240×180 的跟踪窗,窗口跟着上一帧光斑走。max(0, min(...))防止窗口越界。注意 ROI 坐标要跟着检测结果一起传给坐标解算,否则归一化中心会错。动态 ROI 的窗口不要小于 160×120,太小时光斑一出窗就彻底丢失,连重捕获的机会都没有。固定 ROI 和动态 ROI 的切换阈值要写进配置,别在代码里散落魔法数。

4.3 像素到角度:四点标定与畸变补偿的简化做法

像素偏差到云台角度不是线性关系,镜头有畸变,摄像头光轴和云台轴也不一定平行。精确标定要拍多张棋盘格,赛场上更常用简化四点法:把光斑打到靶面四个已知点,记录像素坐标和对应的云台角度,拟合一个仿射关系。精度够用,标定十分钟能做完。

import numpy as np # 采样:光斑在靶面四点时的像素坐标与云台角度 P = np.array([[100, 80], [540, 80], [100, 420], [540, 420]], dtype=np.float64) A = np.array([[-8, -6], [8, -6], [-8, 6], [8, 6]], dtype=np.float64) M = np.hstack([P, np.ones((4, 1))]) coef_x, *_ = np.linalg.lstsq(M, A[:, 0], rcond=None) coef_y, *_ = np.linalg.lstsq(M, A[:, 1], rcond=None) def pixel_to_angle(px, py): v = np.array([px, py, 1.0]) return float(v @ coef_x), float(v @ coef_y)

逻辑说明:P 是像素点,A 是对应角度,单位按你的云台协议来,示例用度。np.linalg.lstsq解最小二乘,四个点刚好能拟合仿射,点多于四个就变成超定,误差更稳。这个模型假设角度和像素近似线性,靶面小、角度范围小时够用;角度范围大就要加二次项或分象限拟合。标定采样时云台先回中,记录角度用下位机反馈值,不要用指令值,指令和实际角度可能有静态误差。标定完拿靶面九个点验一遍,最大误差超过 0.5 度就要查镜头畸变或安装角。

4.4 帧率与延迟:时间戳、缓冲队列和丢帧策略

视觉链路最怕“看起来帧率很高,控制端拿到的却是旧坐标”。采集线程、检测线程、串口发送如果各跑各的,中间不加时间戳和队列长度限制,延迟会越积越大。常见做法是只保留最新帧,旧帧直接丢,控制端永远拿当前画面算出来的坐标。

import collections import time class FrameBuffer: def __init__(self, maxlen=2): self.q = collections.deque(maxlen=maxlen) def push(self, frame, ts=None): self.q.append((frame, ts if ts is not None else time.time())) def latest(self): return self.q[-1] if self.q else (None, None)

逻辑说明:maxlen=2表示最多存两帧,第三帧进来挤掉最旧的,天然防积压。ts用time.time()或time.perf_counter()都行,关键是全链路统一。串口发送前取latest(),不要遍历队列逐帧发。如果发现控制端响应慢半拍,先打印now - ts,超过两帧周期就说明链路有积压,优先查串口写阻塞和检测耗时,不要先调 PID。帧率不是越高越好,30 帧稳定比 60 帧抖动强,尤其是颜色跟踪这种受光照影响的检测。

5. 避坑与排查:光斑丢了、云台抖了、串口乱码分别查什么

视觉部分翻车,八成不是算法多复杂,而是几个工程细节没锁死。下面这几条是这类代码包联调时反复出现的现象,按“现象→原因→解决”记,现场能省很多时间。

5.1 现象:阈值白天能用,赛场灯一变就丢目标

现象很典型:实验室里掩膜干净,换到赛场灯下红色光斑只剩几个像素,或者整幅画面都变成候选。原因通常不是阈值本身,而是摄像头自动曝光和自动白平衡还开着。画面一亮,S 和 V 整体漂移,昨天调的参数今天就不成立。另一个原因是阈值卡得太窄,H 低段只设了 0 到 5,光斑偏橙就出界。

解决分三步:先锁曝光和白平衡,再重调阈值,最后留余量。OpenCV 下不同后端控制项取值不一样,下面这段是起点,具体数值看画面。

cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # 部分后端 0.25 表示手动 cap.set(cv2.CAP_PROP_EXPOSURE, -6) # 先降曝光,避免光斑过曝 cap.set(cv2.CAP_PROP_AUTO_WB, 0) # 关自动白平衡

逻辑说明:自动曝光关闭后,画面整体亮度固定,HSV 阈值才可复现。曝光值 -6 不是通用值,要看摄像头支持范围,过暗会把红色压没。锁完再跑一遍 Trackbar,把 H 低段放宽到 0 到 10,高段保留 170 到 180,S 下限留 20 的余量。现场如果灯特别暖,H 低段可以收到 0 到 8;灯偏冷,高段往上抬。阈值改完把配置写死,别在比赛时开自动。

5.2 现象:云台低频摆动,PID 越调越糟

云台像钟摆一样来回晃,加大 P 晃得更快,加大 D 又变成高频抖。很多人第一反应是 PID 参数,实际常见原因是视觉坐标在跳。光斑质心每帧差几个像素,归一化后放大成几十个整数单位,控制器以为目标在动。另一个原因是丢帧时坐标归零,云台被拉回中心又弹出去。

解决先看日志,不看 PID。把每帧的 cx、cy、valid、时间戳写进 CSV,回放看坐标序列是不是毛刺。如果是毛刺,先加死区和限幅,再做一阶低通;如果是丢帧归零,改成 valid=0 时保持上一帧或缓慢回中。滤波不要加太重,截止频率太低会引入相位滞后,云台反而更晃。调 PID 前先把视觉坐标的抖动压到 2 个像素以内,再动控制参数。

5.3 现象:串口能收数但云台响应慢半拍

串口助手里能看到坐标在变,云台动作却明显滞后,或者动一下停一下。原因通常是发送频率高于下位机解析能力,串口缓冲积压;也可能是视觉端每检测一帧就写一次串口,检测帧率不稳定,下位机收到的帧间隔忽长忽短。还有一种情况是帧头校验太弱,下位机把数据段里的 0xAA 当成帧头,解析错位。

解决用“只发最新帧”加“固定发送周期”。检测线程更新共享坐标,发送线程按 50Hz 或 100Hz 取最新值打包,旧坐标直接丢。帧格式加长度和异或校验,下位机解析时先找帧头,再验长度,再验校验,三步都过才更新目标。波特率不够就往上提,但先确认线材和串口芯片支持。不要用print代替串口调试,打印本身会拖慢主循环。

5.4 现象:程序跑十分钟后帧率断崖下跌

刚启动 30 帧,跑十分钟掉到 10 帧,重启又恢复。常见原因是日志列表无限增长、视频窗口没释放、串口对象反复打开,或者每帧都创建大数组。Python 里列表 append 不删,内存慢慢涨,GC 一抖帧率就掉。OpenMV 上更明显,图像对象没复用也会吃内存。

解决给主循环加帧率计数,每秒打印一次;日志用固定长度队列或按文件滚动写;VideoCapture和VideoWriter在异常分支也要 release。检测函数里尽量复用掩膜和核矩阵,不要在每帧里np.ones新建。跑长时间测试至少二十分钟,看帧率曲线是不是平的。如果帧率缓慢下降但内存没涨,查串口写是不是阻塞,写超时会拖住整个循环。

5.5 现象:同一靶纸,左半边准右半边偏

光斑在靶面中心跟得很准,移到左边或右边误差变大,换另一边又反向偏。原因通常是镜头畸变、摄像头光轴和云台转轴不平行,或者四点标定只取了中心附近。像素到角度的线性模型在边缘误差最大,角度范围一大就暴露。

解决先把标定点铺开,至少覆盖靶面四角和中心,有条件做九点。检查摄像头安装:镜头平面尽量平行靶面,云台水平轴和垂直轴没有明显夹角。标定后用九点验,边缘误差超过中心误差两倍,就考虑分象限拟合或加二次项。机械安装的偏差不要靠软件硬补,补出来的模型换个场地就废。每次拆装摄像头后重新标定,不要沿用旧参数。

6. 进阶验证:用日志回放和误差统计把视觉链路压到可交付

视觉部分能不能上场,不看单帧截图好不好看,看的是连续运行时的误差分布和丢帧率。赛前我一般会录一段带时间戳的原始视频,再把检测参数离线跑一遍,至少能分清是算法问题还是现场光照问题。

6.1 录一段带时间戳的原始视频,离线复算

现场调试时间紧,录原始视频是最便宜的后悔药。把摄像头画面和每帧时间戳一起落盘,赛后用同一套检测代码复算,改阈值、改 ROI 都能重跑,不用把云台再搭一遍。

import cv2 import csv import time cap = cv2.VideoCapture(0) vw = cv2.VideoWriter( "raw_capture.avi", cv2.VideoWriter_fourcc(*"MJPG"), 30, (640, 480) ) log = open("raw_capture.csv", "w", newline="") writer = csv.writer(log) writer.writerow(["ts", "fps"]) t0 = time.time() n = 0 while n < 30 * 20: # 录 20 秒,按需改 ok, frame = cap.read() if not ok: break n += 1 vw.write(frame) writer.writerow([f"{time.time() - t0:.3f}", round(n / (time.time() - t0), 2)]) vw.release() log.close() cap.release()

逻辑说明:MJPG 编码对 CPU 压力小,帧率稳,缺点是文件大,录短片段够用。CSV 记的是写入时刻,不是曝光时刻,精确分析要用硬件时间戳或至少用perf_counter。复算时按视频帧号对齐日志,比较不同阈值下的丢帧率和质心轨迹。录的时候把现场灯、云台位置、靶纸距离记在文件名里,后面回看不会混。

6.2 用误差椭圆和丢帧率判断“能不能上场”

单看平均误差会骗人,边缘几个大误差点被平均掉,场上偏偏就是那几下打偏。至少看四个数:平均误差、95 分位误差、最大误差、丢帧率。下面这段统计可以直接接在标定采样后面。

import numpy as np err = np.array(errors) # 单位:度,来自九点验证 print("mean", round(float(err.mean()), 3)) print("p95", round(float(np.percentile(err, 95)), 3)) print("max", round(float(err.max()), 3))

逻辑说明:p95 比均值更能反映“大部分时候够不够准”,最大误差决定极端情况会不会出界。丢帧率用总帧数减有效帧数再除以总帧数,超过 5% 就要查光照或阈值。判断标准按题目要求倒推,比如允许偏差 1 度,p95 至少压到 0.5 度以内留余量。误差统计要分区域做,中心和边缘分开看,不然边缘问题会被中心数据稀释。

6.3 赛前固化参数:一份可回滚的配置表

调参过程中最怕“越调越回不去”。我习惯把关键参数集中到一张表里,每次改动前备份旧值,赛前只认最后一份。下面这张表是这类视觉链路的最小集合。

配置项起点允许范围作用
摄像头分辨率640×480320×240 到 1280×720影响算力和最小面积
曝光模式手动手动锁亮度
H 低段0 到 100 到 12红色低色相
H 高段170 到 180168 到 180红色高色相
S 下限8060 到 140压背景饱和
面积范围30 到 2000按分辨率缩放轮廓筛选
死区4 像素2 到 8中心防抖
发送周期20 ms10 到 50 ms串口节流

这张表不要只放在代码注释里,导出成独立配置文件,改完跑一遍最小闭环再上场。从那以后我每次拆装摄像头、换场地灯光,都会强制走一遍“锁曝光、重录视频、九点验证、备份配置”的

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