用YOLOv8和CNN实现麻将手牌截屏识别实战

发布时间:2026/9/2 18:06:46
用YOLOv8和CNN实现麻将手牌截屏识别实战 简介面向 VC2010 开发者的麻将图像识别实战资源以 Windows 屏幕截屏为切入点实现从抓取牌面到输出识别结果的完整流程。资源定位清晰既适合刚接触图像处理的初学者逐段调试也适合参加智能识别类项目或竞赛的读者快速复用。压缩包共含 69 个文件除 15 个头文件与 13 个 C 源文件构成完整工程外还包含 15 张位图、10 个图标等界面素材以及 4 个文本笔记和 Visual Studio 工程配置整体约 297KB代码与素材分层存放便于按模块检索。内容覆盖图像灰度化、二值化、噪点消除、Canny 边缘检测、模板匹配与 OCR 识别等关键环节并附开发计划、知识点总结、特征说明和日志展示了一个可直接运行的 VC2010 工程结构。目前已有 2512 人学习下载读者可直接打开工程查看图像预处理、特征提取与分类的调用关系并结合源码注释理解各模块的输入输出适合希望在 OpenCV/VS2010 环境下快速搭建麻将识别原型并继续扩展识别逻辑的开发者。 做了个小工具平时打在线麻将的时候想复盘手牌又懒得一张张截图记录。干脆写个程序直接从屏幕上抓取画面识别出当前手牌是什么。抱着这个想法折腾了一阵子从截屏到图像识别最后总算跑通了一条完整的链路。这篇就记录一下整个项目的技术选型、实现细节以及那些不跑一遍根本发现不了的坑。不管你是想做个类似的桌面UI自动化识别工具还是对卷积神经网络图像识别在具体场景里的落地感兴趣这篇应该都能给你一些参考。1. 为什么“截屏识别牌面”这件事没有想象中简单先把这个项目的边界划清楚。我需要的功能是程序自动截取屏幕上的麻将牌区域识别出每一张牌的花色和点数输出成类似[W1, W9, T2, T3, B5, B8]这样的结构化数据供后续的提示、统计逻辑使用。听起来很简单对吧截个图识别一下结束。但真正动手才发现“识别一张麻将牌”这个问题的复杂度完全取决于你怎么定义“识别”。1.1 需要识别的牌面内容分类国标麻将和绝大部分地方麻将的牌面可以归纳为三大类万子W1-W9牌面上是中文数字“一”到“九”加上“万”字条子T1-T9牌面上是条状图案数字越大条越多筒子B1-B9也有叫饼的牌面上是圈状图案从一到九字牌东南西北中发白一共7种如果只算一手牌而且是固定游戏、固定分辨率、固定牌桌主题那确实可以用最简单的方式处理。但一旦涉及到多分辨率、多主题皮肤、不同缩放比例事情就开始变复杂了。1.2 模板匹配方案为什么直接被否掉一开始我想过最朴素的办法截取每张牌的图片和预先准备好的模板做模板匹配。试了两个场景就不想继续了。原因有三个牌面尺寸不稳定。窗口缩放、屏幕分辨率不同同一张牌的像素面积可能从 30x40 到 80x110 不等。模板匹配对尺度变化非常敏感必须做多尺度遍历速度慢且容易误匹配。牌桌背景干扰。真实牌桌上的牌不是干干净净摆在白底上的。有的游戏里手牌有高亮边框有的有阴影有的背景纹理复杂。模板匹配是像素级的相似度计算背景一变阈值就失效。同类牌内差异小。比如“二条”和“三条”区别只在于图案中有几条杠模板匹配很容易在边界处抠错像素导致误判。所以方案直接定下来用目标检测模型先定位每一张牌再用分类模型或者直接检测出头尾类别识别具体牌面。这也是当前做图像识别的常规路线。1.3 技术栈选型既然定了目标检测路线模型框架的选择就很关键。我最终选用的是截屏采集Python 的mss库用来快速抓取指定屏幕区域图像预处理OpenCV负责裁剪、缩放、颜色转换目标检测模型YOLOv8ultralytics 版直接用它训练一个单类别检测器检测对象就是“麻将牌”牌面分类模型PyTorch 写的一个轻量CNN分类器输入检测框裁剪出来的牌面图像输出34类万条筒字牌这里有一个可以简化的点如果只是识别“手牌区域”且牌是规整排列的其实可以跳过目标检测直接用固定网格切图每张切块送入分类器。我后面会讲到为什么最终没有用这种“省事”方案。但如果是做屏幕识别mssOpenCVCNN 这条链路基本是通用答案可以套用到很多类似的UI识别需求上。2. 截屏采集管线的实现细节既要快也要准图像识别项目里数据输入端的稳定性决定了后面所有环节的上限。屏幕截屏看似人人会写但和“速度优先”的场景结合起来有几个细节值得展开。2.1 用 mss 而不是 PIL.ImageGrab直接把 PIL 的ImageGrab.grab()拿来用也能截屏但实测帧率和 CPU 占用都不理想。为了拿到尽可能连续的帧我换成了mss。它的工作方式是通过底层API直接读取 framebuffer截一块 1920x1080 的区域实测单帧耗时在 20-40ms 左右比 PIL 快了不少而且内存表现更稳。下面是截取指定区域的核心代码import mss import numpy as np import cv2 def grab_screen(region): region: (left, top, width, height) 返回 BGR 格式的 numpy 数组供 OpenCV 使用 with mss.mss() as sct: monitor {left: region[0], top: region[1], width: region[2], height: region[3]} img sct.grab(monitor) # mss 返回 BGRA去掉 alpha 通道转成 BGR img np.array(img)[:, :, :3] return np.ascontiguousarray(img)2.2 只截手牌区域而不是全屏做一个识别系统最忌讳的是让模型在无关的区域上浪费时间。当然全屏截下来喂给目标检测模型也能用但有两个问题全屏检测会把对手的牌、牌河里的牌、甚至背景装饰都一起检测出来。后续过滤逻辑会很痛苦。截全屏的耗时高于截局部区域对后续需要连续多帧处理的场景不友好。我的做法是手动框定“自己手牌”所在的屏幕矩形区域只截取这一块。这样一张截图上只有 13 或 14 张牌目标检测模型压力小后处理也简单。如果游戏窗口位置固定这个区域可以硬编码如果窗口可以拖动就需要额外做一个窗口位置探测功能比如根据窗口标题找到坐标。2.3 高DPI和缩放比例会导致坐标错位这是我踩的第一个大坑。在 Windows 上如果显示缩放不是 100%比如笔记本常用 150%mss的坐标体系和游戏窗口的坐标体系并不一致。游戏里拿到的窗口坐标是逻辑坐标而截屏采集用的是物理像素坐标。不处理这个问题截出来的区域会整体向左上偏移牌面被切掉大半识别准确率直接崩盘。解决办法是查询系统的缩放比例然后把窗口坐标转换成物理像素坐标import ctypes def get_dpi_scale(): try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass dpi ctypes.windll.user32.GetDpiForSystem() return dpi / 96.0 scale get_dpi_scale() physical_left int(logical_left * scale) physical_top int(logical_top * scale)这一条对任何做桌面自动化、屏幕识别的人都适用记住“逻辑坐标不等于物理坐标”优先在系统层设置 DPI 感知。3. 牌面识别模型数据、标签与训练经验截屏拿到牌面图像后核心工作进入图像识别领域。这里的关键在于数据怎么来、标签怎么设计、模型怎么选。3.1 标签体系34类如何编码麻将牌共34种不计花牌为保证分类器输出的稳定性我用了纯英文代码做标签避免中文在模型输出和日志系统中出现编码问题。类别编码万子一~九W1-W9条子一~九T1-T9筒子一~九B1-B9字牌Z1-Z7对应东南西北中发白分类模型在全连接层输出一个34维向量取 argmax 就得到具体的牌。这个编码方式在后续对接逻辑判断代码时也方便比如判断“是不是清一色”“缺什么牌”直接基于字符串前缀可读性很高。3.2 数据集来源截屏标注 合成数据混合一个很现实的问题是样本从哪来。标准数据集里不会有牌桌截图你必须自己攒。我采用了两条腿走路的方式。第一条路是截屏手工标注。从游戏里截取不同主题、不同尺度的牌面图用 labelImg 或 Roboflow 标注检测框和类别。这个过程非常费时一个人完成几百张图已经是极限但好处是数据真实模型在真实场景下的泛化表现有保证。第二条路是合成数据生成。用公开的麻将牌面素材在随机背景上以随机角度、随机光照条件、随机缩放比例合成训练图片。合成数据可以轻松生成几千上万张覆盖模型在真实场景下可能遇到的变化。操作经验是合成数据和真实数据按 3:1 到 4:1 混合训练既省人工又能达到接近纯人工标注的效果。我的分类模型最终的训练集大概有 8000 张合成图 2000 张真实截屏在测试集上的准确率做到了 98.6%。3.3 模型选型轻量CNN比什么都重要因为最终要跑在本地 CPU 上最多用一下集显模型不能太大。我对比过几个方案ResNet18准确率最高但 CPU 推理一张图要 40-60ms连续处理 14 张牌就要接近一秒无法接受。MobileNetV3-Small准确率稍微降一点但单张推理只要 8-12ms14张牌全识别也才不到200ms。自写轻量CNN3个卷积层2个全连接层单张3-5ms但对复杂图案尤其是二条三条区分能力偏弱。最终我选了 MobileNetV3-Small用 PyTorch 加载预训练权重替换最后的全连接层输入尺寸设为 64x64 的灰度图。之所以用灰度图而不是RGB是因为减少一个通道意味着三分之一的输入计算量而麻将牌的颜色信息红中、白板除外对识别贡献不大。训练时学习率用 1e-3batch size 64跑了大概 20 个 epoch。数据增强部分做了随机亮度扰动、随机缩放0.8-1.2倍、随机旋转±10度这能更好地模拟真实截图中的光线和透视变化。4. 检测框输出到牌面序列的后处理逻辑模型输出一堆检测框和类别之后还不能直接用。手牌是有顺序的从左到右依次排列程序必须按照这个顺序把识别结果串起来交给后续逻辑。4.1 按位置排序YOLO 输出的检测框坐标是(x_center, y_center, width, height)我的手牌是在一条水平线上排列的。所以后处理的第一步就是按 x_center 从小到大排序也就是从左到右排列。但这里有一个经典问题如果某张牌的检测框定位稍微偏了或者两张牌之间被系统自动插入了一个动画特效就会导致排序错位。我的做法是不只是按中心点排序还要做“重叠聚类”。具体思路是按 x_center 排序依次检查相邻两个框是否有较大重叠比如两个框的IoU大于0.3如果重叠则认为是同一张牌的重复检测保留置信度高的那个这一步在实测中非常有用尤其是牌桌上有阴影或者牌被轻微遮挡时YOLO 偶尔会输出重复框。4.2 置信度过滤目标检测模型的输出几乎不可能100%准确。置信度阈值要把握分寸阈值太高会漏检阈值太低会把背景误判成牌。我在实测中把阈值定在 0.45然后对所有置信度低于 0.3 的检测框直接丢弃。对于 0.3-0.45 之间的框额外检查一下框内面积是否合理如果面积过小或过大就判定为噪声。牌面分类器同样存在置信度问题。当预测概率的最大值低于 0.6 时我会把这张牌标记为“未知”同时保留原始截图。这个“未知牌”机制对排查问题特别有帮助否则你只会看到程序输出了错误牌却找不到是在哪一步出的错。4.3 把结果序列化成完整的牌手牌描述当检测和分类完成后最终的输出是一个列表比如hand [W1, W6, W9, T2, T3, T3, T4, B5, B5, B8, Z1, Z3, Z6]这个列表可以直接喂给后续的逻辑模块。比如可以计算向听数、判断是否听牌、推荐切哪张牌。这些进阶功能本质上是另一个问题了但图像识别模块输出格式的稳定性会直接影响这些上游功能的开发难度。在调试时我还会输出一张标注后的图片把检测框、类别、置信度画在原图上保存到本地。这样每一轮的识别结果是否正常图片上直观可见。这类“可视化调试”的习惯我认为比任何代码注释都管用。5. 实测效果与最容易翻车的五个坑项目跑通后我实际在不同的游戏场景里做了验证手牌识别准确率大概在 96% 左右。但有几个坑是在真实测试中反复踩过之后才总结出来的。5.1 不同皮肤、主题的色偏问题麻将游戏一般都有多种牌桌皮肤。有的皮肤是亮色调有的偏暗色有的牌面是高对比度大数字有的是传统手绘风格。训练数据如果只覆盖了一种风格模型换到另一种直接抓瞎。我解决的思路是在数据增强阶段刻意加入 HSV 空间的随机偏移把色调、饱和度、亮度的扰动范围拉大。这样虽然牺牲了一点单一风格下的准确率但换来的是多风格下的稳定表现。如果是特殊皮肤下识别率持续偏低就把该皮肤下的截屏样本补充进训练集。5.2 二条三条的区分这是所有麻将识别项目里最容易翻车的地方。二条是两根竖条三条是三根竖条如果图像尺寸小、分辨率低这两个类别之间的差异确实不够明显。我第一次测试的时候二条和三条的混淆率高达 8%远远超过其他类别。改善需要从两个角度同时考虑数据层面多收集二条三条的样本而且保证不缩小到太小的分辨率。我最终把分类器输入从 48x48 提升到 64x64这个类别上的准确率立刻上去了。模型层面给分类器增加一个“二次判断”逻辑当模型输出的最大概率落在 T2 或 T3 上而且两个概率差值小于 0.15 时把检测框中心的图像区域放大后再跑一次分类取置信度更高的结果。5.3 截屏区域和识别区域错位除了前面说的 DPI 缩放问题还有一个场景容易踩坑如果游戏窗口在两次识别之间被拖动、缩放硬编码的截屏区域就会失效。我后来加了一个简单的“窗口校验”逻辑每次识别前通过窗口标题找到当前窗口位置和大小用实时数据计算截屏区域而不是沿用启动时的固定值。如果要长期稳定运行这一步不要省。5.4 逐帧识别导致的性能浪费刚开始做的时候我让整个程序以 30fps 的帧率跑识别。结果 CPU 占用拉满卡片动作还跟不上后来才意识到大部分的识别结果都是重复的——手牌在短时间内根本不会变化连续两帧识别同一手牌毫无意义。最终改成了事件触发的模式先做轻量级的“画面变化检测”如果当前帧和上一帧的差异超过一个阈值才触发完整推理。用这个方式CPU 占用从原来的 60% 降到 10% 以下识别结果也没有任何损失。如果你做的是实时性要求不高的UI识别强烈建议加这一层变化检测收益非常明显。5.5 离线测试一把梭模型训练完之后我犯过最“低级”的错误是只用离线数据集测一次就感觉万事大吉。上线后才发现真实牌桌上的截图与离线测试集存在分布差异牌面边缘的模糊、动画特效、系统提示文字遮挡都会带来新的错误。现在我养成了一个习惯每次跑完一局游戏之后把整个识别过程中的中间结果保存下来定期复盘并挑出错误样本补充进训练集。模型是“喂”出来的这句话在图像识别项目里一点不假。6. 后续可以怎么扩展目前这个项目已经能够稳定识别手牌输出结构化牌型数据。下一步有几个方向可以继续做接入完整牌局流程识别牌河里的牌、其他玩家打出的牌、自己摸到的牌构建全场累计信息。做规则判断和提示基于完整手牌信息实现向听数计算、切牌推荐、役种提示等功能。跨游戏适配当前识别方案是针对一个游戏定制的但如果换引擎、换分辨率、换主题只需要重新标注一部分数据训练模型管线本身不需要大改。如果你是第一次做类似的项目我的建议是先把端到端管线跑通不要一开始就想把识别准确率做到100%。从截屏到模型再到结构化输出完整链路跑通一次你就能建立起对“图像识别在具体业务中如何开展”的整体感觉。后面有的是时间慢慢打磨准确率。最后再分享一个个人经验做这种桌面识别工具不要只盯着模型训练。截屏的稳定性、区域坐标的准确性、后处理的容错能力这三者和模型本身的参数量一样重要。我至少有一半的调试时间花在了模型之外的部分但正是这些看起来不起眼的细节把识别准确率从“偶尔准”推到了“稳定用”。本文还有配套的精品资源点击获取