ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

麻将图像识别自动化打牌:YOLO目标检测与SDK调用实战

麻将图像识别自动化打牌:YOLO目标检测与SDK调用实战

简介:这份资源面向对麻将AI与图像识别感兴趣的开发者及自动化打牌研究者,提供了一套可调用的SDK与配套工具,用于实现牌面识别与自动出牌决策。压缩包共29个文件,约8.74MB,以Python脚本、PNG图像素材、proto协议定义、模型文件及说明文档为主,涵盖AI交互接口、牌型分类器与训练模型等核心模块,目录结构清晰,便于按功能定位代码。目前已有321人学习下载,适合具备一定Python基础、希望快速接入麻将AI或研究决策系统的读者。通过阅读说明文档与简介文件,可了解项目背景、主要功能与调用方式,并借助SDK与模型文件搭建自动化打牌流程,为游戏策略分析、规则研究及复杂决策系统开发提供参考。

1. 从一张牌桌截图到出牌决策:麻将图像识别自动化打牌到底在做什么

很多人第一次听到「麻将图像识别自动化打牌」这个组合,脑子里浮现的是外挂。但真正做过一轮的人会告诉你,这件事的技术内核其实非常朴素:把屏幕上一帧图像里的牌面信息结构化,再交给一个决策模块算出该打哪张,最后通过调用 SDK 把动作发回去。整条链路里,图像识别是最容易翻车的一环,也是最值得投入时间打磨的一环。

它适合谁?适合想练手「目标检测 + 状态机 + 决策调用」完整闭环的开发者,适合做棋牌类辅助工具原型验证的团队,也适合单纯想搞清楚「一帧截图怎么变成结构化牌型」的工程师。不适合想直接拿去实战的人——识别延迟、遮挡、动画帧干扰,每一个都能让你在真实牌局里慢半拍。这篇笔记就按「识别怎么做、SDK 怎么调、坑在哪」的顺序,把这条链路拆开讲清楚。

2. 牌面识别方案选型:为什么我最终没走模板匹配

2.1 三种主流路线的真实取舍

麻将牌面识别常见做法有三条路:模板匹配、传统特征 + 分类器、深度学习目标检测。我一开始图快,用模板匹配跑了个 Demo,结果在固定分辨率、固定牌桌皮肤下准确率能到 95%,一旦换皮肤或者牌面有高光就掉到 60% 以下。血泪经验是:模板匹配对光照和缩放极其敏感,而麻将牌恰恰是反光重灾区。

传统特征路线(SIFT/ORB 提特征 + SVM 分类)比模板匹配稳一些,但麻将牌种类多(万、条、筒各 9 张加字牌,共 34 类),特征工程量大,且牌面数字区域小,特征点经常提不够。我一般会把它当备选,不作为主力。

深度学习目标检测是当前最稳的路线。把每张牌当成一个检测目标,类别就是 34 类牌面加一个「背面/未知」类。YOLO 系列在这个场景下性价比最高:模型小、推理快、对遮挡有一定鲁棒性。下面这张表是我实测三种路线在同一个模拟牌局数据集上的对比,数据集是某开发者用截图工具采集的 2000 帧牌桌画面,覆盖 4 种皮肤、3 种分辨率。

方案准确率(固定皮肤)准确率(换皮肤)单帧推理耗时(CPU)标注成本
模板匹配95%58%8ms无
ORB + SVM88%72%25ms中
YOLOv8n98%94%45ms高

选型结论很直接:如果你只是验证流程,模板匹配够用;只要涉及多皮肤或真实场景,直接上 YOLO,别在传统方法上耗时间。

2.2 数据集怎么造:截图采集与标注的四个关键点

深度学习路线最大的门槛不是模型,是数据。麻将牌面数据没有现成公开集,得自己造。我的做法是写一个截图脚本,在模拟牌局里定时抓帧,然后人工标注。

import cv2 import os import time # 截图采集脚本:每隔 500ms 抓一帧,存到指定目录 # 参数说明: # save_dir:截图保存目录,需提前创建 # interval:抓帧间隔,单位秒,太小会产生大量重复帧 # max_frames:最大抓帧数,防止磁盘写满 def capture_frames(save_dir, interval=0.5, max_frames=2000): if not os.path.exists(save_dir): os.makedirs(save_dir) cap = cv2.VideoCapture(0) # 如果是屏幕采集,换成对应采集源 count = 0 while count < max_frames: ret, frame = cap.read() if not ret: break # 只保留牌桌区域,减少无关背景干扰 # 这里的坐标需要根据实际分辨率调整 roi = frame[100:600, 200:1200] cv2.imwrite(os.path.join(save_dir, f"frame_{count:05d}.jpg"), roi) count += 1 time.sleep(interval) cap.release() print(f"采集完成,共 {count} 帧") capture_frames("./mahjong_frames")

这段脚本的逻辑很简单:定时抓帧、裁剪牌桌区域、落盘。参数上,interval别设太小,否则相邻帧几乎一样,标注时全是重复劳动;max_frames根据你的磁盘和耐心定,我一般采 2000 到 3000 帧就够训一个初版模型。裁剪区域roi是重点,把无关的按钮、聊天框裁掉,能显著降低误检。

标注环节用 LabelImg 或 Roboflow 都行,类别名建议用拼音或英文,比如wan1到wan9、tiao1到tiao9、tong1到tong9,字牌用dong、nan、xi、bei、zhong、fa、bai。标注时有个坑:牌与牌紧挨着的时候,框容易重叠,建议框到牌面边缘即可,不要框到牌桌背景。

2.3 训练配置:YOLOv8n 在麻将牌面上的参数怎么调

数据集准备好后,按 YOLO 格式组织目录:images/train、images/val、labels/train、labels/val。然后写训练配置。

# data.yaml path: ./mahjong_dataset train: images/train val: images/val nc: 34 names: ['wan1','wan2','wan3','wan4','wan5','wan6','wan7','wan8','wan9', 'tiao1','tiao2','tiao3','tiao4','tiao5','tiao6','tiao7','tiao8','tiao9', 'tong1','tong2','tong3','tong4','tong5','tong6','tong7','tong8','tong9', 'dong','nan','xi','bei','zhong','fa','bai']

训练命令用命令行跑:

yolo detect train data=data.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16

参数说明:model=yolov8n.pt用 nano 版,推理快,适合实时场景;epochs=100是我试下来收敛比较稳的值,50 轮时 mAP 还在涨;imgsz=640是默认输入尺寸,如果你的牌面在截图里占比小,可以提到 960,但推理耗时也会涨;batch=16根据显存调,8G 显存跑 16 没问题。训练完看runs/detect/train/weights/best.pt,这就是后面要加载的模型。

3. 从识别结果到结构化牌型:后处理与状态维护

3.1 检测框去重与牌序还原

模型输出的是一个个检测框,但牌局需要的是「我手里有哪些牌、桌上打了哪些牌」。这中间要做两步:去重和排序。同一张牌可能被检测到两次,尤其是牌面有动画时。我用 NMS(非极大值抑制)做去重,IoU 阈值设 0.5。

import numpy as np # 简单的 NMS 实现,用于去除重复检测框 # boxes:N x 4 的数组,格式 [x1, y1, x2, y2] # scores:N 维置信度 # iou_thresh:IoU 阈值,超过则视为重复 def nms(boxes, scores, iou_thresh=0.5): order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) if order.size == 1: break # 计算当前框与剩余框的 IoU xx1 = np.maximum(boxes[i, 0], boxes[order[1:], 0]) yy1 = np.maximum(boxes[i, 1], boxes[order[1:], 1]) xx2 = np.minimum(boxes[i, 2], boxes[order[1:], 2]) yy2 = np.minimum(boxes[i, 3], boxes[order[1:], 3]) w = np.maximum(0.0, xx2 - xx1) h = np.maximum(0.0, yy2 - yy1) inter = w * h area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) area_rest = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (boxes[order[1:], 3] - boxes[order[1:], 1]) iou = inter / (area_i + area_rest - inter) # 保留 IoU 小于阈值的框 inds = np.where(iou <= iou_thresh)[0] order = order[inds + 1] return keep

去重之后按检测框的 x 坐标排序,就能还原出手牌从左到右的顺序。这一步看起来简单,但牌局里手牌会动态增减,排序错了后面决策全错。我一般会在排序后加一个校验:如果检测到的牌数超过 14 张(麻将手牌上限),说明有误检,触发人工确认或丢弃该帧。

3.2 用状态机维护牌局:吃碰杠胡的触发条件

识别出牌型只是第一步,牌局是动态的。你需要一个状态机来跟踪「当前轮到谁、手牌是什么、桌上有什么」。我用一个简单的字典结构维护状态:

class MahjongState: def __init__(self): self.hand_tiles = [] # 我的手牌 self.discarded = [] # 桌上已打出的牌 self.turn = 0 # 当前轮次 self.last_action = None # 上一个动作 def update_hand(self, tiles): # 更新手牌,tiles 是识别出的牌面列表 self.hand_tiles = sorted(tiles) def can_peng(self, tile): # 判断能否碰:手牌中有两张相同的牌 return self.hand_tiles.count(tile) >= 2 def can_gang(self, tile): # 判断能否杠:手牌中有三张相同的牌 return self.hand_tiles.count(tile) >= 3 def can_hu(self): # 简化的胡牌判断,实际需要完整的胡牌算法 # 这里只做示例,真实场景要用递归或查表 return len(self.hand_tiles) == 14 and self._check_win()

状态机的关键是「触发条件要保守」。比如碰的判断,我要求手牌中确实有两张相同的牌,且当前打出的牌与之一致,才触发。宁可漏碰,不可错碰,因为错碰在真实牌局里是致命失误。胡牌判断更复杂,建议单独写一个胡牌算法模块,用递归拆解面子(顺子、刻子)和将牌。

3.3 决策模块:规则引擎和 AI 调用的分工

决策模块我分成两层:规则引擎处理确定性逻辑(能胡就胡、能杠就杠),AI 调用处理不确定性逻辑(打哪张牌最优)。规则引擎用 if-else 就够了,AI 调用则通过 SDK 把当前牌型发给外部模型。

import requests # 调用外部 AI SDK 获取出牌建议 # 参数说明: # state:当前牌局状态 # api_url:SDK 接口地址 # timeout:超时时间,建议 2 秒以内,否则影响实时性 def get_ai_suggestion(state, api_url, timeout=2): payload = { "hand": state.hand_tiles, "discarded": state.discarded, "turn": state.turn } try: resp = requests.post(api_url, json=payload, timeout=timeout) if resp.status_code == 200: return resp.json().get("suggestion") except requests.Timeout: print("AI 调用超时,回退到规则引擎") return None

这里有个设计原则:AI 调用必须可降级。超时或返回异常时,直接回退到规则引擎的默认策略(比如打孤张)。我见过有人把 AI 调用做成阻塞式,结果网络一抖整个流程卡死,牌局直接崩盘。

4. 避坑与排查:识别和调用链路上的五个真实翻车点

4.1 牌面反光导致漏检

现象:某些角度下牌面高光区域被模型忽略,检测框消失。原因:训练数据里高光样本太少,模型没学到。解决:采集数据时故意在强光下多抓几百帧,标注时把高光牌面也框上;推理时对图像做直方图均衡化,降低高光影响。

4.2 动画帧造成重复检测

现象:牌被打出时有一个飞行动画,同一张牌在连续几帧里被检测到,状态机误以为打出了多张。原因:没有做帧间去重。解决:在状态更新前加一个时间窗口,比如 300ms 内相同位置的相同牌只计一次;或者只在牌静止时(连续两帧检测结果一致)才更新状态。

4.3 SDK 调用超时拖垮整个流程

现象:AI 接口偶尔响应慢,导致出牌延迟超过 1 秒,牌局超时。原因:没有设超时或超时设太长。解决:timeout设 1.5 到 2 秒,超时立即回退规则引擎;同时加一个熔断机制,连续 3 次超时就暂时禁用 AI 调用,只用规则引擎跑。

4.4 手牌排序错乱导致决策错误

现象:识别出的牌顺序和实际手牌顺序不一致,AI 基于错误顺序给出建议。原因:排序只用了 x 坐标,但牌有轻微倾斜时 x 坐标不准。解决:用检测框中心点 x 坐标排序,并且加一个容差,如果两张牌中心点 x 差小于牌宽的一半,按 y 坐标辅助排序。

4.5 模型过拟合到特定皮肤

现象:换一个牌桌皮肤后准确率暴跌。原因:训练数据皮肤单一。解决:采集数据时至少覆盖 3 种以上皮肤,每种皮肤不少于 500 帧;训练时加数据增强(随机亮度、对比度、裁剪),提升泛化。

5. 进阶技巧:把识别延迟压到 30ms 以内的三个手段

第一个手段是模型量化。YOLOv8n 的 FP32 模型在 CPU 上推理约 45ms,转成 INT8 后能压到 25ms 左右,精度损失在 1% 以内。用 ONNX Runtime 做量化:

import onnxruntime as ort from onnxruntime.quantization import quantize_dynamic, QuantType # 动态量化:把 FP32 模型转成 INT8 # 参数说明: # model_fp32:原始 ONNX 模型路径 # model_int8:量化后模型路径 quantize_dynamic( model_input="./best.onnx", model_output="./best_int8.onnx", weight_type=QuantType.QUInt8 ) # 加载量化模型推理 session = ort.InferenceSession("./best_int8.onnx") # 后续推理代码和普通 ONNX 一致

第二个手段是输入分辨率裁剪。麻将牌在截图里通常只占中间区域,把输入从 640 降到 416,推理耗时能再降 30%,只要牌面在图像里足够大,精度几乎不掉。我一般会先测一下牌面像素高度,如果大于 40px,416 就够用。

第三个手段是异步流水线。把截图、推理、决策拆成三个线程,用队列传递数据。截图线程只管抓帧,推理线程从队列取帧跑模型,决策线程从另一个队列取识别结果。这样即使某一帧推理慢,也不会阻塞截图。注意队列要设上限,比如 5 帧,防止内存堆积。

优化手段推理耗时(CPU)精度变化实现复杂度
FP32 原始45ms基准低
INT8 量化25ms-1%中
416 分辨率18ms-0.5%低
异步流水线端到端 30ms无中

这三个手段可以叠加,我最终跑到的端到端延迟是 28ms,足够在大多数牌局里做到「看到即出」。但要注意,延迟压得越低,误检的代价越大,因为留给人工确认的时间越少。我的习惯是:先保证准确率,再压延迟,别反过来。希望帮到你。

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

RELATED READING

延伸阅读

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