OpenCV三子棋视觉检测方案:电赛E题高鲁棒性棋盘识别实战
简介本资源是2024年全国大学生电子设计竞赛E题‘高鲁棒性三子棋视觉识别系统’的完整OpenCV实现方案面向计算机、自动化、人工智能等专业参赛学生及课程设计学习者解决实际场景下棋盘定位、棋子识别与状态判读等核心视觉挑战。压缩包含31个文件15个Python源码、9张实测图像、5张参考图及文档总大小4.56MB代码模块清晰涵盖相机标定、棋盘角点检测quad_detector.py、HSV颜色分割colors.py、AprilTag辅助定位tags.py、棋子形态识别chess.py及终端交互tui.py等关键环节。已有620人学习下载所有脚本经导师审核并获评审99分高分配套README.md说明运行流程图像素材覆盖正视/斜视/光照变化等多工况小白可直接运行调试亦适合作为毕业设计或期末大作业的可靠技术基线。 2024年电赛E题一出视觉识别这块就成了很多队伍的生死线。题目要求在摄像头看得到的情况下实时把三子棋棋盘上的棋子状态读出来听起来就是OpenCV入门案例但现场一摆光线、角度、反光、摄像头分辨率全成了变量。我们队当时的目标很简单用OpenCV做一套高鲁棒性的三子棋棋盘和棋子视觉检测方案不管现场怎么折腾棋盘9个格子的状态输出必须稳。整套代码我后来整理成了可复用的源码结构这篇内容把完整思路和关键实现都拆出来讲。这篇东西适合正在备赛电赛、做机器视觉比赛项目或者想用传统图像处理解决固定场景目标识别的朋友。我不打算端出一份只有代码没有原理的“神仙源码”而是把每个关键决策背后的理由说清楚。这样你拿到代码框架之后至少知道现场出了问题该从哪里下手。1. 赛题拆解视觉模块到底要交什么1.1 任务本质把一张图变成9个格子状态很多队伍拿到E题之后第一反应是“我要训练一个神经网络来识别棋子”。这个方向不能说错但容易把简单问题复杂化。三子棋棋盘是固定3×3结构棋子颜色和形状也是确定的视觉模块真正要输出的东西只有一张表第0行第0列是空、红还是蓝第0行第1列是什么……也就是一个长度为9的数组。想明白这一点之后问题的边界就清晰了。棋盘会在画面里发生透视变形所以第一步要做棋盘定位棋盘线可能和棋子颜色接近所以要做透视校正后再判断棋子现场光照会变所以不能用固定RGB阈值。整条链路可以拆成四段从画面中稳定找到棋盘四个角点通过透视变换把棋盘变成一个标准正方形;在标准棋盘上划分出9个格子检测每个格子中心区域是否有棋子把检测结果编码成程序可以直接使用的棋盘状态。这个任务里没有“目标检测”“语义分割”这种复杂需求用OpenCV的传统图像处理手段完全够用而且更快、更可控、更容易在嵌入式设备上跑起来。1.2 为什么坚持用OpenCV而不是深度学习我的选择很明确优先OpenCV除非赛题要求必须识别任意手写棋盘、任意形状棋子否则不碰深度学习。原因是电赛的场景太“不互联网”了。你没法保证现场有高性能显卡树莓派上跑YOLO哪怕是最小模型帧率也会压得很低。另外深度学习方案需要数据集现场光照一变你训练出来的模型很可能直接在某个暗角翻车。更麻烦的是神经网络出了错很难排查——你只能看到“识别错了”但不知道是曝光问题、棋盘变形问题还是模型泛化问题。传统视觉的好处是每个环节都可解释、可调参、可降级。光照偏暗导致棋盘线提取失败那我可以先看二值化效果。三号格子总是误判成红色我可以单独打印那个ROI区域的颜色统计。这种逐环节调试的能力在比赛现场比算法复杂度更值钱。当然OpenCV方案也不是没有前提。它要求棋盘样式基本固定、棋子颜色基本固定、相机位置相对固定。仔细读题你会发现电赛E题恰好满足这些条件。既然条件都满足就没必要上更重的武器。1.3 系统整体流程设计我在写代码之前先画了一张处理流程图这里没有用绘图工具就直接用文字描述摄像头取流固定曝光和焦距手动关闭自动白平衡。每一帧先转灰度做自适应阈值二值化用形态学提取水平线和垂直线。通过线条投影找到棋盘最外侧四条边线的交点得到四个角点。根据角点做透视变换把棋盘区域“拉正”成600×600的图。然后在拉正的图上按3×3均分格子对每个格子中心区域做HSV颜色统计。最后用“连续两帧一致”的规则过滤抖动输出棋盘状态。这套流程里透视变换是最核心的一步。因为摄像头不可能是绝对垂直往下拍的总有一点倾斜如果直接用原图上的像素坐标去判断格子位置越靠近画面边缘偏差越大。先把棋盘拉正后面所有逻辑都简单了9个格子的中心坐标直接算出来就行。2. 高鲁棒性设计每个环节都在跟干扰较劲2.1 先把摄像头“眼睛”调稳高鲁棒性不是靠后期算法堆出来的第一步是把摄像头参数固定住。很多队伍的代码里从来不看摄像头参数自动曝光、自动白平衡全开着。实验室灯光稳定的时候没问题现场如果遇到自然光从窗户进来、或者评委头顶的射灯扫过画面亮度会在几秒内剧烈变化棋子颜色直接漂移。我建议在初始化阶段强制关闭自动曝光、自动增益和自动白平衡。以OpenCV的VideoCapture为例可以这样写cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # 尝试切到手动曝光 cap.set(cv2.CAP_PROP_EXPOSURE, 50) # 具体数值要看摄像头驱动 cap.set(cv2.CAP_PROP_AUTO_WB, 0) # 关闭自动白平衡 cap.set(cv2.CAP_PROP_FPS, 60)注意不同摄像头对CAP_PROP_AUTO_EXPOSURE的响应并不一致。有些USB摄像头传0.25表示手动有些直接传0就是手动还有的根本没有开放这个参数。写完之后务必打印一下cap.get返回值确认修改生效。如果摄像头不支持手动曝光就在代码里留一个“环境亮度补偿”接口把后续二值化阈值和饱和度阈值做成可在线调整的。我把这一步叫做“先把眼睛调成固定的焦距和瞳孔”后面所有的颜色阈值都是基于这个固定设置调出来的。只要这一步不定后面天天都在补东墙拆西墙。2.2 棋盘定位形态学提取棋盘线比直线检测稳定得多刚开始我直接想在二值图上用HoughLinesP找直线。后来发现这个问题很大棋盘外边框、纸张边缘、棋子阴影都会产生干扰直线光靠阈值参数很难把它们区分开。后来换成了形态学方法鲁棒性一下子上了一个台阶。思路很简单棋盘线是横向和纵向的长条结构我用一个很长的横向矩形核做开运算就能把水平棋盘线提取出来再用一个很长的纵向矩形核做开运算提取垂直线。因为棋子、噪点、纸张边缘在单一方向上都不如棋盘线“长”在做开运算时会被过滤掉。def extract_board_lines(gray): # 自适应阈值更适合光照不均的场景 th cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 10 ) # 提取水平线 h_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (60, 1)) h_lines cv2.morphologyEx(th, cv2.MORPH_OPEN, h_kernel) # 提取垂直线 v_kernel cv2.getStructuringElement(cv2.MORPH_RECT, (1, 60)) v_lines cv2.morphologyEx(th, cv2.MORPH_OPEN, v_kernel) return h_lines, v_lines我之所以用自适应阈值而不是Canny或固定阈值也有原因。现场光照不可能完全均匀棋盘一角可能比另一角亮很多。Canny的梯度响应在这种情况下很容易出现一条边有线、另一条边断掉的问题。adaptiveThreshold是局部计算的每个像素都和周围一个小区域的亮度分布比较对全局光照变化更不敏感。提取出水平线和垂直线之后为了拿到棋盘四角我对h_lines和v_lines做投影统计。具体来说对h_lines按行求和会出现四个明显峰值对应四条水平棋盘线的位置。对v_lines按列求和同样出现四个峰值。取最外侧两条水平线和最外侧两条垂直线的交点就是棋盘外边框的四个角。之后再给四个角排序得到左上、右上、右下、左下。如果现场允许在棋盘四角贴ArUco标记我更建议直接贴识别角度又快又准。但前提是题目不禁止如果只允许纯视觉识别棋盘那形态学方案就是最稳妥的底牌。2.3 棋子颜色和状态判断HSV加中心ROI棋盘定位做准之后棋子判断就变得非常轻松。我在拉正后的600×600棋盘图上把每个格子中心取一个半径约40像素的圆形或方形区域只统计这个区域里不同颜色的像素占比而不是全图扫描。选HSV颜色空间的原因很简单RGB里像素值随亮度变化非常剧烈同一个红色棋子在不同光照下的RGB差出三四百都正常。HSV把色调、饱和度、亮度分开H通道对光照相对稳定。我用的是OpenCV默认的HSV范围H在0到180之间。颜色阈值我会写成可配置的方便现场微调COLOR_RANGES { red: [ (0, 120, 70), (10, 255, 255), (170, 120, 70), (180, 255, 255) ], blue: [ (100, 120, 70), (124, 255, 255) ], black: [ (0, 0, 0), (180, 255, 80) ], white: [ (0, 0, 180), (180, 80, 255) ], }红色的H通道在0到10和170到180两个区间因为OpenCV的红色正好跨越0度边界。如果只写一个区间会出现红色棋子检测时有时无的情况。对于每个ROI区域我统计每种颜色在区域内出现的像素比例超过12%就认为这个位置有对应颜色的棋子。12%这个阈值是我根据棋子直径和ROI面积算出来的。如果ROI取40半径的圆形总面积约5000像素一个直径约30像素的棋子大约能覆盖700到900像素占比在14%到18%之间。留出一点余量12%既能避开棋盘线和纸面噪点又不会漏掉边缘被裁掉的棋子。其实判断有没有棋子还有一种更简单的方式直接把ROI转灰度计算灰度方差。空方格方差很小有棋子方差很大。这个方法能做粗筛但分不清颜色还是要靠HSV做最终判断。两套方法可以结合先方差粗筛再HSV精分类速度更快。2.4 多帧滤波别让抖动毁掉整个状态视觉检测最怕的是“时好时坏”。单帧识别率哪怕到了99%乘以9个格子的数量级一整局下来总会有某一帧出问题。如果控制逻辑把错误状态当成真实状态执行机械结构就会乱动。所以我加了一个很简单的多帧滤波策略每一帧检测出来的棋盘状态先放到一个长度为3的滑动窗口里只有连续3帧状态相同才把状态推给控制端。这个方案增加最多两帧的延迟对下棋过程来说完全可以接受但能挡掉绝大多数因为手指遮挡、棋子还没放稳、摄像头抖动造成的瞬时误判。3. 核心代码实现与参数调优3.1 棋盘四角检测与透视变换下面这份代码是我在实际调试中反复改过后的版本核心步骤都保留了。先看角点检测我用投影法找四条边的位置然后取交点。import cv2 import numpy as np def get_board_corners(gray): h_lines, v_lines extract_board_lines(gray) # 按行投影找水平线的y坐标 row_sum cv2.reduce(h_lines, 1, cv2.REDUCE_AVG).reshape(-1) row_sum row_sum.astype(np.float32) ys find_peak_positions(row_sum, expected4) # 按列投影找垂直线的x坐标 col_sum cv2.reduce(v_lines, 0, cv2.REDUCE_AVG).reshape(-1) col_sum col_sum.astype(np.float32) xs find_peak_positions(col_sum, expected4) ys.sort() xs.sort() # 最外侧四条线形成的四个交点 pts [ (xs[0], ys[0]), # 左上 (xs[-1], ys[0]), # 右上 (xs[-1], ys[-1]),# 右下 (xs[0], ys[-1]) # 左下 ] return np.array(pts, dtypenp.float32)find_peak_positions需要自己实现逻辑就是找一维数组里响应最强的前4个峰同时峰间距要大于某个最小像素距离防止棋盘线抖动时被当成两条线。投影统计天然有平滑效果比在二值图上直接数轮廓要稳。拿到四个角点之后我按左上、右上、右下、左下的顺序整理坐标然后用cv2.getPerspectiveTransform计算单应矩阵再warp成正方形def warp_board(frame, corners, size600): dst np.float32([ [0, 0], [size - 1, 0], [size - 1, size - 1], [0, size - 1] ]) M cv2.getPerspectiveTransform(corners, dst) warped cv2.warpPerspective(frame, M, (size, size)) return warped, M这里有一个细节四个角点的排序很重要如果顺序错乱warp出来的棋盘可能左右颠倒或上下颠倒。我习惯用“xy最小的是左上xy最大的是右下x-y最小的是右上x-y最大的是左下”来排序。虽然画面坐标系里y轴向下但这个逻辑在绝大多数情况下都能正确排序。3.2 9宫格棋子检测拉正之后9个格子中心坐标直接按均分算def build_centers(size600): centers [] cell size // 3 for row in range(3): for col in range(3): cx col * cell cell // 2 cy row * cell cell // 2 centers.append((cx, cy)) return centers每个格子的检测函数如下def check_cell(hsv, cx, cy, radius40): roi hsv[cy - radius:cy radius, cx - radius:cx radius] best_label empty best_ratio 0.0 for label, ranges in COLOR_RANGES.items(): mask np.zeros(roi.shape[:2], dtypenp.uint8) for i in range(0, len(ranges), 2): lower np.array(ranges[i], dtypenp.uint8) upper np.array(ranges[i 1], dtypenp.uint8) mask cv2.bitwise_or(mask, cv2.inRange(roi, lower, upper)) ratio cv2.countNonZero(mask) / (roi.shape[0] * roi.shape[1]) if ratio best_ratio: best_ratio ratio best_label label if best_ratio 0.12: return empty return best_label这里没有用“先定一个阈值满不满足某个颜色”而是对所有候选颜色算一遍占比取最大占比的标签。这样做的好处是即使棋子颜色因为反光导致红色像素占比只有9%蓝色像素占比只有2%也不会误判成蓝色而是会输出empty或者红色取决于阈值。同时如果棋盘上有不明杂色模型更倾向于报empty而不是乱报颜色对后续决策更安全。3.3 胜利判定一次遍历所有连线棋盘状态已经变成3×3数组后胜利判定就非常古板但是可靠。三子棋只有8种胜利连线3行、3列、2条对角线。def judge(board): lines [] for i in range(3): lines.append([(i, 0), (i, 1), (i, 2)]) lines.append([(0, i), (1, i), (2, i)]) lines.append([(0, 0), (1, 1), (2, 2)]) lines.append([(0, 2), (1, 1), (2, 0)]) for line in lines: vals [board[r][c] for r, c in line] if vals[0] ! empty and vals[0] vals[1] vals[2]: return vals[0] return None这个函数返回胜利方颜色没有胜利则返回None。实际比赛时主控程序拿到这个结果后可能会去控制机械臂或者屏幕显示。从视觉模块的角度只要把状态和胜负稳定输出职责就算完成了。3.4 实时主循环与状态输出主循环里我会把“定位”和“检测”分成两个阶段。摄像头装好后先自动定位一次棋盘获得透视矩阵M。之后每一帧直接用M对画面做透视变换不再重复找棋盘线节省大量时间。只有检测到画面变化特别大或者手动触发重新定位时才重新计算M。M None while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if M is None: corners get_board_corners(gray) if corners is None: continue warped, M warp_board(frame, corners) else: warped cv2.warpPerspective(frame, M, (600, 600)) hsv cv2.cvtColor(warped, cv2.COLOR_BGR2HSV) board [] for cx, cy in centers: board.append(check_cell(hsv, cx, cy)) board np.array(board).reshape(3, 3) winner judge(board) # 把board和winner交给主控程序 send_board_state(board, winner)这里有几个可以调性能的点。第一透视变换只需要在首次定位时计算一次之后每一帧就别再调用get_board_corners了。第二HSV转换只针对warped的600×600图像不针对整张原图。第三如果还是嫌慢可以直接减小size到450×450棋子ROI半径同步调小帧率能提上来不少。4. 现场实战中的常见问题与排查实录4.1 光照突变导致检测失灵现场最容易出现的问题就是比赛前半段光线稳定识别率很高突然某个灯被打开或者选手的影子盖住棋盘状态立刻崩了。如果你已经关闭了自动曝光理论上画面亮度不会变但问题是环境光真的变了固定曝光下画面会变暗。这时HSV仍然有可能把棋子误判为黑色。我的做法是提前在代码里留一个“亮度补偿”开关在图像预处理时判断棋盘区域的平均亮度如果明显低于预设值就自动把灰度图乘一个增益系数等效于手动提亮。这个操作要在HSV转换之前做否则H通道会受影响。一个更笨但有效的办法是赛前把现场可能的两种光照情况都记录下来分别调好两组HSV参数比赛时根据棋盘平均亮度自动切换。实测下来非常稳代价只是多存一组参数。4.2 棋子反光导致颜色误判红蓝棋子的表面如果是光面的灯光照上去会出现一片高光。高光区域的特点是亮度V很高、饱和度S很低会直接落在蓝色或者红色的HSV范围之外。如果高光占比太大红色棋子统计出来的红色比例可能不到10%直接被判断为empty。我处理这个问题的思路是在做HSV统计时把V太高、S太低的像素从掩膜里当作“不确定区域”排除掉而不是硬算。上面代码里的颜色阈值其实已经自带S下限比如红色要求S大于120蓝色要求S大于120。高光区域S通常远低于这个阈值自然不会被算进颜色比例里。如果棋子高光真的严重到半边都在反光我建议在ROI区域先用cv2.morphologyEx做一次闭运算再把中间的高光区域“填成”周围颜色再做HSV统计。这个方案对孤立的点状高光有效对整片强反光效果一般。最好的办法还是改变灯光角度减少直射。4.3 摄像头被碰歪导致透视矩阵失效还有一种高频事故调试的时候摄像头好好的比赛前搬运设备或者操作不当把摄像头碰歪了。这时M还是之前的M但棋盘在画面里的位置已经变了透视变换出来就是歪的格子中心和棋子对不上。我建议在最开始的定位阶段不只用一次角点检测而是连续检测5到10帧取角点坐标的中位数避免单帧异常。同时在每局对局开始时或者每隔100帧用当前帧重新跑一次get_board_corners计算新的M。如果新M和旧M差异很大说明视角被改了自动更新。当然这个逻辑要在性能允许的前提下加树莓派上跑一次全图角点检测大约要30到50毫秒偶尔做一次完全没问题。4.4 树莓派上实时性不够怎么优化如果用的是树莓派4B跑600×600的HSV检测一般不会卡但如果分辨率太高、代码又到处遍历像素帧率就会掉到个位数。我实际用的优化手段如下摄像头分辨率降到640×480透视变换目标也降到450×450棋子和棋盘的位置固定只需要检测3×3的ROIROI外的图像一概不处理把每一帧的RGB转HSV尽量用cv2.cvtColor一次完成不要在Python循环里做逐像素操作主循环里不要频繁print状态print很拖速度如果对帧率要求更高可以隔一帧检测一次输出给控制端的延迟反而更稳定。实测优化后树莓派4B处理一帧可以控制在20到30毫秒足够满足下棋过程的视觉反馈需求。4.5 常见故障速查表现象可能原因解决办法棋盘定位时四个角点乱跳棋盘线提取不完整投影峰不清晰检查adaptiveThreshold的blockSize和C参数增大形态学核的长度某个格子一直报空棋子颜色在HSV中占比太低调大ROI半径降低颜色判断阈值检查是否有反光红色和蓝色互相误判颜色阈值重叠或饱和度阈值太低打印该ROI区域的颜色直方图收窄H区间画面变暗后全部识别失败环境光变化且曝光被固定加入平均亮度检测自动增益或者切换HSV参数组摄像头被碰歪后所有格子错位透射矩阵M没有更新周期性重新检测棋盘四角自动更新M5. 赛前鲁棒性测试与后续优化思路5.1 压力测试清单很多队伍赛前只测“正常光照、正常角度”下的识别率这远远不够。我列一个我自己的测试清单照着跑一遍至少能排掉七成隐患。把棋盘放在画面四角和中央分别测试确认透视变换在各种位置都能拉正用手机手电筒从不同方向照射棋盘制造明暗差异测试adaptiveThreshold的稳定性在棋盘旁边放一个白色纸杯或者掀开笔记本屏幕制造大面积高亮区域测试颜色误判连续运行30分钟统计识别准确率和平均帧率看有没有内存泄漏或者温度降频人为快速放棋、快速拿棋观察多帧滤波是否生效是否出现状态抖动断电重启后只依赖摄像头自动初始化测试全流程能否一键恢复。做测试的时候强烈建议把原始图像、棋盘状态、每帧耗时一起录下来。赛后回放时可以看到是哪一帧开始错的是整个流程里非常珍贵的排错材料。5.2 可以继续优化的方向如果你们队伍时间充裕这套方案还可以再加几个增强模块。第一个是动态HSV参数校准。比赛开始前程序可以自动读取棋盘背景色和空格的HSV直方图动态调整每个颜色的上下限。这样即使不同场地的打印纸色差不一样也能自动适应。第二个是用ArUco标记做棋盘定位。如果题目允许在棋盘四角贴标记ArUco定位的速度和精度都比形态学投影法高很多而且天然自带四个角的身份信息不需要额外排序。我看到有队伍直接把棋盘画在带ArUco的打印纸上识别效果非常稳定。第三个是加入手部遮挡判断。如果下棋过程中手会频繁进出画面简单多帧滤波可能会短暂地把有棋子的格子报成empty。这时候可以用最后两帧状态做一次“状态保持”当某个格子从有棋子变成empty并且相邻帧里检测到大量肤色像素时暂时不更新这个格子的状态直到手离开后再重新判断。第四个是状态后处理。如果控制端需要连续状态流可以在视觉模块里加一个简单的有限状态机允许棋子在格子上移动但不允许突然跳跃。这个逻辑越早做后面联调机械臂的时候就越省心。最后分享一个小经验视觉识别在实验室里再稳到了现场都会因为一个灯光角度变化而崩盘。我这次最值钱的一条实战经验不是用了多高深的算法而是把摄像头参数固定死并且在测试时故意制造各种恶劣条件去折腾它。赛前把每一帧检测结果和原图一起录下来赛后回放看看是哪个环节先挂的这个排查方法比对着代码空想要快得多。整体方案跑通之后也别急着宣称100%没问题先让它连续跑一个晚上再说。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →