YOLOv8扑克牌识别计数系统实战:从数据集标注到Web部署全流程

发布时间:2026/10/21 11:59:20
YOLOv8扑克牌识别计数系统实战:从数据集标注到Web部署全流程
去年年底我接了一个扑克牌识别与计数系统的活儿模型选型、数据准备、前后端链路全部走了一遍前后折腾了两周多。做完之后回头看这个项目真正花时间的不是训练模型本身而是数据集的构造和前后端串链路——YOLOv8跑起来很快真正让你头疼的是数据标得对不对、类别怎么设计、Web端推理延迟能不能压下来。这篇博文就把完整链路拆开讲一遍从数据集标注规范、训练脚本设计到Web前端展示每一步都给出可直接照抄的方案适合打算做目标检测实战项目、或者正在做类似卡牌识别场景的同学参考。1. 为什么扑克牌识别比看起来难先想清楚你要解决的是哪一类问题很多人一看“扑克牌识别”就觉得简单——52张牌再加大王小王目标检测嘛标好数据训练就行。但实际做下来会发现扑克牌识别是目标检测里很典型的“多类别细粒度分类”问题它的难点并不在检测框而在类别的区分度。一张扑克牌上有两个信息数字从A到K和花色黑桃、红心、梅花、方块。同一张牌上数字和花色是空间上分离的标准牌面左上角和右下角有缩小的索引中间是大的数字和花色图案。你在做检测时如果直接把“红心A”作为一个类别那数据集要做52个类别但如果按“数字花色”拆开识别又要做两步推理逻辑复杂一截。我最后采用的方式是把左上角缩索引区域作为检测目标以“数字花色”组合成类别标签。比如左上角显示的是红色的“A”和红心符号就标注为“A_hearts”。这样虽然类别数多但一个框就能拿到完整牌面信息推理链路简单对Web端部署友好。1.1 识别场景决定你的标注粒度动手标注之前先确认你的使用场景。如果是做德州扑克辅助分析通常只需要识别玩家手里的两张牌牌面大、遮挡少用52类没问题。如果是做斗地主、掼蛋的牌型统计需要识别整副牌的明牌区域牌可能叠在一起、互相遮挡这时候标注粒度就要考虑“是否加入半遮挡样本”。我做的是通用扑克牌计数场景一张牌一个框所以类别粒度就是“数字花色”一共52个类别大小王单独成类共54类。类别设计还有一个容易踩的坑不要用纯数字标签。比如把黑桃A标注成“1”红心A也标注成“1”模型训练时类别冲突根本收敛不了。每个类别必须唯一编码我用了“s_A”“h_A”“c_A”“d_A”这种带前缀的命名方式后续写代码做映射也方便。1.2 数据规模起步多少张才够扑克牌检测和行人检测这类任务不同它的类间差异小类内差异也小——所有黑桃A长得几乎一样区别只在拍摄角度、光线、形变。这意味着你不需要海量数据但需要足够的角度和环境覆盖。我实际使用的数据集规模是每张牌约250张标注图片整体接近13500张图片每张图片上平均出现4~8张牌。这个量级在YOLOv8上训练大概60个epoch就能稳定收敛到mAP50在0.95以上。如果你的数据集是纯牌面大图、没有复杂背景那每类50张也能跑出不错的效果但如果要检测桌面散落的牌、存在遮挡和反光建议每类至少120张起步。数据规模不够的时候优先补“难样本”而不是单纯堆量。2. 数据集标注与增强一个规整的数据集比模型结构更影响上线效果很多初学者把精力全放在模型结构上结果训练出来的模型在测试集上表现不错一上真实场景就各种误检。我的经验是数据集的组织方式和标注一致性决定了模型的上限。YOLOv8本身是成熟的检测模型该有的能力它都有你喂给它什么数据它就学什么。2.1 标注规范统一框选区域扑克牌检测最容易出现的标注不统一问题是对“检测框范围”的认知不一致。有人框整张牌有人框左上角索引还有人把整张牌的中心大花色也框进去。混在一起训练模型会无所适从。我推荐的规范是框选左上角索引区域也就是数字和花色符号所在的小矩形区域框的边缘贴着内容边缘收紧。这么做有几个好处一张牌无论怎么旋转左上角索引始终可见被遮挡的概率最低索引区域内容紧凑类别特征集中模型容易学后续做计数时可以按框的中心点做去重逻辑简单标注工具我用的是LabelImg输出YOLO格式的txt文件。如果样本量大可以试试半自动标注——先用一个初步模型做预标注再人工修正效率至少翻一倍。2.2 类别编码与目录结构我在项目里按下面的方式组织数据集这个结构可以直接让YOLOv8的训练脚本识别dataset/ ├── train/ │ ├── images/ │ └── labels/ ├── val/ │ ├── images/ │ └── labels/ ├── test/ │ ├── images/ │ └── labels/ └── data.yamldata.yaml里写清楚类别数量nc和类别名称names。顺序要和标注文件里的class id一一对应这一点非常关键。我见过不少人在这里出错——names的顺序和标注数据不对齐训练的时候cosmetic loss不降还死活找不到原因。2.3 数据增强不要无脑全套叠加YOLOv8自带马赛克增强、随机仿射变换、HSV扰动等。很多人图省事全开结果模型在真实场景表现反而差。我的配置习惯是增强项配置说明mosaic1.0开启有利于小目标检测但最后几个epoch建议关掉mixup0.2轻微开启防止过拟合hsv_h0.015轻微色相扰动扑克牌颜色很关键不宜过大hsv_s0.7饱和度扰动适应不同光线hsv_v0.4亮度扰动适应阴影fliplr0.5水平翻转degrees10旋转10度以内牌的角度变化通常不大特别是HSV扰动很多人会调大但对扑克牌来说红心和方块的颜色区分本身就是重要特征饱和度和色相扰动太大会导致模型对颜色不敏感红心和方块容易混。我最终的配置里hsv_h只有0.015这个细节对花色分类准确率影响非常明显。2.4 按牌型场景补数据标注完基础数据集之后会遇到一个典型问题模型对单张牌识别很好但真实场景里牌是叠在一起的。我第二次迭代的时候专门补了一批“扇形展开的牌堆”图片牌与牌之间有局部遮挡。这批数据加起来不到2000张但让模型在遮挡场景下的mAP提升了将近8个点。补数据的时候有一个原则新场景的图片比例不要超过总数据集的10%~15%。太多会导致模型对单一场景过拟合泛化能力下降。3. 一键训练脚本设计从环境配置到模型收敛的完整流程“一键训练”听起来像噱头但它背后的逻辑是对的训练过程应该是可重复、可复现的。我的做法是把环境准备、数据校验、参数配置、训练启动、结果评估全部封装成一个入口脚本跑完一次全流程环境就固化了下次换机器也能快速恢复。3.1 环境准备想清楚用CPU还是GPU先说结论扑克牌识别这种任务训练阶段建议用GPU推理阶段CPU也能扛但要看视频流还是单张图片。训练环境我用的依赖版本是Python 3.9、PyTorch 2.0.1、CUDA 11.8、ultralytics 8.0.36。安装命令很常规但有一个坑CUDA和PyTorch的版本必须严格匹配。很多人直接pip install torch装出来的PyTorch是CPU版本训练慢到怀疑人生。正确做法是先确认NVIDIA驱动支持的CUDA版本再去PyTorch官网选对应的安装命令。3.2 数据校验脚本训练前先查一遍在正式训练前我习惯先跑一遍数据集校验脚本主要检查这几项每张图片是否都有对应的标注文件标注文件里的class id是否在0到53之间标注框是否有为空、越界的情况训练集和验证集的类别分布是否均匀这里分享一个具体的校验代码片段直接放入数据检查脚本中即可import os from pathlib import Path def check_dataset(images_dir, labels_dir, num_classes54): img_files list(Path(images_dir).glob(*.jpg)) issue_count 0 for img_file in img_files: label_file Path(labels_dir) / (img_file.stem .txt) if not label_file.exists(): print(fMISSING LABEL: {img_file}) issue_count 1 continue with open(label_file) as f: lines f.readlines() for line in lines: parts line.strip().split() class_id int(parts[0]) if class_id num_classes: print(fINVALID CLASS {class_id} in {label_file}) issue_count 1 bbox list(map(float, parts[1:])) if not all(0 v 1 for v in bbox): print(fOUT OF BOUND BOX: {label_file}, {bbox}) issue_count 1 print(fChecked {len(img_files)} images, found {issue_count} issues.)这个脚本看起来简单但能避免训练到一半才发现数据问题的尴尬。尤其是标注框越界训练时可能会产生梯度异常模型完全无法收敛。3.3 训练脚本的参数设计我的训练命令大致长这样yolo detect train \ modelyolov8s.pt \ datadataset/data.yaml \ epochs100 \ imgsz640 \ batch16 \ device0 \ patience15 \ cos_lrTrue \ close_mosaic10 \ projectrun_results \ namepoker_train \ seed42几个关键参数说明一下modelyolov8s.pt我选的是s版本平衡了速度和精度。如果对精度要求更高可以上m但对Web端实时推理来说s是更现实的选择。imgsz640标准分辨率。扑克牌上的索引区域不大不需要用1280这种大分辨率640足够。close_mosaic10最后10个epoch关闭马赛克增强。这是让最终收敛更稳定的关键技巧马赛克增强虽然能提升模型的鲁棒性但最后阶段不关闭的话模型的精度会在一个区间内来回震荡。patience1515个epoch内没有提升就提前停止。我实际训练时大约到60个epoch就收敛了提前停止省时间。3.4 训练过程的监控技巧训练过程中不要只盯着一行loss看。我习惯同时关注两个指标训练集和验证集的loss曲线是否同步下降——如果训练loss降、验证loss不降说明过拟合了要加正则化或者减少训练轮次每个类别的precision和recall——YOLO训练输出里会有per-class的数据扑克牌识别最常见的现象是红心A和方块A的precision偏低说明颜色特征没学好训练结束后我还会额外跑一次混淆矩阵分析专门看哪些类别之间在互相混。这一步对后续调优效果立竿见影。4. Web端实时展示推理服务、接口设计与前端页面系统要真正可用训练好的模型还得通过Web前端的形态展示出来。这里说的“Web前端展示”最常见的需求是打开浏览器页面上传一张扑克牌图片或者接入摄像头视频流页面实时显示检测结果和计数统计。4.1 后端服务框架选型我用的是FastAPI原因是它的异步特性和性能表现很适合做实时推理服务。为了不阻塞Web服务我把模型推理放进异步任务里并使用线程池执行这样当多个请求同时进来时不需要排队等待模型处理。初始化模型的时候有一个值得注意的问题每次请求都重新加载模型速度会极慢正确做法是在应用启动时加载一次后续复用同一个模型实例。下面是我的初始化方式from fastapi import FastAPI, UploadFile, File from ultralytics import YOLO import numpy as np import cv2 app FastAPI() model YOLO(best.pt) # 应用启动时加载一次 app.post(/detect) async def detect(file: UploadFile File(...)): content await file.read() img_array np.frombuffer(content, dtypenp.uint8) img cv2.imdecode(img_array, cv2.IMREAD_COLOR) results predict_sync(img) return format_result(results) def predict_sync(img): # 在线程池中执行推理避免阻塞事件循环 return model.predict(img, conf0.45, iou0.5)4.2 检测结果的归一化输出前端拿到检测结果之后通常需要渲染检测框和类别标签所以接口返回的数据结构要设计好。我的约定是返回一个列表每个元素包含box归一化的x、y、width、height、class_name比如“A_hearts”和confidence。前端拿到后按百分比换算成像素坐标即可。计数逻辑也放在后端处理统计当前画面中出现过的不同牌面类别数量、总牌数。如果检测到同一张牌被多次框选也就是多个框的中心点距离很近、类别相同就按置信度最高的框保留其余丢弃。4.3 前端页面的核心实现前端页面我用原生HTML加Canvas实现没有引入重量级框架。页面分为左右两栏左侧是图片预览区或者视频播放区右侧是实时检测结果列表包括每张牌的类别、置信度和当前画面总牌数。核心代码段是前端轮询请求检测结果然后把检测框画到Canvas上async function fetchDetectionResult() { const formData new FormData(); const fileInput document.getElementById(imageInput); formData.append(file, fileInput.files[0]); const response await fetch(/detect, { method: POST, body: formData }); const result await response.json(); drawBoxes(result.detections); updateCountPanel(result.count); } function drawBoxes(detections) { const canvas document.getElementById(resultCanvas); const ctx canvas.getContext(2d); detections.forEach(d { ctx.strokeStyle #00ff00; ctx.lineWidth 2; ctx.strokeRect(d.box.x * canvas.width, d.box.y * canvas.height, d.box.width * canvas.width, d.box.height * canvas.height); ctx.fillStyle #00ff00; ctx.font 14px sans-serif; ctx.fillText(${d.class_name} ${Math.round(d.confidence * 100)}%, d.box.x * canvas.width, d.box.y * canvas.height - 5); }); }页面清爽逻辑直观整套代码量不大但麻雀虽小五脏俱全。4.4 摄像头视频流的处理方案如果要在浏览器里接入摄像头实时识别有两个方案值得对比方案一是通过浏览器getUserMedia直接获取视频流前端每隔几百毫秒截图一张发送给后端做检测再把结果绘制在Canvas上。这个方案实现简单但延迟稍高适合演示场景。方案二是后端拉取视频流比如RTSP用OpenCV逐帧读取检测后把结果推给前端。这个方案延迟低但需要额外维护一个视频流处理线程。我实测下来方案一在本地局域网内延迟大约200~300毫秒日常演示完全够用。5. 实测效果与避坑记录遮挡、反光、花色误判与重复计数训练好了、页面也搭好了真正的考验才开始——上线后的实测才是项目的试金石。这一节把我踩过的坑和对应的排查思路完整列出来。5.1 场景一牌面反光导致花色误判第一次实测的时候我用了一张带塑封膜的新扑克牌灯光打在桌面上牌面有明显反光。模型把几张红心牌识别成了方块牌置信度还不低。排查思路是反光区域会让牌面的红色色块过曝红心的形状特征被破坏模型只能依赖颜色判断而红心和方块在低分辨率下颜色相近自然容易混。解决办法有两步第一步是训练数据里加入反光样本第二步是推理前对图片做一次轻度的高光抑制也就是把过曝区域的像素值压缩回正常范围。我用的是OpenCV的形态学膨胀后取反光mask再对mask区域做平滑暗化实测花色识别准确率提升了6个点左右。5.2 场景二牌与牌互相遮挡扇形牌堆是最难搞的情况。靠外侧的牌还好靠内侧的牌左上角索引可能被旁边一张牌压住一部分。模型一开始的检测框会漂移框住半个索引加一部分牌面花纹类别判断自然出错。后来我在推理端加了一个后处理规则倾斜角的牌堆优先保留每个类别中置信度最高的框并且通过IoU抑制掉同类别重复框。这一步让遮挡场景的检测稳定性提升明显。另外训练阶段的旋转增强也是针对这个场景设计的。5.3 场景三同一张牌被重复计数用视频流做实时计数时同一个位置的两张牌出现在连续帧里检测模型正常输出两个框导致同一张牌被重复计数。这个问题本质上是计数逻辑的问题不是检测模型的问题。我的处理思路是构建一个简易的目标追踪器记录每个检测框的中心点和类别当两个中心点距离小于一定阈值且类别相同、置信度接近时将它们视为同一目标的重复检测仅保留一次。用这种方式做帧间去重实测在摄像头小幅移动的场景下计数误差稳定控制在2%以内。5.4 训练时的“教训”复盘这个项目里我犯过一次比较严重的错误训练数据里不小心混入了几十张网络图片那些图片的牌面风格和我的拍摄数据集不一样结果模型在验证集上的表现很好但一旦面对真实场景的牌面误检率明显上升。排查了半天最后发现是数据来源不一致导致域偏移。从那以后我定了两条规矩第一训练数据尽可能完全来自目标使用场景的相同拍摄方式第二实在要混外部数据混入比例必须控制在5%以内并且在验证集里单独加入真实场景的样本用来监控域偏移。这个教训做目标检测的人都值得听一听。6. 改进点不是为了堆数量按需选型的理性分析标题里的“70全套改进创新点”让人看着心动但我实际操作下来的真实感受是改进点必须按问题选不是越多越好。我在这个项目的具体推进过程中只选了三类改造每一类都对应一个真实问题。6.1 针对小目标的注意力模块扑克牌左上角索引在640分辨率下只占几十个像素。如果你的场景是远距离拍牌桌索引区域更小模型可能会丢失目标。这时候可以考虑在YOLOv8的backbone后段引入轻量级注意力模块让模型更关注小目标区域。我试过在C2f模块后面插入注意力结构检测小目标的能力确实有一定提升但推理时间也增加了大约15%。6.2 针对颜色特征的通道注意力花色误判的场景里一个有效的改进是让模型对颜色通道更敏感。这个可以通过调整损失函数针对易混淆类别加大分类权重来实现。这比改模型结构简单成本低效果也直接。我在训练配置里试着对红心、方块两类提高loss权重训练过程中二者的召回率提升非常明显。6.3 针对推理速度的模型剪枝如果对Web端响应时间有严格要求单纯堆改进结构反而有害。我试过一种简单思路训练好大模型之后用蒸馏的方式让小模型学习大模型的输出小模型可以达到接近大模型的精度同时推理速度提升一倍。这种“大模型教小模型”的方法比直接用小模型训出来的效果好不少。6.4 改进点的验收标准面对大量改进点我给自己定了一个验收清单改进点验收标准注意力模块在相同epoch数下mAP提升超过1%推理时间增加不超过10%损失函数调整易混淆类别的召回率提升超过3%模型剪枝推理时间缩短超过20%mAP下降不超过0.5%任何复杂度增加必须有对应的实测性能提升而不是只看训练集上的表现如果一项改进无法通过验收那就果断回退。这个标准帮助我在实际实验中少走了很多弯路也保证了最终交付的模型真正适合部署。7. 项目源码结构与部署发布一条龙的最后环节整套项目做完之后我的源码目录结构如下基本做到“拿到就能跑”poker_recognition_project/ ├── dataset/ ├── train_script.sh ├── validate_data.py ├── train.py ├── app/ │ ├── main.py │ ├── model_loader.py │ └── static/ │ ├── index.html │ ├── app.js │ └── style.css ├── best.pt └── requirements.txttrain_script.sh是入口脚本执行顺序是数据校验、环境检查、启动训练、导出best.pt。整个流程可以在文本输出里实时看到进度。部署时先把best.pt放到指定目录再启动FastAPI服务浏览器打开http://localhost:8000就能看到检测页面。7.1 模型导出与部署优化训练完成之后我习惯把模型导出为TorchScript格式这样部署时不需要依赖完整的ultralytics环境推理速度也会略微提升。YOLOv8提供了非常简单的导出接口from ultralytics import YOLO model YOLO(best.pt) model.export(formattorchscript, imgsz640)导出的模型文件大小大约在20MB左右在CPU上推理一张640分辨率的图片大约耗时150到200毫秒在GPU上能跑到20毫秒以内。7.2 数据集与模型的版本管理最后提一个长期维护的建议每次迭代数据集或模型后都用带版本的目录保存比如dataset_v1、dataset_v2、best_v1.pt、best_v2.pt。我在这个项目里因为迭代了几版数据集中间有两次需要回退到旧版模型。如果不做版本管理想找回之前的模型时就会很被动。一个小技巧是每次数据集迭代后把data.yaml和标注工具的版本信息记录在README里。你过一个月再回头看会发现这个习惯救了你不少时间。7.3 从“能检测”到“能部署”的关键一步很多教程止步于“模型训练完了给出一个mAP数字”。但我个人更看重的是放下dataset之后模型在真实拍摄环境下是否还能输出稳定结果。我建议训练结束后先跑一遍你自己的真实照片不要急着看mAP。真实场景才是决定项目交付质量的最终考场。