做图像分类这几年,我接过不少"看起来简单、实际全是坑"的需求,扑克牌识别就是其中一个。表面上看,54 张牌、图案规整、颜色分明,随便套个卷积神经网络似乎就能到 99%。真正上手之后你会发现,光照一变、牌一歪、反光一起来,准确率能从 98% 掉到 70%,而且 6 和 9、黑桃和梅花这些天生"长得像"的类别,永远在混淆矩阵的对角线外打转。这篇内容就是围绕「基于卷积神经网络对扑克识别」这个方向,把数据集怎么采、类别怎么定、模型怎么搭、训练怎么调、上线后怎么排查问题,一整套流程拆开讲清楚。文中会给到可直接复用的代码骨架和参数取值,也会把我在实际项目里踩过的坑原样摆出来。适合两类人看:一类是想找一个完整计算机视觉入门项目练手的同学,另一类是手上有牌类识别、卡片识别、票据识别需求,想快速搭一版可用原型的开发者。哪怕你只会一点点 Python,跟着思路走也能拼出能跑的东西。
1. 项目整体设计与技术路线拆解
1.1 为什么是卷积神经网络,而不是模板匹配
先说一个很多人第一反应会想到的方案:模板匹配。把 54 张标准牌面存成模板图,来一张新图就做特征点匹配或者归一化互相关,谁的分数高就是谁。这个思路在实验室里、在扫描仪下确实能跑,成本也低,不用训练、不用显卡。但我实测下来的结论是:它的鲁棒性天花板非常低。只要出现轻微的透视变形、光照不均、牌面有磨损或者指纹,匹配分数就会大幅抖动,而且阈值极难统一——在一批图上调到 0.85 好用,换一批图可能 0.7 都过不了。
卷积神经网络解决的恰恰是这个问题。卷积核在整张图上滑动,本质是在学习局部纹理的统计规律,而不是死记硬背像素位置。第一层学边缘和颜色块,中间层学角标数字的笔画组合,深层学"红桃的形状 + 角标字符"这种语义组合。这种层次化的特征表达,天然对平移、轻微旋转、亮度变化有容忍度,而且不需要你手工设计特征。更关键的一点是:加入数据增强之后,模型见过"歪的、暗的、模糊的"样本,它学到的决策边界就包含了这些扰动,泛化能力自然比模板匹配强一个量级。
所以这个项目的技术底座定在 CNN,是经过了成本与收益权衡的:训练成本可控(单卡甚至 CPU 慢跑都能完成),推理成本极低(一张图几毫秒),而鲁棒性远超传统方法。
1.2 先把问题定义清楚:单牌分类还是多牌检测
这是我在项目启动阶段踩过的第一个坑,也是最值得先说的一点:很多人上来就写分类网络,结果发现自己的场景根本不是分类问题。
如果你的输入是"一张已经裁剪好、只包含一张牌的图片",那这就是标准的图像分类任务,输入一张图,输出 54 个类别中的一个,用 CNN 直接做 softmax 分类完全够用,这也是本文后续代码的主线。
但如果你的输入是"一张桌面照片,上面散落着 3 到 5 张牌",那这就是目标检测加分类的两阶段任务。你需要先定位每张牌的边界框,再对每个框做分类。这种情况下直接上分类网络,模型会被迫学习"这张图里可能有哪些牌的组合",效果会非常差,而且类别空间变得不可控。
判断标准很简单,问自己一句话:我的图里会不会同时出现多张牌?会,就走检测路线;不会,就走分类路线。这个判断必须在写第一行代码之前完成,否则后面数据标注格式、模型结构、评估指标全都要推倒重来。我在实际项目里见过不少同学,标了半个月的分类数据集,最后发现业务场景是整桌拍照,只能全部重标。
1.3 完整流水线应该长什么样
把路线定下来之后,整个项目可以拆成五个环节,环环相扣,任何一环偷懒都会在后面以"准确率上不去"的形式还回来:
| 环节 | 核心任务 | 常见偷懒做法 | 后果 |
|---|---|---|---|
| 数据采集 | 覆盖真实场景的光照、角度、背景 | 只在一个桌面上拍 | 换环境准确率断崖下跌 |
| 标注与清洗 | 类别体系设计、错标剔除 | 直接按文件名分文件夹 | 标签噪声导致训练震荡 |
| 增强与划分 | 合理增强、防数据泄漏 | 全量随机划分 | 验证集虚高,上线翻车 |
| 模型与训练 | 结构选型、超参调整 | 盲目堆深网络 | 过拟合、推理变慢 |
| 部署与迭代 | 量化、加速、badcase 回流 | 训练完就交付 | 实际帧率不达标 |
这张表我建议贴在显示器边上。项目做得越久越会发现,真正决定成败的不是模型有多复杂,而是前面三行的工程质量。一个 20 万参数的小网络配上干净数据,往往能打赢一个 2000 万参数的大网络配脏数据。
1.4 硬件与框架的务实选择
训练环境上,我的建议是:先用小图跑通,再考虑放大。输入分辨率定在 128×128 或者 160×160,一个轻量 CNN 在普通笔记本的 CPU 上跑几百轮也能出结果,两三千张图大概几十分钟量级。等你确认流程没问题了,再考虑上 224×224 和 GPU。
框架上,下面代码统一以 torch 体系为例,主要是它的数据加载和调试体验比较顺手,社区里能查到的资料也多。选框架这件事,本质上选的是"你出问题时能不能快速搜到答案",而不是谁的 benchmark 高零点几个点。对个人项目或者小团队原型来说,生态成熟度的权重远高于性能数字。
还有一点值得提醒:如果你最终的部署目标是嵌入式板子或者手机,务必在训练阶段就把"模型要小"这件事作为约束条件写进来。不要先训一个 ResNet50 拿到 99%,再想办法压缩到 2MB,那个过程会非常痛苦,而且精度损失不可控。正确的做法是从一开始就按目标算力倒推模型规模。
注意:本文涉及的牌类识别技术,适用于正规娱乐辅助、教学演示、自动化分拣等场景,请勿用于任何违反相关规定的用途。
2. 数据集构建:从拍摄到可训练的样本
2.1 采集方案与环境控制
数据集是这个项目里最耗时间、也最决定上限的部分。标准扑克牌是 13 个点数乘以 4 个花色,共 52 张,加上大小王一共 54 类。如果每类准备 200 张,总量大约一万出头,这个规模对 CNN 来说是够用的起点;如果每类能到 400 到 500 张,效果会更稳。
采集方式上,我建议至少准备三套环境,而不是只在一个地方拍完:
- 环境 A:标准白光,纯色桌面。这是"简单模式",用来保证模型能学会基本特征,也是你验证代码有没有 bug 的基准集。
- 环境 B:暖光/冷光混合,木纹或其他纹理桌面。模拟真实家居或桌游场景,重点考验模型对色偏和背景干扰的鲁棒性。
- 环境 C:强反光、局部阴影、牌面倾斜 30 度以上。这是"困难模式",专门用来暴露问题。很多人数据集里全是正面平铺的漂亮图,一上线就崩,原因就在这里。
具体操作上,每张牌在每个环境下拍摄 50 到 80 张,通过旋转角度(0 到 360 度,步长 15 度)、平移位置、距离远近(远到近,让牌在画面里占比从 30% 到 90%)来制造变化。光源位置也换两三个,比如正上方、左上 45 度、侧逆光。别忘了把牌倒着放、斜着放、部分被手指遮住的样本也拍一些,真实场景里这些都是常态。
还有一个容易被忽略的点:牌的来源要多样。不同品牌、不同批次的扑克牌,红桃的红色深浅、背面的纹理、纸张的厚度反光都不一样。如果整个数据集只用一副牌拍的,模型相当于记住了这一副牌的"指纹",换一副就废了。我的做法是至少用 5 到 8 副不同来源的牌混合采集,让"花色和点数"成为唯一稳定的判别依据。
2.2 类别体系与标注策略
类别怎么定,直接决定了模型要学什么。最直觉的做法是 54 类:黑桃 A 到 K、红桃 A 到 K、梅花 A 到 K、方块 A 到 K,加大王、小王。这个方案简单直接,评估也直观。
但它有个隐患:类别之间存在严重的视觉相似性。黑桃 8 和梅花 8,主体数字完全一样,只有角落的小花色符号和中间图案有区别;如果图像被裁掉了角标,人类都分不出来。所以在划分类别时,我建议顺手做一件事:统计每个类别"关键判别区域"的位置。对扑克牌来说,最关键的信息其实在左上角那个"点数 + 花色"的小角标上,而不是中间的大图案。理解这一点之后,你会发现裁剪策略、增强策略、甚至模型结构都应该围绕角标来设计。
标注环节,如果走分类路线,最省事的组织方式是目录结构:
dataset/ ├── train/ │ ├── S_A/ # 黑桃A │ ├── S_2/ │ ├── ... │ ├── H_K/ # 红桃K │ ├── JOKER_BIG/ │ └── JOKER_SMALL/ ├── val/ └── test/每类一个文件夹,文件夹名就是标签名,用现成的 ImageFolder 就能直接加载,省掉写标注文件的功夫。如果走检测路线,就得用标准的框标注格式,逐张画框,工作量会翻好几倍,这也是我在 1.2 节强调"先想清楚路线"的原因。
标注清洗这件事必须做,而且要认真做。我见过太多项目里混进了错标样本——文件名写着红桃 Q,图里其实是方块 Q,可能是采集时摆错了牌。几百张里混进十几张错标,损失曲线就会莫名其妙地抖,而且怎么调学习率都压不下去。建议的做法是:训练前随机抽 100 张人眼看一遍,训练后再用模型的高置信度错误样本反查一遍,两头堵。
2.3 数据增强的取舍与参数设置
增强是这类项目里性价比最高的手段,但也是最容易被滥用的一环。原则只有一条:增强方式必须模拟真实场景里可能出现的变换,不能引入现实中不存在的形态。
对扑克牌识别,我常用的增强组合是这样的:
| 增强类型 | 参数建议 | 为什么这么设 |
|---|---|---|
| 随机旋转 | ±12 度 | 真实拍摄的倾斜一般不会超过这个幅度,转太多会破坏角标朝向 |
| 随机缩放裁剪 | 缩放 0.85 到 1.0 | 模拟距离变化和轻微裁偏 |
| 亮度/对比度抖动 | 各 ±0.25 | 模拟灯光强弱和阴影 |
| 饱和度抖动 | ±0.2 | 应对不同品牌牌面的色差 |
| 高斯模糊 | 概率 0.15,核 3 | 模拟对焦不准、手抖 |
| 随机擦除 | 概率 0.2,面积占比 5% 到 15% | 模拟手指遮挡、反光斑 |
这里有两个坑必须点出来。
第一个坑是大角度旋转。有人图省事直接上 ±30 度甚至 ±45 度,结果 6 和 9 彻底混在一起了。因为扑克牌的 6 和 9 本来就是中心对称的翻转关系,你把 6 转 180 度它就长得像 9。如果你的增强策略让模型无法利用"角标在左上角"这个方向信息,那这两个类别就永远分不开。所以旋转幅度必须收着,同时确保裁剪时保留完整的角标区域。
第二个坑是水平翻转。很多图像分类任务里 flip 是标配,但扑克牌不行。你左对齐的角标翻转之后跑到右上角去了,这在现实中不存在。开了水平翻转,等于给模型灌入了错误的先验,训练损失可能降得很快,但测试必然掉点。这一条我在早期项目里吃过亏,加了 flip 之后训练集准确率 99%,测试集只有 82%,排查了半天才反应过来。
2.4 划分策略与数据泄漏防范
划分比例上,训练、验证、测试按 7:2:1 或者 8:1:1 都可以。但比比例更重要的是划分的维度。
最忌讳的做法是把所有图片打散后随机划分。为什么?因为你是连续拍摄的,同一张牌在相邻几帧里可能只差几度,几乎一模一样。随机划分会让高度相似的图片同时出现在训练集和验证集里,验证准确率就会虚高到 99%,让你误以为模型很棒,结果是自欺欺人。
正确的做法是按采集批次分组划分:把"某一副牌 + 某一种环境 + 某一次拍摄会话"作为一组,整组进训练集或整组进验证集。这样验证集评估的是模型对"没见过的牌、没见过的环境"的泛化能力,数字才可信。我的习惯是留一整个环境 C(强反光那批)完全不给训练集看,专门当测试集,这样测出来的数字最接近上线表现。
还有一个细节:测试集在最终评估之前不要碰。不要一边看着测试集分数一边调超参,那测试集实际上就变成了验证集,你对泛化能力的估计会偏乐观。这个纪律在个人项目里最容易被破坏,因为只有一份数据,忍不住就想多看一眼。
3. 模型结构设计与关键参数
3.1 从零搭一个轻量 CNN
先说结论:这个任务不需要很深的网络。输入 128×128 的彩色图,五到七个卷积块足够,参数量控制在一百万以内,模型文件大概 3 到 4MB,CPU 推理单张只要几毫秒。我从零写一个结构给你看,每一层的设计理由都写在注释里。
import torch import torch.nn as nn class PokerNet(nn.Module): def __init__(self, num_classes=54): super().__init__() def block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.Conv2d(cout, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) # 128 -> 64 -> 32 -> 16 -> 8 -> 4 self.features = nn.Sequential( block(3, 32), # 浅层:边缘、色块 block(32, 64), # 中层:笔画、花色轮廓 block(64, 128), # 深层:数字与花色的组合 block(128, 256), ) self.pool = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.pool(x).flatten(1) return self.classifier(x)算一下参数量帮你建立直觉。第一个卷积层:3×3×3×32 加 32 个偏置,约 900 个参数;第二层 32×3×3×32,约 9200 个;整个特征提取部分加起来大概在 70 万上下,最后的全连接层 256×54 加 54,不到 1.4 万。总共不到八十万参数。这个规模意味着什么?意味着你在 CPU 上训也能忍,在树莓派上跑也能到每秒十几帧。这就是"从目标算力倒推模型规模"的实际体现。
几个设计细节值得展开说:
- 用 BatchNorm 而不是丢掉它。这个数据集里光照差异很大,BN 能把不同批次、不同亮度的特征分布拉到同一量纲,收敛速度和稳定性提升非常明显。我做过对比,去掉 BN 之后同样的学习率,损失曲线会出现明显的台阶状卡顿。
- 每块用两个 3×3 卷积而不是一个 5×5。两个 3×3 的感受野等于一个 5×5,但参数只有后者的 72%,且多了两次非线性激活,表达能力反而更强。这是 VGG 时代就验证过的结论,在小模型上尤其划算。
- 最后用全局平均池化而不是大号全连接。全连接层是参数大户,256×54 看着不多,但如果特征图是 8×8×256 再接到全连接,光这一层就是一百多万参数,而且极易过拟合。全局平均池化把每个通道压成一个数,参数为零,还自带一点正则化效果。
- Dropout 放在分类头前面,比例 0.3。小数据集上这个位置最有效,放在卷积层中间收益反而有限。
3.2 什么时候该上迁移学习
当你的数据量到不了一万张,或者类别内部差异特别大(比如牌面有大量不同设计的纪念款),从零训可能收敛慢、精度低。这时候可以借一个在通用图像数据上预训练过的主干网络,比如轻量级的 MobileNet 系列或者 ResNet18,把最后的分类头换掉,前面几层用小学习率微调。
判断要不要上迁移学习,我给一个粗略的经验线:每类样本低于 150 张,考虑迁移;每类 300 张以上,从零训通常够用。原因是扑克牌的特征相对简单、规整、几何性强,从零学完全能学到,而通用预训练模型学到的是自然图像的语义特征,对"黑色梅花符号"这种高度人工化的图形,迁移收益没有想象中大。
如果确实要用迁移学习,注意两点:输入尺寸要跟预训练时保持一致(通常是 224×224),否则第一层的统计分布会对不上;分类头要重新初始化,训练初期先冻结主干只训头部三五轮,再解冻整体用 1/10 的学习率微调,这样不容易破坏已经学好的特征。
3.3 损失函数、优化器与学习率的具体取值
这部分给出我实际用的一组参数,可以直接抄:
import torch.optim as optim criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60)逐个解释为什么这么设。
标签平滑 0.1:这个对扑克牌识别帮助很大。因为数据集里存在少量难以避免的错标和极度模糊的样本,硬标签会逼着模型去拟合这些噪声,导致损失下不去、准确率上不来。标签平滑相当于告诉模型"别把话说太满",允许一定的置信度余量,实测能带来一到两个点的提升,而且训练曲线明显更平滑。
AdamW 配 1e-3:Adam 系列对学习率不敏感,1e-3 是个很稳的起点,配上 AdamW 的解耦权重衰减,正则化效果比原来的 Adam 好。weight_decay 设 1e-4 是常用的起点,如果你的训练损失很快就到 0 而验证不降,说明正则不够,可以加到 5e-4。
余弦退火:比阶梯式衰减更平滑,训练后期学习率缓慢趋近于零,模型能收敛到更平坦的极小值,泛化能力更好。T_max 设成总轮数,配合 60 到 80 轮训练,基本不用手动干预。
批大小 32 或 64:显存够就 64,BN 在小批量下统计量不稳;显存紧张就 32,别低于 16。如果确实只能用很小的批量,考虑把 BN 换成 GroupNorm。
训练轮数 60 到 80:这个规模的数据集,通常 20 轮左右验证准确率就到 90% 了,后面是慢慢磨。如果 80 轮之后还在稳定上升,说明模型欠拟合,可以适当加深或者减少增强强度;如果 30 轮就开始过拟合,加 Dropout 或者加大权重衰减。
4. 训练过程与核心环节实现
4.1 数据加载与增强的完整代码
数据管道写不好,训练速度和结果都会受影响。下面这段是完整可用的骨架,包含增强、加载和划分。
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.Resize((144, 144)), transforms.RandomCrop(128), transforms.RandomRotation(12), transforms.ColorJitter(brightness=0.25, contrast=0.25, saturation=0.2), transforms.RandomApply([transforms.GaussianBlur(3)], p=0.15), transforms.ToTensor(), transforms.Normalize([0.5]*3, [0.5]*3), transforms.RandomErasing(p=0.2, scale=(0.05, 0.15)), ]) val_tf = transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize([0.5]*3, [0.5]*3), ]) train_set = datasets.ImageFolder('dataset/train', transform=train_tf) val_set = datasets.ImageFolder('dataset/val', transform=val_tf) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=4)几个细节值得说明。先 Resize 到 144 再随机裁到 128,是为了在裁剪时还有一定余量,不至于把角标裁掉——如果直接 Resize 到 128 再裁,边角信息很容易丢。这个"放大再裁"的小技巧叫 Resize 加 RandomCrop 组合,比单纯的 RandomResizedCrop 更可控,因为它保证了主体内容始终在画面内。
drop_last=True在训练集上建议开着,避免最后一个小批量只有一两张图,BN 的统计量会严重失真,导致那一步的梯度很脏。验证集不用开。
num_workers设成 CPU 核数的一半左右比较合适,太大反而会因为进程调度开销变慢。pin_memory=True只在有 GPU 时有意义,能加快主机到显存的数据搬运。
4.2 训练循环与关键监控指标
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) out = model(imgs) loss = criterion(out, labels) total_loss += loss.item() * imgs.size(0) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total这段代码没什么玄机,但有两个地方很多人写错。一是loss.item() * imgs.size(0),必须乘上批大小再加权,否则最后一个小批量会把平均值带偏。二是评估时一定要model.eval()加torch.no_grad(),前者让 BN 用滑动统计量而不是当前批次的统计量,后者省显存,两个都忘了的话,验证准确率会有莫名其妙的波动。
监控上,别只看准确率。我习惯每轮记录四个数:训练损失、训练准确率、验证损失、验证准确率。健康的曲线形态是:训练损失持续下降,验证损失先降后平,大约在 20 到 40 轮之间达到最低点,两条准确率曲线一起爬升然后趋于平缓,差距保持在 3 到 5 个百分点以内。
出现这几种形态就要警惕了:
- 训练准确率 99%,验证 75%,差距越来越大:典型过拟合。先检查增强是不是太弱,再加 Dropout 或 weight_decay,最后才考虑减模型容量。
- 训练损失来回震荡,不收敛:学习率太大,或者学习率跟批大小不匹配。先把 lr 降到 3e-4 试试。
- 训练损失卡在某个值一直不动:数据或标签有问题,最常见的是类别不平衡极端严重,或者标签全错位了。手动跑一个批次,把标签打印出来核对。
- 验证准确率高于训练准确率:这其实不是坏事,通常是训练时开了强增强和 Dropout、而验证时关掉了,属于正常现象。但如果差距超过 5 个点,就要怀疑验证集和训练集是不是有重复样本了。
4.3 推理部署与速度优化
训练完之后要落地,就得考虑推理效率。第一步是把模型导出成通用格式,这样部署端不用装整个训练框架:
model.eval() dummy = torch.randn(1, 3, 128, 128) torch.onnx.export( model, dummy, "poker_net.onnx", input_names=["input"], output_names=["logits"], opset_version=11, dynamic_axes={"input": {0: "batch"}} )导出之后,如果部署在算力有限的板子上,还能做一步 INT8 量化,把权重从 32 位浮点压到 8 位整数,模型体积缩到四分之一,推理速度通常能提升两到三倍,精度损失一般在一个点以内。量化的关键是准备一批有代表性的校准数据,五百到一千张覆盖各种光照的图就够,校准集的分布一定要贴近真实推理数据,否则精度掉得会很难看。
部署端还有几个实用技巧。把预处理也放进推理图里,也就是归一化和尺寸变换直接在模型外部用矩阵运算实现,避免每次推理都调用完整的图像处理库。批处理:如果场景允许一次处理多张,把它们拼成一个批次,吞吐量能提升好几倍。固定输入尺寸:不要开启动态尺寸,静态图才能让推理引擎做充分的内存优化。
实测下来,这个不到八十万参数的模型,在普通笔记本 CPU 上单张推理 5 到 8 毫秒,在一颗中低端嵌入式芯片上大约 20 到 30 毫秒,也就是每秒 30 到 50 帧,做实时识别完全够用。这个数字是我比较满意的,因为整个方案的成本控制在了"不需要独立显卡"的水平。
5. 常见问题与排查技巧实录
5.1 离线指标很漂亮,实际情况一塌糊涂
这是最高频的问题,没有之一。训练集 99%,验证集 97%,一拿到真实场景里跑,准确率掉到 70% 出头。遇到这种情况,我一般按下面的顺序排查,通常前三步就能定位问题。
第一步,看错误样本长什么样。把线上识别错的图导出来,肉眼扫二十张。如果发现全是反光的、逆光的、或者牌只露出一半的,那说明数据集的覆盖度不够,解决办法就是把这些错例捞回去重新标,补充进训练集,再做一轮。这个环节叫 badcase 回流,是上线之后唯一有效的持续优化手段。
第二步,检查预处理是否一致。这一点特别隐蔽。训练时用的是 BGR 还是 RGB?归一化的均值方差跟线上是不是同一套?我在一个项目里排查了两天,最后发现训练用的是 0.5 均值归一化,部署端写成了 ImageNet 的均值方差,模型看到的输入分布完全错位,精度掉了十几个点。建议把预处理逻辑封装成一个函数,训练和推理共用同一份代码,从根上杜绝这类问题。
第三步,检查分辨率与裁剪是否匹配。训练时输入是 128×128,线上如果送进来一张 1920×1080 的大图直接 Resize,牌在画面里只占很小一块,角标信息被压成几个像素,模型当然认不出来。正确做法是先做检测定位,再裁剪出一块区域送进分类网络,或者至少保证送进网络的图里,牌的区域占比和你训练时一致。
第四步,考虑类别不平衡。如果某些牌在真实场景里出现频率远高于其他牌(比如 A 和 K 出现得多),模型会倾向于预测高频类。这时候可以在损失函数里加类别权重,权重按频率的倒数或者开方来设,不要设得太极端,否则低频类会过拟合。
5.2 常见问题速查表
下面这张表是我这些年攒下来的排查清单,遇到问题先对号入座,能省不少时间。
| 现象 | 最可能的原因 | 优先尝试的解决办法 |
|---|---|---|
| 6 和 9 混淆严重 | 增强里旋转幅度过大,方向信息被破坏 | 把旋转限制在 ±12 度内,确保裁剪保留左上角标 |
| 黑桃和梅花混淆 | 角标被裁掉,只剩中间图案 | 调整裁剪策略,保证角标区域完整 |
| 红桃和方块混淆 | 图像被灰度化,颜色信息丢失 | 检查输入通道数,确保是三通道彩色输入 |
| 验证准确率虚高 | 相似帧跨集合泄漏 | 按采集批次分组划分数据集 |
| 训练损失不降 | 标签错位、学习率过大 | 打印一个批次的标签人工核对,lr 降到 3e-4 |
| 训练损失降但验证不降 | 过拟合 | 加强增强、加 Dropout 和 weight_decay |
| 大小王几乎全错 | 样本太少导致长尾 | 单独对这两类做重采样或加权 |
| 换一副牌就崩 | 牌面风格差异未被覆盖 | 补充多品牌多批次的采集数据 |
| 边缘的牌识别不出 | 裁剪时被切断 | 缩小主体区域,留出更多平移余量 |
| 推理速度不达标 | 输入尺寸过大、未量化 | 降到 128、导出 ONNX、做 INT8 量化 |
| 同一张图两次结果不同 | 忘了 eval 模式,BN 在用批次统计量 | 推理前调用 model.eval() |
这张表里最值得单独说说的是"大小王几乎全错"这一条。大小王的样本量天然就少——你有 5 副牌,也只是 5 张大王的图,而其他每类可能有几百张。这种极端长尾,模型会直接躺平,全预测成出现最多的类。解决办法不是简单加权,而是对这两类单独采集,或者用小样本合成的方式,把这两类样本数至少补到其他类的三分之一,问题就基本消失了。
5.3 我踩过的几个坑,你最好别再踩
第一个坑:一开始就把数据集做得很"完美"。我最早做的一版,把每张牌都裁得方方正正、光线均匀、背景纯白,训练集准确率直接 100%,我当时还挺得意。结果拿去测实拍图,准确率 60%。原因很简单,我训练的是一个"完美牌面"的分类器,而真实世界里的牌是不完美的。这个教训让我从此改变了数据集的构建顺序:先做一版粗糙但真实的数据,跑通流程看基线,再逐步补充边界样本,而不是花两周时间去打磨一个不真实的完美数据集。
第二个坑:忽略了样本采集的物理顺序带来的相关性。连续拍摄的视频帧之间高度相似,如果你按帧编号随机划分数据集,必然造成严重的泄漏。我现在统一的做法是:先按会话分文件夹,再按文件夹划分,最后才打散。这个顺序不能反。
第三个坑:过度追求准确率,忘了业务需要的其实是响应速度。有一次我花了两天把一个模型从 96.5% 调到 98.2%,代价是模型大了三倍、推理慢了五倍,结果在目标设备上根本跑不动,只能回退到原来那版。这个事让我记住了一个原则:先确定延迟预算和算力预算,再在预算内追求精度。在移动端、嵌入式场景里,快而准 96% 往往比慢而准 98% 有价值得多。
第四个坑:没有留一份"完全不训练"的测试集。前面提到过,但值得再强调。我现在的习惯是,除了标准的三份划分,还会额外留一个"压力测试集",专门收集最难的那批图:强反光、大角度倾斜、部分遮挡、不同品牌的牌。这份数据只在每次要交付之前跑一次,看整体趋势,其他时候绝不碰。它能提前告诉你上线后会遇到什么,比任何离线指标都有说服力。
第五个经验:把推理服务做成可以单张调试的形式。部署之后出问题,如果没有办法把一张图丢进去看中间结果——比如送进网络前的实际张量长什么样、各类的置信度分布如何——排查会极其低效。所以我现在都会留一个调试入口,能打印预处理后的图像、输出预处理快照、查看 top3 的类别和分数。这个功能做起来十几行代码,但省下来的排查时间可能是几十个小时。
最后分享一个我自己觉得挺有用的小技巧。当模型在某个类别上反复出错的时候,不要急着改网络结构,先做一件事:把这几个类的所有样本挑出来,单独拼成一张九宫格图,自己盯着看。十有八九你会发现,问题出在数据本身——某几张图裁歪了、某个类的样本全是同一个角度、或者有一个类的标签整体错位了。模型很少无缘无故犯错,它只是在忠实地学习你喂给它的东西,包括你的疏忽。