资讯动态

电赛E题视觉追踪:OpenCV HSV光斑识别与增量PID云台控制

发布时间:2026/10/26 11:37:55 来源:尧图企业网站定制
简介面向2023年全国大学生电子设计竞赛E题的视觉代码包适合备赛团队、机器视觉初学者及需要搭建K210视觉系统的开发者参考。内容围绕sipeed Maix Bit开发板的视觉处理展开并配套STC32G12K128主控、主机云台与从机云台工程可用于图像采集、预处理、特征提取、目标检测跟踪以及颜色识别、物体分类、距离测量等实战场景。压缩包共352个文件约19.47MB包含34个C源文件、42个头文件、5个Python脚本以及Keil工程配置、HEX与库文件、编译中间文件等便于直接编译核对和按模块查阅。已有619人学习适合用来对照赛题梳理视觉方案、复用关键函数并排查工程配置问题。1. 屏幕上一颗绿点在跑云台追成了弧线屏幕上一颗绿色光点横着走云台上的激光点跟过去轨迹是条弧线。这是我第一次跑 2023 年电赛 E 题视觉部分代码时的现场识别没丢目标串口也没丢包可闭环就是追不上——摄像头出图、OpenCV 处理、串口下发、舵机响应这四段延迟串起来相位已经落后了半个周期。这份代码解决的事情很具体从画面里把红/绿光斑抠出来算亚像素质心转成云台角度增量按固定频率下发。整份实现是纯软件Python OpenCV 加一个串口没有闭源插件依赖也不绑定特定硬件。它适合两类人正在做这道题、需要一条能跑通的视觉基线以及刚接触视觉伺服、想知道“像素怎么变成角度”这一步到底怎么落地的人。下面按我拆代码的顺序讲。2. 系统总览从摄像头到舵机的三段式链路2.1 把题目需求翻译成视觉接口这道题的功能拆开看就三块云台复位到指定点、控制激光点沿屏幕边框走一圈、以及发挥部分的追踪——红激光打靶、绿激光跟人。很多人第一版就把视觉和控制在同一个循环里混着写结果调 PID 的时候根本分不清是识别抖还是控制抖玄学得很。这份代码的分层很干脆视觉只负责“测量”控制只负责“决策”。视觉节点对外只吐一个结构目标在图像里的亚像素质心(u, v)、目标半径r、置信度一般是轮廓面积或圆度填充率、是否丢失、以及这一帧的采集时间戳。别的什么都不给。这个约定的好处是你在上位机调阈值、在下位机调 PID两边互不干扰出了偏差看一串数据就知道是测量错了还是执行错了。# comm/protocol.py —— 视觉节点对外输出的结构字段顺序就是串口帧顺序 from dataclasses import dataclass dataclass class Target: u: float 0.0 # 质心横坐标像素带亚像素小数 v: float 0.0 # 质心纵坐标 r: float 0.0 # 等效半径用来粗判距离/光斑大小 area: int 0 # 掩膜面积突降说明被遮挡 lost: bool True # 本帧没找到合格目标 stamp: float 0.0 # perf_counter() 时间戳用于算端到端延迟字段看着朴素但stamp别省。我见过不止一支队伍在调“为什么追得慢”全程靠肉眼猜实际上把采集时间戳和舵机实际响应时间一比问题立刻现形——多半是采集线程被阻塞了几十毫秒。area也很有用画边框任务里激光点扫到边框拐角时面积会突变可以用它做状态机切换的判据。2.2 目录结构与硬件链路硬件是四段摄像头固定在云台上和激光笔同轴→ 视觉板卡跑 OpenCV → 串口发角度增量 → 主控驱动两路舵机。摄像头与激光笔刚性同轴这点很关键否则你算出来的偏差里混进了安装偏角标定怎么做都不准。常见做法是用一块 3D 打印的支架把摄像头、激光笔、云台绑死装完就不再动。路径作用现场改动频率main.py主循环、线程编排、参数入口低vision/detect.pyHSV 掩膜、形态学、轮廓筛选中现场调阈值vision/centroid.py亚像素灰度加权质心低vision/calib.py像素→角度拟合与标定装完调一次control/pid.py增量式 PID、死区、限幅中调参comm/protocol.py定长帧打包、校验低config/threshold.yaml颜色阈值、面积上下限高tools/tune_hsv.py拖条调阈值并保存调试用tools/replay.py录原始视频离线回放调参调试用跑起来就一条命令参数全走命令行别把 IP、串口号写死在代码里换台机器就得改代码是很低级的翻车。# 最小启动320x24060fps串口 115200关掉调试窗口 python3 main.py --cam 0 --width 320 --height 240 --fps 60 --port /dev/ttyUSB0 --baud 115200 # 调试模式开窗口看掩膜画质降一档保帧率 python3 main.py --cam 0 --debug2.3 分辨率、帧率、曝光这三件套先定死分辨率不是越高越好。追踪场景里你要的是帧率不是清晰度。320×240 下单个 5 像素半径的光斑已经够算质心帧率能跑到 60上到 1280×720就算用 MJPG 也就 30 帧出头端到端延迟直接翻倍追移动目标必然画弧。我一般锁 320×24060只有在画边框需要更高定位精度时才临时切到 640×480 做一次静态采集。比分辨率更致命的是自动曝光和自动白平衡。默认开着的时候画面里光斑一亮一暗摄像头自己就开始调增益HSV 阈值瞬间失效——白天调好的参数灯一变全白。所以采集打开的第一件事就是把这两个自动项关掉。# main.py —— 摄像头初始化重点是“关自动” import cv2 def open_camera(args): cap cv2.VideoCapture(args.cam) # MJPG 才能跑满高帧率YUYV 在多数 USB2.0 摄像头上会掉到 15fps cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*MJPG)) cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height) cap.set(cv2.CAP_PROP_FPS, args.fps) cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1) # 1手动部分驱动取值是 0/1 反转需实测 cap.set(cv2.CAP_PROP_EXPOSURE, args.exposure) # 负数绝对值越大越暗 cap.set(cv2.CAP_PROP_AUTO_WB, 0) # 关自动白平衡 cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 只留一帧缓冲防旧帧堆积 return capCAP_PROP_AUTO_EXPOSURE这个属性是血泪坑V4L2 后端下1表示手动、3表示自动而某些 USB 摄像头驱动写的是反的。写完不要信返回值一定要用v4l2-ctl -d /dev/video0 --get-ctrlauto_exposure,exposure查实际生效值。BUFFERSIZE1也不是所有后端都支持设不上就靠后面的线程覆盖策略兜底。3. 颜色识别核心从 HSV 阈值到亚像素质心3.1 为什么阈值判断放在 HSV 而不是 RGBRGB 三个通道是耦合的光照强度一变R、G、B 一起往上抬你调好的(200, 30, 30)阈值立刻就框不住红光了。HSV 把色相 H 单独拎出来红色在 H 轴上聚在 0 和 180 两端HSV 的 H 是环形量红色跨零得开两个区间饱和度和明度只用来排除灰白背景。这就是为什么现场调参调的是 H 的上下界S/V 基本只做粗筛。# config/threshold.yaml —— 红、绿两条通道分开配红色跨零所以有两段 red: low1: [0, 120, 90] # H 0~10 这一段 high1: [10, 255, 255] low2: [170, 120, 90] # H 170~180 这一段与上一段合并 high2: [180, 255, 255] min_area: 40 max_area: 20000 green: low: [45, 100, 90] high: [85, 255, 255] min_area: 20 max_area: 8000红色的双区间是这道题第一大坑。只配[0,10]遇到偏洋红或者暖白灯下的光斑色相漂到 175 就直接丢目标。正确做法是两次inRange结果按位或。另外min_area别设太小20 像素以下的基本是反光噪点留着只会让质心乱跳。调阈值不要靠猜写个拖条工具现场十分钟能调完比对着论文抄参数靠谱得多。# tools/tune_hsv.py —— 拖条调 HSV按 s 存 yaml按 q 退出 import cv2 import numpy as np import yaml def nothing(_): pass WIN tune LIMITS {Hmin: 180, Hmax: 180, Smin: 255, Smax: 255, Vmin: 255, Vmax: 255} INIT {Hmin: 0, Hmax: 10, Smin: 120, Smax: 255, Vmin: 90, Vmax: 255} def build_ui(): cv2.namedWindow(WIN) for k, v in INIT.items(): cv2.createTrackbar(k, WIN, v, LIMITS[k], nothing) def read_ui(): g cv2.getTrackbarPos low (g(Hmin, WIN), g(Smin, WIN), g(Vmin, WIN)) high (g(Hmax, WIN), g(Smax, WIN), g(Vmax, WIN)) return np.array(low, np.uint8), np.array(high, np.uint8) if __name__ __main__: cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 320) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 240) build_ui() while True: ok, frame cap.read() if not ok: break low, high read_ui() hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, low, high) cv2.imshow(WIN, mask) cv2.imshow(raw, frame) key cv2.waitKey(1) 0xFF if key ord(q): break if key ord(s): with open(config/threshold.yaml, w) as f: yaml.safe_dump({low: low.tolist(), high: high.tolist()}, f) print(saved, low.tolist(), high.tolist())拖条上限H 是 180S/V 是 255别写成 256越界会直接抛异常。存盘用 yaml 而不是 pickle是为了现场能用文本编辑器改一个数就重跑不用再开窗口。3.2 掩膜、形态学与轮廓筛选inRange出来的掩膜一定是脏的光斑边缘有锯齿、背景有零星反光、激光笔在屏幕上还会打出一圈微弱光晕。直接findContours会得到上百个碎轮廓取最大那个也未必是目标。所以中间必须过一遍形态学。这里有个细节决定了算力核大小。3×3 椭圆核做一次开运算在 320×240 上大约 0.4ms5×5 就要 1ms 以上。追踪场景一次开运算 两次闭运算就是上限了迭代次数贪多没意义闭运算迭代两次足够把光斑内部的黑洞填掉。# vision/detect.py —— 掩膜 - 形态学 - 轮廓 - 圆形度筛选 import cv2 import numpy as np class BlobDetector: def __init__(self, low, high, min_area30, max_area20000, fill_min0.5): self.low np.array(low, np.uint8) self.high np.array(high, np.uint8) self.min_area min_area self.max_area max_area self.fill_min fill_min # 圆形填充率下限用来剔掉长条形反光 self.k cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) def detect(self, bgr): hsv cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, self.low, self.high) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, self.k, iterations1) # 去椒盐 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, self.k, iterations2) # 补内部空洞 cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best None for c in cnts: area cv2.contourArea(c) if not (self.min_area area self.max_area): continue (cx, cy), r cv2.minEnclosingCircle(c) fill area / (np.pi * r * r 1e-6) # 面积 / 外接圆面积 if fill self.fill_min or fill 1.0: continue # 细长反光 fill 很低直接扔 if best is None or area best[3]: best (cx, cy, r, area, fill) return best, maskfill这个判据比“取最大面积”稳得多。屏幕边框的反光条面积往往是光斑的好几倍单纯取最大必然选错而长条形的填充率通常低于 0.3圆形光斑一般在 0.7 以上一刀切在 0.5 就够用。fill 1.0这个判断是防浮点误差——某些轮廓算出来的面积会略大于外接圆面积不加这行会出现“明明识别到却全被丢掉”的怪现象。3.3 亚像素质心整像素和小数点后一位差着 0.3 度minEnclosingCircle出来的圆心是整像素的320×240 画面里 1 像素对应大约 0.3 度视场角。也就是说目标静止不动仅靠整像素量化你的角度输出就会在 ±0.3 度之间来回跳PID 会把这个抖动当成真实误差去追舵机就一直嗡嗡响。解决办法是亚像素加权质心。# vision/centroid.py —— 掩膜 ∩ 亮度做权重求亚像素质心 import numpy as np def weighted_centroid(mask, gray, pad3): ys, xs np.nonzero(mask) if xs.size 0: return None x0 max(0, int(xs.min()) - pad) y0 max(0, int(ys.min()) - pad) x1 min(mask.shape[1], int(xs.max()) pad 1) y1 min(mask.shape[0], int(ys.max()) pad 1) sub_m mask[y0:y1, x0:x1].astype(np.float32) / 255.0 sub_g gray[y0:y1, x0:x1].astype(np.float32) w sub_m * sub_g # 掩膜限制范围亮度决定权重 w w - w.min() # 去直流防止背景权重大面积抵消 s w.sum() if s 1e-6: return None yy, xx np.mgrid[0:w.shape[0], 0:w.shape[1]] # 向量化生成坐标网格 cx float((xx * w).sum() / s x0) cy float((yy * w).sum() / s y0) return cx, cy逻辑很直白光斑亮度近高斯分布中心最亮、边缘衰减用亮度做权重求出来的是光强重心比轮廓几何中心更接近激光的实际落点。pad给 3 像素的外扩是为了让权重包含光斑的边缘衰减部分给太大反而把邻近反光拉进来。w - w.min()这一步是我吃过亏才加的光斑在画面边缘时掩膜外扩区域里会有大片暗背景权重总和被这部分压住质心会被往画面中心方向拉误差能到 2~3 像素。还有一个前提光斑不能过曝。V 通道一旦饱和到 255整个光斑内部亮度一样加权质心就退化成几何质心亚像素精度全丢。所以曝光值要压到光斑中心 V 值在 200~240 之间tune_hsv.py里开个V通道窗口看一眼就知道。3.4 像素到角度标定这一步别省符号错了全盘皆输图像坐标是左上原点、y 向下云台角度是水平偏航 俯仰y 轴方向是反的。这个映射关系如果不显式标定靠“试出来”写个k 0.3换个摄像头焦距就得重来。常见做法是采若干组点让云台走已知角度记录光斑落在画面上的像素坐标然后最小二乘拟合。# vision/calib.py —— 一阶 二次项拟合把镜头畸变和云台非线性一起吃掉 import numpy as np def fit_poly(pix_offset, angle, use_quadTrue): pix_offset: 像素相对主点的偏移angle: 该点对应的实际舵机角度(度) d np.asarray(pix_offset, np.float64).reshape(-1, 1) a np.asarray(angle, np.float64).reshape(-1, 1) cols [d, d * np.abs(d)] if use_quad else [d] A np.hstack(cols) coef, *_ np.linalg.lstsq(A, a, rcondNone) return coef.ravel() # [k1, k2] class PixelToAngle: def __init__(self, cx, cy, coef_x, coef_y, sign_x1.0, sign_y-1.0): self.cx, self.cy cx, cy self.coef_x, self.coef_y coef_x, coef_y self.sign_x, self.sign_y sign_x, sign_y # 装配方向由标定数据决定 def __call__(self, u, v): dx, dy u - self.cx, v - self.cy ax self.sign_x * (self.coef_x[0] * dx self.coef_x[1] * dx * abs(dx)) ay self.sign_y * (self.coef_y[0] * dy self.coef_y[1] * dy * abs(dy)) return ax, ay为什么不用纯线性因为摄像头有桶形畸变、舵机连杆本身也是正弦关系画面边缘的线性拟合误差能到 1~2 度追踪大范围移动时会明显滞后。加一个d*|d|项保留正负号拟合出来是奇函数几乎不增加算力边缘误差能压到 0.5 度以内。sign_x/sign_y单独抽出来是因为舵机装配方向、摄像头是正装还是倒装每台机器都可能不同把它做成参数标定时看数据符号决定别硬编码在公式里。cx, cy用画面几何中心就够用棋盘格标出来的主点在这道题的精度要求下差别不大。4. 追踪链路串口协议、增量式 PID 与延迟预算4.1 定长帧 累加和别用换行分割视觉和下位机的通信是整个系统里最容易翻车的地方。“发一行 ASCII下位机按\n切”这种写法在实验室没事现场电磁环境一乱就粘包、半包PID 拿到半个角度值直接抽一下。定长帧 帧头同步 校验这三样成本很低但很管用。# comm/protocol.py —— 12 字节定长帧 import struct HEAD b\xAA\x55 def pack_frame(ax, ay, lost, fps_x10): ax/ay: 角度增量(度)lost: 0/1fps_x10: 帧率×10 # hhBB 小端两个 int16 两个 uint8 6 字节 body struct.pack(hhBB, int(round(ax * 100)), int(round(ay * 100)), int(lost) 0x01, int(fps_x10) 0xFF) chk struct.pack(H, sum(body) 0xFFFF) return HEAD body chk b\x00\x00 # 补齐 12 字节留作扩展位角度乘 100 转成整数是为了在 115200 波特率下不多占字节同时保留 0.01 度分辨率int16的范围是 ±327.67 度对舵机来说绰绰有余。校验用累加和而不是 CRC16是因为下位机如果是 8 位单片机CRC16 查表要占几百字节 ROM累加和在 12 字节帧上已经能挡住绝大多数误码。最后两个填充字节留着以后加序号或者时间戳改了也不影响帧长。下位机侧的关键是同步策略丢一个字节之后不能永久失步必须能自己找回来。// 下位机解析伪代码帧头同步 定长收满才处理 uint8_t buf[12]; uint8_t idx 0; void uart_rx_isr(uint8_t byte) { if (idx 0 byte ! 0xAA) return; // 等第一个帧头 if (idx 1 byte ! 0x55) { idx 0; return; } // 帧头错了重来 buf[idx] byte; if (idx 12) return; uint16_t sum 0; for (uint8_t i 2; i 8; i) sum buf[i]; // 只累加 body 6 字节 uint16_t chk buf[8] | (buf[9] 8); if (chk (sum 0xFFFF)) { int16_t ax (int16_t)(buf[2] | (buf[3] 8)); int16_t ay (int16_t)(buf[4] | (buf[5] 8)); apply_angle(ax / 100.0f, ay / 100.0f, buf[6]); } idx 0; // 无论校验成败都重置 }idx 0放在校验之后无条件执行这一行很重要校验失败时如果不清零下一个帧头会从缓冲区中间开始收之后全乱。只有校验通过才 apply、但无论如何都重置是最省心的写法。4.2 增量式 PID抗饱和、加死区、给输出限幅追踪用增量式而不是位置式理由是积分饱和。目标被挡住几帧位置式 PID 的积分项会一直累积目标重新出现时云台猛冲过头来回振两次才稳住看起来就像“系统坏了”。增量式输出的是本次相对上次的角度增量误差消失时增量自然归零不需要额外做积分限幅。# control/pid.py class IncPID: def __init__(self, kp, ki, kd, out_limit8.0, dead_zone0.3): self.kp, self.ki, self.kd kp, ki, kd self.out_limit out_limit # 单帧最大角度增量(度)防猛冲 self.dead_zone dead_zone # 死区(度)压住静止时的抖动 self.e1 self.e2 0.0 def reset(self): self.e1 self.e2 0.0 def update(self, err): if abs(err) self.dead_zone: return 0.0 e0 err du (self.kp * (e0 - self.e1) self.ki * e0 self.kd * (e0 - 2.0 * self.e1 self.e2)) self.e2, self.e1 self.e1, e0 return max(-self.out_limit, min(self.out_limit, du))参数怎么起手kp从 0.5 试能跟上但不振荡ki从 0 开始只在有明显稳态偏差时加到 0.2 左右加多了必然超调kd在这类有延迟的系统里作用有限0.05 就是上限再大等于把摄像头噪声放大。dead_zone给 0.3 度是配合 320×240 的 1 像素量化定的给太小舵机一直微调给太大又会出现明显的“台阶感”。out_limit也不是越大越好8 度/帧意味着满速 60fps 下角速度上限 480 度/秒一般舵机根本达不到写了也是白写实际按舵机标称转速折算更合理。4.3 主循环延迟预算表比调参更重要追不上移动目标九成不是 PID 参数问题是延迟。把每一环的耗时列出来就知道该动哪里。环节典型耗时压缩手段采集16~33 msMJPG 320×240 60fps转 HSV 掩膜1.5~3 ms限制 ROI别对全图做形态学1 开 2 闭1~2 ms3×3 核迭代别贪轮廓 质心0.5~1 ms只算最大轮廓质心用向量化串口下发~1 ms12 字节 115200舵机响应10~20 ms数字舵机 提高 PWM 频率采集和舵机响应是两块大头代码能压的只有中间那几毫秒。所以正确的思路是采集用独立线程做永远只保留最新一帧处理慢就覆盖绝不排队——排队等于把延迟越积越大。# main.py —— 采集线程只写不读主循环永远拿最新帧 import threading import time class FrameGrabber(threading.Thread): def __init__(self, cap): super().__init__(daemonTrue) self.cap cap self.lock threading.Lock() self.frame None self.stamp 0.0 self.alive True def run(self): while self.alive: ok, img self.cap.read() if not ok: time.sleep(0.005) continue with self.lock: self.frame img # 直接覆盖旧帧自动丢弃 self.stamp time.perf_counter() def latest(self): with self.lock: return self.frame, self.stamp主循环里再补一道“过期帧丢弃”拿到帧先算now - stamp超过 60ms 的直接跳过不处理。因为处理一帧旧画面得出的角度增量打给舵机的时候早就过时了宁可少发一帧也不要发错的。grab FrameGrabber(cap); grab.start() pid_x, pid_y IncPID(0.8, 0.20, 0.05), IncPID(0.8, 0.20, 0.05) last_stamp 0.0 while True: frame, stamp grab.latest() if frame is None or stamp last_stamp: continue # 没有新帧空转等 if (time.perf_counter() - stamp) * 1000 60: last_stamp stamp continue # 过期帧丢弃 last_stamp stamp det, mask detector.detect(frame) if det is None: ser.write(pack_frame(0, 0, 1, 0)) # 丢失就发零增量 标志位 pid_x.reset(); pid_y.reset() # 清历史误差防重新捕获时猛冲 continue cx, cy, r, area, fill det px, py weighted_centroid(mask, gray) or (cx, cy) ax, ay mapper(px, py) du_x pid_x.update(ax) du_y pid_y.update(ay) ser.write(pack_frame(du_x, du_y, 0, 60))stamp last_stamp这个判断比用队列干净避免了“处理比采集快时反复处理同一帧”导致的伪高频。丢失时reset()那两行是必须的否则 PID 里留着丢失前的误差历史重新捕获的第一帧会算出一个巨大的微分项云台直接甩出去。5. 避坑与排查现场翻车的五条记录5.1 白天调好的阈值换了灯光全白现象实验室里绿点识别稳定换到另一个房间掩膜一片全白或者全黑目标彻底丢。 原因摄像头自动曝光和自动白平衡没关或者关了但没生效。灯光一亮画面整体增益下降V 通道整体被压低你设的Vmin90就把光斑也切掉了。 解决先用v4l2-ctl --get-ctrl确认auto_exposure和white_balance_automatic实际值再手动把曝光锁死然后把Vmin留出足够余量宁可多进一点噪点靠面积和填充率筛掉也不要卡死在边缘。跑之前花两分钟在两个不同亮度环境下各看一次掩膜窗口比现场手忙脚乱强。5.2 目标在画面右边云台往左转现象识别、通信都正常云台就是往反方向跑越跑越偏直到丢目标。 原因坐标系符号错。图像 x 向右增、y 向下增而云台俯仰通常是向上为正再加上舵机装配方向可能是反的两个反向叠起来符号就乱了。 解决别猜标定。让云台水平走 5 度记录光斑像素偏移方向看dx是正还是负据此定sign_x。俯仰同理。PixelToAngle里把两个符号抽成构造参数就是为这一步准备的改参数不改代码。5.3 目标丢失后重新出现云台猛地甩出去现象遮挡两秒再放开第一帧云台猛冲来回振好几次才稳。 原因PID 里残留着丢失前和丢失期间的误差历史。增量式的e1/e2还在第一帧算出来的微分项e0 - 2*e1 e2是个大数。 解决lost分支里必须调reset()把e1/e2清零同时发零增量。另外out_limit也要有双保险。这条我踩过两次第二次是因为新加了一条“短时丢失保持上次输出”的逻辑忘了在里面也调reset。5.4 串口偶发抽一下日志里能看到半个角度值现象追踪大体正常但每隔几秒云台抖一下抓串口数据发现有帧长度不对。 原因按换行分割的文本协议遇到干扰丢一个字符就整行错位或者发送端没有做一次性write被系统拆成两次发。 解决换成定长帧 帧头 累加和接收端收满 12 字节才处理校验失败就丢帧并从下一个 0xAA 重新同步。发送端用一次ser.write(整帧)别分多次写。5.5 标称 60fps实测只有 15fps现象参数里写的 60实际跑起来明显卡顿CAP_PROP_FPS读回来却是 60。 原因没设 MJPG 四字码走默认 YUYV 格式USB 2.0 带宽不够或者分辨率给成了 640×480带宽直接超了。读回来的属性值只是“期望值”不代表实际。 解决先设 FOURCC 再设分辨率和帧率顺序不能反然后用tools/replay.py录 5 秒原始视频数一下总帧数除以时长这才是真帧率。我一般会在启动时打印一行实测帧率跑之前扫一眼。6. 二次开发把能跑的 demo 改成能调的系统这份代码的价值不在算法多新而在链路完整、每个环节都能单独替换。真要基于它做二次开发我建议按下面这个顺序动投入产出比最高。改造点具体做法收益工作量阈值外置全部挪到config/*.yaml启动时加载现场改参数不用碰代码半天录制回放tools/replay.py支持从 mp4 喂帧离线反复调参不用占着硬件半天双色并行红/绿各一个检测器同一帧跑两次追踪 打靶同时进行一天状态机复位 / 巡边 / 追踪三种模式切换应对赛题多任务切换一天在线调 PID串口加一条下行命令改参数免去反复烧程序半天配置加载这块别写复杂用 dataclass 加 yaml 就够别引入重量级配置框架# config/loader.py import yaml from dataclasses import dat p a hrefhttps://download.csdn.net/download/qq_63055262/89658468 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑