数独和计算机视觉这两个词放在一起,乍看有点跨界。一个是最传统的逻辑推理游戏,一个是人工智能里最热闹的感知分支。可真把一套数独识别工具从零做到能跑通,你会发现数独这个载体刚好把计算机视觉的主干流程串了个遍:图像预处理、轮廓检测、透视矫正、目标分类、模型部署,再到逻辑求解和结果回显。这篇文章就用我个人整理的一套数独识别方案,把整条视觉链路拆开讲清楚,适合刚接触CV、想找个具体项目练手的学习者,也适合想给学生找一个可解释、可验证案例的开发者参考。
先给这个项目定个位:输入端是一张随手拍的数独题目照片(手机拍、扫描件、截图都行),输出端是重建后的完整答案图,每个空格里被填上正确数字,整个过程全自动。核心要解决两件事——图像里的数字怎么被可靠地读出来,以及读错之后怎么通过逻辑求解做二次校验。前一半考验的是计算机视觉的基本功,后一半考验的是工程意识和容错设计。
1. 为什么选数独,以及整条视觉链路怎么设计
1.1 数独题到“视觉任务”的映射
数独识别这个题目表面上是解数独,实际上是“检测 + 识别 + 理解 + 推理”四层任务的串联,这四层刚好对应计算机视觉领域最常见的问题类型。
最外层的检测任务是找到棋盘。在一张照片里,数独棋盘不一定居中,背景里可能有桌面、纸张边缘、阴影、水印。视觉系统要做的第一件事就是从这个杂乱场景里定位出整块棋盘的位置,对应CV里的目标检测或显著性区域提取。定位到棋盘之后,下一个任务是把透视变形的棋盘矫正成规整的矩形,这对应图像几何变换中的透视矫正,也是相机标定、增强现实等领域的基础操作。
再往深一层是分割。81个格子整整齐齐,但每个格子里的数字位置、大小、笔画粗细都不一样,要让模型去做识别,得先把每个数字从格子里单独切出来。切分完成后,就进入分类环节——判断这个格子里的图像是1到9中的哪个数字,还是空白的0。这个环节本质是一个细粒度图像分类问题,也是整套流程里计算量最大、最需要模型支撑的地方。
最后一层最容易被忽略:逻辑求解。从视觉系统得到的数字列表可能存在识别错误,比如把7认成1,把4认成9。如果直接把错误结果拿去求解,大概率无解。这就需要利用数独本身“每行每列每宫数字不重复”的强约束,反过来校验识别结果,把矛盾信息暴露出来,再由人来介入修正或让系统自动换一个候选答案。这种“感知 + 推理”的闭环结构,恰恰是工业级视觉系统的常态。
1.2 技术选型的三个理由
理论上,数独识别可以用一条端到端的深度学习模型完成,输入一张图,直接输出答案列表,中间过程一概不管。但我最后选择的是“传统图像处理 + 轻量卷积网络 + 回溯求解”的混合方案,主要原因有三点。
第一是可解释性。传统图像处理每一个步骤都有明确的数学意义:阈值分割、轮廓检测、透视矩阵变换,每一步都可以把中间结果可视化输出。如果系统出错,我能立刻知道是棋盘定位失败,还是数字分割错位,还是分类器置信度不足。端到端模型则是一个黑盒,出了错无从下手。
第二是数据成本。数独识别这种任务,棋盘结构非常规整,用几十张带标注的真实照片就能把传统预处理调好;数字分类这块,自己构造几千张样本就能训练一个效果不错的小模型。这样的数据量完全不需要大算力,普通CPU笔记本就能扛住训练。
第三是稳定性。真实场景里的数独照片质量参差不齐,混合方案的每个环节都有明确的可替换性:光照变化大就换更抗干扰的二值化策略,手机拍歪了就用透视矫正兜底,某个数字常被误识别就把它的候选集调大一点。这种工程上的灵活性,是单一大模型短期给不了的。
2. 图像预处理:把一张随手拍拉直成标准棋盘
2.1 从彩色图到二值图的关键参数
预处理的第一步是把输入图像转成模型友好的格式,整体流程我用OpenCV实现,所有输入先统一缩放到长边不超过1000像素,降低计算量的同时减少图片噪点干扰。
先做灰度化。这张图可能是彩色照片,也可能是灰度扫描件,直接取cv2.COLOR_BGR2GRAY转换,得到单通道灰度图。接下来做高斯模糊,这一步我固定用5x5核。很多初学者会忽略模糊的意义,实际上数独照片在手机拍摄时往往有摩尔纹、轻微噪点和纸张纹理,5x5的高斯核可以在不模糊数字边缘的前提下揉平这些干扰。核太大了数字笔画会被抹掉,太小了纹理噪声又压不住。
然后是最关键的一步——二值化。数独棋盘通常是白底黑字,但也存在黑底白字的反色情况,我采用OTSU自适应阈值:
# 图像预处理核心代码 import cv2 import numpy as np def preprocess_image(img_path): img = cv2.imread(img_path) # 统一尺寸 height, width = img.shape[:2] scale = 1000 / max(height, width) img = cv2.resize(img, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_AREA) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自动阈值二值化,保留笔画为白色 _, binary = cv2.threshold( blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) return img, gray, binaryOTSU的核心思路是自动寻找一个全局阈值,把像素分为前景和背景,使得两类像素的类内方差最小、类间方差最大。放在数独这个场景里,它的好处是给定一张对比度正常、光照基本均匀的图,不管整体偏亮还是偏暗,都不用人工调参数。
实际测试时我碰到过一个典型的反色情况:某张数独截图是黑底白字,直接做THRESH_BINARY_INV会得到白底黑字的效果,数字区域反而是黑色。这种情况下我加了一个简单的判断逻辑——统计二值图里白色像素占总像素的比例。数独棋盘加上周围留白区域,白色像素占比通常在40%到80%之间,如果超过90%,说明很可能背景被翻成了白色,原图其实是黑底白字,此时需要反转一次。
2.2 轮廓检测与透视矫正
二值化完成后,原图里除了数独棋盘,还可能存在纸张边缘、手机屏幕边框等干扰轮廓。我要做的是从这些轮廓里找到那个面积最大、且形状最接近四边形的目标。
这里用cv2.findContours提取所有外轮廓,然后用cv2.approxPolyDP做多边形逼近。为什么用逼近而不是直接用原始轮廓?因为手机拍摄的棋盘边界在图像里通常不是严格的直线,有畸变、有锯齿,直接拿原始轮廓做四点变换会引入误差。approxPolyDP的思路是把一条连续曲线用尽可能少的点去拟合,当拟合结果正好是4个点,且这四个点围成的面积在图中所有四边形的面积里最大时,就可以认定它是数独棋盘的外边界。
# 轮廓检测与透视矫正核心代码 def find_board_contour(binary): contours, _ = cv2.findContours( binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) max_area = 0 board_contour = None for c in contours: area = cv2.contourArea(c) if area < 10000: continue peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4 and area > max_area: max_area = area board_contour = approx return board_contour拿到棋盘四个角点之后,我统一把它们变换到一个486x486像素的正方形画布上,这样棋盘被拉直后每条边刚好是486像素,每个格子正好是54x54像素,方便后续切分。变换用的是单应矩阵cv2.getPerspectiveTransform,再交给cv2.warpPerspective执行重投影。这里有一个细节:四个角点的顺序必须一致,否则变换结果会出现镜像或旋转。我的做法是先把四个点按x、y坐标排序,确定左上、右上、右下、左下,再对特殊情况做补充判断。
透视矫正的效果直接影响后续所有步骤。矫正不到位,格子的切分线就会歪,数字会被拦腰切开,再好的识别模型也白搭。所以这一步做完,我会把矫正结果可视化保存,确认棋盘边界平直、格子基本呈正方形,再继续往下走。
2.3 两个经常踩的坑
第一个坑是阴影导致的边框断裂。手机拍带着环境阴影的纸质数独时,一条影子斜着划过棋盘,二值化后棋盘外框被分割成好几段,findContours只能找到零碎的小轮廓,最大的四边形面积反而落在桌面上。解决思路是先用形态学操作里的闭运算(cv2.morphologyEx,核大小取7x7到11x11)把断裂的边框重新连起来,再做轮廓检测。闭运算的本质是先膨胀后腐蚀,能填补前景区域里的小孔洞并把邻近区域连接,用在拉链断裂的边框上非常合适。
第二个坑是非正方形拍摄角度导致的严重透视变形。正常手机拍摄角度在30度到60度之间时,透视变换都能很好矫正。可如果拍摄角度几乎贴着桌面,棋盘在两个方向上的压缩非常严重,四点变换后数字会出现明显的拉伸畸变。碰到这种情况,我一般直接放弃自动矫正,改用人工鼠标选点的交互方式,四角一指定再做变换。识别工具偶尔引入一次人工干预,比硬设计一个“全场景万能”的自动方案划算得多。
3. 数字识别:从MNIST说到自建数独数据集
3.1 为什么不能直接搬MNIST模型
看到“识别单个数字”这个任务,很多人第一反应是直接用MNIST数据集训练好的模型。我在第一次做这个项目时也这么干过,结果验证集上97%的正确率,一放到真实数独照片上,直接掉到85%左右,问题就出在数据分布差异上。
MNIST里的手写数字经过了预处理:图片是28x28像素、数字居中、笔画纯白、背景纯黑、几乎没有透视变形。但数独格子里面的数字完全不同,它们可能是印刷体、可能是手写体、可能有倾斜,数字在格子里可能偏左上也可能偏右下,笔画粗细不固定,还有格子边框残留。模型在MNIST上见过的模式太“干净”了,真实环境里的一点扰动都足以让它犯错。
用一个简单的分布对比说明:
| 维度 | MNIST手写数字 | 数独格子图片 |
|---|---|---|
| 背景 | 纯黑,无边框干扰 | 可能有网格残留和有阴影渐变 |
| 数字位置 | 严格居中居正 | 偏左、偏右、偏上、偏移都有 |
| 字体风格 | 单人书写风格为主 | 印刷体、手写体、艺术字混用 |
| 分辨率 | 固定28x28 | 54x54切分后需要重新缩放 |
| 变形程度 | 无透视变形 | 轻微透视残留、可能倾斜 |
数据分布错配,是绝大多数“公开数据集训练得好、实测就拉胯”的根因。结论很清楚:要么对MNIST做贴近数独场景的数据增强,要么干脆自建数据集。我选了后者,因为数独数字的生成成本其实很低。
3.2 构建自己的数据集与数据增强
自建数据集有两种途径:一种是收集上千张真实数独照片,但这需要反复拍照、标注格子位置,成本太高。另一种是程序合成,我用PIL加载几套开源字体文件,把1到9的数字绘制到54x54的模拟格子里,同时写入随机的格子偏移、字体大小变化、轻微旋转和模糊。另外再补充一些真实手写数字样本,让模型见过更丰富的笔画风格。
为了模拟摄像头拍摄的退化和噪声,我对合成样本做了四类数据增强。第一类是轻微随机旋转,角度控制在±5度内,模拟手机没端平。第二类是平移,数字在格子内的中心位置偏移,模拟切割误差。第三类是添加高斯噪声和运动模糊,模拟室内暗光环境。第四类是缩放,让数字笔画粗细和字体大小在40到50像素范围浮动。最终我合成了约5000张训练图片,验证集用另外收集的真实照片切分结果,模型在真实数据上的准确率回升到96%以上。
这里有个操作细节:增强幅度不能太猛。旋转角度超过10度、平移超过15像素,生成的样本反而不像真实数独格子里的数字,模型会学到“错误的艺术变形”,实测反而掉点。适度的困难是正则化,过度的困难是污染。
3.3 一个轻量CNN的结构
数据就位后,模型结构我没有选择ResNet这种大力出奇迹的做法。数独数字识别是个10分类问题(0到9,0代表空格),输入是54x54的单通道灰度图,复杂度并不高,一个三层卷积加两层全连接的小网络就完全够用。
# 轻量卷积网络 import torch.nn as nn class SudokuNet(nn.Module): def __init__(self): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.fc = nn.Sequential( nn.Flatten(), nn.Linear(128 * 6 * 6, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 10), ) def forward(self, x): return self.fc(self.conv(x))训练时我把输入统一缩放到48x48,batch size取64,学习率初始0.001,用Adam优化器,训练15到20个epoch就收敛。最终验证集准确率在98%左右。之所以输入取48x48而不是直接用54x54,是因为连续三层池化后,54x54会被缩到6x6的下采样尺寸,48x48可以正好被整除。这类小尺寸的细节问题在模型设计时就要想清楚,不然后面算特征图维度老是对不上。
4. 求解器与端到端整合
4.1 两种求解思路对比
数字识别全部完成后,我得到的是一个长度为81的数字数组,里面夹杂着识别错误的结果。接下来要让求解器给出一份可靠答案,顺便反向检验识别系统有没有出错。
数独求解的主流方案有两种。一种是精确覆盖模型配合舞蹈链(DLX)算法,思路是把数独的“行列宫不重复”约束转化为一个精确覆盖矩阵,再用高效的递归搜索找出唯一覆盖。这个思路在解超大数独或需要枚举所有解时很有优势。另一种是传统的DFS回溯,按从左到右、从上到下的顺序空格填充候选数字,每填一个就检查一次合法性,冲突立刻回退。
我最终选了回溯法。原因很直接:9x9标准数独的搜索空间有限,剪枝充分的情况下,一颗普通CPU处理单个题目平均用时在几毫秒到几十毫秒级别,DLX在性能上没有压倒性优势,但实现复杂度却高得多。回溯法的合法性检查可以用三个布尔数组来加速——row[i][d]、col[j][d]、box[i//3][j//3][d]分别记录行、列、宫内数字是否已被占用,这样每次尝试填数只需查表,时间复杂度为O(1)。
# 数独回溯求解核心代码 def solve_sudoku(board): def is_valid(r, c, val): box_r, box_c = r // 3 * 3, c // 3 * 3 for i in range(9): if board[r][i] == val or board[i][c] == val: return False for i in range(3): for j in range(3): if board[box_r + i][box_c + j] == val: return False return True def dfs(): for r in range(9): for c in range(9): if board[r][c] == 0: for val in range(1, 10): if is_valid(r, c, val): board[r][c] = val if dfs(): return True board[r][c] = 0 return False return True dfs() return board4.2 识别结果与求解器的互相校验
这里面最值得讲的是识别和求解的联动容错。裸识别结果直接喂给求解器,经常遇到无解的情况,这通常是某一个数字识别错了。简单粗暴的做法是:报错后让用户手工检查,但这不够自动。
我采用了一套“Top-2候选 + 回溯校验”策略。识别阶段不直接取分类器softmax输出的最大概率值,而是保留概率最高的两个数字作为候选。比如某个格子softmax输出是:1的概率0.83,4的概率0.14,其余都小于0.03。那这个格子的候选集合就是{1, 4},优先尝试1。接下来把81个格子全部替换成各自的Top-1候选,跑一遍求解器;如果无解,就轮到第二个候选替上去再试。
这个策略的巧妙之处在于,它把“识别错误”这件事从不可恢复的硬错误,转换成了“多试几次”的软错误。经常遇到的情况是:识别阶段把4看成了1,两个候选是{1, 4},求解器用1无解,自动换成4之后立刻得到全局唯一解。整个过程中用户无感知,体验好不少。
如果两个候选都试完仍然无解,我会退回更原始的办法:把求解失败时的冲突单元格打印出来,看看是哪行哪列哪宫出了问题。多数情况下是某个候选集中根本没有正确答案,这时候就需要回到图像预处理阶段,检查那个格子的切分是不是切歪了,或者是不是有阴影把笔画连到了一块。
4.3 把答案画回原图
求解完成后,需要把答案叠加到原始照片上。这里的关键不是单纯地把数字画在棋盘里,而是要把答案准确投影回原始图像的透视位置。
我用了之前计算过的透视变换矩阵的逆矩阵,把矫正图像中的每个格子中心坐标映射回原图坐标。映射关系是一个3x3矩阵的线性变换,每个坐标点经过矩阵乘法后归一化得到原图坐标。设计回显数字字体时注意三点:字体大小略小于格子宽度的一半;颜色选反差明显的亮绿或亮红,方便肉眼辨认;数字本身绘制成粗体,避免细线条在小尺寸下看不清。回显完成后,一张“原图识别 + 答案叠加”的对比图就出炉了。
5. 我实际踩过的坑和排查实录
5.1 常见问题速查表
整个项目从零跑通到稳定运行,我遇到了不少问题,挑典型的整理成一张速查表,方便后来者按图索骥:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 棋盘轮廓定位不到 | 阴影或反光切断棋盘外框 | 先做7x7~11x11闭运算连接断裂边,再检测轮廓 |
| 数字连在一起识别成一个 | 二值化阈值过低、笔画过粗 | 提高OTSU的对比度预处理,优先做形态学腐蚀分离笔画 |
| 透视矫正后棋盘变形扭曲 | 四边形角点排序不一致 | 按x+y和x-y联合排序,先定左上右下,再定右上左下 |
| 识别结果整体偏低 | 训练集里印刷体和手写体比例失调 | 扩大字体种类,加入真实手写数字,做旋转平移增强 |
| 识别对但求解无解 | 某一个格子Top-1候选错误 | 启用Top-2候选回溯,自动切换候选值 |
| 手机拍照白平衡发黄 | 灰度化后数字和背景对比度不足 | 先做直方图均衡化,再做OTSU阈值 |
5.2 一个典型的失败案例复盘
我印象最深的一个案例是某次测试时拿了一张带浅色水印的数独截图,水印正好横穿整个棋盘。二值化后水印被一起保留成了白色前景,切分出来的格子图像全是噪点和干扰笔画的混合物,识别准确率直接跌到70%以下。
排查过程是这样的:第一步,把预处理阶段的中间结果保存成图片,发现二值图里水印区域和数字区域的灰度值高度接近,OTSU无法自动分离。第二步,查看切分后的格子图,水印笔画和数字笔画粘连在一起,标准的形态学开运算已经无法分离。第三步,回到源头——既然水印是规则纹理且贯穿全图,我采用频率域滤波思路,用傅里叶变换把水印对应的周期性亮斑在频谱上抹掉,再做反变换恢复棋盘区域,水印就被成功去除了。
这个案例给我的启发是:预处理方案不能只有一条路,真实场景里的干扰千奇百怪,前期多保存中间结果,多准备几套备用方案,关键时刻能救命。
5.3 最后的实用建议
项目做到后期,性能已经不是唯一追求,更多的精力花在流程鲁棒性和调试效率上。这里分享几个让我省了很多事的小习惯。
第一,全链路可视化。程序每跑完一个关键步骤,都把中间产物保存到独立的目录。棋盘定位画个框、透视矫正单独成图、每个格子切分完拼成一张总览图、分类置信度打印到日志。有了这些,任何一步出错都能立刻定位,不用黑盒猜。
第二,从小就做优先级。“最小闭环”很关键,我先不做模型训练,先用一个随机数生成器充当分类器,把整条流程从读图到求解跑通,再把随机数换成真模型。每一步都是窄接口,替换起来非常顺手。
第三,输入尺寸控制要做对。照片解析度太高会让预处理和模型推断都变慢,但缩得太狠又会让数字笔画丢失细节。我测试下来1000像素是合理平衡点,既保证数字可辨识,又把整条流程的耗时控制在一秒以内。
我个人的体会是,用数独理解计算机视觉的关键价值,不在于把某个模型训得多准,而是通过一个具体的闭环任务,把感知、推理、容错、交互这些割裂的概念串成了一根线。拿到一张图,知道怎么一步步拆问题、怎么为每个环节选择合适方案、怎么让各个模块互相兜底,这套思维迁移到通用视觉项目里完全复用。如果非要给一个从零开始的建议,我会说:先把棋盘定位、格子切分这些传统处理跑扎实,再回头折腾数字分类模型,顺序反了容易把锅全甩给“深度学习不给力”,其实问题往往出在前面几步。