中国象棋棋盘识别实战:网格拟合与交点定位的完整方案
2026/9/15 15:41:20 网站建设 项目流程

做中国象棋自动局面识别之前,我一直以为“把棋盘找出来”是整条链路里最白给的一步。直到自己架好摄像头、拍了真实照片才发现,即使棋盘端端正正摆在正下方,想同时准确拿到90个交叉点也不是轻松事。这个项目我明确拆成了两个阶段,第一阶段的工作全部是“把棋盘本身认出来”,也就是后面做棋子识别、生成局面字符串的必要前提。本文不涉及完整棋局AI,只讲棋盘识别这个子模块的完整思路、几何原理和调试经验;如果你正在做棋盘类图像的检测、几何校正、交点定位相关的事,这一段经验应该能帮你少踩不少坑。

1. 棋盘识别项目的真实边界:不是“画个框”那么简单

1.1 我开始时低估的三个约束条件

大多数人拿到“识别中国象棋棋盘”的第一反应是找外框。抓四个角、做透视变换、裁图,看起来一套流程下来就完事。但中国象棋棋盘和围棋棋盘有一个根本差异:很多普通象棋棋盘根本没有完整闭合的外边框,最外侧那圈线就是边界,线上没有加粗,也没有额外包裹。这意味着依赖“闭合轮廓”的策略一开局就废掉一半。

第二个被低估的点是“楚河汉界”这条横带。棋盘上下半场各有5条横向线,中间隔着一个较高的空白区域,横带区域里通常印着“楚河”“汉界”或一些装饰纹样。对于霍夫直线检测来说,单看横向线的位置分布,中间天然存在一个比正常行距大得多的间隔,如果不做针对性的“断带识别”,聚类时很容易把上下半场的横线当成两组独立结构,甚至在拼接棋盘时丢失整体比例。

第三个约束是棋盘上有大量“干扰线段”。九宫格内的斜线(士角斜线)、河界两侧的断线、木纹背景、棋子造成的遮挡,这些都会在边缘检测阶段被放大。真正的难度不是“找不找得到线”,而是“怎么从几百条线段里精确挑出属于棋盘的9条竖线、10条横线”。

1.2 拆解完整识别链路,棋盘定位的位置在哪里

我把整套局面识别拆成四步链:

  • 棋盘定位:找到棋盘区域,确定其边界与朝向;
  • 网格拟合:解算出9列10行的交点在图像中的坐标集合;
  • 棋子识别:在交点或其相邻邻域检测、分类棋子;
  • 局面生成:把棋子映射到棋规坐标系,输出局面字符串。

棋盘识别这个环节同时承担前两步。它输出的不是一张“好看的裁剪图”,而是一组经过透视校正后的网格交点坐标。后续棋子识别完全可以基于这组坐标去做:不需要重新找棋盘,只需要判断每个交点上有没有棋子、是什么棋子。

很多业余项目失败在第三步,回头查原因十有八九是棋盘识别精度不够。比如交点偏移了半个棋子直径,切割出来的子图像自然不准确;如果俯视角度稍微偏一点,不做透视校正就去检测棋子,边缘棋子图被拉长,分类器误判率会明显上升。所以棋盘识别必须当作一个精确的几何解算任务来对待,而不是当作粗定位任务。

1.3 识别通过的标准怎么定义

我给自己定了一个容易量化的验收标准,而不是含糊地说“看起来识别对了”:

  • 必须稳定输出90个交叉点坐标,顺序对应逻辑棋盘的第0行到第9行、第0列到第8列;
  • 在没有棋子的干净棋盘图上,90个点全部检出,且误差小于2个像素;
  • 在有棋子遮挡场景下,至少保持80%以上的交点被正确确定,未被遮挡部分不允许出现错位;
  • 透视校正后,任意相邻行间距与相邻列间距的相对误差小于5%。

这个标准定出来之后,后续所有调参都有了明确方向。第4条尤其关键,它决定了识别结果在物理意义上是否逼近真实棋盘结构。

2. 预处理中的关键取舍:如何让线条从木纹背景里“干净”地露出来

2.1 真实拍摄棋盘时常见的四类画面

摄像头正对棋盘、光线均匀的“理想画面”在真实项目中极少见。我实际遇到的主要有四类:

  • 木纹背景明显的实木棋盘,棕色或深红色表面上有深色线条,边缘检测后纹理边缘和棋盘线混在一起;
  • 裱布棋盘或塑料棋盘,表面有轻微反光,斜着看时线条被高光吞掉;
  • 透明玻璃板下垫棋谱的棋盘,玻璃反光带出周边环境,产生大量虚假边缘;
  • 用手机随手拍、存在明显透视畸变的场景,线条在远端逐渐密集。

这四类场景对预处理的要求其实完全不同。实木棋盘需要抑制纹理;反光场景需要局部对比度增强;玻璃场景需要避开大块反光区域;透视场景则更多依赖后续的几何校正而非预处理本身。所以预处理不能只调一套固定参数,至少要区分“纹理型干扰”和“光照型干扰”两条路。

2.2 滤波、边缘检测与形态学:我保留的处理顺序

我最终保留的预处理顺序是:转灰度 → 中值滤波 → CLAHE增强 → Canny边缘检测 → 形态学膨胀。每一步都踩过不同的坑,简单说一下为什么这样排。

转灰度是所有后续操作的前提。这里建议使用 OpenCV 的cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),而不是直接取RGB某一通道。单独取红色通道在某些红木棋盘上效果尚可,但一旦棋盘线条是黑色,而木底偏红,红通道反而会拉低线条与背景的对比度。灰度转换的加权公式已经很好兼顾了人眼感知亮度。

中值滤波我选的核大小为5。为什么不用高斯滤波?高斯滤波对高斯噪声效果好,但对“椒盐型”噪声和细小纹理毛刺的抑制能力不足;中值滤波在保留边缘锐度的同时,能有效去点状干扰。木纹边缘是缓慢变化的纹理,中值滤波对它的抑制比高斯更自然。

CLAHE(对比度受限自适应直方图均衡)是我后来补上的。棋盘线在暗光下经常只有一种暗淡的棕色,直接阈值化根本分离不出来。CLACHE 把画面局部对比度拉开,之后Canny能够稳定捕捉到线的梯度。参数上我建议clipLimit=2.0, tileGridSize=(8, 8),太大容易出现光晕伪影,反而把木纹细节放大。

Canny 的阈值我用的是低阈值40、高阈值100到120这个区间,比例大致落在1:2.5到1:3之间。这个比例的意义在于保留连续边缘的同时,过滤零散的小梯度响应。注意,阈值需要相对图像分辨率微调,不建议每次都用固定值。

最后做了一次3×3矩形核的膨胀。这一步很多人忽略,但它对霍夫直线检测的帮助非常直接。边缘检测出来的线往往是断续的,膨胀操作把相邻几像素的断口搭接起来,后续 HoughLinesP 更容易把一条完整棋盘线检测出来。

2.3 两条只用一次就会上瘾的增强技巧

第一个技巧是针对木纹底色的。如果棋盘背景颜色比较统一(比如棕色、红色、深黄),可以先用颜色直方图找出主色调,然后用cv2.inRange把背景色区域压低甚至直接置黑,这样木纹纹理大部分被“关掉”,棋盘线就凸显了。但要注意,当棋盘使用浅色木材、线条也是深色时,这个方法可靠;如果棋盘是深色底黑色线,压制背景色调会同时吞掉棋盘线,不能用。

第二个技巧是连续帧平均。我在做视频流识别时,让程序取连续5帧,做一个逐像素均值后再进入检测流程。因为棋盘是静止的,而光照反射和环境噪声是随机变化的,均值处理能把随机噪声打散,棋盘的线条却不会丢。每帧计算量不大,对稳定性的提升却非常明显。静态图片识别用不了这个技巧,但如果你做实时识别,强烈建议加上。

3. 霍夫直线检测的实战调优,以及“楚河汉界”断带问题

3.1 概率霍夫优先,标准霍夫备用

OpenCV 里有两套常用直线检测接口:cv2.HoughLines(标准霍夫)和cv2.HoughLinesP(概率霍夫)。标准霍夫输出的是极坐标公式(ρ, θ),没有线段端点;概率霍夫输出的是线段起点和终点。我做棋盘网格拟合时,需要知道每条线段落在画面的哪个区域,还要按线段位置做空间聚类,所以概率霍夫几乎是唯一选择。

这里给出一个经过反复调整的起始参数组合:

import cv2 import numpy as np # edges 来自前面预处理得到的边缘图 lines = cv2.HoughLinesP( edges, rho=1, theta=np.pi / 180, threshold=70, minLineLength=int(0.15 * min(h, w)), maxLineGap=15, )

threshold是累加器阈值,表示至少要有多少像素投票支持该直线才会被接受。我遇到的大部分“检测不到线”问题,都不是控制这个值太小,而是太大。70是一个中庸值,在分辨率约为1000×800的图像上效果尚可。

minLineLength我建议写成图像短边的比例,而不要用固定长度。棋盘线通常横跨整个棋盘,棋盘约占整图面积的50%以上,所以取短边的15%作为起始下限很稳妥。maxLineGap控制线段断裂时最多允许多大间隙被连接,设为15像素,对于棋盘线跨过一个棋子带来的断裂已经足够。

3.2 线段分类:豎线、横线与反向线段

霍夫变换返回的线段角度范围覆盖0到360度。横线线段既可能是接近0度的,也可能是接近180度的。如果不处理这个方向歧义,分类任务会多出一倍无意义的检视线段。

我的分类逻辑是:计算线段的斜率角,取绝对值后判断。若abs(angle) < 12abs(abs(angle) - 180) < 12,归为横向线段;若abs(angle - 90) < 12abs(angle + 90) < 12,归为纵向线段。12度容差能容忍拍摄时棋盘本身有轻微旋转;如果画面旋转超过12度,说明取景太随意,建议先做一个整体旋转校正,而不是无限制放宽容差。

分类结果大概率会出现两类问题:一是同一条棋盘线被检测成好几段,分别落在不同位置;二是木纹或背景中混进来一些伪线段,导致某一行出现两条平行线。此时引入聚类是必要的。

3.3 用一维聚类得到9条竖线和10条横线的位置

对于横向线段,我们关心它的纵向位置y;对纵向线段,关心它的横向位置x。把多条线段投影到目标轴后,用一维聚类合并邻近的投影位置,就能得到棋盘线的候选位置。

以横线为例,实现思路如下:

def cluster_positions(positions, gap_tol=12): positions = np.sort(positions) groups = [] current = [positions[0]] for p in positions[1:]: if p - current[-1] <= gap_tol: current.append(p) else: groups.append(np.mean(current)) current = [p] groups.append(np.mean(current)) return np.array(groups)

gap_tol的选择需要参考棋盘在画面中的实际行距。比如棋盘区域高800像素,10条横线对应9个间隔,正常行距约80像素;那么gap_tol取12到15像素比较合理,既能把同一行的碎线段合并,又不会把相邻两行粘在一起。如果拍出来的图片分辨率差距很大,这个阈值最好按行距比例换算。我一般用gap_tol = 0.12 * 估计行距做初始值,再根据聚类结果反馈修正。

聚类完之后,通常不会刚好得到10个横线簇,可能多于10也可能少于10。多出来的多半是木纹产生的伪横线,少则是棋盘线被棋子严重遮挡。解决这个问题的顺手办法是利用“期望数量”:横线簇保留与期望数量最接近的10个簇,远离棋盘区域的直接丢弃;竖线同理保留9个。这属于先验约束的合理使用,不算作弊,因为象棋棋盘结构是确定已知的。

3.4 断带处如何借助“河”间隔把棋盘恢复完整

第一次调试时我盯着聚类结果看了半天,明明10条横线应该分别落在上下两个半区,聚类却把它们切成了“上半场5条+下半场5条”,中间一条巨大的空隙。后来意识到,这不是聚类算错了,而是横线在河界处天然断开,霍夫检测到的横向线段本来就不覆盖河的中央区域。

更大的麻烦在于:如果不把上下半场的横线放在同一个坐标系里,后续求网格交点时会丢掉中间5条线的总间隔,整个棋盘纵向比例就错了。

解决思路是显式检测“河带”。在得到横线簇的位置列表后,计算相邻簇间距数组,正常情况下上下半场内部的行距接近一致,而第5条横线到第6条横线之间会出现“异常大的间隙”。判断标准是:当某个间隙大于内部平均行距的1.6倍时,就认为这里就是楚河汉界。

确定河带之后,直接把所有横线簇按顺序拼接为10条横线,河的宽度作为正常行间距处理即可。虽然河的实际宽度通常比普通行距宽一点,但这不影响后续交点坐标的求解,因为我们要的是每条线的真实像素位置,不是等间距理想网格。

3.5 网格拟合后的残差校验

拟合出9条竖线、10条横线之后,我习惯做一次共线校验:用每条线的所有线段端点拟合一条直线,计算端点到拟合直线的平均距离。这个残差超过3像素时,说明原始线段质量很差,可能是边缘检测阶段吃了噪声,需要回到预处理调参,而不是硬把它拿去算交点。

这一步看着不起眼,却帮我筛掉过好几次“肉眼看不见但实际存在”的误检。画面反光造成的细小伪边缘,在聚类阶段可能侥幸混了进来,共线校验会直接暴露它:因为伪线条往往只覆盖一小段区域,很少能与完整棋盘线共享同一方向。

4. 交点解算与透视校正:从像素坐标到逻辑棋盘

4.1 如何通过线线相交得到干净的交叉点

有了9条竖线和10条横线,90个交叉点其实就是线线相交的结果。不用额外检测角点,也不用做模板匹配,直接解析几何求交就行。

两个线段交点公式用 Python 可以这样实现:

def line_intersect(p1, p2, p3, p4): x1, y1 = p1 x2, y2 = p2 x3, y3 = p3 x4, y4 = p4 d = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) if abs(d) < 1e-8: return None t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / d return x1 + t * (x2 - x1), y1 + t * (y2 - y1)

把第i条竖线和第j条横线代入,得到的就是逻辑坐标(i, j)对应的图像坐标。这一步输出的数组形状应为(10, 9, 2),其中第一维是行号,第二维是列号,第三维是(x, y)

实际操作中我会同步把90个点画在调试图上,用不同颜色区分行列索引,肉眼快速确认有没有交叉点错位。调试图是一切计算机视觉项目的“仪表盘”,没有它,很多误检要很久才能发现。

4.2 透视校正在什么情况下必须做

俯拍镜头正对棋盘中心时,交点坐标基本是规则的,不做透视校正影响不大。但只要摄像头与棋盘平面存在倾斜角,远端交叉点的间距会被压缩,直接拿这些坐标去切棋子图像,边缘棋子的图会明显变形。

更麻烦的是,视角倾斜会让同一行棋子在图像中的像素间距不一致。比如开局阶段,位于边线的车马炮还能看清,而贴近河界的卒子被压缩得只剩一半大小,后续分类器的输入尺度完全乱了。

我建议无论摄像头安装角度如何,都固定做一次透视校正。校正目标是把棋盘变换成一个理想化的矩形画面:棋盘正好居中,上下边平行,行距和列距比例接近真实几何比例。这样后续棋子识别的ROI定义和分类器训练都只需要处理“正视图”一种形态。

4.3 单应矩阵的估计方法与角点选择策略

透视校正的数学工具是单应矩阵(Homography Matrix)。OpenCV 提供了cv2.getPerspectiveTransform(src, dst)cv2.findHomography两种常用入口。前者适用于精确的4组对应点,后者可以带更多对应点并用RANSAC排除异常点。

在棋盘识别场景下,90个交叉点都是已知的,只是我们想从中选4个稳定点作为变换基准。直觉上应该选左上、右上、左下、右下四个角点,但在倾斜视角下,最外沿的交点可能受边缘干扰严重。更稳妥的做法是:取第1列、第8列和第1行、第9行的4个交点作为基准,而不是直观的“四个角”,因为第1行和第1列的交点在霍夫检测中通常覆盖更长的线段,定位更稳。

目标点集合可以将棋盘映射到固定尺寸,比如1400×1600像素(即9列对应宽度1400,10行对应高度1600,比例9比10):

src = np.array([ pts[0][0], # 第0行第0列 pts[0][8], # 第0行第8列 pts[9][0], # 第9行第0列 pts[9][8], # 第9行第8列 ], dtype=np.float32) dst = np.array([ [100, 100], [1300, 100], [100, 1500], [1300, 1500], ], dtype=np.float32) M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(img, M, (1400, 1600))

这样校正后,棋盘的逻辑坐标与图像坐标就建立了稳定的一一对应关系。后续棋子识别只需要在固定ROI内检测,不必每次重新解算棋盘。

4.4 校正结果自检:从90个交点校验变换质量

透视变换完成之后不要急着进入棋子识别,我先做三件校验:

第一,把90个交叉点全部用cv2.perspectiveTransform映射到目标画面,检查坐标是否落在预期网格上。如果某个点偏离超过2像素,说明单应矩阵选点有问题。

第二,计算目标画面中相邻交点的行距和列距,统计标准差。理想情况下,同一行内相邻列距应基本一致,不同行的列距也应一致;如果列距差异超过5%,多半是原始交点定位不够准。

第三,检查校正后棋盘图像的边界是否出现明显空白或拉伸。拉伸通常是因为 src 里四个基准点选得太贴近内侧,目标尺寸设得过大;空白则说明基准点选歪了。

这步校验是我整个项目里性价比最高的一段代码,它把棋盘识别的“定性正确”变成了“定量可复查”。

5. 有棋子遮挡、反光与畸形棋盘时的容错处理

5.1 棋子挡住棋盘线,交点怎么办

棋盘识别在实际局面识别中不可能等到把所有棋子拿走再检测。棋子一旦摆在交点上,那条棋盘线段就被遮挡了一部分。好在概率霍夫处理断裂线段的能力足够强:只要一条横线在棋子的两侧还有足够长度,霍夫检测仍然能找出来。

但如果棋子尺寸偏大,完全把某段线盖死,聚类后的候选位置就可能漏掉这条线。我的补救策略是按“行/列期望总数”反推缺失线:先看现有聚类簇的位置,如果已经有9条竖线中的8条,缺失的那条大概率在两个已有簇的中间位置附近。按照象棋棋盘横向近似等间距的特性,用左右相邻线的中点补上缺失线即可。

对水平横线也同理。不过要注意,在河界区域补线时不能简单等间距,因为跨过河带后的两条横线位置来自不同半场,间距更大。正确做法是先判断缺失线是否落在河带附近,如果是,则使用上下半场的局部间距分别推算,而不是全局等间距。

5.2 光线突变时,局部对比度增强比重新调阈值更可靠

棋盘识别的失败场景里,因光线问题导致的多于因算法问题导致的。我在白天窗边拍摄时,棋盘左侧被阳光照亮,右侧落在阴影中,整体阈值怎么调都顾此失彼。后来换成 CLAHE 之后,左右两块区域的局部对比度被分别拉伸,棋盘线在亮区和暗区都能稳定显现,不再需要手工调全局阈值。

另外要留心一个反直觉的问题:过度增强也不是好事。CLAHE 的clipLimit调得太大时,木纹和棋子的纹理会被放大成伪边缘,反而干扰霍夫检测。我试过 3.0 和 4.0,线条确实更清晰,但木纹中的年轮也被清晰地提取了出来,伪横向线段数量暴增。最终固定在 2.0,既能区分线条和背景,又不至于把木纹细节全盘端出来。

5.3 弧线纹装饰、印字和磨损棋盘的处理建议

很多工艺棋盘会在边框处做大量装饰性弧线,在河界印“楚河”“汉界”大字,这些内容对直线检测基本是致命干扰。弧线不会进入横线或竖线的候选集,但它们会产生大量方向散射的短线段,拉低霍夫检测的有效信噪比。处理办法有两个方向:要么在预处理阶段把装饰区域裁剪掉,只保留棋盘网格核心区域;要么在聚类阶段用期望几何强制筛选,只保留与网格方向高度一致的线段。

磨损棋盘的问题是线条本身有些地方颜色很淡,边缘检测输出会出现明显断口。断口在maxLineGap参数足够时会被霍夫连接,但磨损导致线条局部变淡后,断口两侧的灰度差异已经小于Canny高阈值,就算maxLineGap再大也无济于事。这种情况只能降低Canny高阈值或降低霍夫threshold,但这两个参数调低后会引入更多噪声。我最后的折中方案是:高阈值从120降到90,霍夫 threshold 从70降到55,然后靠聚类和共线校验筛掉噪声。

5.4 实测数据的几点体会

我用手机拍了大约30张不同场景的棋盘照片,覆盖光面木棋盘、亚克力棋盘、布纹棋盘和纸质棋盘。在干净棋盘图上的交点检出率稳定在95%以上;有棋子遮挡时,扣除被完全盖住的交点后,剩余交点的定位偏差基本保持在2像素以内。

最难受的其实是“半遮挡”场景:一枚棋子刚好压住交点边缘,但交点中心还露在外面。边缘检测时棋子圆弧会形成大量杂乱梯度,导致交点坐标被向外拉偏。针对这个,我在求交点后加了一个局部微调步骤:以交点坐标为中心、15×15像素的窗口内,寻找线条交叉强度最大的位置重新定位。这个操作把半遮挡场景的误定位率降低了大约三分之一。

如果你也要做同样的棋盘识别,我的建议是不要追求“一个万能的算法处理所有棋盘”,而是把程序做成“场景可配置”:给预处理参数、聚类容差、后处理开关分别留出配置项。我的配置里目前有木纹模式、反光模式、低对比度模式三档,用起来比调一套通用参数省心得多。

6. 从棋盘识别到局面识别的接口设计,别给自己挖坑

棋盘识别做扎实之后,接后续的棋子识别会顺畅很多。但接口设计这一步出现过不少返工,简单说几个我亲测有效的数据约定。

第一,棋盘交点数组一定用固定的行列索引存储,形状固定为(10, 9, 2)。不要用列表套列表的松散结构,否则下游代码每次取点都要自己处理边界。

第二,透视校正之后的棋盘图要规定一个统一尺寸。我选了 1400×1600,这个尺寸刚好让每个交叉点周围的棋子区域大约有 140×120 像素,既不浪费计算,也不会因为过小损失细节。后续训练棋子分类器,直接把交点附近的ROI裁剪出来做输入,可以省掉大量标注对齐工作。

第三,把“没有检测到棋盘”这种情况也作为输出的一部分。我的接口是返回一个字典,包含successcornersgrid_pointswarp_matrixdebug_image五个字段。如果检测失败,success=False,但debug_image依然返回,便于事后定位失败原因。这个习惯帮我省了非常多调试时间。

第四,棋盘识别结果要做一次与棋规的一致性检查。比如第0行和第9行都排在画面最上沿,说明棋盘上下颠倒;第1列和第8列分别位于左右两侧,这是正常状态。如果起点和终点搞反,后续局面字符串的顺序就会完全错乱,而且极其隐蔽。我在接口里加入了一个简单的排序校验:行索引必须从上到下单调递增,列索引必须从左到右单调递增,不满足就触发重新校正。

把棋盘识别当成一个独立模块来打磨,是值得的。它虽然不产生任何“聪明结果”,但直接决定后面所有步骤的上限。我现在回头去看当初最早版的代码,最庆幸的是没有为了省事跳过透视校正和交点回归校验,这两个东西在后面棋子识别和局面生成里帮了大忙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询