简介:面向希望学习计算机视觉与SVM应用的开发者,这份基于C++的云飞针图像麻将识别项目,将每张麻将牌从图像中分离并完成分类。项目采用颜色直方图和25维像素占比两种特征,搭配SVM分类器,完整覆盖图像预处理、牌面分割、特征提取、模型训练与识别结果展示等环节,并配有Qt界面,适合作为毕业设计、课程设计或工程实训参考。资源包共2000个文件,约22.26MB,以1958张PNG样本图像为主,另有C++源文件、界面UI、XML配置及makefile等,便于对照源码理解算法实现细节、编译工程并调试过程。已有95人学习,适合具备一定C++基础、希望动手实践图像识别项目的中级学习者,既能从中借鉴分割与特征设计的思路,也能基于现有框架自行扩展识别类别、替换数据集或优化SVM参数以提升准确率。
1. 麻将识别不玄学:云飞针图像上的SVM分类落地
上个月帮朋友调一套洗牌机视觉模块,牌面识别率卡在 83% 上不去,换了好几种深度学习方案都不理想,最后回头用传统特征加 SVM 反而稳了。这篇笔记想拆的就是这么一套基于 C++ 的云飞针图像麻将识别方案:先把牌从整幅图中分离,再提取颜色直方图和 25 维像素占比两类特征,最后交给 SVM 分类。整套东西适合正在做棋牌自动化、桌面识别或者想用 OpenCV + SVM 做小规模分类任务的读者,不依赖 GPU,一台普通桌面机就能跑,这也是我推荐先试它的原因。
2. 拆牌与特征计算:颜色直方图和 25 维像素占比的完整算法
先立住一个总逻辑:SVM 根本不关心麻将长什么样,它只接收一串数字并映射到类别,所以这个项目真正决定成败的是两件事——牌切得干不干净、特征提得稳不稳。颜色直方图负责回答“牌面以什么颜色为主”,25 维像素占比负责回答“字符图案分布在牌的哪些位置”。前者抗形变,后者抗色偏,两者拼成一个向量喂给 SVM,比单用任何一种都稳。
2.1 云飞针图像的预处理与单张牌切割
“云飞针图像”这个叫法,对应到实际数据就是从洗牌机顶部垂直俯拍的牌面图:镜头光轴近似垂直于桌面,牌与牌挨得近,背景是深色绒布。这种图透视畸变小,但牌与牌容易粘连,边缘偶尔有反光。所以预处理不能省:先转灰度,再用大津二值化把牌从背景里抠出来,最后通过连通域找到每张牌的外接矩形。
#include <opencv2/opencv.hpp> #include <vector> using namespace cv; using namespace std; // 从云飞针原图中分离单张麻将牌 vector<Mat> splitMahjongTiles(const Mat& src) { Mat gray, binary; cvtColor(src, gray, COLOR_BGR2GRAY); // 大津二值化:牌面浅色与深色绒布背景分离 threshold(gray, binary, 0, 255, THRESH_BINARY_INV | THRESH_OTSU); // 闭运算把牌面字符的断笔连起来,kernel 不要太大 Mat kernel = getStructuringElement(MORPH_RECT, Size(3, 3)); morphologyEx(binary, binary, MORPH_CLOSE, kernel); vector<vector<Point>> contours; findContours(binary, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); vector<Mat> tiles; for (const auto& c : contours) { double area = contourArea(c); if (area < 500 || area > 20000) continue; // 滤掉噪点与大面积背景 Rect box = boundingRect(c); if (box.width < 20 || box.height < 20) continue; Mat tile = src(box).clone(); // 按外接矩形抠图 tiles.push_back(tile); } return tiles; }逻辑说明:threshold用了THRESH_BINARY_INV | THRESH_OTSU,大津法自动计算阈值,深色背景置 0,浅色牌面置 255,省去针对每张图手调阈值的麻烦。findContours拿到轮廓之后用contourArea过滤掉噪点和整块背景,再用boundingRect把牌面矩形裁出来。
参数说明:面积阈值500 ~ 20000是在 1080P 采集图上调的,单位是平方像素。换了摄像头分辨率要按比例缩放,公式很简单——新阈值等于旧阈值乘以分辨率倍数的平方,比如从 1080P 换到 720P,面积阈值约乘 0.44。
2.2 颜色直方图特征:为什么在 HSV 空间算
麻将牌印刷色非常固定:万字是蓝黑或黑,条子是绿黑,筒子是红蓝黑,字牌里的“中”红底白字、“发”绿底白字。但 RGB 空间里亮度分量很容易干扰颜色判断——同样一张“一筒”,暖光下偏黄,冷光下偏蓝,RGB 直方图会把这两幅图当两种东西。HSV 的 H 通道只保留色调信息,和亮度基本解耦,因此这个项目里我坚持用 HSV。
// 计算单张牌面的颜色直方图,返回 1 行 N 列的行向量 Mat calcColorHist(const Mat& tile, int hBins = 30, int sBins = 32) { Mat hsv; cvtColor(tile, hsv, COLOR_BGR2HSV); // 只统计 H 和 S 两个通道,V 受光照影响大,直接丢弃 int channels[] = {0, 1}; int histSize[] = {hBins, sBins}; float hRange[] = {0, 180}; float sRange[] = {0, 256}; const float* ranges[] = {hRange, sRange}; Mat hist; calcHist(&hsv, 1, channels, Mat(), hist, 2, histSize, ranges); normalize(hist, hist, 1.0, 0.0, NORM_L1); // L1 归一化,总和为 1 return hist.reshape(1, 1).clone(); // 展成一行,维度 960 }逻辑说明:calcHist用 H 和 S 两个通道生成一个二维直方图,30×32=960 个 bin。normalize用 L1 范数把直方图总和归一化成 1.0,这样牌面像素多少不一样也不会影响特征的量级。reshape(1, 1)把二维直方图摊平成单行向量,方便后续和 25 维像素占比拼接。
参数说明:hBins和sBins是经验值。如果后续发现 960 维对 SVM 压力太大,可以把hBins降到 20,维度变成 20×32=640。但我试下来 960 维在 34 类麻将识别上反而更稳,SVM 对中等维度特征并不敏感。
2.3 25 维像素占比特征:5×5 网格里的空间分布
颜色直方图有个明显短板:完全不关心字符画在牌的左上角还是右下角。“一条”的图案在牌面左侧,“一筒”的图形居中,颜色都是红蓝搭配,单靠直方图容易撞车。25 维像素占比补的正是这个盲区:把牌面统一缩放到固定尺寸,切成 5 行 5 列共 25 个格子,每个格子统计前景像素(字符和图案)占该格总面积的比例,最后拼成长度为 25 的向量。
// 计算 25 维像素占比特征 vector<float> calcPixelRatio(const Mat& tile, int grid = 5) { Mat gray, binary; cvtColor(tile, gray, COLOR_BGR2GRAY); threshold(gray, binary, 0, 255, THRESH_BINARY_INV | THRESH_OTSU); resize(binary, binary, Size(grid * 10, grid * 10)); // 统一到 50x50 vector<float> feature; int cellW = binary.cols / grid; int cellH = binary.rows / grid; for (int row = 0; row < grid; ++row) { for (int col = 0; col < grid; ++col) { Mat cell = binary(Rect(col * cellW, row * cellH, cellW, cellH)); float ratio = countNonZero(cell) / (float)(cellW * cellH); feature.push_back(ratio); } } return feature; }逻辑说明:二值化后字符是白色前景,背景是黑色,countNonZero直接统计每格白色像素个数,除以格子面积得到占比。resize到 50×50 是为了让网格划分固定下来,原图尺寸不一致也能统一到 25 维输出。
参数说明:grid = 5对应标题里的“25 维像素占比”。如果你遇到“六万”和“九万”这类图案分布接近的牌,可以把 grid 改成 7,维度变成 49,空间分辨率更高,代价是 SVM 特征维度变大。我一般先跑 5×5,混淆矩阵里哪一对分不开再针对性加密度。
两个特征最终拼成一个向量:
// 训练和推理共用的特征组装函数 vector<float> buildFinalFeature(const Mat& tile) { Mat hist = calcColorHist(tile); // 960 维 vector<float> ratio = calcPixelRatio(tile); // 25 维 vector<float> feat(hist.cols + 25); // 注意 hist 必须是 CV_32F、连续存储 memcpy(feat.data(), hist.ptr<float>(0), hist.cols * sizeof(float)); memcpy(feat.data() + hist.cols, ratio.data(), 25 * sizeof(float)); return feat; }逻辑说明:memcpy之前要确认hist是CV_32F类型,否则字节数对不上。buildFinalFeature同时服务训练和推理两端,杜绝“训练一套特征、推理另一套特征”的维护问题,这一点在后面的避坑章节还会专门讲。
3. SVM 分类的 C++ 实现:训练、持久化与单张推理
特征工程做完,剩下的就是经典的 C++ 机器学习落地流程。这一章覆盖 SVM 选型、训练数据组织、参数搜索,还有模型保存和推理时的调用方式。读完你能直接把这段流程搬进自己的工程,不用再翻一堆分散的文档。
3.1 在 OpenCV 的 SVM 和 libsvm 之间怎么选
我先给结论:直接用 OpenCV 的cv::ml::SVM,不额外引 libsvm。理由有三条。第一,图像读取、预处理、特征提取全在 OpenCV 里,再引 libsvm 等于多维护一套数据格式转换。第二,麻将识别是 34 类、每类几十到上百张样本的小规模问题,C_SVC 配 RBF 核完全够用。第三,OpenCV 的 SVM 模型可以 save/load 成文件,推理线程加载一次,其他线程只调predict,不依赖任何外部运行时。
如果你的样本量到了几万张,或者需要概率输出、自定义核函数,再考虑 libsvm。我见过有人为了“libsvm 更专业”把项目搞成两套 IO——训练用 libsvm,推理用 OpenCV,结果预测结果对不上,查了两天发现是特征顺序写反了。
3.2 训练数据组织:标签与特征文件格式
按照国标麻将的牌型,我定义 34 个类别:万 1-9、条 1-9、筒 1-9、东南西北中发白,按这个顺序编号 0 到 33。花牌这个项目不涉及,就不进类别。每类样本我控制在 60 张左右,覆盖正拍、左右偏移 10 度、上下偏移 5 度、轻微旋转,总计约 2000 张,全部用buildFinalFeature提成 985 维向量写进 CSV。
#include <opencv2/ml.hpp> #include <fstream> #include <sstream> using namespace cv::ml; // 读取 CSV 特征文件,行格式: 标签, 特征1, 特征2, ..., 特征985 bool loadDataset(const string& path, Mat& samples, Mat& labels) { ifstream in(path); if (!in.is_open()) return false; vector<float> sampleBuf; vector<int> labelBuf; string line; while (getline(in, line)) { if (line.empty()) continue; stringstream ss(line); string token; int idx = 0; int label = 0; vector<float> feats; while (getline(ss, token, ',')) { if (idx == 0) { label = stoi(token); } else { feats.push_back(stof(token)); } ++idx; } if (feats.empty()) continue; sampleBuf.insert(sampleBuf.end(), feats.begin(), feats.end()); labelBuf.push_back(label); } int dim = (int)sampleBuf.size() / (int)labelBuf.size(); if (labelBuf.empty() || dim == 0) return false; samples = Mat((int)labelBuf.size(), dim, CV_32F, sampleBuf.data()).clone(); labels = Mat((int)labelBuf.size(), 1, CV_32S, labelBuf.data()).clone(); return true; }逻辑说明:CSV 第一列是标签,后面跟着 985 个特征值。dim由样本总数和特征总数动态算出来,这样即使以后特征维度从 985 变成 960,读取代码也不用改。注意Mat构造时传了sampleBuf.data(),后面必须跟.clone(),否则数据离开作用域就失效了。
参数说明:标签用CV_32S整型,特征用CV_32F浮点型,这是 OpenCV SVM 接口约定的输入类型,混用会在训练时报类型不匹配的错误。
3.3 训练流程:C_SVC 与 RBF 核的参数选择
麻将牌特征线性可分性一般,所以我用 RBF 核而不是线性核。核心参数是 C 和 gamma:C 控制误分类惩罚,越大越容易过拟合;gamma 控制单个样本的影响半径,越大越容易把训练样本圈成孤岛。我通常先跑一轮粗糙网格搜索,C 从 0.1 到 100 按对数取 5 个值,gamma 从 0.001 到 0.1 取 5 个值,25 组组合各做 5 折交叉验证,选平均准确率最高的那组。
Ptr<SVM> trainSVM(const Mat& samples, const Mat& labels) { Ptr<SVM> model = SVM::create(); model->setType(SVM::C_SVC); model->setKernel(SVM::RBF); model->setC(10.0); model->setGamma(0.01); model->setTermCriteria( TermCriteria(TermCriteria::EPS + TermCriteria::MAX_ITER, 1000, 1e-6)); Ptr<TrainData> data = TrainData::create(samples, ROW_SAMPLE, labels); model->train(data); return model; }逻辑说明:TrainData::create把样本和标签封装成 OpenCV 训练接口需要的数据结构,ROW_SAMPLE表示每一行是一个样本。setTermCriteria设了最大迭代 1000 次和精度 1e-6,防止训练不收敛。
参数说明:上面代码里的C=10.0, gamma=0.01是我这份数据上的较优值。你换了自己的图像采集环境,这两个值必须重新搜一遍,直接抄会翻车。如果类别数量不平衡,还要在train之前调setClassWeights,给样本少的类别更高权重。
3.4 模型保存、加载与单张推理
训练完成后模型存到文件,部署时加载。OpenCV 的 SVM 模型序列化很简单,一个save一个load就够。推理时最容易被坑的是特征对齐,训练时用 985 维,推理时也必须 985 维,顺序还不能变。
// 推理:输入单张牌面,输出类别编号 int predictTile(const Ptr<SVM>& model, const Mat& tile) { vector<float> feat = buildFinalFeature(tile); // 985 维 Mat feature(1, (int)feat.size(), CV_32F, feat.data()); // 推理结果就是类别编号,0-33 float result = model->predict(feature); return (int)result; } // 模型持久化 model->save("mahjong_svm.xml"); Ptr<SVM> loadedModel = SVM::load("mahjong_svm.xml");逻辑说明:predictTile先把牌面图片转成特征向量,再包装成CV_32F的 1×985 矩阵丢给model->predict。这里有个细节:feat是本地的vector<float>,构造Mat时传的是feat.data(),只要feature和feat在同一个作用域内使用就没问题。save出来的 XML 文件里含所有支持向量,换机器部署时记得一起拷贝。
单张 985 维特征在 SVM 下预测耗时是毫秒级,我实测单线程每秒能处理 500 张以上,远高于摄像头采集速度,性能不是瓶颈。
4. 避坑排查:麻将牌识别最常见的五个翻车现场
做这类视觉项目,真正耗时间的从来不是写代码,而是排查那些“训练好好的、一上线就出问题”的诡异现象。这一章我把踩过的坑按“现象 → 原因 → 解决”整理出来,基本覆盖这个项目里大多数能遇到的翻车点。
4.1 牌面反光让绿色“发”被识别成白板
现象:训练集里“发”牌准确率很高,换到实际洗牌机上,一半的“发”被 SVM 判成白板。
原因:牌面覆膜在 LED 灯下产生镜面反射,高光区域的颜色饱和度趋近 0,HSV 空间里 H 通道完全失效,颜色直方图把“发”的绿色特征压到了和白板一个量级。
解决:硬件端在灯箱前加偏振片,软件端先用 V 通道阈值检测高光像素,对高光区做中值滤波或邻域颜色扩散后再提特征。我当时的做法是给灯箱贴了一层偏振膜,高光区域面积立刻少了七成,识别率直接回升。
4.2 训练完的模型在推理时报特征维度不匹配
现象:训练正常结束,predict时 OpenCV 抛出“输入样本特征数量与训练时不一致”的异常。
原因:训练代码里的hBins和推理代码里的hBins不是同一个值;或者推理时忘了先resize牌面,导致特征维度漂移。这种问题隐蔽在:编译期不会报错,运行时才崩。
解决:特征维度写死在唯一的头文件常量里,训练和推理共用buildFinalFeature同一份代码。我给训练和推理各加了一个断言assert(feat.size() == 985),维度不对直接中断,把问题暴露在开发阶段而不是部署阶段。
4.3 SVM 过拟合:训练集 99%,验证集只有七成
现象:交叉验证时选出来的模型,训练集准确率逼近满分,一到新牌面就露馅,验证集只有 70% 左右。
原因:RBF 核的 gamma 设得太大,单个样本的影响半径过小,模型把训练样本一个个“圈”成孤岛,泛化能力自然差。C 值过大也会放大误分类惩罚,让模型过度拟合噪声。
解决:把 gamma 从 0.001 开始往上试探,C 控制在 1 到 10 区间。如果你的样本数超过每类 100 张,优先增加样本变化而不是加大 C。网格搜索看着土,但对 SVM 这种对尺度敏感的分类器,比默认参数可靠太多。
4.4 两张紧挨的牌被切成一个整体
现象:轮廓外接矩形里出现“两张牌并排”的大块,特征完全错乱,识别结果毫无规律。
原因:形态学闭运算的 kernel 设成 5×5,把两张牌之间那条细缝填平了,连通域算法把两张牌当成了一张。
解决:kernel 降到 2×2,甚至直接去掉闭运算,改用其他方式处理字符断笔。同时加宽高比过滤:牌的宽高比不会超过 1.2,出现明显长条形外接矩形就丢弃。这个过滤规则能挡住大部分粘连误切。
4.5 字牌样本少,SVM 把“东”判进其他类
现象:数牌识别率都在 95% 以上,字牌明显偏低,“东”经常被分到“发”或“中”。
原因:34 类样本不均衡,数牌每类 60 张,字牌因为收集困难只有 20 张,C_SVC 的优化目标里多数类占了主导,少数类的决策边界被挤占。
解决:给SVM::create()出来的模型设置setClassWeights,按类别频率反比给权重;同时对字牌做小角度旋转、亮度抖动增强,把样本数补到和其他类接近。增强时不要做水平翻转,因为“东”“西”翻转后会变成另一张牌。
5. 多帧投票与混淆矩阵:给 SVM 模型做最后的体检
开发阶段我们习惯拿单张牌做预测,但部署到洗牌机上摄像头是持续出帧的,同一张牌会在视野里停留大约 0.5 秒,能抓到 3 到 5 帧。与其单帧硬判,不如把这 5 帧的预测结果做多数投票。我在代码里加了一个简单的predictWithVoting,实践下来误判率比单帧低一个数量级,尤其对“五筒”和“七筒”这种网格特征本来就很接近的牌。
// 多帧投票:至少 2 帧一致才输出该类别,否则返回 -1 int predictWithVoting(const Ptr<SVM>& model, const vector<Mat>& frames) { map<int, int> votes; for (const Mat& frame : frames) { int label = predictTile(model, frame); votes[label]++; } for (const auto& kv : votes) { if (kv.second >= 2) return kv.first; // 少数服从多数,阈值 2 } return -1; }逻辑说明:votes用map<int, int>统计每个类别获得的票数,遍历 3 到 5 帧后,出现次数不小于 2 的类别胜出。阈值设为 2 是因为 3 帧时 2 票就是绝对多数,5 帧时还能容忍两帧误判。
配套还有一个动作我从头坚持到尾——看混淆矩阵。34 类全量跑一遍混淆矩阵太大,我只挑最容易混的 5 对:五筒对七筒、一条对一筒、六万对九万、东对西、中对发。每类拿 100 张图丢进模型,统计误判去向。如果某个数字超过 5%,我就把这两张牌的 25 维像素占比特征拉出来逐一比对,看是不是网格分布太像。遇到过“一条”和“一筒”反复互判,原因就是 5×5 网格里两者的图案都集中在中央三行,最后把网格改成 7×7,空间分辨率上去了,这对就分开了。
整套工程代码我按上面流程整理过:特征提取、SVM 训练、推理例程各一个文件,解压后按 README 的顺序编译运行就能复现这个识别流程。那次把“五筒”连续误判成“七筒”的事故,就是因为偷懒只测了单帧,没做投票也没看混淆矩阵。从那以后我每次训练完都强制走一遍“多帧投票 + 易混淆对核查”,先看对角线谁掉队,再看谁把谁拽走。这个习惯让我在麻将识别上少熬了不少夜。希望帮到你。
本文还有配套的精品资源,点击获取