ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLOv8麻将识别数据集实战:从标注格式到97.5% mAP复现

YOLOv8麻将识别数据集实战:从标注格式到97.5% mAP复现 简介面向目标检测学习者和麻将类应用开发者的带标注麻将识别数据集覆盖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.yamllabels 里的每个 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, encodingutf-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(encodingutf-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 \ datamajiang-dataset/data.yaml \ modelyolov8s.pt \ epochs120 \ imgsz640 \ batch16 \ projectruns/majiang \ nameexp_97 \ close_mosaic10 \ patience20模型权重我习惯从 yolov8s 开始显存吃紧就换 yolov8n但不要一上来就上 yolov8x。原因很简单麻将牌面的类别差异集中在细小纹理这轮实验要先确认数据质量而不是压榨模型容量。data.yaml 里至少要写清三样东西train 路径、val 路径、names 列表。参数推荐值为什么这么设imgsz640牌面在画面里往往偏小416 会让 2条 和 3条 的差异被压没close_mosaic10训练最后 10 个 epoch 关闭马赛克增强让小目标特征稳定收敛batch16根据显存调整24G 显卡可以提到 32显存小就 8epochs12034 类任务 60 轮就能出结果给到 120 是要等 mAP 平台期patience20连续 20 轮不涨就提前停避免熬到半夜看训练尾巴第一次跑训练命令时会自动下载预训练权重yolov8s.pt网络正常情况下没什么需要配置的。如果多次中断手动把权重文件放进当前目录训练命令就不会去重复触发下载。3.3 识别率 97.5% 的评估口径mAP50、置信度阈值与结果表训练结束后跑验证yolo detect val \ modelruns/majiang/exp_97/weights/best.pt \ datamajiang-dataset/data.yaml \ conf0.25 \ iou0.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 扰动让模型不依赖绝对颜色。推理时对低纹理类别单独放宽阈值比如全局用 conf0.45白板这个类用 0.25能明显减少闪烁。4.2 牌离摄像头远时漏检小目标问题把 mAP 拉低现象牌桌全景图里远处一排牌只有 20 到 30 像素高模型完全无反应把镜头推到近景后又一切正常。原因YOLOv8 在 640 分辨率下对 20 像素级别的目标很吃力尤其條子牌的点数纹理占比例更小相当于“目标里的目标”。用 imgsz416 训练时这种现象会更严重。解决先看训练时实际分辨率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, confCONF, imgsz640, verboseFalse) 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 \ modelruns/majiang/exp_97/weights/best.pt \ formatonnx \ imgsz640导出后可以用 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), conf0.25, imgsz640) 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, indexFalse)回归集里的图片要刻意挑“难”的有反光的、牌面倾斜的、附近有手指的、距离远的。每轮训练后跑一遍能立刻看到哪些类别在变好、哪些类别被新增强“改坏”。我曾经在一次调参后主 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 并不是一个需要靠运气的数字。希望这些经验帮你在自己的牌桌项目里少走一段弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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