☰
扑克牌识别实战:COCO标注转YOLOv8训练全流程
2026/9/28 12:35:22 网站建设 项目流程

简介:面向扑克牌识别与计算机视觉入门者的数据集,涵盖A至K全部牌面字母对应的标注图像,可直接用于目标检测、图像分类等模型训练与算法验证。压缩包共1853个文件,其中1850张JPG原始图片,覆盖不同拍摄角度、光照条件与背景环境,另附3个JSON标注文件,采用COCO格式记录每张图片中的牌面位置与类别,兼容主流深度学习框架的输入要求。数据集整体大小约110.21MB,结构清晰,解压后即可按标准COCO训练流程使用。目前已有259人学习下载,具有较好的参考热度。利用该数据集,可快速搭建扑克牌识别模型,获得用于牌面字母识别的完整样本集,官方标注的识别正确率可达98.7%,有助于在数据预处理、目标检测、模型调参与精度验证等环节积累真实项目经验。

1. 扑克牌识别数据集:从 A 到 K 的 98.7% 到底怎么落地

扑克牌识别,听起来是玩具项目,真上手才发现最烦的不是模型,而是数据:牌面字母有 13 类,背景千奇百怪,拍摄角度多,没有一份标注干净的数据集,训练就是在浪费 GPU。这份数据集一共 1850 张原始图,标注是 COCO JSON 格式,覆盖 A 到 K 的所有牌字母,描述里给的识别正确率是 98.7%。这个数字放到目标检测里,大概率对应 mAP50 或者单牌分类准确率这一档,适用的场景很直接:棋牌玩法识别、直播内容审核、桌游自动计分,都能拿去做二次训练。因为标注格式通用,YOLOv8、MMDetection、Detectron2 都能接。但别急着跑训练,第一件事是把 JSON 拆开看明白。

2. 先看懂 COCO JSON:一张牌是怎么被框出来的

COCO JSON 是目标检测领域最通用的中间格式之一,很多平台导出数据最终都会落到这一层。它把图片信息和标注信息分开存放,靠 ID 关联,理解了这个结构,后面的转换脚本才写得对。常见做法是先解析再动手训练,因为 JSON 里不只有框坐标,还有图片尺寸、类别 id、单图框数等细节,这些直接决定转换公式和数据划分方式。

2.1 数据集文件结构:images 和 annotations 靠 image_id 关联

一个标准 COCO JSON 文件顶层有三个数组:images、annotations、categories。images 每一项包含 id、file_name、width、height;annotations 每一项包含 id、image_id、category_id、bbox、area、iscrowd。在扑克牌场景里,iscrowd 基本都是 0,不用管,重点是 image_id 和 bbox。图片和标注不放在同一个对象里,而是由 annotations 里的 image_id 指向 images 里的 id。一张图里有多少张牌,annotations 里就有多少条记录复用同一个 image_id。

这份数据集的图片文件名是 IMG_1838-2_jpeg_jpg.rf.xxx.jpg 这种带随机哈希的形式,一看就是标注平台导出时重命名过的。这种命名不影响读取,但别指望从文件名判断图片内容,也不要手动改文件名,否则和 JSON 里的 file_name 对不上。我拿到任何数据集都会先跑一段 Python 把结构摸一遍,重点确认图片实际文件名和 JSON 是否一致。

import json from collections import Counter coco_path = "annotations.json" with open(coco_path, "r", encoding="utf-8") as f: coco = json.load(f) print("images 数量:", len(coco["images"])) print("annotations 数量:", len(coco["annotations"])) print("categories 数量:", len(coco["categories"])) # 按 image_id 统计每张图的标注框数量 per_img = Counter(a["image_id"] for a in coco["annotations"]) print("框数最多的前 5 张图:", per_img.most_common(5)) # 按 category_id 统计每个类别的标注数量 per_cls = Counter(a["category_id"] for a in coco["annotations"]) print("类别分布:", per_cls) # 抽查第一张图的基本信息 print("第一张图信息:", coco["images"][0])

这个脚本不做任何训练,目的是先把家底摸清楚。images 数量和实际图片文件夹数量不一致,多半是下载时丢了文件,后面训练会报找不到图片;annotations 数量除以图片数量,能算出平均每张图有几张牌。类别分布尤其关键,如果 A 有 300 张,K 只有 80 张,后面训练就得对 K 做增强或者做类别重加权,否则整体识别率虚高,单类 AP 惨不忍睹。

2.2 categories 数组:A 到 K 到底是从 0 还是从 1 开始

categories 数组里每一项通常是 {"id": 1, "name": "A", "supercategory": "none"} 这种结构。注意 COCO 官方类别 id 一般从 1 开始,但很多第三方数据集导出时会把 id 改成从 0 开始,甚至中间跳号。这份扑克牌数据集如果按 A 到 K 排列,常见是 13 个类别,不区分花色;如果 JSON 里出现 14 个类别,那很可能是加了 Joker,或者把花色也算进去了。训练之前必须把 categories 完整打印出来,而不是凭标题猜。

for cat in coco["categories"]: print(cat)

把 id 和 name 的对应关系记下来。后面写转换脚本时,我会用一条 dict 把原始 category_id 重映射到从 0 开始的连续编号,而不是直接拿 category_id 当训练标签。原因很简单:YOLO 的类别编号要求从 0 开始连续,而 COCO JSON 里的 id 可能从 1 开始或跳号,不重映射轻则类名错位,重则越界报错。这个坑在下载数据集里出现频率极高,几乎每三个项目就能踩到一次。

2.3 统计 bbox 尺寸:扑克牌是大目标还是小目标

建模之前最好知道牌面在整张图里占多大比例。用 bbox 的 width 乘 height,除以图片的 width 乘 height,能得到框占比分布。扑克牌识别这类任务,如果牌面占图比例普遍超过 30%,输入尺寸 640 就够了;如果很多牌占全图不到 10%,就需要考虑增大输入尺寸或者换更强的网络。1850 张的量不算大,但牌面的拍摄距离差异可能很大,统计一下能帮你决定 imgsz 参数。

box_ratios = [] for ann in coco["annotations"]: im = next(img for img in coco["images"] if img["id"] == ann["image_id"]) bw, bh = ann["bbox"][2], ann["bbox"][3] box_ratios.append((bw * bh) / (im["width"] * im["height"])) box_ratios.sort() print("框占比中位数:", box_ratios[len(box_ratios) // 2]) print("框占比最小的 10 个:", box_ratios[:10])

这段代码在线性遍历 images 数组,数据量小,1850 张跑起来很快。如果以后换到几万张的数据集,应该先把图片尺寸转成以 id 为 key 的 dict,不然 O(n×m) 的复杂度会明显变慢。框占比中位数能反映任务难度,也能帮你判断是否要在训练时开 mosaic 增强。这类统计虽然原始,但比直接翻看几十张图靠谱得多,至少不会被个别极端样本带偏判断。

3. 转成 YOLO 格式:bbox 基准与目录约定的坑

COCO JSON 是通用标注格式,但当下跑检测最常用的是 Ultralytics YOLO,它需要的标注是一张图一个 txt,每行格式是“类别 中心点 x 中心点 y 宽 高”,所有数值归一化到 0 到 1。所以拿到这份扑克牌数据集后的第二步,是写一个转换脚本。转换本身不难,但很容易在路径和坐标基准上出问题,而且往往不报错,只会让训练结果变差。

3.1 目录结构:先把 train 和 val 分开

我一般会先把目录建好,再执行转换脚本,避免训练集和验证集混在一起。训练集和验证集经过同一套管线但放在不同子目录,YOLO 靠 data.yaml 里的路径区分它们。

poker_dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── poker.yaml

train 和 val 的比例,常见做法是 8:2 或 9:1。1850 张的量,8:2 大约是 1480 张训练、370 张验证,足够看出模型有没有过拟合。切分时一定要按图片名随机打乱,不能把同一来源的连续图片全塞进训练集或验证集。这份数据集的图片文件名带着随机哈希,本身已经是乱序,但我还是习惯自己再 shuffle 一次,并且固定随机种子,保证下次运行能复现同样的切分。

import os import random from shutil import copyfile random.seed(42) all_imgs = [f for f in os.listdir("images_all") if f.endswith(".jpg")] random.shuffle(all_imgs) val_count = int(len(all_imgs) * 0.2) os.makedirs("images/train", exist_ok=True) os.makedirs("images/val", exist_ok=True) for i, name in enumerate(all_imgs): src = os.path.join("images_all", name) if i < val_count: copyfile(src, os.path.join("images/val", name)) else: copyfile(src, os.path.join("images/train", name))

这段代码把全部图片按 8:2 拆到 train 和 val。固定 seed 是重要习惯,不固定的话每次跑结果不同,后面想复现实验就会很被动。val_count 用整数取整,1850 张里有 370 张进验证集。如果后续要加数据,建议保留一份图片名清单,新增样本只往 train 里补,不要再重新随机切分。

3.2 转换脚本:从左上角坐标到归一化中心点

下面这个脚本可以直接复用。它读取 COCO JSON,把 annotations 按 image_id 归并,然后为每张图生成一个同名 txt。

import json import os def coco_to_yolo_txt(coco_path, output_dir): with open(coco_path, "r", encoding="utf-8") as f: coco = json.load(f) # 原 JSON 的 category id 不保证从 0 开始,这里重映射 cat_map = {c["id"]: i for i, c in enumerate(coco["categories"])} img_info = {im["id"]: im for im in coco["images"]} ann_by_img = {} for ann in coco["annotations"]: ann_by_img.setdefault(ann["image_id"], []).append(ann) os.makedirs(output_dir, exist_ok=True) for img_id, anns in ann_by_img.items(): im = img_info[img_id] w, h = im["width"], im["height"] txt_path = os.path.join( output_dir, os.path.splitext(im["file_name"])[0] + ".txt" ) with open(txt_path, "w", encoding="utf-8") as out: for ann in anns: x, y, bw, bh = ann["bbox"] cx = (x + bw / 2.0) / w cy = (y + bh / 2.0) / h nw = bw / w nh = bh / h cls_id = cat_map[ann["category_id"]] out.write(f"{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n") coco_to_yolo_txt("annotations.json", "labels_all")

这段代码的关键点有三个。第一,cat_map 用 enumerate 重新编号,保证类别从 0 开始连续无跳号;第二,bbox 里的 x、y 是左上角坐标,必须加半宽半高才能得到中心点;第三,所有值都要除以图片宽高做归一化,否则 YOLO 会按像素坐标去理解,训练基本发散。txt 文件名与图片名完全一致,只换后缀,YOLO 靠同名规则匹配图片和标签。如果图片有宽高不一致的情况,脚本里用 JSON 里的 width 和 height 而不是实际读图,因此要保证 JSON 元信息可靠。

这个脚本只负责把标注转出来,不负责切分 train 和 val。实际操作时,我会在生成 txt 时判断 file_name 属于 train 还是 val,直接写到对应目录,避免转换完再搬文件。你也可以先全部生成到一个目录,再按图片归属移动,但先切分再转换是更省事、更不容易出错的顺序。

3.3 转换后可视化:不要直接进训练

转换完不能只看终端里“生成 1850 个 txt”就收工。我见过太多次这种翻车:脚本没报错,但框全偏了,因为有人把 x、y 当成了中心点,或者忘了归一化。所以我在训练前一定会用 OpenCV 把 txt 里的坐标画回图片,人工抽查十张左右。

import cv2 img = cv2.imread("images/train/IMG_1617_jpeg_jpg.rf.xxx.jpg") h, w = img.shape[:2] cls_names = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] with open("labels/train/IMG_1617_jpeg_jpg.rf.xxx.txt", "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() cls_id = int(parts[0]) cx, cy, nw, nh = map(float, parts[1:]) x1 = int((cx - nw / 2) * w) y1 = int((cy - nh / 2) * h) x2 = int((cx + nw / 2) * w) y2 = int((cy + nh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, cls_names[cls_id], (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imwrite("check.jpg", img)

这段代码把归一化中心点转回像素坐标画框,同时把类别名显示在框上方。注意 cls_names 的顺序必须和 data.yaml 的 names 顺序一致,否则你看图时看到的标签和训练时用的不是一套,排查起来非常折磨。检查时重点看两个问题:框是不是正好框住整张牌;牌的边缘和框边缘之间有没有大面积留白或切边。如果框偏了,十有八九是转换公式的问题;如果框没问题但类别名对不上,那是 cat_map 顺序问题,回去改映射,不要手动改 json。

4. YOLOv8 训练实战:超参数怎么设、98.7% 怎么复现

训练环节先说句实话:扑克牌不是难识别的目标,牌面平坦、纹理固定,光照影响主要在反光。用默认超参数就能跑到很高的 mAP,难的是把结果稳定复现并判断模型是否真的可信。下面这套流程以 Ultralytics YOLOv8 为例,其他框架只是配置写法不同。如果不知道怎么设参数,先照抄,再根据显存和任务调整。

4.1 data.yaml:类别顺序必须和转换脚本一致

YOLOv8 用 data.yaml 描述数据集位置和类别。路径写法我建议用绝对路径,相对路径会因为当前工作目录不同而找不到图片,这个错误非常常见。

path: /home/you/datasets/poker_dataset train: images/train val: images/val nc: 13 names: 0: A 1: "2" 2: "3" 3: "4" 4: "5" 5: "6" 6: "7" 7: "8" 8: "9" 9: "10" 10: J 11: Q 12: K

nc 是类别总数,names 列表的索引就是训练时用的类别编号。数字开头的类别名加引号,避免 YAML 解析时把 "2" 当作整数,减少不必要的类型问题。如果数据里有 Joker,按 JSON 中实际 categories 的顺序往后排,同时改 nc 和 names。最忌讳的是不看 JSON,凭标题想象是 13 类还是 14 类,然后直接改文件,最后发现训练时类别数和数据对不上。

4.2 训练命令:用预训练权重还是从头训

对 1850 张图的目标检测,我建议用 yolov8n.pt 或 yolov8s.pt 作为预训练权重,不要从随机初始化开始。原因很现实:牌面识别虽然是特定任务,但 COCO 预训练模型已经见过大量纹理、边缘和矩形目标,迁移过来收敛快得多,过拟合风险也低。如果手里 GPU 显存只有 6GB,yolov8n 是起步选择。

yolo detect train \ data=poker.yaml \ model=yolov8n.pt \ epochs=120 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ project=runs/poker \ name=exp1

参数的含义:epochs 120,第一次跑建议最少 100 轮;imgsz 640 是输入尺寸,如果之前统计框占比发现很多小目标,可以提升到 960,但显存和训练时间都会涨;batch 16 在 8GB 显存左右能跑 n 模型,显存不够就降到 8;lr0 保持默认 0.01,先不要调,等第一轮 loss 曲线出来再说;patience 20 表示验证集 20 轮不涨就提前停止,防止挂机跑废。project 和 name 只是把输出放到 runs/poker/exp1,方便多个实验对比。

如果只想快速验证数据转换对不对,可以把 epochs 降到 30,imgsz 降到 416,batch 降到 8,这样半小时内能看到 loss 是否下降。但最终要复现接近 98.7% 的指标,120 轮值得跑。模型选择上先 n 后 s,不要一上来就 yolov8x,1850 张图喂 x 级模型,很容易陷入过拟合。

4.3 验证输出:mAP50 与 mAP50-95 的差别

训练结束后重点看 runs/poker/exp1/weights/best.pt 和 results.png。results.png 里有 train loss、val loss、mAP50、mAP50-95 四条曲线。所谓 98.7% 识别率,在目标检测里通常对应 mAP50,也就是 IoU 阈值取 0.5 时的平均精度;如果标的是 mAP50-95,98.7 就是很高难度的数字,不太常见。扑克牌数据集框大、背景简单,mAP50 到 0.95 以上不稀奇,mAP50-95 一般会低 5 到 15 个点,这是正常现象。

yolo detect val \ model=runs/poker/exp1/weights/best.pt \ data=poker.yaml

验证命令会输出每类的 precision、recall、mAP50、mAP50-95。别只看总平均,打开 per-class 结果,重点看 10、K、Q 这类容易混淆的类。如果某个类 AP 明显低,基本是样本太少,或者该类在训练集和验证集切分时漏进验证集太多。J、Q、K 字形接近,置信度天然低一点,这是正常表现,不代表模型坏了。如果想让单类指标更好看,可以把混淆矩阵打出来,看看错误主要落在哪些相邻类别之间。

5. 避坑清单:识别率虚高的五个常见原因

这一章全是实操里踩过的坑。每条都按现象、原因、解决来写,你可以直接对照自己的流程排查。这些坑不会同时出现,但中一个就足以让 98.7% 变成一张好看的训练曲线,实际落地效果垮掉。

5.1 训练集与验证集重叠

现象:训练时 mAP 高得离谱,98.7 甚至 99,但换一批新图一测就垮。

原因:很多下载数据集本身做了数据增强,同源图片可能同时出现在 train 和 val 里。也有人图省事,把全部图片放进一个文件夹,data.yaml 里 train 和 val 指向同一个目录。模型等于把答案背下来了,指标自然好看,但没有任何泛化能力。

解决:严格把 images/train 和 images/val 分开,保证同一张原始图不会同时出现在两边。用固定随机种子切分,第一次切完就把图片名清单保存下来,后面新增样本时增量划分,不要重跑随机。如果数据集已经带 train/val 目录,先查重复文件名再开工。

5.2 类别编号错位

现象:A 被识别成 2,J 被识别成 10,单个类精度很低,但总 mAP 看起来还行。

原因:COCO JSON 的 category id 不一定是 0 开始,转换脚本如果没有重映射,或者 data.yaml 的 names 顺序和转换脚本不一致,就会出现系统性错位。这个 bug 最坑的地方是不报错,只是结果错,你看 loss 曲线甚至觉得一切正常。

解决:转换脚本里用 dict 做原始 id 到新编号的映射,输出 labels 后抽一张图,打开 txt 看第一个数字是多少,再和那张图实际内容对比。把可视化抽查脚本跑一遍,别拿文本文件自欺欺人。

5.3 bbox 坐标基准转换出错

现象:训练能跑,loss 降不下来,或者画出来的框整体平移、大小不对。

原因:把 COCO 的 [x, y, w, h] 当成 [cx, cy, w, h] 用了。COCO 约定 x、y 是左上角,YOLO 需要中心点。另一个常见错误是归一化用错了尺寸,某张图的 width、height 和实际读入图片不一致。

解决:转换公式固定为 cx=(x+w/2)/width,cy=(y+h/2)/height,宽高直接除以 width、height。如果怀疑尺寸不对,用 cv2.imread 打印实际 shape,再对比 JSON 里同一张图的 width、height。多分辨率混合的数据集,这一步尤其重要。

5.4 J、Q、K 互相混淆

现象:A、2 到 10 的识别率都不错,但 J、Q、K 三个类别互相误判。

原因:牌面字母字形接近,尤其是带装饰体的设计,低分辨率下差异更小。输入尺寸如果只有 416,网络根本没有足够像素去区分这些细节。

解决:imgsz 提高到 640,训练时开 mosaic 增强。如果还是混,可以考虑额外加一个裁剪牌面的分类模型做二次验证。另一个实际技巧是控制翻转增强,牌面方向乱了之后,字母镜像变形会让相近类别更难区分,必要时关掉垂直翻转。

5.5 数据量不足引发的过拟合

现象:epochs 从 120 加到 200,验证集 mAP 反而下降;训练 loss 一直降,验证 loss 在 20 轮后开始上升。

原因:1850 张数据里有些类别可能只有几十张,模型把训练图背熟之后,验证集表现自然变差。大模型在这种规模下更容易过拟合。

解决:先看 per-class 数量,少的类别考虑复制并做亮度、透视增强。模型用 yolov8n 起步,别一上来就大模型。早停 patience 设在 15 到 20,让训练在最优点附近停下来。权重衰减保持默认,不要为了刷训练集指标把正则降没。

6. 验证闭环:怎么证明模型真能复现那份数据

训练结束只是开始。我每次拿到这类数据集,都会用独立测试目录做最终验证,而不是只信 val。val 在训练过程中已经参与过早停判断,多多少少会影响模型选择。真正要确认的是,把模型放到没参与训练的新图里,效果还在不在。

6.1 独立测试集推理与阈值选择

先把 10% 到 15% 的图放到 test 子目录,跑推理:

yolo detect predict \ model=runs/poker/exp1/weights/best.pt \ source=test \ conf=0.30 \ imgsz=640 \ save_txt=True \ save_conf=True

conf 是置信度阈值,默认 0.25。如果检测结果里有大量置信度 0.3 到 0.5 的误检,就把阈值调到 0.4 或 0.5;如果真实牌被漏检,说明阈值太高或模型本身不够强,回头加数据或换模型。save_txt 会输出坐标和置信度,可以自己算更严谨的 mAP,而不是只看 predict 命令打印的几句话。

6.2 扩展思路:加花色、换牌背

这份数据覆盖的是 A 到 K 的牌面字母,如果要做完整扑克识别,通常还需要识别花色。我的经验是别急着把花色也塞进同一个检测器,先识别牌面字母,再在框内做花色四分类,两个模型各干各的,调试成本低得多。数据不够时还可以对牌面 bbox 做透视变换,保留字母形状,模拟不同拍摄角度。

从那以后,我每下载一个识别数据集,第一天只干三件事:解析 JSON、统计类别分布、随机抽十张图画框验证,全部确认没问题才开始训练。宁可第一天慢,也不要第三天返工。这份扑克牌数据集也一样,把基础工作做扎实,98.7% 才不只是一个宣传数字。希望帮到你。

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

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

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

立即咨询