日麻 AI
赛后使用 AI 复盘有时想不起来几打抽象的原因,希望能实时跟踪牌局情况,在玩家打出牌后给出 AI 的选择。
开源社区中已有成熟的 Bot (被滥用为外挂),但基本都已 Web 端,或本地代理的形式抓包得到精确的事件数据。
我更喜欢客户端游玩,并且不希望有太多的侵入式操作,本着学习的目的,尝试使用 YOLO 目标检测来实现目的。
技术方案
flowchart LR Game Client Server YOLO Mortal Game-->Client Client-->Server Server<-->YOLO Server<-->Mortal
Client 在本地运行,频繁截图并调用 Server 端 API 得到反馈
Server 端将图片丢给 YOLO 识别得到所有麻将牌的类别及坐标,通过算法将其分类为自家手牌、自家牌河、自家鸣牌、下家牌河、下家鸣牌、对家牌河、对家鸣牌、上家牌河、上家鸣牌以及 Dora(Desktop对象)。随后与 Server 端保存的 Desktop 对象对比,得到差异后进行数据的维护,并记录 mjai-protocol 格式的 event log,当需要自家做出响应(出牌、鸣牌)时,将 event log 丢给 Mortal,得到 AI 的决策暂存于 Server 端,当 event log 接收到下一次自家的事件时,接口返回 AI 的决策信息,Client 端进行展示(即滞后响应,仅用于事后思考)
YOLO v11
YOLO v11 拥有更好的性能,并且很大程度上与 YOLO v8 兼容,因此大部分文档和教程可以通用
数据集
使用雀魂的牌谱回放截图作为数据集,借助 X-AnyLabeling 标注工具和 Segment Anything (Vit-Base Quant) 进行标注
第一次训练使用 60 张手动标注数据,得到的 best.pth 模型用来预测另外 60 张数据,随后将预测结果导入 X-Anylabeling 并人为修正后进行第二次训练 (120)
其中 20% 划分为验证集,不参与任何一次训练
截图细节
- 使用不同桌布截图
- 使用不同牌面截图
- 当 Dora 发光时也需要一定的截图数据
训练
clone github 源码,在根目录下创建 train.yaml
train: /data/cv/yolo_dataset/train # 训练集目录
val: /data/cv/yolo_dataset/val # 验证集目录
# Classes
# 与 mjai 标签略有差异,此处只是为了对齐 index,降低后续代码编写出错概率
names:
0: 0m
1: 1m
2: 2m
3: 3m
4: 4m
5: 5m
6: 6m
7: 7m
8: 8m
9: 9m
10: 0p
11: 1p
12: 2p
13: 3p
14: 4p
15: 5p
16: 6p
17: 7p
18: 8p
19: 9p
20: 0s
21: 1s
22: 2s
23: 3s
24: 4s
25: 5s
26: 6s
27: 7s
28: 8s
29: 9s
30: north
31: south
32: east
33: west
34: blank
35: mid
36: fort
from ultralytics import YOLO
# 使用下载的预训练模型
model = YOLO(r'ultralytics/cfg/models/11/yolo11.yaml').load("weight/yolo11n.pt")
# 在自己训练的基础上继续训练
# model = YOLO(r'ultralytics/cfg/models/11/yolo11.yaml').load("/data/cv/ultralytics/runs/train/exp2/weights/best.pt")
model.train(
data='train.yaml',
cos_lr=True, # 余弦退火
epochs=200, # (int) 训练的周期数
patience=50, # (int) 等待无明显改善以进行早期停止的周期数
batch=-1, # (int) 每批次的图像数量(-1 为自动批处理)
imgsz=[760, 420], # (int) 输入图像的大小
save=True, # (bool) 保存训练检查点和预测结果
save_period=-1, # (int) 每x周期保存检查点(如果小于1则禁用)
cache=True, # (bool) True/ram、磁盘或False。使用缓存加载数据
device='', # (int | str | list, optional) 运行的设备,例如 cuda device=0 或 device=0,1,2,3 或 device=cpu
workers=8, # (int) 数据加载的工作线程数(每个DDP进程)
project='runs/train', # (str, optional) 项目名称
name='exp', # (str, optional) 实验名称,结果保存在'project/name'目录下
pretrained=True, # (bool | str) 是否使用预训练模型(bool),或从中加载权重的模型(str)
optimizer='SGD', # (str) 要使用的优化器,选择=[SGD,Adam,Adamax,AdamW,NAdam,RAdam,RMSProp,auto]
verbose=False, # (bool) 是否打印详细输出
seed=0, # (int) 用于可重复性的随机种子
box=7.5, # box 惩罚权重 7.5
cls=0.5, # label 惩罚权重 0.5
close_mosaic=0, # (int) 在最后几个周期禁用马赛克增强
resume=False, # (bool) 从上一个检查点恢复训练
amp=False, # (bool) 自动混合精度(AMP)训练,选择=[True, False],True运行AMP检查
)
第一次训练时参数 box=7.5, cls=0.5,侧重于 box 准确度,用于快速制作后续训练集
后续训练将 cls 适当调高,从 1、1.5、3、5 我都试了,总有一炉效果好的
预测
model = YOLO(r'/data/cv/ultralytics/runs/train/exp_02/weights/best.pt') # YOLOv11n模型
model.predict(
source=r'/data/cv/yolo_dataset/test',
save=True, # 保存图片
#save_txt=True, # 保存预测结果txt
project='runs/predict', # 项目名称
iou=0.5, # 重叠阈值
conf=0.25, # label 阈值,用来标注低一些,实际使用需要 0.75 或 0.85
name='exp', # 实验名称,结果保存在'project/name'目录下
show_conf=False
)
结果
最终训练的模型,在 IoU=0.5(mAP50)下达到了 98%+ 的精度,IoU=0.5~0.95(mAP50-95)约为 91%
综合准确率达到了 94%+
实际测试在 iou=0.5, conf=0.75 的参数下,label 基本没有错的,但是由于下家鸣牌的数据集较少,所以右上角的 box 与 label 识别率均较低,扩大训练集可以改善这一情况,或使用算法调优(被打出来的那一下与牌河其他牌间隔较大,基本都可以识别到)
下图中牌河中有不少没有 box 和 label 的,可能是绘制的原因,在实际 Result (见后文)中都是有的
算法分析
不同位置的麻将牌在相对坐标上有较为明显的差距,通过简单的算法限制几块区域,就可以将麻将牌分组,并按时间排序,下面图4为上文图2的 Result 解析结果(Desktop 对象)
注,图4与图2是同一次预测的结果,其 Result 完全相同,图2 可能是绘制原因导致部分 box 未显示,而 图4 已经经过处理转换为 mjai 的 label
Mortal
Mortal 开源了代码,但为了防止被外挂滥用,并没有给出模型,不过社区中有其他开发者提供的模型。
这里 clone 代码,并修改 config.toml
后测试运行,摸牌后,AI 的决策是摸切 9s
Server
Server 端使用 Flask 定义 Http 接口,不断接收 Client 的截图并分析处理
由于 Mortal 刚需手摸切信息,所以需要以 OpenCV 来得到其他家手牌是否出现空隙来判断,自家则可以根据手牌变化判断
其他家鸣牌也可根据 Desktop 中相关数据变化判断得出
直到需要自家操作时,将 event log 丢给 Mortal
意外
由于训练集是通过比赛 Demo 获取,所以没有自家鸣牌的选项,也不会有鸣牌特效,制作数据集时忽略了这一点
实际场景中由于鸣牌特效的存在,导致其他家的舍牌无法被识别出来
这需要添加新的数据集来解决,简单来讲,不想做了。所以就弃坑了