☰
PuzzleSolver v1.0.4:从照片到答案的自动化谜题求解实践
2026/9/25 5:47:00 网站建设 项目流程

实不相瞒,PuzzleSolver 这个项目最开始是因为一次家庭聚会上被"考倒"才动手做的。姨夫用手机拍了张数独题照片发到家庭群里,说要考考我。我盯着那张歪了将近 15 度的照片,手动把 45 个已知数字填进表格,用了整整八分钟,最后还填错了一个。当时我就觉得,这类从"照片里的谜题"到"最终答案"的过程,应该有一条完全自动化的链路,而且不该只服务数独。

PuzzleSolver v1.0.4 就是这条链路的完整实现:输入一张包含标准谜题的照片或截图,工具会自动完成图像矫正、谜题区域定位、格子切分、符号识别、求解计算、答案标注六个环节,最终输出标注好的结果图和结构化数据。它不仅支持数独,也覆盖单词搜索谜题和标准矩形拼图。整份说明书写给两类人看:一是想省事、想直接部署这套工具解决实际谜题的普通用户;二是想参考"图像识别 + 算法求解"模块化架构、打算把这套思路挪到其他领域(比如问卷调查自动识别、表格数字化)的开发者。就算你对图像处理不熟,按着文章里的参数和步骤也能跑通。

1. 先圈定项目地盘:能解的、不能解的和六个模块的职责

1.1 六个模块,各自只干一件事

从 1.0 版开始,PuzzleSolver 就确立了"采集—识别—求解"三层逻辑,到 v1.0.4 一共稳定拆出六个模块:

模块名核心职责输入输出
im_ingest图像读取、格式统一、方向矫正任意图片文件/剪贴板截图标准化 RGBA 数组
geom_fix透视矫正、谜题区域定位标准化图像矫正后的谜题区域图
grid_split网格检测、单元格切分矫正后的区域图单元格坐标与子图列表
symbol_recog符号识别、置信度评估单元格子图列表候选符号 + 置信度列表
solver_core谜题求解、多解判定结构化谜题数据完整解及状态码
render_out答案可视化、结果导出原始图 + 解标注图、JSON、PDF

这里有个容易被忽略的细节:每个模块的输入输出都尽量设计成"无状态"。也就是说,grid_split 不知道上一个模块是谁,solver_core 也不关心符号是 OCR 识别出来的还是手工录入的。无状态的模块可以单独调试、单独测试、单独替换,这是 v1.0.x 系列重构中最值得保留的决定。

为什么非要把一个"看起来不大的工具"拆成这样?因为真实世界里,失败可能发生在任何一环。如果所有逻辑写在一个大脚本里,照片歪了、网格没对齐、识别错了,你根本分不清是哪一步出了问题。拆开之后,每个环节都能独立 dump 中间结果,我能直接看到"哦,是 grid_split 把第三行切歪了",而不是对着堆栈猜。

1.2 边界是怎么定下来的

很多用户拿来就想解"所有谜题",我要在这里把范围钉死。PuzzleSolver v1.0.4 支持三类问题:

  • 标准数独 9×9,以及 6×6、12×12 这类矩形变体;
  • 单词搜索谜题(字母矩阵 + 目标单词表);
  • 标准矩形拼图(碎块是规整矩形、边线清晰的照片)。

明确不做的事:需要自然语言语义理解的逻辑谜题(比如爱因斯坦谜题、房屋颜色推理)、依赖外部知识库的填字游戏、自由曲线切割的异形拼图。这个边界在 v1.0.2 之后定死。当时我试图把语义型逻辑题也收进来,结果发现单单"把一段人话转成约束条件"就是一个独立的 NLP 项目,硬塞进 solver_core 只会把核心算法拖垮,还会让错误更难定位。做工具的人得学会拒绝,明确不做什么,反而能让用户更信任你。

1.3 v1.0.4 在版本线里的位置

v1.0.x 阶段主要是在补"稳定性"欠账。v1.0.1 修掉了 Windows 下中文路径读取失败的问题;v1.0.2 加入了模块状态码规范;v1.0.3 改进了 grid_split 的网格线检测;v1.0.4 则是一次比较大的模块边界调整和性能优化,具体变更在第 6 节展开。如果你是从 v1.0.0 一路用过来的老用户,这次升级需要改一点调用方式,但整体迁移成本不高。

2. 模块间的"契约":中间数据格式决定了系统稳定性

2.1 一次完整调用长什么样

把六个模块串起来,主流程是一个单向管道。为了让你对整体有个直觉,我贴一段入口代码(简化掉异常处理和日志):

def solve_puzzle(image_path: str) -> dict: image = im_ingest.load(image_path) # 读取 + 格式统一 region = geom_fix.locate_puzzle(image) # 找出谜题区域并矫正 cells = grid_split.split_into_cells(region) # 切分成单元格 symbols = symbol_recog.recognize_batch(cells) # 批量识别符号 puzzle_data = build_puzzle_data(symbols) # 组装成结构化谜题 result = solver_core.solve(puzzle_data) # 求解 return render_out.compose(image, result) # 标注答案并导出

这段代码看起来简单,但它的每一行背后都是前面那个表格的模块边界。替换任何一个xxx.xxx都不会影响其他模块,因为它们的依赖被中间格式挡住了。

2.2 PuzzleData:连接识别和求解的中间结构

这里的关键设计是 PuzzleData 这个中间结构。它本质上是一份带类型标注的 JSON 描述:

@dataclass class PuzzleData: puzzle_type: str # "sudoku" | "wordsearch" | "jigsaw" grid: list[list[str]] # 二维符号表,空白用 '.' 表示 meta: dict # 谜题元信息:尺寸、候选词表等 confidence: list[list[float]] # 每个格子的识别置信度

比如数独,grid 就是 9×9 的字符矩阵;单词搜索,grid 是字母矩阵,meta 里带 words 列表;拼图,grid 会编码碎块的边缘特征标签。confidence 是 v1.0.3 之后加的,它在求解器里扮演"备用线索"的角色——某个格子识别置信度很低时,求解器会把它当作灵活变量处理,而不是直接采信。

2.3 为什么坚持用可序列化的 JSON 而非内存对象

有朋友问我:同一个 Python 进程里,直接传对象不是更省事吗?为什么要 JSON 化?我有三个非常实际的理由。

第一,可调试性。任何一个环节出问题,我可以把中间结果存成.json文件,用文本对比工具直接看差异。如果用内存对象,就必须用 IDE 的调试器,沟通成本高很多。

第二,模块可独立测试。solver_core 的单元测试完全可以不经过图像链路:手工构造一份 PuzzleData JSON,丢给求解器验证算法正确性。有一次我在没有摄像头、没有测试图的火车上,硬是通过手工造 JSON 把求解器 bug 复现了。这个体验让我彻底坚持了"跨模块边界必有 JSON"的原则。

第三,跨语言友好。虽然现在主体是 Python,但重识别任务很可能以后会拆出去用 Rust 或 C++ 做,JSON 契约让语言替换变得毫无压力。

3. 影像预处理模块:八成失败都发生在这道关口

3.1 输入规格和自动矫正

先说输入。v1.0.4 接受 JPG、PNG、BMP、WebP 以及剪贴板截图。内部统一转成 RGBA 数组后,第一步是判断是否需要旋转和透视矫正。

透视矫正最常用的方法是检测图像中的四边形轮廓,然后用四点变换把轮廓映射到正矩形。对于数独这类"外框明显"的谜题,这一步非常可靠:

import cv2 import numpy as np def locate_puzzle(image: np.ndarray) -> np.ndarray: gray = cv2.cvtColor(image, cv2.COLOR_RGBA2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) edges = cv2.Canny(blurred, 50, 150) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True) puzzle_contour = contours[0] # 用 approxPolyDP 拟合出四边形的四个顶点 epsilon = 0.02 * cv2.arcLength(puzzle_contour, True) approx = cv2.approxPolyDP(puzzle_contour, epsilon, True) # 四点变换映射到标准矩形 # ... 此处省略顶点排序与透视矩阵计算

这里最坑的是顶点排序。cv2 返回的四个点不是左上、右上、右下、左下的固定顺序,必须先按坐标求和、求差判断相对位置,再统一顺序,否则透视变换出来的图是翻转或旋转的。

3.2 网格检测与格线断裂处理

grid_split 是预处理里最容易翻车的模块。它的核心任务是找到谜题的行列分割线。对于打印体数独,黑白分明,用二值化 + 形态学操作就能稳定提取:

def find_grid_lines(binary: np.ndarray): horizontal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1))) vertical = cv2.morphologyEx(binary, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, 40))) # 统计每行/每列的白点数量,超过阈值就认为是分割线

这里的关键参数是形态学核的尺寸(上面是 40 像素)。它必须大于格线可能出现的断裂缺口,但又不能大到把相邻线连起来。这个值在 300 DPI 的扫描图上和手机照片上的最优值差别很大,所以 v1.0.4 做成了可配置项,并支持根据图像尺寸自动估算初始值。

3.3 二值化不能只用一个全局阈值

光照不均是手机拍照的常态,用一个固定阈值做二值化必然翻车。v1.0.4 默认采用自适应阈值(cv2.adaptiveThreshold),并在前景背景对比较弱时切换到 Otsu 阈值作为兜底。

一个我反复踩过的坑:对于浅色铅笔答题的数独,全局 Otsu 经常把浅色笔迹识别成背景,结果某些格子直接"空掉"。后来我加了一步对比度拉伸(CLAHE),在自适应阈值之前对灰度图做限制对比度增强,识别率立刻上一个台阶。这一步很多人会忽略,但对真实照片场景特别有效。

3.4 实测:哪类照片预处理会挂

根据我用 v1.0.3 到 v1.0.4 期间积累的测试集统计,预处理失败主要集中在三种情况:

失败场景失败原因v1.0.4 的处理
强反光 / 玻璃板下的谜题高光区域二值化后全白增加 CLAHE + 中值滤波,仍不建议拍玻璃
大幅倾斜(超过 30 度)透视矫正后采样变形限制四点变换的宽高比,超限直接报错提示重拍
手绘图劣质网格线形态学核无法连接断线开放核尺寸配置,建议使用扫描模式

玻璃板反光这种场景做了一些缓解,但根治不了,说明文档里直接建议用户把谜题从玻璃下抽出来再拍。工具能做的不是放大镜,而是告诉用户"这个输入我不建议处理"。

4. 符号识别模块:模板匹配、OCR 与置信度输出

4.1 三种识别策略,按场景切换

symbol_recog 模块支持三种策略,很多项目在这里栽过跟头——上来就想上深度学习,实际效果反而不如简单方法稳定。

策略原理适用场景v1.0.4 定位
模板匹配对每个单元格做尺寸归一化后与字符模板库比对打印体、字体固定默认策略
轻量 OCR用 Tesseract 的 digit 模式识别扫描件、清晰印刷体可选用策略
CNN 分类器训练一个不到 5 万参数的小模型手写体、跨字体实验性策略

默认模板匹配的原因很朴素:数独的数字、单词搜索的字母都是固定字形,模板匹配在 CPU 上跑得飞快,而且不需要 GPU、不需要依赖一个巨大的 OCR 运行时。只有遇到手写体或奇怪字体时,模板匹配会掉链子,这时切换到 CNN 分类器会好很多。

如果你用的是 Tesseract,建议使用--psm 10(单字符模式)而不是默认的整行识别。实测默认模式下,单个数字经常被识别成 "1\n" 或把 7 识别成 1,psm 10 能直接把单字符区域当作唯一目标,准确率提升非常明显。

4.2 置信度融合:别把话说死

v1.0.4 在识别输出上做的一个重要改变是:每个格子不再只输出一个"最终答案",而是输出候选符号的置信度排序。比如某个格子输出[('5', 0.87), ('6', 0.10), ('S', 0.03)],就表示识别模块觉得最可能是 5,但也不敢排除 6。

这个设计对求解器的帮助是巨大的。传统做法是识别错了就错了,求解器拿到错误输入只能算出无解或者错解。有了候选置信度,求解器可以把低置信度的格子标记为"可变",在无解时自动尝试第二候选,大大提升了整体成功率。

实现上,就是把原来简单的str输出改为list[tuple[str, float]],加上前面的 confidence 矩阵,接口变更在 v1.0.4 中属于破坏性变更(见第 6 节)。

4.3 手写体的兼容思路

对真实用户来说,手写数独才是刚需。但手写体的字符形状千奇百怪,模板匹配基本无能为力。我的建议是:如果目标场景主要是手写体,直接用 CNN 分类器路线,而不要试图在模板匹配上堆训练数据。

v1.0.4 内置的 CNN 分类器是实验性模块,但代码里留了清晰的训练接口。你只需准备每个数字若干张带标注的单元格图片,跑一下train_symbol_cls.py就能拿到自己的模型。这个模型非常小,用 MobileNetV2 的简化版就能跑到 99% 以上准确率,在 CPU 上单张推理不超过 5ms。

5. 求解引擎:三种谜题三种打法

5.1 数独:约束传播打头阵,回溯兜底

先说求解器里最简单也最经典的一类。数独求解的基础是回溯算法,但裸回溯很容易在小分支里空转。v1.0.4 的实现是两步走:先用"唯一候选 + 隐性唯一"这类约束传播规则把能确定的格子全填上,剩下的再用回溯暴力收尾。

def sudoku_solve(grid): propagate_constraints(grid) # 约束传播:唯一候选、隐性唯一... if is_complete(grid): return grid row, col = best_empty_cell(grid) # 选候选数最少的格子 for candidate in grid.candidates(row, col): grid[row][col] = candidate result = sudoku_solve(grid) if result: return result grid[row][col] = EMPTY return None

这里的核心优化是best_empty_cell:每次都挑候选数字最少的空格去尝试,而不是按顺序从头扫描。这个技巧能让搜索树规模缩小几个数量级,普通 9×9 数独基本在 10 毫秒内出解。

另一个必须处理的情况是多解数独。v1.0.4 的 solver 会继续搜索第二个解,如果发现存在多解,会在结果里标记 MUTLI_SOLUTION 状态,而不是随便返回第一个解误导用户。这个状态码设计是 v1.0.2 引入的,实际使用中价值很高。

5.2 单词搜索:Trie 剪枝 + 上下左右遍历

单词搜索谜题(Word Search)是另一个常见类型。它的求解可以看作"在字母矩阵中找目标单词"。最粗暴的做法是每个词从每个起点朝 8 个方向跑一遍,复杂度是 O(单词数 × 格子数 × 方向 × 词长),碰上几十个词的长列表就吃力了。

v1.0.4 的优化思路是:先把目标单词表建一棵 Trie,然后 DFS 遍历矩阵时边走边看前缀是否在 Trie 里,如果前缀不存在就直接剪枝。这样实际搜索量远小于暴力法。

class WordSearchSolver: def __init__(self, words): self.trie = build_trie(words) def search(self, board): paths = [] for r in range(rows): for c in range(cols): self._dfs(r, c, board, set(), [], paths) return paths

实际体验:一个 20×20 的字母矩阵、50 个目标单词,Trie 剪枝方案在普通笔记本上基本一秒内完成。如果不用 Trie,这个量级的题可能要等十几秒。

5.3 拼图:边缘特征匹配而不是像素匹配

拼图求解是三类里最"重"的。标准矩形拼图,每个碎块有上下左右四条边,边由凸凹特征决定。v1.0.4 的做法是:先对每个碎块提取四边轮廓,转成边缘特征向量,然后通过特征相似度进行拼接匹配。

这里有个反直觉的经验:不要直接用原图边缘的像素灰度做匹配,因为拍摄光线不一致,左块的右边和右块的左边灰度差异可能很大。正确做法是把边缘转为二值轮廓描述(凸点/凹点序列),再计算相似度。v1.0.4 内置了几种二值轮廓编码,实测在标准拼图上,匹配准确率比像素匹配高 15 个百分点以上。

不过拼图模块的计算复杂度明显高于前两类,碎块个数超过 100 就建议用配套的批处理模式,不要在前端线程里跑。这个我在第 7 节的性能部分还会提。

5.4 解不出来时:降级策略与清晰报错

求解器最忌讳的是闷头算半天然后返回一个 "None" 让用户猜。v1.0.4 统一了状态码设计:SOLVED、UNSOLVABLE、MULTI_SOLUTION、LOW_CONFIDENCE_INPUT、UNSUPPORTED_TYPE。每个状态码都附带结构化说明。

LOW_CONFIDENCE_INPUT 状态是 v1.0.4 新加的。当识别模块给出的全局平均置信度低于阈值时,求解器会正常尝试求解,但在结果里明确提示"输入可能存在识别错误,结果仅供参考";在交互界面里,这个状态会把低置信度格子高亮显示,用户一眼就能看到哪些格子可能需要人工确认。这个设计极大减少了"工具给错答案用户却不知道"的信用危机。

6. v1.0.4 到底改了什么:升级与迁移说明

6.1 相对 v1.0.3 的关键变更

老用户最关心的其实是这里。v1.0.4 的改动可以分成三类:功能增强、破坏性变更、修复项。

类别内容影响面
功能增强识别模块输出候选置信度列表接口调整
功能增强新增 LOW_CONFIDENCE_INPUT 状态码状态码扩展
功能增强CLAHE 对比度增强默认开启预处理效果提升
破坏性变更symbol_recog.recognize 返回值从 str 改为 list老代码需适配
破坏性变更solver_core.solve 结果对象新增 status 字段老代码需适配
修复Windows 中文路径问题(v1.0.1 已修,这里合并确认)无感知
修复透视变换顶点排序在极端角度下的错误无感知

6.2 迁移步骤

从 v1.0.3 升级到 v1.0.4,主要改动集中在调用方对识别结果和求解结果的解析上。

旧代码(v1.0.3)拿识别结果通常是这样:

symbol = recognizer.recognize(cell_image) # 旧版返回字符串 grid[row][col] = symbol

新版需要多取一层候选:

candidates = recognizer.recognize_with_confidence(cell_image) # 返回候选列表 grid[row][col] = candidates[0][0] # 取最高置信度候选

如果你的业务不关心置信度,老接口其实也保留了一个兼容入口recognize,它会内部取 top1 并返回字符串。但注意,风格上还是建议迁移到新接口,因为 top1 在很多场景下并不是最优策略。

求解结果同理。v1.0.4 的 solve 返回值统一为 Result 对象,包含 .grid、.status、.message 三个字段。如果你原来直接拿返回值当二维数组用,需要在外面套一层.grid操作。

整体迁移我用一个下午就完成了,主要工作量不在代码,而在重新阅读文档确认状态码含义。对,这就是文档的价值。

6.3 性能提升的实际数据

更新到 v1.0.4 之后,用我手头的一组测试集跑了一遍:

场景v1.0.3 平均耗时v1.0.4 平均耗时提升
9×9 数独全流程(含预处理)1.8s1.1s39%
20×20 单词搜索12.4s0.9s93%
60 块拼图匹配8.2s6.5s21%

单词搜索的提升最明显,主要来自 Trie 剪枝的引入;数独的提升一部分来自 CLAHE 减少了识别重试,一部分来自求解器候选格选择优化。拼图模块的提升有限,因为它主要吃计算量,后面想再提速得考虑 C 扩展或 GPU。

7. 真实使用中的坑与调参记录

7.1 光照不均导致分格偏移

我实际遇到最多的问题是:照片左半边亮、右半边暗,自适应阈值在暗部把格线识别断了,grid_split 把三行切成了两行。排查的时候看中间结果图,才发现问题出在二值化后的格线缺失,而不是网格坐标计算。

后来参数上做了三个调整:一是开启 CLAHE 增强;二是形态学核尺寸从固定 40 改成max(20, image_width // 25),保证核尺寸跟随输入分辨率缩放;三是在 findContours 之前加一道闭运算,把断裂线焊接起来。三个改动叠加之后,网格检测失败率明显下降。

7.2 手机照片的透视矫正参数

另一个高频坑:手机拍照角度稍微歪一点,透视矫正后的文字就会变形,识别模块的模板匹配立刻失效。我的做法是在 geom_fix 模块里加一个"形态合理性校验":矫正出来的矩形宽高比如果偏离正常值超过 15%,就判定为透视矫正失败,并在结果里提示用户"请正对谜题拍摄"。

这个校验看似多此一举,实际上帮用户省了大量重新对焦的时间。实践里,很多用户并不知道自己拍照角度有问题,你只要把结果标注好显示出来,他们下次就会主动把手机端平。

7.3 识别 batch 与内存控制

symbol_recog 在 v1.0.4 里加了批处理接口,一次把一个 9×9 数独的 81 张单元格子图丢进去统一识别。批处理的好处是能充分利用向量化计算,但坏处是如果你的图像分辨率很高,一次性把所有子图放大到模型输入尺寸,内存会涨得很快。

我的经验是:批大小控制在 32 到 64 之间,超过就分批,速度几乎不损失,但峰值内存能降不少。如果是在低配设备上跑,建议把批大小下调到 16,同时把中间子图直接保留为 uint8 numpy 数组,避免转成 PIL Image 再转回来造成额外的内存拷贝。

7.4 几个养成习惯的小技巧

最后分享几个我在维护这个项目过程中养成的小习惯。第一,版本发布前一定要把"中间结果导出"跑一遍,确保每个模块都能独立 dump 当前状态,这个能力在线上排查时是救命稻草。第二,任何调参都不要直接改源码里的硬编码,而是放进 config 文件,哪怕一开始只有一个参数也要这么做,因为你会很快发现第二个、第三个参数也需要暴露出来。第三,状态码文档要跟代码一起更新,v1.0.2 之后我吃过一次"状态码改了但 README 没改"的亏,用户按文档查状态码查不到,非常尴尬。

做 PuzzleSolver 这一年多,最大的体会不是算法有多难,而是"把边界守住、把接口定好、把中间结果暴露出来"这三件事,对一个小工具的长线维护帮助远大于任何花哨的模型。如果你也在做类似的图像识别 + 算法求解项目,不妨先把模块间的 JSON 契约定好,再投入精力去优化算法本身。很多看起来是算法的问题,最后发现都是数据流和边界的问题。这个道理,我觉得比 v1.0.4 本身更值得带走。

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

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

立即咨询