ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用OpenCV实现围棋截屏图片自动识别与SGF生成

用OpenCV实现围棋截屏图片自动识别与SGF生成 简介围棋截屏图片扫描工具是一份基于Python3开发的实用资源面向围棋爱好者、在线对弈复盘者与OCR/图像处理入门者用于将棋盘截屏图片批量转换为标准SGF棋谱文件。工具以OCR识别和图像预处理为核心通过灰度化、二值化、边缘检测、形状识别与模式匹配等技术定位棋盘格线与棋子位置再经坐标解析生成SGF文本可服务于照片、截图或纸质棋谱的数字化整理。压缩包共6个文件包含主程序go-ocr.py、README说明文档、两张示例输入图片以及两个对应的SGF输出示例整体仅4KB小巧便携便于查看和二次修改。已有1200余人学习下载。借助示例图片与输出文件读者能够直观对比识别效果快速验证工具流程同时完整Python脚本可作为图像识别实战的学习样例帮助理解从图像预处理到棋谱数据生成的关键环节对于需要整理历史棋谱或研究围棋数字化的用户具有实用与学习双重价值。 平时看棋、下棋、复盘最烦的一件事就是手动摆谱。尤其当别人甩过来一张围棋截屏图片你想把它导入棋谱软件继续研究只能一格一格地摆。我自己写了一个围棋截屏图片扫描工具用来把这类图片自动识别成局面数组甚至SGF文件实测处理19路棋盘截屏基本一次到位。这篇文章把整体思路、关键代码、踩过的坑都拆开讲一遍适合想给围棋相关工具做点小东西的开发者也适合喜欢复盘又不想手动摆谱的棋友。这个工具解决的核心问题很简单截屏图片里的棋盘是二维平面棋子是圆形色块电脑不能像人眼一样直接看出“黑棋在星位、白棋在小目”。扫描工具要做的就是把图像里每一个交叉点的状态判断出来——空点、黑子还是白子然后输出成程序可读的数据。这套逻辑看起来不复杂但真正落地时你会碰到透视变形、颜色干扰、提子后无气子残留等一堆问题。下面我按自己的实现顺序把方案拆给大家看。1. 项目思路与方案选型1.1 输入输出与核心流程先明确这个工具的输入输出。输入是一张围棋截屏可能来自棋谱软件、直播画面、拍照后裁切好的图片输出我设计了两种格式一种是纯文本局面数组方便自己写脚本继续处理另一种是标准SGF棋谱文件可以直接导入Sabaki、Lizzie这类棋谱软件。整体流程分四步第一步检测棋盘边界和交叉点坐标第二步对棋盘区域做透视矫正把倾斜的棋盘拉正第三步在每个交叉点附近取小区域分析颜色判断是黑子、白子还是空点第四步根据局面数据生成SGF或文本。这里有一个很重要的设计决策我不打算做“棋谱识别”只做“局面识别”。简单解释一下棋谱识别要还原每一步落子的先后顺序局面识别只负责还原当前棋盘上哪些位置有子、分别是什么颜色。对绝大多数复盘场景来说局面识别已经完全够用而且能避开很多合法性校验的麻烦。1.2 技术选型为什么用传统视觉方案一开始我也纠结过要不要上深度学习模型比如用YOLO或分割网络直接检测棋子。后来仔细想了一下截屏图片和真实照片的差异太大深度模型在这类干净图像上反而容易过拟合。而且围棋截屏有个天然好处棋盘线是规则的直线棋子是接近正圆的色块这些特征用OpenCV的传统图像处理手段就能稳定提取完全没必要杀鸡用牛刀。传统方案还有一个优势可解释性强。某一格识别错了你可以直接打开调试信息看那个区域的平均亮度、色相、饱和度立刻知道是阈值问题还是采样区域偏移问题。换成神经网络出了错你只能一脸懵。当然如果你的输入源是手机拍摄的斜视角照片光照复杂、棋盘变形严重、棋子还有反光那我会建议你换深度模型方案。但如果只是截屏OpenCV能解决90%的问题而且处理一张图只要几十毫秒。1.3 坐标规范化的底层逻辑识别过程中最核心的抽象是“交叉点坐标”。围棋棋盘不管是9路、13路还是19路所有棋子都落在交叉点上。只要我能拿到每个交叉点在图像里的像素坐标后面的颜色判断就只是一次区域取样。所以整个项目的地基是检测到棋盘横线和竖线求出交点坐标然后按顺序排列成19x19的网格。只要这一步稳了后面全稳。这个“先找交叉点再判断状态”的思路比直接跑目标检测找棋子要稳定得多因为交叉点的数量是固定的位置关系是刚性的天然自带几何约束。2. 核心细节棋盘定位与棋子识别2.1 棋盘直线检测与格点计算棋盘定位我用了Canny边缘检测加HoughLinesP直线检测的组合。具体流程是先转灰度图用高斯模糊去噪然后Canny提取边缘再用HoughLinesP寻找线段。拿到一堆线段之后需要对这些线段做筛选和聚类。这一步有个关键参数棋盘线在图像里通常有多条断断续续的线段不能直接用得先把它们按角度和截距归类成“横线组”和“竖线组”。简单实现是这样的对每条线段计算角度接近水平的分到横线组接近垂直的分到竖线组。然后对横线组的截距排序聚类把距离相近的线合并成一条代表线。理论上19路棋盘应该有19条横线和19条竖线但实际上有些线会被棋子完全挡住导致检测不到。所以我做了一手“保底操作”如果检测到的线数不足就用已知棋盘边界的间距做均匀插值。这里有一个我从项目中总结出来的经验检测棋盘线时不要死等19条全部齐了才继续而是只要检测到最外侧的4条边界线就可以通过边界交点反推出整个格网。围棋棋盘是等距网格知道左上、右上、左下、右下四个角点后长宽各等分18份就是所有交叉点坐标。2.2 透视矫正到底解决了什么问题很多人会问截屏图片不是正的吗为什么要做透视矫正实际遇到的截屏千奇百怪直播画面常有偏移、棋盘可能不是完整矩形、手机截屏还会带上UI元素。如果直接按原始像素坐标取样遇到棋盘有一点旋转或缩放就会全部错位。我的做法是先用检测到的交叉点建立映射关系。把检测到的每一个交叉点映射到一个理想棋盘坐标例如0到18的整数网格然后用cv2.getPerspectiveTransform计算单应性矩阵再用cv2.warpPerspective把整个棋盘区域矫正成一个正方形。这张矫正后的图是后续识别的基础。矫正之后每个交叉点在图上都有固定的相对坐标比如19路棋盘的第5行第5列在矫正图上就在(width * 4/18, height * 4/18)的位置。这时候取样就变成了一件非常机械的事遍历所有行列在对应坐标附近取圆形区域计算区域内的像素统计量。2.3 棋子状态识别棋子识别我选了最朴素的方案取样区域内的亮度和色相统计。围棋棋子特征非常明确黑子暗、白子亮、棋盘背景介于中间且通常偏暖色。对每个交叉点取以该点为中心、半径约为格子间距30%的圆形区域计算区域内像素的平均亮度、饱和度、色相。判断逻辑我用了一套规则而不是简单的单一阈值如果区域平均亮度低于设定下限判定为黑子如果区域平均亮度高于设定上限判定为白子如果介于两者之间再参考饱和度棋盘木质纹理通常饱和度偏高白子则接近中性色。这套规则应付绝大多数截图已经够了。但要注意一个细节不同棋谱软件渲染的棋盘底色差异很大有的偏黄有的偏绿有的接近白底。为了让阈值自动适应我建议在识别前先对矫正图做一次背景色采样用棋盘四角区域的均值作为背景基准然后黑子和白子的判定阈值都基于这个背景值做相对偏移。提子后的“无气子”问题也在这一步处理。正常情况下任何一方的落子如果导致对方棋子无气那些棋子应当立刻被提掉所以最终局面里不应该存在无气的棋子。但截屏来自不同软件偶尔会有渲染残留。我在生成局面数组后会跑一次连通块气数检查把所有无气的连通块过滤掉确保输出的局面合法。3. 实操过程与代码实现3.1 环境准备与依赖安装这个工具我建议用Python来写生态最省事。核心依赖只有三个OpenCV、NumPy和Sgfmill用来生成SGF文件。安装命令如下pip install opencv-python numpy sgfmill如果只是做实验Sgfmill不装也行自己拼SGF文本也就几十行的事。我先用Sgfmill主要是想少写点字符串拼接的坑尤其是坐标转换那一块。3.2 棋盘定位与透视变换核心代码第一步先把图片读进来转灰度、去噪、检测线段。这一步的代码并不长关键在于线段筛选逻辑。import cv2 import numpy as np def detect_board_lines(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blur, 50, 150) lines cv2.HoughLinesP(edges, 1, np.pi / 180, threshold100, minLineLength80, maxLineGap20) h_lines, v_lines [], [] for line in lines: x1, y1, x2, y2 line[0] angle abs(np.degrees(np.arctan2(y2 - y1, x2 - x1))) if angle 45 or angle 135: if angle 45 and angle 135: if abs(angle - 90) 10: v_lines.append((x1, y1, x2, y2)) else: h_lines.append((x1, y1, x2, y2)) return h_lines, v_lines这段代码里HoughLinesP的参数需要根据实际图片调整。minLineLength80意味着小于80像素的线段会被忽略如果棋盘在整张图中占比很小这个值要调小。maxLineGap20允许断开的线段接合直播画面线条经常有断裂这个值很有用。得到横竖线后下一步是求交点并按顺序排序。我在这里用了一个比较省事的技巧不直接对图像求交点而是先对横线和竖线的坐标做聚类得到等间距的横坐标列表和纵坐标列表然后用笛卡尔积生成19x19网格。def cluster_lines(lines, axis, tolerance15): positions [] for x1, y1, x2, y2 in lines: if axis h: pos (y1 y2) // 2 else: pos (x1 x2) // 2 found False for i, ref in enumerate(positions): if abs(pos - ref) tolerance: found True break if not found: positions.append(pos) positions.sort() return positions聚类完成后理想情况横坐标和纵坐标各有19个值。如果数量不对就取最小值和最大值按平均间隔插值补全。这一步做好透视变换的映射点就有了。def get_grid_points(h_positions, v_positions): grid [] for r, y in enumerate(h_positions): row [] for c, x in enumerate(v_positions): row.append((x, y)) grid.append(row) return grid有了网格点坐标就可以计算透视矩阵了。目标点很简单就是0到18的整数网格乘以一个固定的格子边长比如把棋盘矫正成640x640的纯正方形区域。def warp_board(img, grid, board_size640): src np.float32([ grid[0][0], # 左上 grid[0][-1], # 右上 grid[-1][0], # 左下 grid[-1][-1] # 右下 ]) dst np.float32([ [0, 0], [board_size - 1, 0], [0, board_size - 1], [board_size - 1, board_size - 1] ]) M cv2.getPerspectiveTransform(src, dst) warped cv2.warpPerspective(img, M, (board_size, board_size)) return warped, M3.3 棋子检测与局面重建矫正后的棋盘是正方形19路棋盘有18个间距。取点坐标公式是x c * cell_size其中cell_size board_size / 18。有了这个基础循环遍历361个交叉点每个点取半径约cell_size * 0.3的圆形区域。def classify_point(warped, cx, cy, cell_size, bg_brightness): radius int(cell_size * 0.3) mask np.zeros(warped.shape[:2], dtypenp.uint8) cv2.circle(mask, (cx, cy), radius, 255, -1) mean_val cv2.mean(warped, maskmask)[:3] brightness np.mean(mean_val) if brightness bg_brightness - 40: return B # 黑子 elif brightness bg_brightness 20: return W # 白子 else: return . # 空点这里背景亮度bg_brightness取矫正图四个角部区域的平均亮度。代码里的阈值-40和20是我调过的经验值不同软件截图上可能略有差异建议在工具里暴露成可配置参数。遍历完361个点后得到一个19x19的字符数组。此时要跑一遍“无气子清理”流程用广度优先搜索找出所有被围住的连通块检查该连通块是否有气如果没有则视为无效残留从局面中移除。def remove_liberties(board): n 19 # 检查每个连通块是否有气无气则置空 # 这部分逻辑就是 flood fill 找气 return board清理之后生成SGF就很机械了。SGF的棋盘坐标有特殊性列坐标是小写字母a到s行坐标也是a到s但为了表达19路棋盘有坑是跳过字母i的SGF规定i不用。很多人第一次写都会栽在这个坑上我直接贴出自己的转换函数。def to_sgf_point(row, col): # SGF 坐标列在前行在后跳过字母 i cols abcdefghjklmnopqrs return cols[col] cols[row] def board_to_sgf(board, result): sgf (;FF[4]GM[1]SZ[19] if result: sgf fRE[{result}] for r in range(19): for c in range(19): stone board[r][c] if stone in (B, W): sgf f;{stone}[{to_sgf_point(r, c)}] sgf ) return sgf这样生成出来的SGF文件里每一手棋都是独立的落子节点虽然没有手数顺序但棋子分布是准确的复盘软件打开后可以正常显示当前局面。3.4 识别结果调试与可视化为了调试方便我在工具里加了一个可视化模式把识别结果直接画在矫正图上黑子用蓝色圆圈标出白子用绿色圆圈标出空点用红色小点标出。这一步对排查问题帮助极大很多阈值问题你盯着数据看不到但一看可视化结果立刻就明白了。可视化代码就几行用cv2.circle在对应坐标上画圆即可。我建议所有做图像识别工具的朋友都保留这个功能它能让你在开发阶段少走很多弯路。4. 常见问题排查与避坑实录4.1 直线检测失败对比度与噪点我最初在本地测试时用的是自己电脑上的棋谱软件截图棋盘线条清晰怎么检测都能过。后来朋友发来一张手机直播截图棋盘区域偏小线条发虚HoughLinesP直接检测不到任何线段。排查后发现两个问题一是minLineLength设得太大把短线段全部滤掉了二是图片本身有直播弹幕叠加干扰了Canny边缘检测。解决方法也很粗暴先用cv2.resize把输入图统一缩放到宽1200像素再处理然后对Canny边缘做一次cv2.dilate膨胀把断裂的边缘连起来。这两个调整之后检测成功率明显提升。4.2 黑白子误判棋盘底色不均另一个高频问题是黑白子误判典型场景是棋盘上方有半透明悬浮图标或棋谱软件用了渐变色背景。全局固定阈值在这种情况下完全失效。我的应对策略是把“固定阈值”改成“自适应基准”。不再用固定值判断黑白而是取棋盘四个角部小区域的平均亮度作为背景基准再结合每个交叉点周围区域的实际亮度做相对比较。相当于每次都先问一句“这个棋盘的‘空’到底有多亮”再决定棋子是暗于空点还是亮于空点。实测下来这种方法对大部分棋谱软件和直播画面都有效但代价是阈值参数需要微调。我在工具里保留了一个配置函数用户可以根据自己常用的截图源调整偏移量。4.3 提子场景的歧义处理识别过程中最麻烦的坑是“提子后的局面合法性”。有些棋谱软件在提子瞬间截屏棋盘的显示状态可能是一方刚落子、被提的子还没来得及消失或者是某些动画效果让多颗棋子重叠。这时候如果直接按颜色分类会得到包含无气子的局面。我的处理方式是先跑一遍“气检查”把所有无气的连通块清掉。但这里也有歧义如果局面本身是正常的但黑棋把白棋包围了气检查也会把白棋当作无气子清掉。这显然会误伤。所以我加了一个前提条件只有局面中同时存在“理论上不该存在的无气块”时才清理。更具体地说我比较了清理前后的棋子总数如果清理前黑白双方都很多清理后某一方数量骤减那就说明这一方是被“冤枉”的恢复原始状态。这个启发式规则不算完美但已经能处理绝大多数截屏情况。4.4 SGF坐标的字母坑最后说一个纯写代码时容易踩的坑SGF坐标用的是字母a到s但跳过了字母i。也就是说第8列的下标是h第9列直接跳到j。我第一次写坐标转换时没注意到这个规则生成的SGF被Sabaki直接当作非法文件拒绝加载。解决方式就是在转换函数里明确用abcdefghjklmnopqrs这个字符串绝对不要用连续的英文字母表。这种坑一旦踩过就不会再忘也希望看到这篇文章的朋友别再踩一次。5. 后续可以扩展的方向这个工具做到现在已经可以满足我日常复盘的需求了。不过我觉得它还有一些值得扩展的方向如果大家有兴趣可以在自己的版本里尝试。第一个方向是动态截屏序列。如果每隔几秒自动截屏一次然后把前后两张棋盘的差异找出来就能推断出每一步落子的位置和提子情况从而还原出手数顺序。我简单实验过效果还不错核心逻辑就是对比相邻两帧的棋子数组找到新增棋子和消失棋子然后推导这一步是哪方下的、提走了哪些子。第二个方向是接入AI形势判断。把识别出的局面数组喂给KataGo等引擎的API可以实现“截屏即分析”看直播的时候随时掏手机扫一下棋盘就知道当前胜率走向。这个体验非常沉浸适合观赛场景。我在整个项目里的最大感受是识别准确率不是靠某个高深算法提上来的而是靠对输入源的细致观察和针对性处理。每处理一种新截屏就能发现一个没见过的渲染风格然后给工具打一个补丁。这个工具从一开始的一百行脚本慢慢变成了一个还不错的通用扫描器整个过程本身比结果更有意思。如果你手头也有棋谱归档、观赛复盘、棋谱转换之类的需求建议直接拿这个思路去改一版自己的工具。遇到奇怪图片也不要怕把中间步骤的可视化打开一格一格看识别结果问题通常十分钟就能定位。本文还有配套的精品资源点击获取
返回列表