公司动态

屏幕感知实战:用OpenCV和模板匹配实现牌面自动识别

📅 2026/9/2 4:45:00
屏幕感知实战:用OpenCV和模板匹配实现牌面自动识别
简介基于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数组默认是只读视图记得copy2.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 templates3.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_score3.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字典的left和top指的就是当前坐标系下的绝对坐标。最简单的方法是把副屏也扩展成主屏右侧这样左坐标就是主屏宽度不存在负数。如果副屏在左侧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次匹配每次都在整张牌区域上滑窗计算。后来我发现这个性能问题主要出在重复计算上其实每张牌只需要匹配一次缩小后的模板没必要对整张牌区域反复滑窗。优化手段有三个缩小匹配范围每张牌已经被分割到固定大小识别的只是这个小区域和整桌截图相比计算量降到千分之一级别。提前缩放模板在构建模板库的时候就统一resize到牌面尺寸避免每次识别时重复缩放。跳过低置信度帧如果连续两帧识别结果完全相同第三帧直接不识别沿用上一次结果。这个技巧在连续录像分析里非常有效能省掉一半以上的计算量。优化后单帧识别耗时压到了15ms左右已经能流畅处理30fps的视频流。4.5 识别结果可信度判断识别结果不能只看最高分还要关心第二高分和最高分之间的差距。如果两张牌模板的分数只差0.01那说明模型并不确定这时应该标记为“存疑”。我在输出结果里加了一个阈值最高分低于0.7的标记为“未知”第二高分相差小于0.05的标记为“待复核”。这个机制在复盘场景里非常实用能自动把可能识别错误的牌挑出来人工二次确认。5. 踩坑记录与后续扩展回头复盘整个项目最大的收获不是把识别写出来了而是学会了一套“稳定大于花哨”的方法论。模板匹配虽然听起来传统但用对了场景它就是比深度学习更稳、更快、更省心的方案。别一天到晚想着上大招先把最简单可靠的方案跑通再根据真实数据缺陷迭代这才是小项目的正路。最后再分享一个小技巧。如果你的目标不限于麻将而是想做一个“截屏即识别”的通用工具建议把截屏、分割、识别这三层抽象成独立的类或服务底层用OpenCV识别层可以先模板后模型。后续哪一天你真拿到了足够多的数据要换成CNN只需要替换识别层的接口实现前面的截屏和分割逻辑一行都不用动。想做实时分析的话可以把识别结果按时间戳写入SQLite配合录像文件建立索引。这样复盘牌局的时候就能直接按时间点检索比盯着视频回放高效太多。这算是我踩完坑之后觉得最值得扩展的方向。本文还有配套的精品资源点击获取