ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数独照片识别与求解:OpenCV透视校正到答案回填实战

数独照片识别与求解:OpenCV透视校正到答案回填实战

几个月前接到个小需求:拿手机拍一张数独照片,几秒内把答案算出来并叠回原图上。我当时觉得这事不难——数独识别不就是 OCR 加回溯求解嘛,现成库一调就完事。真正动手才发现,随手拍的照片里网格是歪的、数字是糊的、反光会把白格变成灰格,光线一变阈值就崩。这个看似简单的 demo,实际上把计算机视觉里最经典的一整套流程全走了一遍:图像预处理、边缘检测、几何变换、字符识别、坐标映射。今天就把我当时怎么一步步从“照片”变成“答案图”的过程完整拆开讲,想入坑计算机视觉的朋友可以照着这条路走一遍,比单纯看理论印象深刻得多。

1. 为什么偏偏是数独:一个任务串起计算机视觉的基本功

1.1 数独图像处理要面对的真实问题

很多入门教程喜欢拿“识别一张卡牌”“检测一个红绿灯”当例子,但那些任务都太单薄了。数独这个场景最妙的地方在于,一张真实拍摄的数独照片里几乎包含了所有经典干扰:

  • 透视畸变:手机不可能完全正对着纸面拍,网格必然是个不规则的四边形,切出来是梯形的。
  • 光照不均:室内灯光、窗外阳光、手指阴影都会让同一张纸上的亮度差一大截。
  • 线条断裂:铅笔画的格子线、打印墨迹偏淡、皱褶处的反光,会让边框或内部格线断掉。
  • 前景混乱:网格旁边可能有别的文字、手指、桌面纹理,全都需要排除。
  • 数字风格多样:印刷体、手写体、粗细不一,甚至还有写一半的。

这些问题在教科书里是每个独立章节,在数独项目里是一连串必须连贯处理的现实障碍。做完这个项目,等于把图像去噪、二值化、边缘检测、轮廓查找、透视校正、图像分割、字符识别、坐标变换、结果可视化全打通了一遍。

拿我自己举例,最初我直接对原图做全局二值化,结果阴影区域里的数字全被当成背景吃掉了,后来才换成自适应阈值;最初我假定网格是正的,直接均分 9 份,结果歪了的照片切出来的格子边缘全是斜线,数字被切得七零八落。这些坑,每一个都对应一个经典知识点,踩一遍比看十遍书都管用。

1.2 从像素到答案的完整链路

整个项目我拆分成了 9 个步骤,每个步骤都有明确的输入输出:

步骤操作输入输出对应知识点
1图像加载与缩放手机照片统一尺寸的彩色图图像格式、缩放插值
2灰度化与去噪彩色图干净的灰度图色彩空间转换、高斯滤波
3二值化/边缘检测灰度图前景与背景分离的二值图阈值分割、边缘检测
4提取网格外轮廓二值图数独外边框的四个角点轮廓查找、多边形逼近
5透视校正原图+角点正视角的方形数独图单应矩阵、透视变换
6切分 9x9校正后的图81 个格子的小图投影法、图像切片
7数字识别格子小图81 个数字或空模板匹配/CNN
8数独求解81 个数字完整答案矩阵回溯搜索
9结果叠回原图答案+变换矩阵带答案的标注图逆透视、坐标映射

很多人一开始会想:能不能搞一个端到端深度学习模型,输入照片直接输出答案?技术上可行,但作为学习项目非常不推荐。端到端模型是个黑盒,哪一步错了你根本不知道是“网格没找到”还是“数字没认出”,也没法逐步调试。分步管线的好处是每一步都能输出中间结果图,出了问题看一眼就知道坏在哪一步。这个调试思路,在真实工程里比任何花哨算法都重要。

1.3 工具选型:Python 加 OpenCV 就够了

整个项目我用的是 Python 和 OpenCV,求解部分自己写了个回溯(大约 50 行),识别先用模板匹配跑通,最后换成了简单卷积网络。为什么不直接用 C++?因为前期大量工作是调参数、看中间结果,Python 的交互式环境效率高得多。等以后要做实时视频流或者部署到手机上,再换成 C++ 或者模型端侧推理,那是后话。

OpenCV 里我主要用到的模块就几个:cv2.findContours找轮廓、cv2.approxPolyDP做多边形逼近、cv2.getPerspectiveTransform算透视矩阵、cv2.warpPerspective做校正变换、cv2.matchTemplate做模板匹配。每个函数单独拿出来都不难,难的是把它们串起来的时候才知道参数怎么配合。

2. 拍照时网格是歪的:先做预处理和透视校正,而不是急着识别

2.1 灰度化、去噪和自适应阈值的选择逻辑

拿到一张手机照片,第一件事绝对不是识别数字,而是先把图像变干净、把网格找出来。我先用cv2.cvtColor转灰度,然后用 5x5 的高斯滤波去噪。高斯滤波的核大小是个经验值:太小去不掉传感器噪声,太大容易把数字边缘糊掉。

关键的一步是二值化。我最初用了全局阈值cv2.threshold,发现只要照片上有不均匀阴影,全局阈值就废了——阴影区的白底被当成黑,数字直接消失。后来换成了cv2.adaptiveThreshold,它的原理是对每个像素和它周围一个小邻域的平均亮度做比较,相当于每个局部区域自动选取阈值。这就是为什么它能在光照不均的情况下仍然把数字和背景分开。我用的参数是blockSize=15, C=2,意思是每个像素和周围 15x15 窗口的平均灰度比较,谁更暗超过 2 个灰度级就置为黑。

如果是在强光、带纹理的纸面上拍,单纯自适应阈值偶尔会把纸面纹路当成前景。这种情况我建议先试Canny边缘检测再做轮廓查找,效果往往更稳。

2.2 用轮廓查找定位数独外边框

二值化之后,数独网格的外边框会形成一个大的闭合轮廓。我使用cv2.findContours提取所有轮廓,然后筛选:

  • 先按面积过滤,只保留占整张图面积一定比例以上的轮廓;这一步能去掉桌面纹理、手指、纸屑。
  • 再用cv2.approxPolyDP对方形轮廓做多边形逼近,筛选出四边形。approxPolyDP的 epsilon 参数我取周长的 2%,这个值很重要,太大导致四边形失真,太小又把弯曲的边界拆成很多段。
  • 数独外框在图像中通常是面积最大的四边形,所以按面积排序,取最大那一个。

这里有个很容易翻车的点:如果照片里数独边框恰好和照片边缘重合了一部分,那么最大的“轮廓”可能把整张照片都包进去了,不收敛成一个四边形。我后来加了一个校验:四个点形成四边形之后,检查四条边的长度是否接近,四个角是否接近直角。如果校验不通过,就把这个候选丢弃,换下一个轮廓。

2.3 透视变换不是简单的裁剪,是平面单应

找到四个角点之后,它们对应的是数独网格在真实纸面上的一个正方形。我们要把照片里那个任意四边形“拉正”成正方形,这个操作叫透视变换,数学上用一个 3x3 的单应矩阵描述,OpenCV 里cv2.getPerspectiveTransform会根据四对对应点直接算出这个矩阵。

我用下面的代码完成校正:

import cv2 import numpy as np def order_points(pts): # 四个点按左上、右上、右下、左下排序 rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect src = order_points(approx.reshape(4, 2)) dst = np.array([[0, 0], [450, 0], [450, 450], [0, 450]], dtype="float32") M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(img, M, (450, 450))

这段代码里最有信息量的是M。这个矩阵不只在这次校正中用到,后面把答案写回原图时,需要用它的逆矩阵做反向映射。我一开始没保存M,第五步往回画的时候还得重新算,后来学乖了,一开头就把M和M_inv = cv2.linalg.inv(M)都存好。

2.4 三个经典翻车场景:边框断裂、反光和阴影

  • 边框断裂:铅笔线断了一截,findContours找出来的轮廓是断开的,无法形成闭合四边形。解决办法是在二值化之后先做一次闭运算,用小核把断点连起来。核用 3x3 就够了,核太大会把数字和附近的格线黏在一起。
  • 反光:照片上有强反光时,反光区域会变成纯白,整个网格看起来缺了一个角。这种情况自适应阈值的局部窗口也会失效。我实测下来,先用cv2.medianBlur或者用Canny检测边缘,再补一次膨胀,比单纯调阈值管用。
  • 阴影边缘:纸面上有手指阴影,且阴影恰好压在边框线上。这时外轮廓面积可能变小。我给轮廓筛选加了一个“最小包围边长”的约束,同时把输入图像先等比缩放到固定宽度,保证不同距离拍摄的轮廓面积在一个可比尺度下。

3. 切开 9x9:网格分割里的坐标陷阱

3.1 为什么不能直接均分九等份

很多人透视校正完就认为大功告成,直接把 450x450 的图像切 9 块,每块 50x50。我一开始也这么干,结果发现:网格的粗线本身占了 2~4 个像素,直接按均分会导致某些格子切到了相邻数字,某些格子把边框线算进去了。更麻烦的是,校正之后如果仍有 1 度左右的旋转残余,越靠近右下角的格子误差越大。所以必须找到真正的格线位置,而不是假定它们均匀分布。

3.2 用投影法找到格线的精确位置

格线提取我用的方法是投影法。对校正过的二值图,分别按行和列做像素值求和:白线(格线)所在的行或列会有明显的高峰,非线区域数值很低。找到这些峰值的位置,就得到了所有横线和竖线的坐标。

def find_lines(binary_image, axis=0): proj = binary_image.sum(axis=axis) lines = [] in_line = False for i, v in enumerate(proj): if v > threshold and not in_line: start = i in_line = True elif v < threshold and in_line: end = i mid = (start + end) // 2 lines.append(mid) in_line = False return lines

注意我取的是每一条连续高亮区间的中点,而不是峰值点。因为格线可能有 2~3 个像素宽,取中点可以消除宽度带来的偏差。因为数独内有 10 条横线和 10 条竖线,检测到 10 个横坐标和 10 个纵坐标,就得到了 10x10 个交叉点。用这些交叉点的坐标作为切割边界,每个格子两侧各留 2 个像素的余量,再把格子小块裁剪出来。

3.3 切割后的格子校验与空白格判别

切出来的格子小图,我在送入识别前做三件事:

  • 尺寸归一化:把所有 48x48 的格子小图直接 resize 到统一尺寸,识别网络的输入固定。
  • 背景清理:检查格子小图边缘是否有非零像素,如果有,说明格线残留,做一次腐蚀边缘处理。
  • 空白判定:统计小图中黑色像素占比,如果低于某个阈值(比如 2%),判定为空格,不送识别器,直接标记为 0。这个步骤能大幅减少误识别,因为空格如果硬送进分类器,反而可能被莫名其妙分类成某个数字。

3.4 坐标陷阱:直线有斜率,交叉点需要矩心校正

用 HoughLines 提取格线时若直接求两线交点,由于照片歪斜矫正后仍有残余旋转,横线和竖线并不绝对垂直,直接相交得到的点可能偏离真实网格节点。我的做法是把检测到的所有交叉点收集起来,然后对每个真实节点周围的候选点求平均值,相当于做一次聚类的矩心。这一步对后面的答案回填非常重要,坐标偏几个像素,原图上数字位置看起来就歪了。

有一个小经验:校正后的图像尺寸我固定用 450x450,一方面保证格子约 50x50 大小适合识别,另一方面让交叉点坐标基本是整数,调试时肉眼看一下输出数组就能发现问题。

4. 数字识别是计算机视觉和机器学习的交接处

4.1 模板匹配:不需要训练数据也能跑的 MVP

先把识别这条线跑通,我用的是模板匹配。做法是准备 10 张标准打印数字模板(0~9,我直接用绘图软件生成),每张缩放到 32x32。然后对每个格子小图做同样的预处理,再用cv2.matchTemplate加TM_CCOEFF_NORMED计算相似度,取分数最高的作为结果。

模板匹配的优点是零训练成本、代码量小,打印体数字识别率很高。我实际测试了几张印刷清晰的数独照片,正确率大概在 90% 左右。缺点是极其死板,换一种字体、换一个笔迹,分数立刻掉下去;数字稍微旋转一点也会失效。刚开始调试 pipeline 时,用它当占位符是非常明智的,因为你需要先确认网格分割、透视校正这些环节是对的,再去优化识别。

4.2 简单卷积网络:更稳但要会做数据增强

流程稳定之后,我把识别模块换成了一个小型卷积网络:三层卷积加池化,接两个全连接层,输入 48x48 灰度图,输出 10 类。训练数据用的是公开手写数字数据集,训练集几万张,足够用了。

但直接拿公开数据集训练出来的模型放到数独照片上表现并不好。原因很简单:公开数据集里的数字是居中的、手写的、笔画集中的,而数独照片里的数字可能偏低、偏大、边缘残缺。我的解决办法是训练时做数据增强:随机平移最多 3 个像素、随机缩放 0.9~1.1 倍、随机旋转正负 5 度。这么一弄,真实照片上的识别率明显提升,误判主要集中在“3”和“8”、“1”和“7”这几组容易混淆的数字上。

4.3 识别前的归一化细节:保持宽高比而不是直接拉伸

把格子小图送进网络之前,我踩过一个细节坑:如果直接cv2.resize拉伸到 48x48,会把原本细长的“1”拉宽成粗胖的“1”,也会把横向较宽的“3”压扁,导致误识别。正确的做法是:先找数字的连通域,必要时用cv2.connectedComponentsWithStats找到数字包围盒,然后把包围盒按比例缩放到 40x40 左右,再居中放进 48x48 的白底画布。这样一来数字的原始宽高比保留住了,网络输入也不会因为白边太多而丢失特征。

4.4 误识别的常见来源和投票策略

总结下来,误识别基本来自四类情况:

  • 空格被识别成数字(所以空白阈值必须先判断)
  • 数字偏小、笔画太细,导致特征不足
  • 格线残边进入小图,干扰了分类
  • “6”和“8”、“9”和“3”这类结构相似的数字,在分辨率低的情况下确实容易混

我最后的方案是引入投票:对同一张格子图,分别用原始图、轻微膨胀图、轻微腐蚀图各识别一次,三个结果取多数。如果三个结果完全不一致,就标记为低置信度,人工或按概率最高的结果处理。这个投票策略简单有效,能把整体识别率达到 95% 以上。

5. 把答案写回原图:坐标逆变换的最后一公里

5.1 一开始就要保存单应矩阵,别返回去找

前面的步骤做完,识别出的 81 个数字(空位为 0)组成了一个棋盘矩阵。求解器我用的是回溯,纯 Python 跑一个数独题大概 30~50 毫秒,完全够用。问题是答案最后要显示在用户拍的那张歪斜照片上,这时候如果当初没保存透视矩阵,就得重新找一遍角点再算一次,非常麻烦。

所以再次强调:在第 2 步计算M = cv2.getPerspectiveTransform(src, dst)时,立刻保存M和M_inv。把答案从校正图坐标映射回原图坐标,用M_inv对校正图上的点做透视变换即可。

pts_corrected = np.array([[col * cell, row * cell] for col in range(9) for row in range(9)]) pts_original = cv2.perspectiveTransform(pts_corrected.reshape(-1, 1, 2).astype(np.float32), M_inv)

5.2 反向映射到原图写数字的细节

得到原图坐标后,我在每个格子内部用cv2.putText写答案。这里有一个显示上的问题:直接把答案覆盖到题目数字上,会遮住原有题目,用户看不出来哪个是题目哪个是答案。我的做法是:

  • 题目原有的数字保留黑色
  • 答案数字用深红色写在格子中心偏下的位置,避免和原有数字重叠
  • 如果格子里原本有数字,把答案写到右下角空白处

写字前最好先根据每个格子的实际四边形边长估算字体大小。校正图上格子是 50x50,但原图上距离不同、透视不同,远处格子看起来小,近处格子看起来大。直接用一个固定字体大小会让远处数字挤满格子、近处数字显得太小。我按照每个格子四边形的平均边长比例动态计算字体大小,效果会自然很多。

5.3 防止答案遮挡和视觉混乱的处理

还有一个不起眼但很重要的点:当答案数字和题目数字在同一个格子里时,要处理遮挡。我测试时发现直接把红色答案写在题目黑色数字正上方,不仅看不清,还容易被用户误认为印刷重叠。后来我把答案统一放在格子对角位置,并给答案加一圈白色描边,这样即使和题目交叠,也能清楚地分离出来。这个处理在实际演示中效果非常好,别人一眼就能分清印刷的题目和红色答案。如果你做的是视频流版本,还可以把答案区域做成半透明浮层,动态切换显示与隐藏。

6. 项目复盘:这些参数和思路值得存档

6.1 我最终固定下来的参数参考表

环节参数/操作取值说明
缩放等比缩放宽高宽 600~900太大影响速度,太小轮廓不稳定
高斯滤波核大小5x5去除传感器噪声
自适应阈值blockSize, C15, 2光照不均时明显优于全局阈值
形态学闭运算核3x3连接断裂网格线,一次即可
外轮廓筛选多边形逼近 epsilon周长 x 2%偏小容易多出顶点,偏大丢失直角
校正输出尺寸450x450每格约 50x50,识别器输入友好
格线提取投影阈值最大值 x 0.5峰值超过中位值一半才认作格线
数字模板模板尺寸32x32配合 matchTemplate 足够
识别输入归一化尺寸48x48保留宽高比后居中填充
空白判定黑像素占比< 2% 判为空避免空格被误识别

这些参数不是从哪本书抄来的,而是反复试出来的。你换一批照片很可能需要微调,但调整的方向和逻辑是一样的:先看中间结果,确定崩在哪一步,再顺势调参。比如线条断了就搞形态学,不要埋头加高斯核;数字模糊就加大模板尺寸或者换网络,不要盲目膨胀。

6.2 从失败案例中学会的调试习惯

这个项目我做了一半进度时,识别率一直在 60% 徘徊,每天改参数都像无头苍蝇。后来意识到一个问题:我从来没把每一步的中间结果保存下来过。于是我在每个模块后加了一行cv2.imwrite,把灰度图、二值图、轮廓图、校正图、格子切分图全部导出。导出之后,20 秒就能定位到问题是出在轮廓提取还是在数字分类。这个习惯帮我节省了至少两天的改参数时间。

强烈推荐你也这么做。不要省那几行代码,中间结果是你调试时唯一的眼睛。尤其是轮廓绘制那一步,把找到的四个角点点在原图上看看位置,如果角点偏了,后面一切都会跟着错。

6.3 这个项目还能往哪些方向延伸

  • 实时视频识别:手机或摄像头对准数独,画面里实时叠加答案,需要把单应矩阵每一帧重新计算或做平滑跟踪。
  • 手写数独:把模板匹配换成更鲁棒的字符识别模型,或者用手写数字增强数据重新训练。
  • 模糊/遮挡数独:加入超分辨率或去阴影模块,这是更接近真实场景的需求。
  • 边缘人像拍摄:倾斜角度特别大时,四边形的检测容易失效,可以研究更鲁棒的角点检测方法。

最后说点实践经验

做完这个项目,我最大的体会是:计算机视觉不是一个“调库”的领域,而是一个“理解像素如何流转”的领域。数独题目看起来简单,但它把图像的获取、增强、分割、识别、几何变换、结果合成全部串起来,每一步都对应一个经典问题。如果哪天有人问我入门 CV 做什么项目好,我还是会推荐做一遍数独识别,数据好获取、问题边界清晰、成就感来得快。

最后分享一个小技巧:先拿一张打印清晰、正对拍摄的“标准图”把整条流水线调通,再拿一张随手拍的“地狱图”来打磨每个环节。这样做的好处是,你永远知道问题出在哪个环节,而不是在两张照片之间胡乱改参。这个思路,也适用于很多其他视觉任务。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进