课程名称: 智能光电感知
项目名称: 基于计算机视觉的麻将牌识别系统 (AutoMajsoul)
随着人工智能技术的普及,游戏AI已成为检验计算机视觉与决策算法的重要场景。传统的游戏脚本多依赖于底层数据接口(如抓包)或固定的屏幕坐标点击,前者容易被反作弊系统检测,后者对分辨率适应性差。
本项目旨在开发一个非侵入式、基于纯视觉感知的《雀魂麻将》自动化系统。系统完全模拟人类玩家的行为模式:通过“眼睛”(计算机视觉)观察屏幕,通过“大脑”(深度学习与策略算法)分析局势,通过“手”(ADB控制)执行操作。
- 自适应视觉感知:提出了一种基于自适应颜色采样和透视变换的桌面配准算法,能够适应不同设备分辨率和游戏皮肤。
- 高精度目标分割:设计了基于统计学滤波和几何切分的麻将牌分割算法,解决了手牌粘连、倾斜和动态干扰问题。
- 轻量级识别网络:构建并训练了 TileNet 卷积神经网络,实现了对37种麻将牌的高精度(>98%)实时分类。
- 全流程自动化:实现了从登录、大厅匹配到游戏内自动对局的完整闭环。
本系统采用模块化设计,主要由以下三个核心层级构成:
- 感知层:
- 模块:
IngameRecognizer,HandTileExtractor,TileNet - 功能:负责屏幕图像获取、UI状态识别、游戏场景重构(透视变换)、手牌与牌河的分割及分类。
- 决策层:
- 模块:
GameManager,BoardState,StrategyFactory - 功能:维护全场游戏状态(BoardState),基于断幺九等策略算法计算最优出牌。
- 执行层:
- 模块:
LoginAutomator - 功能:封装
adbutils和uiautomator2接口,执行点击、滑动和文本输入操作。
CV 模块是本项目的核心技术难点,主要包含场景配准、目标分割和特征分类三个阶段。以下是对各阶段实现原理的详细解析。
由于不同设备的屏幕比例不同,且麻将牌桌呈3D梯形视角,直接处理原始图像极其困难。本系统通过透视变换将3D视角的桌面“拉正”为2D正视图。
实现类:Android/ingame_recognizer.py
为了准确定位牌桌的四个角点,系统采用了自适应颜色阈值分割与边缘检测相结合的混合策略:
- 自适应颜色采样:
-
原理:考虑到不同牌桌皮肤颜色不同,系统不使用固定阈值。而是自动在屏幕中心偏左和偏下的区域(避开中心Logo)截取
$10% \times 10%$ 的采样窗口。 -
计算:计算采样区域在 HSV 空间下的均值
$(\bar{H}, \bar{S}, \bar{V})$ 。 -
动态阈值:构建动态阈值范围
$[L, U]$ ,其中$L = (\bar{H}-\Delta H, \bar{S}-\Delta S, \bar{V}-\Delta V)$ ,$U$ 同理。项目中取$\Delta H=15, \Delta SV=60$ 。 -
掩膜生成:利用
cv2.inRange生成二值化掩膜,并进行形态学闭运算(Kernel$7\times7$ )以填充噪点空洞。
代码实现 (_table_mask_by_color):
def _table_mask_by_color(self, hsv: np.ndarray) -> np.ndarray:
h, w = hsv.shape[:2]
# 1. 采样策略:避开中心深色区域,采样稍微靠左/靠下的位置
cy, cx = h // 2, w // 2
# 中心向左偏 1/4 宽度 (左边桌布)
cx1 = int(cx - w * 0.25)
cy1 = cy
dh, dw = int(h * self.SAMPLE_FRAC / 2), int(w * self.SAMPLE_FRAC / 2)
# 确定采样区域边界
y1, y2 = max(0, cy1 - dh), min(h, cy1 + dh)
x1, x2 = max(0, cx1 - dw), min(w, cx1 + dw)
sample = hsv[y1:y2, x1:x2]
# 2. 计算自适应阈值:基于采样区域的平均 HSV 值
mean_h, mean_s, mean_v = np.mean(sample.reshape(-1, 3), axis=0)
# 构建动态上下限 (DELTA_H=15, DELTA_SV=60)
lower = np.array([max(0, mean_h - self.DELTA_H),
max(0, mean_s - self.DELTA_SV),
max(0, mean_v - self.DELTA_SV)], dtype=np.uint8)
upper = np.array([min(179, mean_h + self.DELTA_H),
min(255, mean_s + self.DELTA_SV),
min(255, mean_v + self.DELTA_SV)], dtype=np.uint8)
mask = cv2.inRange(hsv, lower, upper)
# 3. 形态学闭运算:填充掩膜内部空洞
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE,
np.ones((7, 7), np.uint8), iterations=3)
return mask- 轮廓提取与多边形拟合:
- 对掩膜进行轮廓查找,筛选出面积最大的轮廓(需大于屏幕面积的22%)。
- 使用 Douglas-Peucker 算法 (
cv2.approxPolyDP) 对轮廓进行多边形逼近。 - 鲁棒性设计:若拟合出的顶点数不等于4,则逐步增大逼近精度参数 (从周长的0.02倍调整至0.05倍),直至获得稳定的四边形。
- 备用方案:
- 若颜色分割失败,系统自动切换至 Canny 边缘检测模式,提取强边缘并膨胀连接,再次尝试寻找最大四边形。
-
角点排序:将检测到的四个顶点按“左上、右上、左下、右下”的几何顺序重排。
-
矩阵求解:设定目标正视图大小为 。利用
cv2.getPerspectiveTransform(src_points, dst_points)求解 的单应性矩阵(Homography Matrix)。 -
图像校正:应用
cv2.warpPerspective将倾斜的原始截图变换为正方形的俯视图。此时,牌河与各家区域在图像中的相对位置变得固定,极大简化了后续处理。
代码实现 (_find_quad_from_mask 与 warp_board):
def _find_quad_from_mask(self, mask: np.ndarray, img_area: int) -> Optional[Quad]:
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts: return None
# 选取最大轮廓,过滤掉面积过小的噪点
cnt = max(cnts, key=cv2.contourArea)
if cv2.contourArea(cnt) < self.MIN_TABLE_AREA_RATIO * img_area:
return None
# 多边形逼近:Douglas-Peucker 算法
peri = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, self.APPROX_EPS_FRAC * peri, True)
# 鲁棒性处理:如果拟合点数过多,尝试降低精度再次拟合
if len(approx) > 4:
approx = cv2.approxPolyDP(cnt, 0.05 * peri, True)
if len(approx) != 4: return None
# 重塑为 4x2 坐标数组并排序(左上、右上、左下、右下)
quad = approx.reshape(4, 2).astype(np.float32)
# ... (省略排序代码)
return ordered
def warp_board(self, img: Image.Image, quad: Quad, size: Optional[int] = None) -> Image.Image:
# 定义目标正视图的四个角点坐标 (800x800)
size = size or self.WARP_SIZE
dst = np.array([[0, 0], [size, 0], [0, size], [size, size]], np.float32)
# 计算透视变换矩阵并执行变换
mat = cv2.getPerspectiveTransform(quad, dst)
warped = cv2.warpPerspective(
cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR), mat, (size, size))
return Image.fromarray(cv2.cvtColor(warped, cv2.COLOR_BGR2RGB))本环节需要从复杂背景中将每一张牌精确切割出来。项目针对“手牌”和“牌河”的不同特性设计了特定的算法。
实现类:Android/maj_vision/utils.py
V1算法已弃用
手牌区域存在背景干扰、手指点击动画(牌会浮起)以及牌与牌之间紧密粘连的问题。
- ROI与预处理:
- 仅截取屏幕底部约 25% 的区域作为手牌 ROI。
- Otsu 二值化:将 ROI 转为灰度图,使用 Otsu 自适应阈值算法进行二值化,有效分离牌身(亮色)与背景(暗色)。
- 形态学开运算:去除二值图中的微小噪点。
- 智能轮廓筛选:
- 高度筛选:剔除高度不足最大高度 85% 的轮廓(通常是杂物)。
- 悬空距离分析:计算轮廓底部与 ROI 底部的距离。若某轮廓虽然高度足够,但“悬空”过高(
float_distance > max_h * 0.3),则判定为副露(吃碰的牌)或干扰项,予以剔除。这一逻辑精准解决了副露牌与手牌混淆的问题。
- 聚类与几何切分:
- 聚类:计算相邻轮廓的水平间距,将间距小于阈值(牌宽的1.2倍)的轮廓归为一簇,取最宽的一簇作为手牌区。
-
基于宽高比的切分:由于手牌紧密排列时二值化图像会粘连成一块,系统计算轮廓的宽高比。已知单张牌宽高比约 0.74,若某轮廓宽高比为
$W/H$ ,则该轮廓包含的牌数量估计为 。 - 随后将该轮廓在水平方向均分为
$N = \text{round}((W/H) / 0.74)$ 份,完成单张牌的切割。
- 状态感知:
- 为了区分“可打出的牌”(亮色)和“不可操作牌”(暗色/灰色),算法提取每张切片边缘的 V 通道(HSV中的亮度)。
- 若边缘亮度均值高于最大亮度的 90%,标记为
white,否则为gray。这解决了AI在立直的情况下不能出牌的问题。
代码实现 (HandTileExtractorV2.extract):
def extract(self, img, ...):
# 1. ROI 提取与 Otsu 二值化
roi_h_start_resized = int(self.resize_height * 0.75)
roi = img_resized[roi_h_start_resized:self.resize_height, 0:new_w]
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 2. 轮廓提取与智能筛选
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
hand_rects = []
for cnt in contours:
x, y, w_rect, h_rect = cv2.boundingRect(cnt)
# 筛选条件 A: 绝对高度检查(剔除过小的杂物)
if h_rect < max_h * 0.85: continue
# 筛选条件 B: 悬空距离分析 (Float Distance)
# 防止将副露(吃碰)或点击浮起的牌误判
bottom = y + h_rect
float_distance = max_bottom - bottom
is_full_size = h_rect > (max_h * 0.95)
if not is_full_size:
# 非全尺寸且悬空过高视为副露/干扰
if float_distance > (max_h * 0.3): continue
else:
if float_distance > (max_h * 0.8): continue
hand_rects.append((x, y, w_rect, h_rect))
# 3. 聚类与几何切分
# ... (省略聚类代码,获取 best_cluster)
tiles = []
for (x, y, w_rect, h_rect) in best_cluster:
# 基于宽高比估算牌的数量 (标准比例约 0.74)
aspect_ratio = w_rect / float(h_rect)
approx_tiles_num = round(aspect_ratio / 0.74)
if approx_tiles_num < 1: approx_tiles_num = 1
# 均等切分
tile_w = w_rect / approx_tiles_num
for i in range(int(approx_tiles_num)):
cut_x_start = x + int(i * tile_w)
# ... 提取 tile_img
tiles.append(tile_img)
return tiles牌河提取基于校正后的
- 区域映射与旋转:
- 根据硬编码的几何坐标,将正视图裁剪为“本家、下家、对家、上家”四个矩形区域。
- 利用
cv2.rotate将侧边(下家、上家)和对家的牌河旋转回正向(竖直方向)。
自家牌河裁剪结果:
- 基于统计学的自适应分割:
-
颜色分割:利用牌背颜色进行色彩分割,提取前景。
-
中位数滤波:计算所有候选轮廓面积的中位数
$A_{med}$ 。 -
去噪:剔除面积小于
$0.8 \times A_{med}$ 的轮廓(去除闪光特效等噪点)。 -
长宽比过滤:剔除极端长宽比的误检框。
-
动态切分:统计所有有效轮廓的宽度中位数
$W_{med}$ 。若发现某轮廓宽度显著大于$W_{med}$ ,则判定为粘连牌,强制按标准宽度将其切分为多张。
- 排序与截断:
- 对提取出的牌按
$(Y, X)$ 坐标进行排序,还原真实的打牌顺序。
代码实现 (extractPartTilesImg):
def extractPartTilesImg(img, return_boxes=False):
# 1. 颜色分割与轮廓提取
mask = tileBackRange(img)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 2. 统计学过滤:计算面积中位数
areas = [b[2]*b[3] for b in candidates]
median_area = statistics.median(valid_areas)
filtered_boxes = []
for (x, y, w, h) in candidates:
area = w * h
# 过滤过小轮廓 (噪点)
if area < (median_area * 0.8): continue
filtered_boxes.append((x, y, w, h))
# 3. 动态切分:基于中位数宽度处理粘连
widths = [b[2] for b in filtered_boxes]
median_w = statistics.median(widths)
final_boxes = []
for (x, y, w, h) in filtered_boxes:
# 若宽度显著大于中位数,判定为多张牌粘连
if w > (median_w * 1.6):
n_tiles = int(round(w / median_w))
tile_w = w / float(n_tiles)
for k in range(n_tiles):
kx = int(x + k * tile_w)
final_boxes.append((kx, y, int(tile_w), h))
else:
final_boxes.append((x, y, w, h))
return final_tiles实现类:Android/mj_cnn/model.py
- 网络架构 (TileNet):
- 设计了一个轻量级的 4 层卷积神经网络。
网络结构代码:
class TileNet(nn.Module):
def __init__(self, num_classes: int, input_size: int = 64):
super().__init__()
self.features = nn.Sequential(
# Block 1: 3 -> 32
nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
# Block 2: 32 -> 64
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
# Block 3: 64 -> 128
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True),
nn.MaxPool2d(2),
# Block 4: 128 -> 256
nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1, 1)),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.3),
nn.Linear(256, num_classes) # 输出 37 类
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x- 输出:37 类(1-9m, 1-9p, 1-9s, 1-7z, 红5p5s5m)。
- 推理优化:
- 在
classifier.py中实现了批量推理(Batch Inference)。将 CV 模块提取出的所有手牌打包成一个 Tensor 一次性送入 GPU/CPU 推理,大幅降低了延迟。
系统通过 BoardState 类维护当前局面的完整信息。
- 手牌更新:结合
HandTileExtractor的结果(牌名+坐标+状态),实时更新当前手牌列表。 - 数据同步:每一帧都会重新识别牌河并更新,确保决策层获取的是最新场况。
本项目实现了 TanyaoStrategy(断幺九策略) 作为核心算法,KokushiStrategy(国士无双策略) 作为激进算法:
- 幺九优先:遍历手牌,若存在幺九牌(1、9、字牌)且状态为
white,优先打出。 - 向听数回溯:
- 若无幺九牌,算法会对每一张可打出的手牌进行模拟切牌。
- 利用递归回溯算法,计算切牌后剩余手牌的向听数(距离听牌还需要几张牌)。算法模拟拆解面子(3张同色刻子/顺子)和搭子(2张邻近/相同牌)。
- 进张数最大化:
- 在向听数相同的情况下,计算有效进张数(能让向听数-1的牌的数量)。
- 最终选择向听数最小、进张数最大的牌作为决策结果。
在游戏过程中,系统需要识别并响应各种UI按钮,如荣和(Ron)、自摸(Tsumo)、立直(Reach)和跳过(Skip)。本项目采用基于SIFT特征匹配的模板识别算法来实现按钮检测。
实现类:Android/recognizer.py 中的 IngameButtonRecognizer
- 模板预处理:
- 系统预先保存了每个按钮的模板图片(存储在
templates/ingame/目录下)。 - 使用 SIFT 算法提取模板图像的特征点和描述符。
- 模板包括:
ron.png(荣和)、tsumo.png(自摸)、reach.png(立直)、skip.png(跳过)。
- 特征匹配流程:
- 特征提取:对当前屏幕截图和所有模板图像提取SIFT特征点。
- 特征匹配:使用 BFMatcher 进行特征点匹配,筛选出距离小于阈值(75)的优质匹配点。
- 单应性变换:利用 RANSAC算法 计算模板到屏幕的单应性矩阵,剔除离群点。
- 位置定位:通过单应性矩阵将模板的四个角点变换到屏幕坐标系,得到按钮在截图中的精确矩形位置
(x, y, w, h)。
代码实现 (IngameButtonRecognizer._match_template):
def _match_template(self, src_gray: np.ndarray,
kp_src: List[cv2.KeyPoint],
des_src: np.ndarray,
tpl: TemplateInfo) -> Optional[Rect]:
# 1. 特征匹配
matches = self.matcher.match(tpl["des"], des_src)
matches = sorted(matches, key=lambda m: m.distance)
good = [m for m in matches if m.distance < 75] # SIFT阈值
if len(good) < self.MIN_MATCHES: # 至少20个匹配点
return None
# 2. 计算单应性矩阵(RANSAC去噪)
src_pts = np.float32([kp_src[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
tpl_pts = np.float32([tpl["kp"][m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
H, mask = cv2.findHomography(tpl_pts, src_pts, cv2.RANSAC, 5.0)
if H is None or mask.sum() < self.MIN_MATCHES:
return None
# 3. 变换模板四角,得到按钮矩形
h, w = tpl["img"].shape
corners = np.float32([[0, 0], [0, h-1], [w-1, h-1], [w-1, 0]]).reshape(-1, 1, 2)
dst = cv2.perspectiveTransform(corners, H).reshape(-1, 2)
x, y = dst.min(axis=0)
x2, y2 = dst.max(axis=0)
return int(x), int(y), int(x2 - x), int(y2 - y)系统在每一帧处理中,按照以下优先级检测并响应按钮:
- 自摸按钮、荣和按钮(最高优先级):检测到荣和/自摸按钮时立即点击,避免错过和牌机会。
- 立直按钮:若满足立直条件,自动点击立直按钮。
- 跳过按钮:在吃碰杠等操作选择界面,自动点击跳过按钮。
- 尺度不变性:SIFT特征对图像缩放、旋转具有鲁棒性,能够适应不同分辨率的设备。
- 光照适应性:SIFT特征对光照变化不敏感,能够处理游戏内各种光效干扰。
- 数据规模:手动标注了 700+ 张实际游戏截图中的麻将牌。
- 训练配置:300 Epochs,Batch Size 64,Adam 优化器 (LR=1e-3)。
- 增强策略:随机旋转 ,随机水平翻转。
- 性能指标:最终验证集准确率达到 98% 以上,模型参数量仅约 400K,满足实时运行需求。
- 配准稳定性:在测试的20局游戏中,桌面四角检测成功率 >99.9%,能够稳定处理游戏内的光效干扰。
- 识别精度:手牌分割准确率 >98%,极少出现漏检或误切;牌面分类错误率 <1%。
- 运行效率:在真机adb环境下,单帧完整处理(截图+识别+决策)耗时约 1s,完全满足游戏节奏。
本项目综合运用了计算机视觉与深度学习技术,成功实现了一个鲁棒性强、识别精度高的《雀魂麻将》自动化系统。
主要技术突破在于:
- 摒弃了脆弱的固定坐标法,通过透视变换和自适应阈值实现了对不同分辨率和游戏皮肤的泛化支持。
- 设计了精细的几何切分算法,结合Otsu二值化和悬空距离分析,完美解决了手牌的分割难题。
- 验证了深度学习在传统游戏图像识别任务中的高效性,TileNet 以极小的计算代价实现了高精度的分类。
该项目不仅具有实际的应用价值,也为基于视觉的游戏AI开发提供了一套完整的技术验证方案。
本项目采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 (CC BY-NC-SA 4.0) 进行许可。
您可以:
- 共享:在任何媒介以任何形式复制、发行本作品
- 演绎:修改、转换或以本作品为基础进行创作
惟须遵守下列条件:
- 署名:您必须给出适当的署名,提供指向本许可协议的链接,同时标明是否(对原始作品)作了修改。您可以用任何合理的方式来署名,但是不得以任何方式暗示许可人为您或您的使用背书。
- 非商业性使用:您不得将本作品用于商业目的。
- 相同方式共享:如果您再混合、转换或者基于本作品进行创作,您必须基于与原先许可协议相同的许可协议分发您贡献的作品。
不得附加限制:您不得使用法律术语或者技术措施,从而限制其他人做许可协议允许的事情。
完整的许可协议文本请参阅项目根目录下的 LICENSE 文件,或访问 Creative Commons 官方网站 查看。












