OpenCV三子棋视觉检测:棋盘网格透视校正与HSV棋子识别实战
简介面向2024年全国大学生电子设计竞赛E题的OpenCV视觉检测源码包专注于三子棋棋盘与棋子的高鲁棒性识别适合参赛学生、计算机相关专业学习者用于赛题复现、课程设计或毕业设计。压缩包共31个文件以15个Python脚本为核心辅以棋盘正反、斜视角等9张jpg实测图、5张png参考图及README说明整体仅4.56MB便于快速下载与部署。目前已有623人学习下载该方案经导师指导并获评审99分代码完整可运行即使新手也能按模块逐步掌握。内容覆盖相机调用、棋盘定位、棋子状态判断、颜色阈值参考、AprilTag标定等环节包含main、camera、colors、motion、quad_detector、point_detector等脚本方便按OpenCV检测流水线拆解学习是电赛备赛和项目实战的高性价比资料。1. 电赛E题三子棋视觉检测OpenCV 认棋盘这一步决定了后面全盘2024 电赛 E 题里三子棋棋盘棋子的 OpenCV 视觉检测程序承担的任务其实很单纯实时告诉控制系统9 个格子里哪个是空的哪个被红棋或蓝棋占着。但真正让历届队伍翻车的往往不是下棋策略而是视觉程序换个赛场、换个光照就失灵。这个方案的思路很直接先检测棋盘网格并做透视校正再用 HSV 颜色空间识别棋子最后把像素坐标换算成三子棋的逻辑坐标。整套代码围绕“高鲁棒性”三个字设计适合参赛选手、做机器视觉课设的学生以及任何需要在非理想光照下识别棋盘类物体的 OpenCV 项目。它解决的不是“能不能识别出来”而是“现场会不会掉链子”。2. 棋盘网格检测直线提取、交点求取与透视校正2.1 为什么先检测棋盘而不是先找棋子很多第一次做电赛视觉的同学拿到图像就直接调findContours找棋子结果发现棋子位置一会儿偏左一会儿偏右。问题出在棋盘本身摄像头很少能正对着棋盘垂直俯拍通常斜着架在机械臂旁边画面里的棋盘是一个带透视畸变的梯形。这时候每个格子的宽度在图像里不一样棋子的中心点也会整体偏移。如果不先把棋盘“扶正”后面所有坐标换算都是建立在一个不稳定的前提上。另一个原因是棋盘网格本身就是一个天然的坐标系。三子棋棋盘是 3×3横竖各有 4 条线形成 9 个格子。把 4 条横线和 4 条竖线检测出来就能得到 9 个格子的中心点再结合透视校正棋子落在哪个格子就一目了然。所以我的做法永远是先让棋盘检测通过才允许程序进入棋子识别阶段。棋盘检测失败时直接报错不让下游在垃圾数据上继续工作。2.2 用 HoughLinesP 提取棋盘网格线最小代码与 3 个关键参数棋盘线检测最稳的组合是高斯模糊 Canny HoughLinesP。先模糊是为了压掉传感器噪声Canny 负责把边缘提出来HoughLinesP 则把边缘点连成线段。这里用的是概率霍夫变换它比标准 HoughLines 快而且能直接输出线段的两个端点方便后续按角度分成横线和竖线。import cv2 import numpy as np def detect_gridlines(gray): # 棋盘线通常只有 2~3 像素宽先用 5x5 高斯模糊降噪避免 Canny 响应到碎纹理 blurred cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blurred, 50, 150) lines cv2.HoughLinesP( edges, rho1, # 距离分辨率1 像素足够 thetanp.pi / 180, # 角度分辨率1 度 threshold80, # 投票阈值太小会出碎线太大会丢线 minLineLength60, # 小于 60 像素的线段直接丢弃 maxLineGap10 # 同一条直线上断点间隔小于 10 像素就连接 ) if lines is None: return [], [] h_lines, v_lines [], [] for x1, y1, x2, y2 in lines[:, 0]: dx, dy x2 - x1, y2 - y1 angle np.degrees(np.arctan2(abs(dy), abs(dx))) if angle 15: # 接近水平 h_lines.append((x1, y1, x2, y2)) elif angle 75: # 接近垂直 v_lines.append((x1, y1, x2, y2)) return h_lines, v_lines这段代码里最需要调的是三个参数threshold、minLineLength、maxLineGap。threshold太小会让桌面纹理、棋子边缘都变成候选线段太大又会让断断续续的棋盘线检测不出来minLineLength要根据棋盘在画面里占多大来设720p 画面里棋盘边线通常有 100 像素以上取 60 比较安全maxLineGap的值决定了能不能把被棋子遮挡的线段接起来。举个例子棋子恰好压在棋盘线上时霍夫检测到的不是一整条线而是断成两截maxLineGap设 10 像素就能把这两截接回去。2.3 把线段聚类成 4 条横线和 4 条竖线再求交点HoughLinesP 返回的线段数量远多于 8 条同一条棋盘线会被检测出好几段重叠的线段。直接拿这些线段求交点会得到一堆杂乱的点所以要先聚类按线段的平均 y 坐标把水平线段分到 4 组按平均 x 坐标把竖直线段分到 4 组每组取平均得到一条“代表线”。这比直接做最小二乘拟合更抗噪因为聚类过程天然丢弃了离群线段。def cluster_to_4(lines, axish, tol12): # axish 时关注 y 坐标axisv 时关注 x 坐标 centers [] for x1, y1, x2, y2 in lines: centers.append((y1 y2) / 2 if axis h else (x1 x2) / 2) centers np.sort(centers) groups [] for c in centers: if not groups or abs(c - groups[-1][-1]) tol: groups.append([c]) else: groups[-1].append(c) # 取每组均值作为该条棋盘线的位置 return [np.mean(g) for g in groups if len(g) 2]聚类完成后做两层校验横线必须有 4 条竖线必须有 4 条缺任何一条都说明当前画面里的棋盘不完整。校验通过后横线位置和竖线位置做笛卡尔积就得到 9 个交点。这里有个容易被忽略的坑三子棋棋盘偶尔会有网格随机偏移的赛题变形硬编码“把图像均分成 3×3”是错的必须用检测出来的 8 条线来算格子边界。def get_grid_centers(h_centers, v_centers): # 横线和竖线两两相交得到 4x4 个交点相邻交点的中心就是 9 个格子的中心 centers [] for i in range(3): for j in range(3): cx (v_centers[j] v_centers[j 1]) / 2 cy (h_centers[i] h_centers[i 1]) / 2 centers.append((cx, cy)) return centers到这里棋盘在图像里的位置已经有了接下来要做透视校正。方法是找到棋盘最外圈的 4 个角点然后getPerspectiveTransform把它映射成一个固定尺寸的正方形。角点可以从 Canny 边缘的最大轮廓里找也可以用上面求出的 16 个交点里最外圈的 4 个角点。后一种更稳因为它不需要棋盘外边框足够完整。def warp_board(gray, v_centers, h_centers): # 取最外圈的 4 个角点左、右、上、下边界 xs [v_centers[0], v_centers[-1]] ys [h_centers[0], h_centers[-1]] src np.float32([ [xs[0], ys[0]], # 左上 [xs[1], ys[0]], # 右上 [xs[1], ys[1]], # 右下 [xs[0], ys[1]], # 左下 ]) dst np.float32([[0, 0], [480, 0], [480, 480], [0, 480]]) M cv2.getPerspectiveTransform(src, dst) warped cv2.warpPerspective(gray, M, (480, 480)) return warped, M透视校正之后9 个格子的中心坐标就固定了分别是 80、240、400 附近的横竖组合。后面所有棋子识别都在这张 480×480 的正方形图像里做坐标系完全可控。3. 棋子识别HSV 颜色空间、轮廓筛选与圆形拟合3.1 为什么不用 RGB红色在 HSV 里是两段区间电赛棋子的颜色通常是红、蓝两种理论上用 RGB 通道做阈值分割也能分但 RGB 对光照变化非常敏感同一个红色棋子在阴影里和在直射光下RGB 三个通道的值能差出两倍你根本没法写死一组阈值。HSV 把颜色拆成色相 H、饱和度 S、明度 V 三个维度其中 H 通道对人的颜色感知最稳定所以视觉检测程序里做颜色分割我一般直接用 HSV。这里有个新手必踩的坑红色在 HSV 色相环上跨越 0 度它既包括 0 到 10 的区域也包括 170 到 180 的区域。如果你只写一个cv2.inRange(hsv, (0, 80, 80), (10, 255, 255))画面里偏橙红的那部分棋子会被丢掉只写高区间又会丢掉偏粉红的部分。正确做法是同时取两段区间把两个 mask 用加法合并。def red_mask(hsv): # 红色在 HSV 的 H 通道跨 0 度必须分两段取否则棋子会有一半“隐身” lower_red_1 np.array([0, 60, 60]) upper_red_1 np.array([10, 255, 255]) lower_red_2 np.array([170, 60, 60]) upper_red_2 np.array([180, 255, 255]) mask1 cv2.inRange(hsv, lower_red_1, upper_red_1) mask2 cv2.inRange(hsv, lower_red_2, upper_red_2) return cv2.bitwise_or(mask1, mask2)另外注意 S 和 V 的下限不要设得太高。电赛现场的棋子是塑料材质表面会有反光反光区域的饱和度很低如果 S 下限设成 100 以上反光部分就会被判定为“不是红色”棋子中心出现一个空洞。3.2 棋子检测的代码颜色蒙版 轮廓筛选 圆形度校验拿到颜色蒙版后最稳妥的做法是先做形态学处理再用findContours找轮廓。形态学这一步留到第 4 章细说这里先给出完整的棋子检测函数。检测流程是inRange 得到蒙版 → 闭运算填充棋子内部反光空洞 → 开运算去掉边缘毛刺 → findContours → 用面积和圆形度筛选。def detect_pieces_by_color(hsv, low_red1, high_red1, low_red2, high_red2): mask red_mask(hsv) # 闭运算棋子表面的高光会把整体分成“甜甜圈”用 5x5 核把它填满 mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) # 开运算去掉棋盘线、灰尘等造成的小噪点 mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) # OpenCV 4.x 的 findContours 只返回两个值别按 3.x 的方式解包 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) pieces [] for c in contours: area cv2.contourArea(c) if area 150: # 棋盘纸上 50x50 的棋子约占 800~2000 像素具体按标定结果调 continue (x, y), r cv2.minEnclosingCircle(c) perimeter cv2.arcLength(c, True) if perimeter 0: continue circularity 4 * np.pi * area / (perimeter * perimeter) # 圆形度接近 1 才是棋子桌面上的纸屑、阴影通常小于 0.6 if circularity 0.7: pieces.append((int(x), int(y), int(r))) return pieces这段代码的核心思想是“先颜色、后形状”。颜色蒙版已经排除了绝大多数非目标物体再叠加圆形度校验基本能把误检压到零。但要注意circularity不是越高越好棋子反光严重时轮廓会不完全闭合圆形度可能只有 0.7 左右所以阈值取 0.7 比较合适。3.3 用 ROI 法替代全局检测把 9 个格子中心当成检测探针全局检测所有棋子虽然直观但在电赛现场很容易被机械臂、人体遮挡干扰。一个更好的方案是棋盘透视校正后9 个格子的中心坐标是固定的直接用 9 个 ROI 小窗口分别统计每个窗口里红色和蓝色像素的数量。哪个颜色像素多就认为这个格子被哪个棋子占据。这个方法看起来“笨”但鲁棒性极高。机械臂在图像边缘移动、纸张褶皱、光线变化都不会影响判定因为每个 ROI 只有 30×30 像素且集中在格子中心棋盘线正好穿过 ROI 的概率很小。def grid_occupancy(warped_bgr, grid_centers): hsv cv2.cvtColor(warped_bgr, cv2.COLOR_BGR2HSV) blue_low np.array([100, 60, 60]) blue_high np.array([130, 255, 255]) state np.zeros((3, 3), dtypeint) # 0 空1 红2 蓝 for idx, (cx, cy) in enumerate(grid_centers): roi hsv[cy - 15:cy 15, cx - 15:cx 15] red_cnt np.count_nonzero(red_mask(roi)) blue_cnt np.count_nonzero(cv2.inRange(roi, blue_low, blue_high)) r, c divmod(idx, 3) if red_cnt 100: state[r, c] 1 elif blue_cnt 100: state[r, c] 2 else: state[r, c] 0 return state阈值 100 怎么定一个 30×30 的 ROI 总共有 900 个像素棋子占掉一半以上红色像素数通常在 300 以上。如果棋子移出 ROI红色像素掉到 20 以下判断区间非常宽裕。这里的核心经验是ROI 法把“在整幅图里找圆”简化成了“在固定窗口里数像素”后者几乎不可能受环境影响。4. 高鲁棒性设计光照归一化、形态学处理与现场验证4.1 光照归一化CLAHE 和固定相机参数高鲁棒性的第一道防线不是算法是相机参数。电赛现场灯光复杂自动曝光会让画面亮度一直在跳自动白平衡会让颜色偏蓝偏黄来回变。所以我在程序启动时直接关闭相机的自动调节把曝光时间和白平衡固定下来cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # 关闭自动曝光 cap.set(cv2.CAP_PROP_EXPOSURE, 120) # 固定曝光时间数值按实际相机调 cap.set(cv2.CAP_PROP_AUTO_WB, 0) # 关闭自动白平衡 cap.set(cv2.CAP_PROP_WHITE_BALANCE_BLUE_U, 128) cap.set(cv2.CAP_PROP_WHITE_BALANCE_RED_V, 128)固定参数后画面亮度还是会随自然光变化。这时候用 CLAHE 做光照归一化先把图像转到 LAB 颜色空间只对 L 亮度通道做自适应直方图均衡再转换回 BGR。这样能显著提升棋盘线和棋子的对比度而且不会像普通全局直方图均衡那样让颜色失真。def normalize_illumination(bgr): lab cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) l_eq clahe.apply(l) lab_eq cv2.merge((l_eq, a, b)) return cv2.cvtColor(lab_eq, cv2.COLOR_LAB2BGR)4.2 形态学处理闭运算补洞、开运算去噪形态学看起来只是“磨皮”但在棋盘检测和棋子检测里作用完全不同。对颜色蒙版来说闭运算是把棋子表面反光形成的空洞填掉开运算是把棋盘线上零星残留的噪点擦掉。代码里我已经用过一次这里补充参数选择的依据核太大会把相邻的两个棋子连成一个轮廓核太小填不掉反光空洞。5×5 的闭运算核适合 50 像素直径左右的棋子如果棋子更大比如 80 像素就换成 7×7。对棋盘线检测形态学处理的是 Canny 边缘图先用MORPH_CLOSE把断裂的线补起来再用MORPH_OPEN去掉边缘毛刺。这里要特别注意棋盘线经过透视校正后会有轻微锯齿如果不做处理Hough 会把一条直线拆成好几段斜线聚类时横线组里混进斜线段导致交点坐标偏移。def clean_edges(edges): # 闭运算补线棋盘线被棋子压断时线段断口能接上 kernel np.ones((5, 5), np.uint8) edges cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) # 开运算去毛刺让细线更光滑避免 Hough 检测出“歪线” edges cv2.morphologyEx(edges, cv2.MORPH_OPEN, kernel) return edges4.3 “高鲁棒性”的验收方法换光照、换角度、换棋子摆法“高鲁棒性”不是一个形容词需要一套具体的验收指标。我参赛时的做法是录三段视频第一段正常室内灯光第二段把台灯从侧面打强光第三段拉上窗帘模拟阴天。每段视频里依次执行空棋盘、单红棋、单蓝棋、红蓝混合四组测试离线圈结果统计识别准确率。验收标准可以拆成三个指标单帧识别准确率不低于 98%同一个棋盘状态连续 10 帧判读结果必须完全一致棋子刚放上去到程序更新状态延迟不超过 300 毫秒。第一条考验算法第二条考验稳定性第三条考验处理速度。如果换光照后识别率掉到 90% 以下不用怀疑是玄学大概率出在 S 和 V 的区间设置上。把棋子放到阴影里截一张图在 HSV 空间观察鼠标位置的像素值直接用实际数据修正阈值。这个调试方法比靠感觉调参快得多。5. 避坑手册电赛现场最容易翻车的 5 个视觉问题5.1 装好了 OpenCV 却 import 失败conda 和 pip 各装各的现象终端里pip install opencv-python显示安装成功但运行程序时ModuleNotFoundError: No module named cv2。原因电脑上存在多个 Python 环境比如系统 Python、Anaconda 的 base 环境、VS Code 选择的项目环境。pip 装到了 A 环境而程序在 B 环境里跑。很多时候是 conda 和 pip 各管各的conda install装进 conda 环境pip install装进系统 Python互相看不见。解决不要开多个终端装包。固定使用一个环境并且每次跑程序前先确认终端里python指向的是哪个解释器。我一般在项目根目录建.venv统一用python -m pip install opencv-python安装这样环境是绑死在项目上的。5.2 红色棋子“时有时无”HSV 的红色区间不是一段现象红色棋子在画面某些位置能识别挪一格就丢了或者棋子中心出现一圈白色圆环。原因红色在 HSV 色相环上跨 0 度只写(0, 10)或只写(170, 180)都会漏掉另一半红色。另外棋子反光区域饱和度极低S 下限设太高也会把红色误判为白色。解决两段区间都取用cv2.bitwise_or合并S 和 V 下限压到 40~60 而不是默认的 80。调完阈值后用第 4 章说的三段光照视频各测一遍确保每个光照条件下红色棋子中心都被填满。5.3 手刚伸进画面棋子状态就被刷掉了现象机械臂或人手经过棋盘时程序把某个格子判成棋子状态频繁跳变。原因程序每帧都在重新检测棋子遮挡镜头或者手在网格上空划过时ROI 里出现了手的肤色被判成红色或蓝色。解决状态更新做“连续确认 延迟生效”。棋子状态连续 5 帧一致才更新不一致时保持上一帧状态。同时把机械臂下子区域和 ROI 检测区域分离ROI 只取格子中心 30×30手落在格子边缘时不会触发。还可以加一个 100ms 的检测屏蔽窗口机械臂动作期间不更新棋盘状态。5.4 荧光灯下画面像“老电视”棋子颜色跟着漂现象画面里出现横向明暗条纹棋子颜色在不同时刻忽红忽蓝。原因荧光灯频率 50Hz相机曝光时间太短时一帧图像可能刚好落在灯光的一个半波内导致亮度不稳定。更麻烦的是如果曝光时间不是灯周期的整数倍条纹会逐帧移动。解决把曝光时间固定为 10ms即 1/100 秒正好是 50Hz 灯周期的一半能有效减少频闪。如果还是不行就提高曝光时间到 20ms。注意固定曝光后要重新标定 HSV 阈值因为整个画面的亮度尺度变了。5.5 findContours 的返回值随 OpenCV 版本变化C 大小写也分“坑”现象同一段代码在队友电脑上能跑自己电脑上报ValueError: not enough values to unpackC 编译时报contourarea 未定义标识符。原因OpenCV 3.x 的findContours返回三个值(image, contours, hierarchy)OpenCV 4.x 返回两个值(contours, hierarchy)。C 版函数名大小写也容易写错正确的是cv::contourArea不少入门教程写成了ContourArea编译自然不过。解决统一用 OpenCV 4.x解包写成contours, _ cv2.findContours(...)收到不明报错先看版本号cv2.__version__。C 端调用cv::contourArea时按官方头文件核对大小写。6. 进阶验证给视觉系统设计一整套开环自检流程程序写完后不要直接拿去赛场试。我的习惯是给视觉系统单独做一个自检模式启动时自动跑一轮先检测棋盘网格统计横线和竖线数量是否都是 4再把检测结果显示在调试窗口每个格子中心用不同颜色的圆点标注判读结果最后输出一行诊断日志比如“board ok, lines 4/4; occupancy R0 B1 E7”。这个流程能帮你快速定位问题出在棋盘检测还是棋子识别。自检模关键代码是把前面的检测函数串成一个selftestdef selftest(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) h_lines, v_lines detect_gridlines(gray) if len(h_lines) 4 or len(v_lines) 4: return False, {横线: len(h_lines), 竖线: len(v_lines)} h_centers cluster_to_4(h_lines, axish) v_centers cluster_to_4(v_lines, axisv) warped, _ warp_board(gray, v_centers, h_centers) centers get_grid_centers(h_centers, v_centers) state grid_occupancy(warped, centers) return True, state如果自检模式报棋盘线缺失优先看 Canny 阈值和minLineLength如果棋盘线正常但 occupancy 全空把normalize_illumination加进selftest的入口再试一次。另一个验证手段是“黄金帧回放”在实验室保存一帧包含空棋盘和所有棋子状态的图像每次改完参数后用同一帧离线跑一遍保证回归测试结果不变。防止你今天调好了红色明天把蓝色阈值改崩了还不知道。调试的时候把图像处理中的中间结果全部显示出来Canny 边缘、HSV 蒙版、聚类后的 8 条线、透视校正后的棋盘、9 个 ROI 的放大图。每级中间结果都看得到问题出在哪一级一眼就能锁定这比面对黑匣子乱调参数高效得多。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →