屏幕感知实战:用OpenCV和模板匹配实现牌面自动识别
2026/9/2 4:44:51 网站建设 项目流程

简介:基于Visual C++ 2010的麻将图像识别工程,面向具有C++基础的开发者,解决从屏幕截图中自动判别牌面内容的核心问题,可服务于智能麻将桌、自动记分和游戏辅助等实际场景。资源包共69个文件,主要包括头文件、C++源文件、位图样本与图标资源,同时附有工程配置、算法说明和开发计划等文档,整个压缩包仅297KB,结构轻量便于查看。目前已有2512人学习下载。源码基于OpenCV库实现图像采集、灰度化、二值化、边缘检测和模板匹配等预处理步骤,并采用光学字符识别与支持向量机分类完成牌面文字和花色判断,关键算法均有详细注释;此外还配有对话框交互模块,方便调整阈值与参数,可快速验证不同环境下的识别效果。配合任务书和知识点文本,能帮助初学者沿着从截屏到牌面判别的完整链路逐步掌握图像识别开发方法。 做这个项目纯粹是一次技术练手,起因是周末朋友局打麻将,打完复盘的时候全靠嘴,谁也说不清刚才是谁把哪张牌推错了。我就在想,能不能写个小工具,直接截屏游戏画面,用图像识别把牌面内容自动识别出来,录像扔进去就能生成文本记录,省得复盘的时候“我觉得”“你记错了吧”扯来扯去。这个项目核心就一条线:通过截屏抓取画面,对牌面内容做图像识别,输出结构化文本结果。

真正落地之后,我发现这件事最有价值的不是“识别麻将”本身,而是它把一套“屏幕感知”的完整流程走了一遍。你说你想识别游戏里的其他元素、软件里的按钮状态、甚至是股票软件的K线区域,本质都是同一套方法论:截屏、预处理、分割、识别、输出。这篇文章我就把这套方案完完整整拆开讲,适合有一定Python和OpenCV基础、想拿真实小项目练手的开发者,或者想在PC软件里集成“截图OCR/识别”能力的人。

另外先说清楚,我这个项目只做技术研究,用在自己复盘、分析牌局走势,不碰任何在线对局的实时辅助,也不涉及任何违规作弊场景。别拿去做不该做的事。

1. 项目整体设计与思路拆解

1.1 需求场景与目标拆解

先想清楚需求:我到底要识别的“牌面内容”是什么?以常见麻将为例,牌面主要分三类:万(一萬到九萬)、条(一條到九條)、筒(一筒到九筒),再加字牌(东南西北中发白)。牌面内容其实就是一个汉字加上一个数字,但字体是特殊的美术字体,和小图标式图案不同。

目标拆成三步就是:截屏拿到牌桌图像、从图像里抠出每一张牌的牌面区域、对每张牌的牌面做分类识别。这个过程中真正有技术含量的,其实不在“识别”,而在“怎么稳定地把牌从画面里分离出来”。

这里有个容易被忽略的细节:为什么我不直接用现成的OCR(比如PaddleOCR、Tesseract)?因为麻将牌面是固定的小尺寸图形,字体固定、角度固定、光照也相对固定,这恰恰是模板匹配最擅长的场景。OCR在小尺寸、特殊字体上的表现反而不稳定,还有可能把“發”识别成“发”,把“一萬”识别成“一万”。使用模板匹配,本质上是让程序拿一套标准牌面图片去画面里找相似度最高的位置,像个拿着图册比对的人一样,简单可靠。

1.2 技术路线选型:模板匹配还是CNN

项目做到一半,我其实认真考虑过用卷积神经网络(CNN)来做分类。当时从卷积神经网络图像识别相关话题里看到不少案例,感觉这方向挺火,也确实适合图像分类场景。但冷静分析了一下,还是选了模板匹配,原因有三:

  • 样本量问题:CNN要跑得稳,每类牌面至少得几百张标注图。我需要先截屏采集大量画面再手工标注成数据集,这个成本在项目初期是无法接受的。
  • 计算资源问题:CNN推理在CPU上跑,每张牌少说也要几十毫秒到上百毫秒,同时识别十几张牌就会出现明显的卡顿感。模板匹配用OpenCV的matchTemplate,单张牌匹配一张模板在毫秒级就能完成。
  • 场景稳定性:麻将牌面一旦在固定窗口里显示,字体、比例、方向都是固定的,不存在角度旋转、复杂背景干扰。模板匹配没有泛化压力的场景下,它就是最合适的工具。

那CNN是不是完全没用?也不是。如果未来需要识别不同缩放比例、不同花色风格、甚至拍摄的实体牌照片,模板匹配会因为尺寸、光照、斜视角的变化而失效,那时候就必须上CNN做鲁棒分类了。结论是:先解决当前场景,用模板匹配跑通整个流程,后续如果要做泛化,再把识别模块替换成CNN,其他截屏和分割逻辑都可以复用。

1.3 整体流程设计

整个系统可以画成一条流水线:屏幕截取 → 图像预处理 → 牌面分割 → 单牌识别 → 结果输出。每一步做一件事,模块之间用函数隔离,方便单独调试。

  • 屏幕截取:选定屏幕上的固定区域(比如手牌区域),用mss库抓取该区域图像。
  • 图像预处理:灰度化、去噪、二值化等,让模板匹配更稳定。
  • 牌面分割:复用固定的坐标偏移量或动态检测牌面轮廓,把一整行牌切分成单牌。
  • 单牌识别:用模板匹配库把每张牌和预置模板比对,输出相似度最高的模板名。
  • 结果输出:把识别出的牌名按顺序拼接,生成文本供复盘使用。

这套流程有一个很重要的设计原则:把“截屏范围”和“识别逻辑”解耦。这样即使游戏窗口位置变了,只需要重新标定一下区域坐标,不需要改动识别代码。我下面会把每一步的关键实现都讲清楚。

2. 核心细节解析与实操要点

2.1 截屏方案选型:PIL、mss还是PyQt

截屏方案我试过三个:PIL的ImageGrab、mss、还有PyQt的screen grab。最终选型mss,原因是它在Windows上走的是底层桌面接口,帧率高、资源占用低,而且对多显示器支持非常友好。

先说PIL,它写起来最简单,ImageGrab.grab(bbox=(x1, y1, x2, y2))一行就能拿到区域图。但它在连续截屏场景下效率一般,实测每秒也就20帧左右,偶尔还会有截屏重影。如果只是识别静态画面,PIL完全够用,但要做连续录像帧分析,就会明显吃力。

mss的写法稍多一点,但性能好得多。它返回的是一个原始像素缓冲,配合numpy转换成数组后,可以直接交给OpenCV处理,全程零拷贝转换,效率很高。我实测相同区域连续截屏,mss能跑到60帧以上,而且对双屏、高DPI缩放的处理比PIL稳得多。

有一点要特别注意:mss输出的是BGRA四通道数据,在转成OpenCV用的BGR三通道时,要么直接[:, :, :3]取前三通道,要么用cvtColor转一下颜色空间。我第一次写的时候直接拿RGBA当成RGB用,结果整张图颜色偏蓝,模板匹配怎么都配不上。

import mss import numpy as np def capture_area(monitor): with mss.mss() as sct: raw = sct.grab(monitor) img = np.array(raw)[:, :, :3] # BGRA -> BGR return img.copy() # numpy数组默认是只读视图,记得copy

2.2 图像预处理:为什么不能跳过灰度化和均值滤波

很多初学者拿到彩色图像就直接扔给matchTemplate跑,结果发现识别率忽高忽低,牌面一有阴影就完蛋。这里面的坑在于:彩色图像有RGB三个通道,匹配时是三维数据,光照变化、阴影、反光都会直接拉低相似度分数。灰度化之后,匹配只关注亮度分布,反而更稳定。

我的预处理流程是:先灰度化,再用一个3×3的高斯滤波去噪,最后视情况做二值化。高斯滤波这步经常被人忽略,但它的作用很大——能平滑掉牌面反光、轻微锯齿和压缩噪声,让模板匹配的相似度曲线更“干净”。

import cv2 def preprocess(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (3, 3), 0) return gray

至于要不要二值化,要视牌面配色而定。如果牌面背景和边框颜色反差足够大,用固定阈值二值化可以进一步提高匹配稳定性;但如果牌面有渐变色、半透明效果,二值化反而会丢失细节。我的建议是:梯度先上灰度+滤波,批量测试如果准确率不达标,再加二值化做对比实验。

2.3 牌面分割定位:固定坐标还是动态检测

牌面分割是整个项目里最容易踩坑的环节。我一开始想得很美好,用OpenCV的轮廓检测自动找出画面里所有的牌,结果发现牌和牌之间间距、阴影、反光都会干扰轮廓合并,经常把两张牌粘在一起,或者把背景上的装饰物当成牌。

后来我换了个思路:如果牌桌的窗口位置固定,手牌区域里每张牌的左上角坐标和牌的宽高都是固定的,可以直接用坐标偏移来切分,简单粗暴且稳定。我先写了一个小标定脚本,手工点出第一张牌的左上角坐标,以及牌的宽高和间距,然后把这一行牌按固定间隔切出来。

def split_cards(img, start_x, start_y, card_w, card_h, gap, count): cards = [] for i in range(count): x = start_x + i * (card_w + gap) cards.append(img[start_y:start_y + card_h, x:x + card_w]) return cards

如果确实需要自动定位(比如牌桌位置不固定),可以用边缘检测找到牌区的整体外框,再按牌数均分。但手写均分逻辑时需要额外处理牌与牌之间的缝隙,远没有固定坐标省心。我的最终方案是:固定坐标为主,动态检测为辅。程序启动时允许用户框选牌区,框选一次后保存坐标配置,之后每次运行都复用。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

这个项目依赖的东西不多,核心就四个:Python、OpenCV、numpy、mss。为了避免版本混乱,我用虚拟环境单独跑这个项目。

python -m venv mahjong-env source mahjong-env/bin/activate # Windows 用 mahjong-env\Scripts\activate pip install opencv-python numpy mss

这里提醒一句,OpenCV的pip包叫opencv-python,不是cv2。很多人装完发现import cv2报错,就是因为装错包名了。安装完成后,在Python里执行import cv2; print(cv2.__version__)确认版本正常,再继续下一步。

3.2 模板库的构建

模板匹配的前提是有一组标准模板图。我通过截屏工具在游戏里抓了一个“牌面图鉴”,把每种牌截成统一尺寸,保存成以牌名命名的图片。命名规则就是“数字+花色”,比如“1_w”、“1_t”、“1_s”,这样识别结果可以直接格式化。

如果你手里没有现成的牌面图鉴,也可以自己造模板:打开游戏,把每种牌打出来,用截屏工具把牌面区域裁切保存即可。模板图不要带边框和多余的背景,只保留牌面核心图案。保存的时候注意统一尺寸,尽量不要让模板宽高差异过大。

import os import cv2 def build_templates(template_dir): templates = {} for fname in os.listdir(template_dir): if fname.endswith('.png'): name = fname[:-4] # 去掉 .png path = os.path.join(template_dir, fname) tpl = cv2.imread(path) tpl = cv2.cvtColor(tpl, cv2.COLOR_BGR2GRAY) templates[name] = tpl return templates

3.3 核心识别函数:模板匹配怎么做

模板匹配的原理,说穿了就是拿模板图在待识别图上从左到右、从上到下做滑动窗口比对,每移动一个像素就计算一次相似度,最终生成一个相似度“热力图”,取最大值的位置就是最像模板的地方。相似度计算方式有很多种,我用的是TM_CCOEFF_NORMED,它的好处是对光照变化有归一化处理,评分范围固定到-1到1之间,方便统一判断阈值。

单张牌识别的逻辑大概是:先把模板缩放到与待识别牌一致的大小(保证尺寸匹配),然后计算相似度,取所有模板中分数最高的那个作为识别结果。

def recognize_card(card_img, templates): card_gray = cv2.cvtColor(card_img, cv2.COLOR_BGR2GRAY) card_gray = cv2.GaussianBlur(card_gray, (3, 3), 0) best_name = None best_score = -float('inf') for name, tpl in templates.items(): tpl_resized = cv2.resize(tpl, (card_gray.shape[1], card_gray.shape[0])) result = cv2.matchTemplate(card_gray, tpl_resized, cv2.TM_CCOEFF_NORMED) _, score, _, _ = cv2.minMaxLoc(result) if score > best_score: best_score = score best_name = name return best_name, best_score

3.4 主流程串联与效果测试

主流程就是先截屏、再分割、最后逐张识别。我把整个流程封装成一个process_screen()函数,方便反复调用。测试的时候我固定了牌桌区域,把识别结果与游戏界面对照,发现准确率能到98%左右,偶尔出错主要发生在字牌“白板”和“北风”这类图案相近的牌上。

def process_screen(monitor, split_config, templates): # 1. 截屏 img = capture_area(monitor) if img is None: return [] # 2. 分割 cards = split_cards( img, split_config["start_x"], split_config["start_y"], split_config["card_w"], split_config["card_h"], split_config["gap"], split_config["count"] ) # 3. 识别 results = [] for c in cards: name, score = recognize_card(c, templates) results.append((name, score)) return results

测试时我特意录了30秒连续画面,把每帧识别结果记录到文本里。确认没有明显漏帧之后,又做了几轮边界测试,比如屏幕分辨率变化、窗口被部分遮挡等情况,由此发现了不少问题,下面专门讲。

4. 常见问题与排查技巧实录

4.1 截屏黑屏、花屏的问题

我最早用PIL截屏的时候遇到过画面全黑的情况,排查了一圈发现是GPU硬件加速导致的桌面合成器问题。部分显卡在开启硬件加速时,普通API截屏只能拿到桌面合成前的黑帧。这种问题在笔记本上尤其常见,和“笔记本截屏高亮”“怎么关闭win11屏幕截屏”这些话题里讨论的场景很像。

解决思路按优先级排列:

  • 换成mss库,它走的是底层桌面捕获接口,遇到黑屏的概率比PIL低很多。
  • 如果换mss仍然黑屏,检查系统显示设置里的“图形首选项”,把目标程序设为“节能”模式,也就是禁用独立显卡高性能渲染。
  • 实在不行,就在游戏或目标程序里关闭硬件加速/GPU渲染选项。

如果是花屏(画面撕裂或颜色异常),多半是图像格式转换写错了,比如通道顺序、字节对齐问题。我建议在调试时期先保存一帧图片到本地,用看图软件确认颜色正常了再继续跑识别。

4.2 双屏环境的坐标偏移

很多人的电脑是双屏,开发时也经常遇到“一边屏幕能截一边屏幕不能截”的情况。这个问题的本质是屏幕坐标系的原点位置:Windows主屏的左上角是(0,0),副屏根据扩展方向不同,左上角坐标可能是(1920,0)、(-1920,0)或(0,-1080)这样的值。如果用主屏坐标去截副屏画面,坐标全是偏移的。

mss里设定截图区域时,monitor字典的lefttop指的就是当前坐标系下的绝对坐标。最简单的方法是把副屏也扩展成主屏右侧,这样左坐标就是主屏宽度,不存在负数。如果副屏在左侧,left是负数也完全合法,mss能正常处理,只是不要用0起步去猜测位置。

我写了一个小函数,用系统API枚举所有显示器,然后按用户选择的显示器编号自动计算可截屏区域,省得每次手动填坐标。

4.3 识别率低、相似度分数总是不稳定

模板匹配最怕的就是模板和实际牌的尺寸不一致。我一开始从牌面图鉴里扣出来的模板是80×80,但实际截屏分割出来的卡片是120×120,直接用原始尺寸去匹配,分数一路走低。解决方法是先resize再匹配,或者统一用同一套尺寸采集模板和实际牌面。

另一个常见原因是牌面边框干扰。分割出来的牌如果带了多余边框、阴影,匹配时会引入大量噪声。我把分割结果可视化输出到本地磁盘看了一眼,发现有一部分牌把相邻牌的边缘也切进来了。把分割范围向内收缩2~3个像素之后,准确率明显提升。

还有光照变化的问题。虽然TM_CCOEFF_NORMED对光照有一定鲁棒性,但对比度极低的画面还是会误判。这时候可以先用cv2.equalizeHist做直方图均衡化,把灰度分布拉开。我实测均衡化之后,暗光场景下的识别率大约提升了3~5个百分点。

4.4 性能优化:从单张识别到实时视频流

最初的版本处理一张截图要跑近200ms,因为每张牌要和每个模板做一次全图匹配,9×3字牌再加7种字牌,总共34次匹配,每次都在整张牌区域上滑窗计算。后来我发现这个性能问题主要出在重复计算上:其实每张牌只需要匹配一次缩小后的模板,没必要对整张牌区域反复滑窗。

优化手段有三个:

  1. 缩小匹配范围:每张牌已经被分割到固定大小,识别的只是这个小区域,和整桌截图相比计算量降到千分之一级别。
  2. 提前缩放模板:在构建模板库的时候就统一resize到牌面尺寸,避免每次识别时重复缩放。
  3. 跳过低置信度帧:如果连续两帧识别结果完全相同,第三帧直接不识别,沿用上一次结果。这个技巧在连续录像分析里非常有效,能省掉一半以上的计算量。

优化后,单帧识别耗时压到了15ms左右,已经能流畅处理30fps的视频流。

4.5 识别结果可信度判断

识别结果不能只看最高分,还要关心第二高分和最高分之间的差距。如果两张牌模板的分数只差0.01,那说明模型并不确定,这时应该标记为“存疑”。我在输出结果里加了一个阈值:最高分低于0.7的标记为“未知”,第二高分相差小于0.05的标记为“待复核”。这个机制在复盘场景里非常实用,能自动把可能识别错误的牌挑出来人工二次确认。

5. 踩坑记录与后续扩展

回头复盘整个项目,最大的收获不是把识别写出来了,而是学会了一套“稳定大于花哨”的方法论。模板匹配虽然听起来传统,但用对了场景,它就是比深度学习更稳、更快、更省心的方案。别一天到晚想着上大招,先把最简单可靠的方案跑通,再根据真实数据缺陷迭代,这才是小项目的正路。

最后再分享一个小技巧。如果你的目标不限于麻将,而是想做一个“截屏即识别”的通用工具,建议把截屏、分割、识别这三层抽象成独立的类或服务,底层用OpenCV,识别层可以先模板后模型。后续哪一天你真拿到了足够多的数据要换成CNN,只需要替换识别层的接口实现,前面的截屏和分割逻辑一行都不用动。

想做实时分析的话,可以把识别结果按时间戳写入SQLite,配合录像文件建立索引。这样复盘牌局的时候就能直接按时间点检索,比盯着视频回放高效太多。这算是我踩完坑之后觉得最值得扩展的方向。

本文还有配套的精品资源,点击获取

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

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

立即咨询