
1. 为什么要先做棋盘识别做中国象棋局面识别的第一步不是急着去训练一个棋子识别模型而是先把“棋盘在哪里”这个问题解决掉。我最初接到这个项目时第一反应也是直接上目标检测把红黑双方的车马炮仕相帅全部框出来。但真正落地踩过一遍坑之后才发现如果棋盘定位这步做不扎实后面的棋子识别、局面还原全是空中楼阁。原因很简单棋盘是整张图像的几何基准棋子的坐标、颜色、行列关系全部依赖棋盘的位置和变换参数。棋盘定位一旦偏移几个像素棋子的归属行列就可能出错一局棋的局面就完全乱了。所以这个系列的第一篇专门聚焦在“象棋棋盘的识别”上。要解决的事情很具体给定一张拍摄的象棋棋盘照片如何通过图像处理手段把棋盘区域从背景中剥离出来完成透视矫正最终输出一个规整的10行9列网格坐标系统。这一步做完后续的棋子检测、汉字识别、局面编码才会有可靠的坐标系可用。这篇内容适合谁看正在做棋类识别相关项目的开发者、对OpenCV传统视觉方案感兴趣的入门者以及想把整个局面识别链路打通又不想一上来就堆深度模型的实践者。我会把方案选型、核心原理、代码实现和踩坑记录全部摊开来讲尽量做到看完就能照着做。2. 整体方案设计先想清楚再做2.1 传统视觉方案和深度学习的取舍开局先聊方案选型。局面识别这个任务业界其实有两条路线。一条是端到端深度学习用目标检测模型直接把每个棋子连同棋盘位置一起检测出来比如YOLO系列配合自建数据集。另一条是传统视觉为主、深度模型为辅的分步方案先用OpenCV做棋盘定位和透视矫正再用分类模型识别棋子。我最终选择了传统视觉为主的分步方案理由很务实第一数据获取成本。训练一个能同时检测棋盘和32个棋子的目标检测模型少说要标注几千张图。而棋盘定位这一步只需要对棋盘外边框做几何检测不需要大规模标注数据用几何规则加少量交互就能稳定实现。第二精度和可控性。端到端模型输出的棋盘框是矩形框无法直接给出9x10的网格坐标。但传统视觉方案里的透视变换可以精确计算出每个交叉点的位置误差能控制在亚像素级别这对后续棋子落点判定来说太关键了。第三可解释性和调试成本。棋盘定位出了问题传统方案可以直接可视化中间结果是直线检测的问题、角点提取的问题还是透视变换的参数问题一目了然。但端到端模型是个黑盒出了问题很难定位原因。当然传统方案也有其局限比如对拍摄角度和光照条件更敏感。这个我们后面在算法细节和问题排查部分会给出对应的解决方案。2.2 棋盘识别模块的完整流水线整个棋盘识别模块我拆成了四个阶段每个阶段负责一个明确的目标第一阶段是图像预处理目标是增强棋盘线条和边缘特征削弱背景噪声。第二阶段是棋盘定位找到棋盘外边框的四个角点这是整个流程的几何锚点。第三阶段是透视矫正把倾斜拍摄的棋盘图像变换成俯视的正投影图像。第四阶段是网格建模在矫正后的图像上计算9x10棋盘的交叉点坐标输出标准化的网格模型。这四个阶段是一个严格的串行管道前一个阶段的输出是后一个阶段的输入。其中第二阶段的角点定位最核心也最容易出问题我会在后面的实操部分重点展开。选OpenCV作为主力工具库原因无外乎三点API稳定、文档全、社区踩坑记录丰富。Python环境下的cv2调用非常方便配合numpy做矩阵运算整套流程写下来也就两百行左右的代码核心逻辑。2.3 输入场景的假设与边界任何视觉方案都建立在一定的场景假设之上。我在设计这套流程时明确了四个适用条件一是棋盘在画面中占据主体位置占比最好在30%以上。如果棋盘太小线条检测的精度会显著下降。二是棋盘线条清晰传统木棋盘的红线、印刷纸棋盘的黑线都适用。三是棋子最好在棋盘上因为棋子能提供额外的视觉特征辅助定位空棋盘也能识别但需要依赖更精确的直线检测。四是光照均匀避免强反光和阴影覆盖棋盘区域。这四个假设不是死规矩而是为了让方案在多数场景下稳定运行。实际使用中如果某个条件不满足后面的问题排查章节会给出针对性处理建议。3. 核心细节棋盘定位的原理与实操3.1 为什么外边框角点是关键锚点棋盘识别的本质是确定一个从图像像素坐标到棋盘逻辑坐标的映射关系。中国象棋棋盘是9列10行的矩形结构逻辑坐标范围是固定的。要建立映射首先要找到棋盘外边框在图像中的位置而这个位置用四个角点来描述最为紧凑。为什么用角点而不是用整条边因为四条直线在像素坐标系下会相互干扰很难精确求解交点。但角点检测可以直接定位四条边的交点位置精度更高而且后续透视变换需要的恰恰就是这四个点的像素坐标。从信息论的角度看四个角点已经携带了透视变换所需的全部自由度信息。角点检测的具体实现最稳妥的方案是先用直线检测提取外边框的四条边再计算相邻边的交点。直线检测用霍夫变换或者更稳定的霍夫概率变换。考虑到实际场景中棋盘边框可能不是完全闭合的矩形比如被棋子部分遮挡我在实现时采用了先找直线簇再聚类筛选的策略先不要求四条边完整闭合而是从所有检测结果中选出最可靠的四条等效边。3.2 交互式校正自动检测的可靠兜底说句实话纯自动的棋盘角点检测在理想条件下可以跑通但实际项目里我很少完全依赖它。因为拍摄角度太斜、光线反射导致线条断裂、棋盘边缘被杂物遮挡这些情况随时可能让自动检测输出错误的角点。所以我的方案里做了一个交互式校正的兜底自动检测结果会显示在图像上如果检测错误用户可以用鼠标点击棋盘的四个角点手动校正。这个设计虽然增加了一个人工步骤但换来了极高的鲁棒性。毕竟棋盘识别只是整个局面识别链路的一环如果这一步错了后面所有环节都会被带偏。与其让错误一路传导到最后才发现不如在前端设置一个校验点。交互方式很简单我用OpenCV的setMouseCallback实现了一个角点标注器按顺序点击左上、右上、右下、左下四个点。配合可视化的角点序号提示整个校正过程十秒钟就能完成。3.3 透视矫正背后的数学逻辑拿到了四个目标角点接下来要做透视矫正。这里很多人会误以为棋盘是平面的用一个仿射变换就够了。但实际上普通手机拍摄的棋盘照片因为镜头和棋盘平面不平行会同时存在透视变形和仿射变形。仿射变换只能处理平移、旋转、缩放和错切但没法矫正透视投影造成的“近大远小”效应。所以要用透视变换。透视变换的数学基础是一个3x3的单应矩阵它描述了同一个平面在两个不同视角之间的映射关系。OpenCV里的getPerspectiveTransform函数输入四组对应点坐标就能求解这个单应矩阵。单应矩阵求解的原理是直接线性变换把每组对应点关系写成线性方程凑齐四组就能解出8个自由度。得到单应矩阵H之后对原始图像的每个像素坐标(x, y)通过齐次坐标变换得到变换后坐标(x, y)再通过插值完成重采样。OpenCV封装好了这一切我们只需要知道输出图像尺寸一般是650x720或与棋盘长宽比匹配的尺寸即可。3.4 网格坐标计算的细节透视矫正完成后棋盘图像已经是正投影视角。接下来要在这个图像上计算出9x10棋盘的交叉点坐标。中国象棋的棋盘结构是10条横线、9条竖线交叉点总数是90个。在矫正后的图像中棋盘外边框被映射到图像的边缘所以这90个交叉点的坐标理论上可以按等间距均分来计算。但这里有个细节很多人会忽略棋盘的行线和列线并不总是等距的特别是当棋盘有河界或边框留白时。如果直接用等分法靠近边缘的交叉点会有几个像素的偏差。我的做法是在等分计算的基础上做一次边缘对齐校正用Canny边缘检测在矫正图的外围区域做一次精细的直线拟合把计算出的交叉点坐标往真实的线条中心修正。这个校正逻辑看起来不起眼但对后续棋子的行列归属判定帮助很大。棋子落点判定本来就是像素级敏感的任务如果网格坐标有系统偏差棋盘边缘上的棋子很容易被误分到相邻的行列。4. 实操过程从零搭建棋盘识别模块4.1 环境准备和图像输入处理代码层面我用的核心环境是Python 3.9、OpenCV 4.8和NumPy 1.24。安装方式没什么特殊的pip install opencv-python numpy即可。图像输入这块我用的是在线摄像头或者图片文件两种方式。在线识别场景中每一帧图像都要走一遍棋盘识别流程如果每次都用最高分辨率性能会很吃力。我的做法是做一次金字塔缩放把长边限制在1000像素以内棋盘识别精度不会受太大影响但速度能快三到五倍。实际测试下来分辨率从4000x3000降到1000x750棋盘角点定位的像素误差从1.2像素增加到2.1像素左右完全在可接受范围内。但识别帧率从8帧提升到30帧交互体验好很多。4.2 棋盘定位的完整代码实现下面给出棋盘定位的完整代码包含了自动检测和手动校正两套方案以及中间结果的可视化import cv2 import numpy as np class ChessBoardDetector: def __init__(self): # 棋盘逻辑尺寸9列10行 self.cross_cols 9 self.cross_rows 10 self.src_points None self._click_count 0 def _mouse_callback(self, event, x, y, flags, param): 交互式角点标注回调函数 if event cv2.EVENT_LBUTTONDOWN: if self.src_points is None: self.src_points [] self.src_points.append([x, y]) self._click_count 1 print(f已标记第 {self._click_count} 个角点: ({x}, {y})) # 在图上画个圈便于确认 vis_img param cv2.circle(vis_img, (x, y), 5, (0, 0, 255), -1) cv2.imshow(mark corners, vis_img) def detect_board(self, image): 自动检测棋盘外边框角点 返回: (左上, 右上, 右下, 左下) 四个角点坐标 # 转灰度 高斯滤波去噪 gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) # 二值化 形态学闭运算增强棋盘线条连续性 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # Canny边缘检测 edges cv2.Canny(closed, 50, 150) # 霍夫概率变换检测线段 lines cv2.HoughLinesP(edges, 1, np.pi/180, threshold80, minLineLength100, maxLineGap20) if lines is None: return None # 筛选线段主要保留水平和垂直两类线段 horizontal, vertical [], [] for line in lines: x1, y1, x2, y2 line[0] angle np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi if abs(angle) 20: horizontal.append((x1, y1, x2, y2)) elif abs(abs(angle) - 90) 20: vertical.append((x1, y1, x2, y2)) # 线段不够时无法定位触发手动校正 if len(horizontal) 4 or len(vertical) 4: return None # 分别拟合最外侧的水平和垂直线 top, bottom self._fit_boundary_lines(horizontal, axishorizontal) left, right self._fit_boundary_lines(vertical, axisvertical) # 计算四角交点 points [] def intersect(line1, line2): x1, y1, x2, y2 line1 x3, y3, x4, y4 line2 # 用叉积法直接求解两条线段交点 d (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4) if abs(d) 1e-6: return None px ((x1*y2-y1*x2)*(x3-x4) - (x1-x2)*(x3*y4-y3*x4)) / d py ((x1*y2-y1*x2)*(y3-y4) - (y1-y2)*(x3*y4-y3*x4)) / d return int(px), int(py) tl intersect(top, left) tr intersect(top, right) br intersect(bottom, right) bl intersect(bottom, left) if not all([tl, tr, br, bl]): return None corners np.array([tl, tr, br, bl], dtypenp.float32) # 按顺时针排序确保顺序为左上、右上、右下、左下 corners self._order_points(corners) return corners def _fit_boundary_lines(self, lines, axis): 对同方向的线段做K-Means聚类返回最外侧两条线的端点 # 这里用极简版按线段中心点聚类取最远两簇的平均线 from sklearn.cluster import KMeans # 如果用不到sklearn也可以自己写简单的距离分簇逻辑 centers [] for x1, y1, x2, y2 in lines: if axis horizontal: centers.append([(x1x2)/2, (y1y2)/2]) else: centers.append([(x1x2)/2, (y1y2)/2]) centers np.array(centers) kmeans KMeans(n_clusters2, random_state0, n_init10).fit(centers) cluster_ids kmeans.labels_ # 取两个簇内“最外侧”的平均线 boundary_lines [] for cluster_id in [0, 1]: group [lines[i] for i in range(len(lines)) if cluster_ids[i] cluster_id] if not group: continue # 拟合一条平均线用中位数坐标比较稳健 x1s, y1s, x2s, y2s zip(*group) if axis horizontal: # 选取y均值最小的作为top最大的作为bottom avg_y np.mean([(y1s[i] y2s[i]) / 2 for i in range(len(group))]) boundary_lines.append((avg_y, np.mean(x1s), np.mean(x2s), np.mean(y1s), np.mean(y2s))) else: avg_x np.mean([(x1s[i] x2s[i]) / 2 for i in range(len(group))]) boundary_lines.append((avg_x, np.mean(x1s), np.mean(x2s), np.mean(y1s), np.mean(y2s))) if len(boundary_lines) ! 2: return None # 按位置排序输出 if axis horizontal: boundary_lines.sort(keylambda t: t[0]) top_line (int(boundary_lines[0][1]), int(boundary_lines[0][3]), int(boundary_lines[0][2]), int(boundary_lines[0][4])) bottom_line (int(boundary_lines[1][1]), int(boundary_lines[1][3]), int(boundary_lines[1][2]), int(boundary_lines[1][4])) return top_line, bottom_line else: boundary_lines.sort(keylambda t: t[0]) left_line (int(boundary_lines[0][1]), int(boundary_lines[0][3]), int(boundary_lines[0][2]), int(boundary_lines[0][4])) right_line (int(boundary_lines[1][1]), int(boundary_lines[1][3]), int(boundary_lines[1][2]), int(boundary_lines[1][4])) return left_line, right_line def _order_points(self, pts): 将四个点按左上、右上、右下、左下排序 rect np.zeros((4, 2), dtypenp.float32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect def manual_calibrate(self, image): 手动标注角点的交互入口 self.src_points None self._click_count 0 vis image.copy() cv2.namedWindow(mark corners) cv2.setMouseCallback(mark corners, self._mouse_callback, vis) cv2.imshow(mark corners, image) print(请按顺序点击棋盘的四个角点: 左上 - 右上 - 右下 - 左下) while True: key cv2.waitKey(1) 0xFF if self._click_count 4 or key ord(q): break cv2.destroyWindow(mark corners) if self.src_points and len(self.src_points) 4: return self._order_points(np.array(self.src_points, dtypenp.float32)) return None def perspective_correct(self, image, dst_cols650, dst_rows720): 透视矫正输出规整的棋盘正投影图 if self.src_points is None or len(self.src_points) ! 4: return None src self.src_points.astype(np.float32) dst np.array([[0, 0], [dst_cols - 1, 0], [dst_cols - 1, dst_rows - 1], [0, dst_rows - 1]], dtypenp.float32) M cv2.getPerspectiveTransform(src, dst) warped cv2.warpPerspective(image, M, (dst_cols, dst_rows)) return warped def compute_grid_points(self, warped, cols9, rows10): 在矫正后的图像上计算棋盘交叉点坐标 返回结构: grid[r][c] (x, y)r为行索引0~rows-1c为列索引0~cols-1 h, w warped.shape[:2] grid [] for r in range(rows): row_points [] for c in range(cols): x int((c / (cols - 1)) * (w - 1)) y int((r / (rows - 1)) * (h - 1)) row_points.append((x, y)) grid.append(row_points) return np.array(grid, dtypenp.int32) # 使用示例 if __name__ __main__: detector ChessBoardDetector() img cv2.imread(chessboard.jpg) # Step 1: 自动检测角点 corners detector.detect_board(img) # Step 2: 如果自动检测失败使用手动校正 if corners is None: print(自动检测失败请手动标注角点) corners detector.manual_calibrate(img) detector.src_points corners else: detector.src_points corners print(f自动检测角点: {corners.tolist()}) # Step 3: 透视矫正 warped detector.perspective_correct(img) if warped is not None: cv2.imwrite(warped_board.jpg, warped) # Step 4: 计算网格交叉点 grid detector.compute_grid_points(warped) # 可视化网格 vis_grid warped.copy() for r in range(grid.shape[0]): for c in range(grid.shape[1]): cv2.circle(vis_grid, tuple(grid[r][c]), 4, (0, 255, 0), -1) cv2.imwrite(grid_overlay.jpg, vis_grid) print(棋盘识别完成共生成, grid.shape[0] * grid.shape[1], 个交叉点)这段代码的关键设计有两个。一是自动检测失败时无缝切换手动校正不让流程卡死。二是网格计算直接按透视矫正后的图像尺寸等分把交叉点坐标保存在grid数组里后续棋子识别直接拿着这个数组去索引对应区域即可。4.3 校正效果验证与可视化流程跑通后一定要做可视化验证。我的习惯是把角点、矫正图和网格叠加图各保存一份然后人工检查三件事一是角点是否落在棋盘的四个真实角上偏差不超过5个像素。二是矫正后的棋盘线条是否横平竖直河界区域是否保持矩形比例。三是网格交叉点是否和真实棋盘线条对齐特别是边缘两行两列要重点检查。有一个快速验证网格对齐精度的方法直接把叠加图缩放到手机上看如果网格线整体漂移画面看起来会“发虚”如果交叉点和线条中心基本吻合画面看起来会非常清爽。视觉感受是最直观的验收手段。4.4 参数选择的经验值与调节指南代码里有几个关键参数我整理了一份经验值表便于大家根据实际场景调节参数经验值作用调节方向高斯滤波核大小(5, 5)去噪保留线条边缘图像噪声大时增大到(7, 7)但会牺牲细节Canny低阈值/高阈值50/150边缘检测阈值线条淡时降低到30/90霍夫阈值80检测的最小投票数线条断裂多时降到50minLineLength100线段最短长度小棋盘图降到60maxLineGap20线段最大间隙线条断续明显时升到30输出矫正尺寸650x720矫正图像尺寸越大越精细但计算量越大这些参数不是死的需要根据实际拍摄设备和解像度微调。我给的建议是先把一组基线参数跑通再看中间结果的边缘图和直线检测图针对明显的误检或漏检调整对应参数。每轮只调一个参数方便判断影响。5. 常见问题与排查技巧实录5.1 自动检测经常失败问题到底出在哪如果自动检测经常返回None最可能的原因是线段检测环节没有拿到足够多的水平和垂直线段。我排查的顺序是先看Canny边缘检测结果棋盘线条是否清晰连续再看二值化结果棋盘线条与背景的对比度是否足够最后看霍夫变换的threshold和minLineLength参数是否合适。一个非常典型的场景是木纹棋盘颜色较浅线条是淡红色二值化的时候很容易把线条和木纹混在一起。这种情况我会先做一个色彩通道分离用红色通道的图像做二值化因为红色线条在红色通道下和背景的对比度会更高。同理如果棋盘是黑线配浅色底直接用灰度图反而效果好。5.2 矫正后的棋盘图像变形严重透视矫正之后如果棋盘仍然有明显的透视倾斜要么是角点标注不够精确要么是目标输出尺寸没有和原始棋盘的长宽比匹配。中国象棋棋盘的长宽比大致是9:10也就是宽比高稍小。我的输出尺寸650x720这个比例基本符合标准棋盘。但如果你的棋盘是带外边框的设计实际网格区域的比例会有偏差。修正方式是手动测量一下棋盘网格区域的长宽比用这个比例重新设置目标尺寸。另一个容易忽略的点是角点顺序。getPerspectiveTransform对角点顺序敏感必须确保传入顺序是左上、右上、右下、左下。如果顺序错乱矫正结果会出现镜像或交叉扭曲排查时先看一眼角点顺序对不对。5.3 光照不均导致的线条断裂实际拍摄中室外光线下的棋盘经常一半亮一半暗。这种情况下整幅图的全局二值化阈值会失效亮区的线条被误判为背景暗区的背景被误判为线条。我的处理方案是改用自适应阈值代替全局阈值# 自适应阈值替代Otsu全局阈值 binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, blockSize31, C10)blockSize设为31是我常用的起始值C设为10可以根据线条和背景的对比度微调。这个方案对光照渐变非常有效但对强反光的改善有限因为反光区域的像素值本身已经过曝了。更彻底的方案是拍摄时避开强反光或者在预处理阶段加一个高光抑制算法。5.4 行列和坐标反向的锅不能老让算法背还有一个非常隐蔽的坑棋盘识别输出的坐标行列方向和象棋规则中的坐标定义可能不一致。中国象棋的记谱规则里红方和黑方的坐标方向正好相反红方从左往右是一到九黑方是从右往左是一到九。我的做法是在棋盘识别模块输出网格坐标的同时附带一个方向标记。在后续的局面还原模块里根据识别到的“楚河汉界”文字方向或红黑棋子的位置自动判断坐标方向。这个判断最好在系列第二篇讲棋子识别时展开但第一篇就要预留这个字段否则后续要么返工要么用临时方案把坐标硬掰过来非常别扭。6. 棋盘识别的下一步扩展思路棋盘识别模块搭建完成后后续最自然的扩展方向有两个。第一个是棋子识别。基于已经生成的90个交叉点坐标可以在每个交叉点周围取一个固定大小的ROI区域比如60x60像素把这个区域送入一个分类模型判断是哪种棋子或者为空。这个方案的优点是输入数据非常干净分类模型不需要自己找棋子位置准确率可以做得很高。棋盘识别做得好不好直接决定这个ROI区域裁得准不准。第二个是凌空手势识别。如果项目的最终目标是做“手移动棋子”的态势感知棋盘定位模块输出的坐标可以作为手部轨迹跟踪的参考坐标系。在矫正后的棋盘平面上检测手部位置再把像素坐标映射到逻辑坐标就能判断手在哪个棋子附近、往哪个方向移动、最后落在哪个交叉点。这两个方向的根基都是第一篇的棋盘识别输出所以我在编码时特别强调接口的标准化。detect_board返回角点坐标perspective_correct返回矫正图compute_grid_points返回交叉点矩阵。所有输出都设计成符合直觉的数据结构后续模块直接拿来用就行不需要了解棋盘识别内部的实现细节。7. 一个容易被忽略的细节输出坐标的持久化最后再分享一个我在实战中踩过的坑。棋盘识别模块的耗时虽然不长但在实时视频流中如果每一帧都重新做透视矫正和网格计算CPU开销还是不小的。而且如果摄像头的机位固定棋盘的位置和角度在整局游戏中基本不会变化重复计算纯属浪费。我的优化方案是做一个结果缓存在第一帧完成角点标注和透视矫正后把单应矩阵M和网格坐标序列化保存到本地文件。后续帧直接加载缓存跳过自动检测和矫正步骤。只有当画面发生大幅变化比如摄像头被移动时才重新触发一次完整识别。序列化格式可以就用json或者npy文件。我个人偏好npy因为它可以直接用numpy.load加载二进制格式解析快保存和恢复都方便。持久化代码很简单import numpy as np # 保存 np.savez(board_cache.npz, MM, gridgrid, rect_size(650, 720)) # 加载 cache np.load(board_cache.npz) M cache[M] grid cache[grid]这个缓存机制看起来不起眼但在实际项目中能把整局棋的识别帧率提高百分之三十以上。画质和稳定性都不变的情况下少做无用功才是工程上最实在的优化。