☰
YOLOv8麻将识别数据集实战:从标注格式到97.5% mAP复现
2026/10/2 14:16:59 网站建设 项目流程

简介:面向目标检测学习者和麻将类应用开发者的带标注麻将识别数据集,覆盖1条、8萬、2饼以及东南西北中发白板等常见牌面,类别标注完整,可直接按YOLOv8格式使用;按资源说明,配套模型的识别率约97.5%。数据包共2000个文件,以894张JPG麻将图像为主体,1105个TXT标注文件逐图记录目标类别与边界框坐标,另有1个YAML文件声明类别名称与数量,整体压缩后约65.24MB,结构简洁、便于直接接入训练脚本。图像内容涵盖手牌、弃牌等对局场景,比单纯单张牌面更贴近实际麻将识别环境,适合用来练习数据划分、类别映射、YOLOv8训练与验证等完整目标检测流程,也适合作为点数识别、牌型判断等实验的基础数据集。目前已有38人学习/下载,适合需要现成标注数据快速开展麻将识别相关项目的读者。

1. 麻将识别数据集为什么值得上手:97.5% 到底在什么条件下成立

识别麻将的点数和类型,比如 1条、8萬、2饼,以及东南西北中發白板,是自动计分、对局回放和出牌统计里绕不开的第一步。带标注的麻将识别数据集把“自己逐张框牌打标签”这种最耗时的工作先做掉了,标签又直接按 yolo v8 的 txt 格式给好,下载下来就能开训,这是它比一堆裸图更值得投入时间的地方。97.5% 的识别率不是玄学,但拿到手先要问清楚:这个数是在标准牌面、固定光线下用 mAP50 算出来的,还是用准确率算出来的。我的经验是 97.5 在同类数据集里属于比较扎实的水平,可它衡量的是模型在相近拍摄条件下的复现能力,不是对任意牌桌的承诺。适合三类人:做自动麻将机辅助识别的小团队、做对局视频判罚的开发者,以及想学目标检测但缺工业级数据的新手。

2. 数据集的类别设计与 YOLOv8 标注格式:从 1 条、8萬到东南西北的完整映射

拿到数据集第一件事不是跑训练,而是把类别体系理清楚。麻将牌面识别和通用物体检测不一样,它的类别之间有很强的“同构性”:同样是條子,2条和3条的差异只有一条竖线的位置,这类边界情况直接决定最终识别率能不能上 97。

2.1 为什么常见类别设计是 34 类而不是“每张牌单独一个标签”

一套完整麻将共 34 种不同牌面,每种 4 张,总数 136 张。识别任务要的是“这属于哪种牌面”,而不是“这是桌面上第几张”,所以按牌面分组是更合理的设计:数牌三条花各 1 到 9,共 27 类;字牌東、南、西、北、中、發、白板,共 7 类;加起来 34 类。部分数据集还会带上春、夏、秋、冬、梅、兰、竹、菊 8 张花牌,那就是 42 类。

标题里提到的“1条、8萬、2饼”对应数牌,“东南西北中發白板”对应字牌。类别 ID 一旦定下就别再改,否则所有 txt 标签、data.yaml、训练权重都要跟着动。我一般按“条、萬、饼、字牌”的顺序排,这样新增花牌时只在尾部追加,不影响已有 ID。

class_id中文标签说明
01条数牌·條子
12条数牌·條子
.........
89条数牌·條子
91萬数牌·萬子
181饼数牌·餅子
269饼数牌·餅子
27東字牌
28南字牌
29西字牌
30北字牌
31中字牌
32發字牌
33白板字牌

注意“白板”在不同数据集的命名里可能是“白”或“白板”,跑验证前先确认你的 class 列表和 data.yaml 里的 names 一致,否则预测时标签完全错位。

2.2 YOLOv8 数据格式落地:images/labels 目录与归一化坐标

YOLOv8 不认 LabelImg 的 xml,也不认 LabelMe 的 json,它只认一对同名文件:图片放在 images 目录,标签放在 labels 目录,标签是纯文本 txt。目录结构我一般这样组织:

majiang-dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── data.yaml

labels 里的每个 txt 和对应图片同名,每行代表一个标注框,格式是五个空格分隔的字段:

class_id x_center y_center width height

四个坐标全部归一化到 0 到 1 之间。也就是说,x_center 是“框中心点在图片宽度方向的比例”,width 是“框宽占图片宽的比例”,不是像素值。麻将牌是矩形,标注框最好贴住牌面边缘,但如果拍摄时手指挡住了一部分牌,宁可框完整可见牌面,也别把手指框进去,否则模型会学到“手指也是牌”。

2.3 从标注工具的 XML 转成 YOLOv8 的 txt:一份能直接跑的转换脚本

拿到数据集时,原始标注可能是 xml、json 或已经转好的 txt。如果是 xml 格式,用下面的脚本批量转:

import os import xml.etree.ElementTree as ET # 类别顺序决定了 class_id,和 data.yaml 的 names 必须保持完全一致 classes = [ "1条", "2条", "3条", "4条", "5条", "6条", "7条", "8条", "9条", "1萬", "2萬", "3萬", "4萬", "5萬", "6萬", "7萬", "8萬", "9萬", "1饼", "2饼", "3饼", "4饼", "5饼", "6饼", "7饼", "8饼", "9饼", "東", "南", "西", "北", "中", "發", "白板" ] def voc_to_yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in classes: continue cls_id = classes.index(name) bnd = obj.find("bndbox") xmin = int(bnd.find("xmin").text) ymin = int(bnd.find("ymin").text) xmax = int(bnd.find("xmax").text) ymax = int(bnd.find("ymax").text) # 归一化到 0~1 x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") txt_name = os.path.splitext(os.path.basename(xml_path))[0] + ".txt" with open(os.path.join(out_dir, txt_name), "w", encoding="utf-8") as f: f.write("\n".join(lines)) # 用法示例:把 annotations 下所有 xml 转到 labels/train for xml_file in os.listdir("annotations"): if xml_file.endswith(".xml"): voc_to_yolo(os.path.join("annotations", xml_file), "labels/train")

脚本逻辑不复杂:解析 xml 里的 bndbox,换算成归一化中心点和宽高,再写成一行文本。关键在 classes 列表的顺序,它直接决定了每个类别最终的 ID;后面训练时 data.yaml 的 names 要和它一字不差。坐标转出来如果出现负数或大于 1,说明标注框本身越界了,脚本应该加一层告警而不是默默写入,否则训练时会报“all bbox coordinates must be in [0, 1]”。

2.4 训练/验证划分:按对局划分,不按图片随机划分

很多人训练自己的数据集时习惯把所有图片塞进一个池子随机切 train/val,这在麻将识别里是有风险的。同一个视频片段抽出来的相邻帧高度相似,如果其中一帧进训练集、另一帧进验证集,验证精度会被虚高抬起来 3 到 5 个点,看起来 mAP 漂亮,实际换个场景就掉回去。更稳的做法是按“对局”或“拍摄片段”划分:

import os import random raw_images = sorted(os.listdir("raw_images")) random.seed(42) # 假设文件名包含对局前缀,例如 session01_0001.jpg groups = {} for fname in raw_images: session = fname.split("_")[0] groups.setdefault(session, []).append(fname) val_sessions = random.sample(list(groups), max(1, len(groups) // 5)) train_imgs = [] val_imgs = [] for session, imgs in groups.items(): if session in val_sessions: val_imgs.extend(imgs) else: train_imgs.extend(imgs) print("train:", len(train_imgs), "val:", len(val_imgs))

这样做的前提是原始数据本来就按对局组织好。如果文件名没有任何分组线索,就只能人工看一眼视频目录,把同一段视频抽出来的帧归到一个 session 里。数据划分这个步骤不产生任何“识别率提升”,但它决定了你看到的 97.5 到底是真能力还是记忆比赛。

3. 用 YOLOv8 训练自己的麻将识别模型:数据校验、关键参数与 mAP50 验证

格式和划分都就位后,进入真正的训练环节。这一章从跑通最小命令讲起,再解释为什么 imgsz、mosaic、置信度阈值这些参数对麻将这种小目标场景特别敏感。

3.1 训练前先跑数据校验:别让坏标注偷走 7 个点

训练前先用一段小脚本把整个数据目录扫一遍,重点检查三类问题:标签文件缺图、坐标越界、类别 ID 超出范围。这类问题不会让训练直接崩溃,但会让精度悄然下滑。

from pathlib import Path label_dir = Path("majiang-dataset/labels/train") total_lines = 0 errors = [] for txt in label_dir.glob("*.txt"): lines = txt.read_text(encoding="utf-8").strip().splitlines() if not lines: errors.append(f"{txt}: 空标签文件") continue for line_no, line in enumerate(lines, 1): parts = line.split() if len(parts) != 5: errors.append(f"{txt}:{line_no} 字段数不为5 -> {line}") continue cls_id, x, y, w, h = parts if not (0 <= int(cls_id) <= 33): errors.append(f"{txt}:{line_no} 类别ID越界 -> {cls_id}") continue # 中心点必须在 0~1,宽高必须合法 for coord in (x, y, w, h): if float(coord) < 0 or float(coord) > 1: errors.append(f"{txt}:{line_no} 坐标越界 -> {line}") break for err in errors[:50]: print(err) print("总标签行数:", total_lines, "错误数:", len(errors))

脚本同时应该检查同名图片是否存在:labels 里有 txt 但 images 里没有对应 jpg,训练时 ultralytics 会跳过,你根本注意不到数据量少了。我自己遇到过一个案例,数据文件全部齐整,但某个类别的 200 张标注全是同一个对局里的截图,模型在这个类上的 AP 虚高到 99,换新场景直接掉到 60 多。校验阶段多花十分钟,比训练完再排查省得多。

3.2 最小训练命令:yolov8s 起步与五个必调参数

校验通过后,训练命令比我见过的大多数教程都短:

yolo detect train \ data=majiang-dataset/data.yaml \ model=yolov8s.pt \ epochs=120 \ imgsz=640 \ batch=16 \ project=runs/majiang \ name=exp_97 \ close_mosaic=10 \ patience=20

模型权重我习惯从 yolov8s 开始,显存吃紧就换 yolov8n,但不要一上来就上 yolov8x。原因很简单:麻将牌面的类别差异集中在细小纹理,这轮实验要先确认数据质量,而不是压榨模型容量。data.yaml 里至少要写清三样东西:train 路径、val 路径、names 列表。

参数推荐值为什么这么设
imgsz640牌面在画面里往往偏小,416 会让 2条 和 3条 的差异被压没
close_mosaic10训练最后 10 个 epoch 关闭马赛克增强,让小目标特征稳定收敛
batch16根据显存调整,24G 显卡可以提到 32,显存小就 8
epochs12034 类任务 60 轮就能出结果,给到 120 是要等 mAP 平台期
patience20连续 20 轮不涨就提前停,避免熬到半夜看训练尾巴

第一次跑训练命令时会自动下载预训练权重yolov8s.pt,网络正常情况下没什么需要配置的。如果多次中断,手动把权重文件放进当前目录,训练命令就不会去重复触发下载。

3.3 识别率 97.5% 的评估口径:mAP50、置信度阈值与结果表

训练结束后跑验证:

yolo detect val \ model=runs/majiang/exp_97/weights/best.pt \ data=majiang-dataset/data.yaml \ conf=0.25 \ iou=0.45

验证结果里重点看两列:mAP50(B) 和 mAP50-95(B)。数据集描述的 97.5% 基本指的是 mAP50,也就是 IoU 阈值取 0.5 时的平均精度。如果它在你的验证集上落在 0.95 到 0.98 之间,说明复现成功;如果只有 0.8 左右,先回上一章查数据划分和标签质量,别急着换大模型。

还要区分一个概念:mAP50 和“准确率”不是一回事。准确率是“猜对的图除以总图数”,mAP50 是“所有类别在不同置信度下的综合表现”。对于 34 类麻将识别,mAP50 到 0.97 不代表每张牌都绝对正确,它可能掩盖了某个字牌类别单独只有 0.85 的事实,所以验证时一定要看每个类的 AP 明细,而不是只看一个大数字。置信度阈值 conf 影响的是推理时要不要这个框,验证集上的 mAP 不随 conf 剧烈变化,但部署时把 conf 从 0.25 提到 0.5,会让低纹理牌如白板的漏检明显变多。

4. 麻将识别避坑记录:标注偏移、小目标漏检与验证集泄漏

这部分是真实的踩坑合集,每一条都是我在做识别时实际碰过、也帮别人排查过的问题。现象、原因、解决写清楚,遇到可以照抄。

4.1 白板和發牌置信度低,还容易被背景反光带偏

现象:白板、紅中等纹理少的牌在正常光照下能识别,一旦牌面反光或被手挡一半,置信度直接从 0.8 跌到 0.3,视频里表现为“闪烁式消失”。

原因:这类牌面的可区分特征集中在边框颜色和中央字符,反光把中央区域的灰度对比抹平,模型拿不到稳定特征。我在标注里看到过一个共性,白板的标注框边缘经常把牌面外侧的浅色边框切掉,导致模型学习的其实是“一块接近白色的矩形”,而不是“白板”。

解决:标注时把白板的框完整包住整张牌的四边,不要为了“贴合牌面图案”去切掉边框。训练时在增强里加大 HSV 扰动,让模型不依赖绝对颜色。推理时对低纹理类别单独放宽阈值,比如全局用 conf=0.45,白板这个类用 0.25,能明显减少闪烁。

4.2 牌离摄像头远时漏检,小目标问题把 mAP 拉低

现象:牌桌全景图里,远处一排牌只有 20 到 30 像素高,模型完全无反应;把镜头推到近景后又一切正常。

原因:YOLOv8 在 640 分辨率下对 20 像素级别的目标很吃力,尤其條子牌的点数纹理占比例更小,相当于“目标里的目标”。用 imgsz=416 训练时这种现象会更严重。

解决:先看训练时实际分辨率,imgsz 提到 640 是底线,显卡允许就 960。同时检查增强配置,mosaic 默认能提升小目标泛化,但如果是自己写数据加载器,注意别把 mosaic 里的图缩得太小,否则小目标在增强阶段就被缩没了。另外还有一种常见做法是把全景图切分成四个滑窗分别识别,再合并坐标,效果比单纯抬分辨率更直接。

4.3 验证集和训练集来自同一对局,mAP 虚高 5 个点以上

现象:模型在自己的 val 集上 mAP50 有 0.97,拿到另一个视频里测只有 0.88,差距大到不合理。

原因:这是最隐蔽的泄漏。视频相邻帧背景、角度、光影高度相似,随机划分时同一段画面的帧会同时进训练集和验证集,模型在验证集上表现好只是因为“它见过这局牌的背景”,不是“它认识这种牌面”。

解决:按对局分组划分,具体代码见 2.4 节。更严格的做法是把某几局完整留出做 test 集,训练阶段完全不碰。复现数据集描述的 97.5% 之前,先确认你的划分方式没让分数虚高,否则后面所有调参决策都建立在假数据上。

4.4 视频抽帧造成大量重复样本,训练集规模虚胖

现象:数据总量显示 8000 张图,实际去重后只有 1500 张不同画面,模型训练时间翻倍,精度却没有随 epoch 提升。

原因:原始视频每秒 30 帧,抽帧时每隔 3 帧存一张,同一个动作产生的画面几乎一样。这些重复样本对“见过更多形态”没有帮助,只会让模型对重复的背景过拟合。

解决:抽帧前先做感知哈希去重。用 imagehash 库对每帧算一个哈希值,两帧之间汉明距离小于阈值就丢弃后一帧。阈值建议从 10 开始调,太宽松去不动重复,太严格会把同一颗麻将牌在不同角度下的正常变化也删掉。

4.5 自动标注辅助工具生成后不人工抽检,框体偏移悄悄累积

现象:用某类自动标注工具或大模型预标注生成的数据集,整体 mAP 能到 0.95,但逐类看,某个字牌类的框总是偏左上 7 到 10 个像素,AP 比别的类低一截。

原因:自动标注模型对自己的高置信结果也会犯系统性偏移,而且这种偏移会自洽地传递到训练数据里。人工标注偶尔偏移是随机的,自动标注偏移通常是同方向的,模型学到的就是“框比牌偏左上”。

解决:自动标注后抽检比例至少在 5%,每 200 张里挑 10 张打开看框线和牌面的贴合情况。可以用脚本统计同一个类别的框宽高分布,如果某个类别的框宽普遍比其他类别窄 15%,基本就是“只框了牌面字符没框整张牌”的问题,重标这个类别比全局重标成本低得多。

5. 把 97.5% 搬到真实牌桌:视频抽帧、轻量去重与推理加速

验证集上的 mAP 是一回事,真实牌桌上的连续识别是另一回事。这一章讲怎么把训练好的 best.pt 变成一个能看视频流、能计数的推理程序。

5.1 为什么单帧识别在视频里会“飘”,需要抽帧策略

静态图测试时,一张牌只要清晰,识别就稳定。但视频里手部遮挡、出牌动作、快门模糊导致同一张牌在相邻帧里的置信度忽高忽低。如果每帧都做检测,你会看到牌名来回跳,1条 和 4条 在手臂划过时互切。常见做法是抽帧加“确认再计数”:每 5 帧抽一帧做推理,某个位置连续两三次出现同一类别的牌,才认为这张牌真实存在。

5.2 带置信度过滤和轻量去重的推理脚本

下面这段脚本针对一段牌桌视频做抽帧推理,输出所有被确认过的牌。

import cv2 from ultralytics import YOLO model = YOLO("runs/majiang/exp_97/weights/best.pt") cap = cv2.VideoCapture("table_video.mp4") FRAME_STRIDE = 5 # 每 5 帧推理一次 CONF = 0.45 # 全局置信度阈值 MAX_DIST = 40 # 同类别牌去重的中心距离阈值 tracker = {} # 记录牌最近一次出现的位置和类别 confirmed = [] # 最终被确认的牌 frame_idx = 0 while cap.isOpened(): ret, frame = cap.read() if not ret: break if frame_idx % FRAME_STRIDE != 0: frame_idx += 1 continue res = model(frame, conf=CONF, imgsz=640, verbose=False) for box in res[0].boxes: cls_id = int(box.cls) conf = float(box.conf) xyxy = box.xyxy[0].tolist() cx = (xyxy[0] + xyxy[2]) / 2 cy = (xyxy[1] + xyxy[3]) / 2 key = (cls_id, round(cx // 20), round(cy // 20)) if key not in tracker: tracker[key] = {"conf": conf, "times": 1, "cls": cls_id} else: tracker[key]["times"] += 1 tracker[key]["conf"] = max(tracker[key]["conf"], conf) # 连续确认 2 次以上才记录 if tracker[key]["times"] >= 2 and key not in confirmed: confirmed.append(key) # 实际项目里在这里保存截图或推送消息 frame_idx += 1 print("确认出现的牌数:", len(confirmed))

逻辑说明:追踪 key 由“类别 ID + 量化到 20 像素网格的中心点”构成,相当于给每张牌一个粗粒度位置指纹。连续抽两帧都在同一网格且类别一致,才认为检测稳定。参数 FRAME_STRIDE 控制实时性和稳定性,5 帧一抽在 30fps 视频里每秒推理 6 次,足够覆盖出牌动作;MAX_DIST 在这里没有直接使用,因为网格量化已经做了去重,保留它是方便改用中心点欧氏距离时调参。如果视频里牌与牌挨得太近,网格太大会把两张牌合并,太小又会让同一张牌在抖动时跨格,20 像素是个不错的起点。

5.3 导出 ONNX 之后的推理开销与丢帧取舍

训练时用的是 PyTorch 权重,部署到纯 CPU 或低功耗设备前先做一次导出:

yolo export \ model=runs/majiang/exp_97/weights/best.pt \ format=onnx \ imgsz=640

导出后可以用 ONNX Runtime 加载,替换 ultralytics 推理,适合不需要频繁重训的固定模型。实测里一张 640 的图在普通桌面 CPU 上推理约 40 到 80 毫秒,视频流场景按 5 帧抽一次再配合这个速度,足够满足出牌统计的实时性。如果仍然偏慢,可以先把 imgsz 压到 480 或用半精度推理,代价是远处小牌的 mAP 下降,通常不建议在麻将识别里轻易降分辨率。

提示:模型精度和抽帧策略是两套独立的调参。mAP 高不等于视频里不闪,置信度阈值、网格去重参数都需要在真实录像上过一遍,不要只拿 val 集的指标下结论。

6. 让 97.5% 可被踏实复现:固定回归集与每类置信度校准

最后一个技巧,也是我自己每次训练完必做的“后悔药”动作:建一个固定回归测试集。它和 val 集不同,val 集是参与训练节奏判断的,回归集是从最终数据里单独留出的 20 到 30 张有代表性的图,训练结束后固定跑一轮,专门看回归报告。

from ultralytics import YOLO from pathlib import Path import pandas as pd model = YOLO("runs/majiang/exp_97/weights/best.pt") names = model.names rows = [] for img in sorted(Path("regression_set").glob("*.jpg")): # 文件名规范:1条_现场1.jpg、東_灯光暗.jpg gt = img.stem.split("_")[0] res = model(str(img), conf=0.25, imgsz=640) if len(res[0].boxes) == 0: rows.append({"file": img.name, "gt": gt, "pred": "无检测", "conf": 0}) continue box = res[0].boxes[0] pred = names[int(box.cls)] rows.append({"file": img.name, "gt": gt, "pred": pred, "conf": float(box.conf)}) report = pd.DataFrame(rows) report["correct"] = report["gt"] == report["pred"] print(report[report["correct"] == False]) report.to_csv("regression_report.csv", index=False)

回归集里的图片要刻意挑“难”的:有反光的、牌面倾斜的、附近有手指的、距离远的。每轮训练后跑一遍,能立刻看到哪些类别在变好、哪些类别被新增强“改坏”。我曾经在一次调参后主 mAP 涨了 0.2,回归集却显示“發”全灭,原因是那次改了 HSV 增强导致红色字符饱和度过高。没有回归集,这个问题至少会拖到真机测试才暴露。

下面这张表是我固定会打印的每类置信度诊断:对验证集分别算每个类别的最优 conf,而不是全类别用一个阈值。

# 对验证集逐类扫描置信度阈值 import numpy as np for cls_id in range(34): cls_res = [r for r in all_results if r["cls"] == cls_id] best_conf, best_f1 = 0.25, 0 for conf in np.arange(0.05, 0.95, 0.05): tp = sum(1 for r in cls_res if r["conf"] >= conf and r["correct"]) fp = sum(1 for r in cls_res if r["conf"] >= conf and not r["correct"]) fn = sum(1 for r in cls_res if r["conf"] < conf and r["correct"]) f1 = tp / (tp + (fp + fn) / 2) if f1 > best_f1: best_f1, best_conf = f1, conf print(names[cls_id], "最优 conf:", round(best_conf, 2))

这类 per-class 阈值在麻将识别里很有用:白板需要低阈值,發和中需要中等阈值,條子里的 2 条和 6 条需要相对高阈值才能减少误切。把这份阈值表存成 json,视频推理脚本里按类读取,比一个全局 conf 理性得多。

麻将识别这种任务,数据集决定上限,参数决定你能不能摸到上限。先花时间把类别、划分、回归集这三块打牢,再回头调模型,你会发现 97.5 并不是一个需要靠运气的数字。希望这些经验帮你在自己的牌桌项目里少走一段弯路。

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

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

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

立即咨询