简介:面向目标检测入门与实战的扑克牌标注数据集,专门用于识别queen、ten、nine、king、jack、ace六种常见扑克牌面。数据包含363张真实场景图片,每张均配有labelimg生成的Pascal VOC格式xml标注,共726个文件,压缩包大小36.62MB,目录中图片与xml一一对应。标注文件详细记录了每张牌的位置与类别信息,可直接用于YOLO、SSD、Faster R-CNN等主流检测框架的数据加载,省去手动标注的重复劳动。资源整体结构简洁,使用时可快速切分训练集与验证集,适合目标检测初学者了解数据集制作过程,也适合算法工程师在模型对比、消融实验或部署验证中作为基准数据。目前已有395人学习下载,无论是课堂实验、毕业设计还是个人项目,都能帮助使用者高效打通从数据准备、标注解析到模型训练评估的完整链路,快速积累目标检测实战经验。
1. 扑克牌目标识别数据集:363 张标注图,先把六类牌面跑起来
做目标检测的都知道,模型好调、数据难搞。这套扑克牌目标识别数据集一共 363 张实拍图片,标注类别只有六个:nine、ten、jack、queen、king、ace,也就是从 9 到 A 的六种牌面,没有 2 到 8,一看就是从特定牌局规则里裁剪出来的。标注工具是 LabelImg,标签文件是 Pascal VOC 格式的 XML,YOLO 系、SSD 系、Faster R-CNN 都能直接接。它适合三类人:想跑通「数据集到训练」全流程的新手、做棋牌类应用需要现成标注数据的开发者、以及要在小目标识别场景里快速验证模型效果的算法工程师。接下来我会把数据集结构、VOC 转 YOLO 的转换脚本、YOLOv8 训练参数和踩过的坑一次说清楚。
2. 数据集结构拆解:VOC XML 标注文件怎么读、类分布怎么看
2.1 目录组织与命名规则
从资源清单看,图片是 cam_image26.jpg、cam_image14.jpg 这种相机连拍风格的命名,按编号能大致推断采集顺序。这种命名的好处是排序稳定,脚本处理方便;但隐患也很明显——编号不连续,cam_image7.jpg 后面直接跳到 cam_image11.jpg,中间缺了帧。后续要补拍对齐的话,得先做一次全量清点,否则拷文件时很容易漏。
最常见的整理方式是一个数据集拆两个目录:JPEGImages 放原图,Annotations 放同名 XML。VOC 的 XML 核心字段只有三个部分:size 记录宽高,object 列表记录每个目标的 name 和 bndbox 坐标。先拿一个 XML 直接解析看看结构:
import xml.etree.ElementTree as ET tree = ET.parse("Annotations/cam_image26.xml") root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) print(f"图片尺寸: {w}x{h}") for obj in root.findall("object"): name = obj.find("name").text box = obj.find("bndbox") xmin = int(float(box.find("xmin").text)) ymin = int(float(box.find("ymin").text)) xmax = int(float(box.find("xmax").text)) ymax = int(float(box.find("ymax").text)) print(f"{name}: ({xmin},{ymin}) -> ({xmax},{ymax}), " f"宽 {xmax-xmin}, 高 {ymax-ymin}")VOC 的坐标是像素绝对值,左上角为原点,x 向右、y 向下。这段代码最大价值不是看单张图,而是批量统计——把 363 个 XML 全跑一遍,算出标注框的平均宽高分布,训练前就能发现数据里有没有一批惊人大小的目标。不同采集批次的对焦距离不一样,框的尺寸方差会非常大,这个统计结果直接影响你后面 imgsz 怎么选。
2.2 六类牌面的标注边界
类别集合是 queen、ten、nine、king、jack、ace,没有 2 到 8,说明这套数据是从特定牌局规则里裁剪出来的,常见于 Blackjack 记牌或扑克牌排序场景。六个类不算多,但牌面之间长得太像才是真问题:nine 和 king 都是大面积图案,jack 和 queen 都是人物肖像,模型很容易混淆,所以标注质量比类别数量更关键。
用 LabelImg 标牌面,核心原则是「框住牌面,不框桌面」。矩形框四边尽量贴住牌的边缘,留 2~3 像素以内的余量;牌有倾斜时 LabelImg 只能画水平矩形,框里难免混入背景,倾斜超过 30° 的牌宁可漏标也不要硬标,否则模型学进去的是「牌+背景」的混合特征。两张牌重叠时只标完全可见的那张,重叠严重的直接跳过,这种图进了训练集就是噪音。
注意:如果资源里 JPG 和 XML 混在同一层目录,先按扩展名分好目录再跑校验,否则 glob 会把两个类型混在一起,看起来对不上账其实是被目录结构骗了。
2.3 标完先做类分布统计
数据到手第一步不是训练,是对账。除了检查文件一一对应,还要统计每类的样本量,看有没有类别严重失衡:
from collections import Counter from pathlib import Path import xml.etree.ElementTree as ET counter = Counter() for xml_path in Path("Annotations").glob("*.xml"): root = ET.parse(xml_path).getroot() for obj in root.findall("object"): counter[obj.find("name").text] += 1 for name, cnt in counter.most_common(): print(f"{name:<5} {cnt}")如果某个类别只有个位数样本,这个类在训练里基本等于不存在。363 张图均摊到六个类,每类正常应该有 50 个以上的目标框,低于 20 的类就要考虑是不是原始采集就没拍够。统计完类分布再决定要不要给样本少的类做裁剪增强,而不是盲目加训练轮数。
3. 把 VOC 转成 YOLO 格式:转换脚本与四个边界坑
3.1 为什么必须转、类别编号怎么定
Pascal VOC 和 YOLO 的坐标体系完全是两回事:VOC 存左上右下两个角点,YOLO 存归一化后的中心点坐标加宽高;VOC 的类别是字符串,YOLO 标签文件第一列必须是整数类别 id。所以转换不是改个扩展名,是真正的格式迁移。
这里最容易翻车的四个点分别是:类别编号顺序、坐标归一化、越界截断和过小框处理。先说第一个:类别编号顺序。不要按字母序排,要按你后续业务逻辑顺手的顺序固定。我要做牌面点数排序,就把类别按点数降序固定为 ace=0、king=1、queen=2、jack=3、ten=4、nine=5。这样拿到的类别 id 直接对应点数权重,后处理不用再映射一遍。把类别顺序写进一个 classes.txt,转换脚本和训练配置都从它读,保证全链路一致。
3.2 转换脚本与参数说明
import xml.etree.ElementTree as ET from pathlib import Path CLASSES = ["ace", "king", "queen", "jack", "ten", "nine"] def voc_to_yolo(xml_path: Path) -> str: root = ET.parse(xml_path).getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in CLASSES: print(f"[warn] {xml_path.name}: 未识别类别 {name}") continue cls_id = CLASSES.index(name) box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) x_center = (xmin + xmax) / 2 / w y_center = (ymin + ymax) / 2 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h # 防止标注越界导致归一化坐标超出 [0,1] bw = min(bw, 1.0) bh = min(bh, 1.0) if bw < 0.01 or bh < 0.01: print(f"[warn] {xml_path.name}: 框过小 {name}") continue lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}") return "\n".join(lines) for xml_path in Path("Annotations").glob("*.xml"): out = voc_to_yolo(xml_path) if out: txt_path = Path("labels") / f"{xml_path.stem}.txt" txt_path.write_text(out)几个参数说清楚:中心点坐标必须除以图片宽高做归一化,不除的话 YOLO 拿原像素值去算,出来的全是几百上千的大数,模型直接跑飞;宽高做 min 截断,是防 LabelImg 标到图片边缘外的情况;框宽高小于图片 1% 的目标直接丢弃,这种目标下采样几层之后就剩一两个像素,训练时就是噪声。
提示:转换后随机打开几个 txt 抽查,第一列应该是 0~5 的整数,后四列应该都在 [0,1] 区间内,任何一个超出区间都要回头查原始 XML。
3.3 训练集和验证集划分
363 张图不算多,按比例随机分就行。但随机之前先固定随机种子,保证不同人复现结果一致:
import random from pathlib import Path random.seed(42) imgs = sorted(Path("JPEGImages").glob("*.jpg")) random.shuffle(imgs) split = int(len(imgs) * 0.8) train_imgs = imgs[:split] val_imgs = imgs[split:] print(f"train: {len(train_imgs)}, val: {len(val_imgs)}")把 80% 的图片和同名标签拷到 images/train 和 labels/train,剩下 20% 放 images/val 和 labels/val。注意 labels 目录结构必须和 images 完全对应,YOLO 是拿图片名去查同名 txt,任何一层路径没对上都会报 no labels found。拷完再跑一遍对账脚本,确认两边数量一致再进训练。
4. 用 YOLOv8 训练自己的数据集:data.yaml、训练参数与结果解读
4.1 data.yaml 与目录整理
YOLOv8 训练的第一步是写 data.yaml,它告诉模型数据在哪、有哪些类别。目录按官方惯例排:cards/images/{train,val} 和 cards/labels/{train,val}。有一个很多新手翻车的点:path 字段写相对路径,配合 yaml 里其他相对目录,一旦移动项目就找不到文件。我一般直接写绝对路径,省得一换机器就排查半天。
path: D:/workspace/cards train: images/train val: images/val names: 0: ace 1: king 2: queen 3: jack 4: ten 5: ninenames 的顺序必须和第三章转换脚本的 CLASSES 完全一致,差一个位置,训练出来的类别就是错位的。这种错位在混淆矩阵里很难直接发现,因为模型本身没有错,错的是你的标注映射——这玩意就是黑匣子,最坑。我习惯把 classes.txt 和 data.yaml 的 names 用同一个 Python 脚本生成,从源头杜绝手抄错。
4.2 训练命令与参数说明
自己的数据集,模型选型我推荐从 yolov8s 起步。n 模型太轻,对牌面纹理区分度不够;m 及以上在这个数据规模下又容易欠拟合。命令直接跑:
yolo detect train \ data=cards.yaml \ model=yolov8s.pt \ imgsz=640 \ epochs=80 \ batch=16 \ project=runs/detect \ name=cards参数逐个说:model=yolov8s.pt 是从 COCO 预训练权重开始微调,363 张图的规模不迁移学习基本训不动;imgsz=640 是训练输入尺寸,牌在画面里普遍偏小就提到 800,代价是训练速度变慢;batch=16 看显存决定,8G 显存没问题,4G 就降到 8;epochs=80 看起来不多,但数据量小,60 轮左右 loss 就平了,设 80 是留余量。
训练完看 weights/best.pt 和 last.pt。best 是按验证集 mAP 最优保存的,部署只用它;last 是最后一步的权重,一般当备份。这两个别搞反,用 last 部署的人最后都回来查过日志。
4.3 训练结果的解读方式
训练日志里最值得看的不是 loss,而是 confusion matrix 和 P/R 曲线。六类都有正样本,直接看各类别的 Precision 和 Recall 比看均值更直观。最容易出现的场景是 queen 和 king 互相串——两者都是人物构图,印刷小差别,模型分不清是正常的。
遇到混淆偏高,优先补这两个类的样本数量,而不是盲目加 epochs。加 epochs 只能把模型在已有数据上压得更死,对类别混淆基本没用。数据增强上,这个项目可以做轻度 HSV 抖动和上下翻转,但左右翻转要小心:牌面文字是方向敏感的,横拍场景下左右翻转会把 nine 和 ten 的方向特征搞乱,模型学到正反两个方向,推理时反而迟疑。除非你的业务里牌本来就会倒着出现,否则别开。
5. 标注与训练避坑:五条血泪经验,先看再动手
5.1 图片和标注文件数量对不上
现象:转换脚本跑完,labels 目录出现一行 no labels found,或者训练时报 found 300 labels but 363 images。
原因:数据是多批次采集合并的,有人用 LabelImg 标完没保存,或者拷贝时漏了部分 XML。这套数据的命名就有端倪,cam_image7 和 cam_image11 之间编号不连续,说明混入了多批素材。
解决:转换前先跑第二章的校验脚本,两个集合的差集为空再继续。缺 XML 的图直接删掉,别心软留着,训练时它会拖低有效样本比例,还容易让 batch 计算出错。
5.2 类别大小写不一致导致类别丢失
现象:日志里出现 [warn] 未识别类别 Ten,训练出来只有五个类在跑,mAP 还显示异常高。
原因:标注时有人手滑写成了首字母大写 Ten,和标准小写 ten 不一致。XML 里 name 是自由文本,LabelImg 不限制输入,这种错误非常隐蔽,你不去解析 XML 根本发现不了。
解决:转换脚本里做一次 name.lower() 归一化,再和 CLASSES 比对。如果 lower 之后仍不在集合里,打印警告并把该目标跳过,绝不要在转换脚本里静默吞掉未知类别。整理完重新统计类分布,确认六个类都在。
5.3 标注框过宽、包进桌面背景
现象:训练 loss 降得很低,但推理时同一张牌连续输出两个重叠框,或者框明显比牌面大一截。
原因:标注时为了保险把框画大了一圈,把牌桌纹理和周围牌边都包进去了。模型学到的特征是「牌+背景」,而不是牌本身。
解决:回看标注质量,把框超出牌面边缘 10% 以上的样本重新标。363 张图逐张检查完全来得及,这一个小时花得非常值。宁可少标,不要标脏。
5.4 小目标识别漏检:mAP 高、实际部署漏小牌
现象:验证集 mAP 到 0.85,但推理时画面远端的小牌完全没框出来。
原因:标注框面积占比过小的目标,在缩放到 640 之后被压成几个像素,YOLOv8 的下采样倍数决定了小目标特征保留不住。nine 和 ten 这类数字牌在远处就是一小团纹理,最容易漏。
解决:训练 imgsz 从 640 提到 800,小目标特征会更充分;推理 conf 阈值从默认 0.25 降到 0.15。对棋牌场景来说漏检比误检更致命,多给几个低分框交给后处理去过滤,比模型直接吞掉强得多。我还加过 SAHI 切图方案,把大图切块推理,小目标漏检明显下降,代价是推理耗时翻倍,业务允许的话值得试。
5.5 验证集划分泄漏:同批次连续帧被拆到两边
现象:训练集 mAP 接近 1.0,验证集表现也好,但换一台新设备拍摄就崩,漏检明显增多。
原因:这套图是连续拍摄的,相邻帧之间的牌面布局几乎相同。随机切分时,同一局牌的帧同时出现在训练和验证里,模型其实在背答案而不是在学泛化。
解决:按 cam 编号或采集批次分组,保证同一批次的帧只进训练或只进验证。363 张图按文件名前缀手工分桶就够,分完再校验两边没有相同前缀的图。之后每次新数据进来,分区逻辑考虑数据来源是第一优先,不是懒得分就随机切。
6. 进阶用法:把检测结果按牌面点数排序输出
检测跑通之后,下一步就是接业务逻辑。棋牌类应用最常见的下游需求是把画面里的牌按点数排序输出,再做去重。点数排序可以直接用训练时的类别 id 完成,前提是你在第三章已经把类别按点数降序编号:
from ultralytics import YOLO model = YOLO("runs/detect/cards/weights/best.pt") results = model("test/cam_image12.jpg", conf=0.15)[0] # 类别 id 按 0=ace, 5=nine 编号,直接映射点数权重 RANK = {0: 14, 1: 13, 2: 12, 3: 11, 4: 10, 5: 9} cards = [] for box in results.boxes: cls = int(box.cls[0]) conf = float(box.conf[0]) x = float(box.xyxy[0][0]) cards.append({"name": model.names[cls], "conf": conf, "x": x}) # 按点数降序,同点数按牌桌左右位置排 cards.sort(key=lambda c: (-RANK[c["name"]], c["x"])) for c in cards: print(f"{c['name']:<6} conf={c['conf']:.2f} x={c['x']:.0f}")RANK 映射是唯一要手写的部分,训练时改了类别顺序这里要同步。sort 的 key 用负号实现降序,x 作为次级排序,保证同点数的两张牌按从左到右输出,符合牌局阅读顺序。conf 阈值设 0.15 不是失误,实测小目标牌在 0.2 以下就会被滤掉,宁可多输出一个低分框,也不要让业务层看不到牌。
验证这套逻辑时别只看单张图,把整个数据集跑一遍,逐张输出预测到的牌数量,和 XML 里手工统计的 object 数量对比,能快速定位漏检集中出现在哪些构图。血泪教训是:我最早把验证脚本写成只打印汇总 mAP,漏了远处一张牌根本看不出来,后来改成逐图比对数量,才把特征不清的角牌全捞了回来。从那以后每次接新数据集,我都强制先跑一遍「预测数量 vs 标注数量」的对账脚本,再去做任何调参。这个习惯省下的时间远比写脚本的半小时多,希望帮到你。
本文还有配套的精品资源,点击获取