ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于OpenCV与SVM的麻将识别系统设计与实现

基于OpenCV与SVM的麻将识别系统设计与实现 简介面向希望学习计算机视觉与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; // 从云飞针原图中分离单张麻将牌 vectorMat 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); vectorvectorPoint contours; findContours(binary, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); vectorMat 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×32960 个 bin。normalize用 L1 范数把直方图总和归一化成 1.0这样牌面像素多少不一样也不会影响特征的量级。reshape(1, 1)把二维直方图摊平成单行向量方便后续和 25 维像素占比拼接。参数说明hBins和sBins是经验值。如果后续发现 960 维对 SVM 压力太大可以把hBins降到 20维度变成 20×32640。但我试下来 960 维在 34 类麻将识别上反而更稳SVM 对中等维度特征并不敏感。2.3 25 维像素占比特征5×5 网格里的空间分布颜色直方图有个明显短板完全不关心字符画在牌的左上角还是右下角。“一条”的图案在牌面左侧“一筒”的图形居中颜色都是红蓝搭配单靠直方图容易撞车。25 维像素占比补的正是这个盲区把牌面统一缩放到固定尺寸切成 5 行 5 列共 25 个格子每个格子统计前景像素字符和图案占该格总面积的比例最后拼成长度为 25 的向量。// 计算 25 维像素占比特征 vectorfloat 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 vectorfloat 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混淆矩阵里哪一对分不开再针对性加密度。两个特征最终拼成一个向量// 训练和推理共用的特征组装函数 vectorfloat buildFinalFeature(const Mat tile) { Mat hist calcColorHist(tile); // 960 维 vectorfloat ratio calcPixelRatio(tile); // 25 维 vectorfloat feat(hist.cols 25); // 注意 hist 必须是 CV_32F、连续存储 memcpy(feat.data(), hist.ptrfloat(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; vectorfloat sampleBuf; vectorint labelBuf; string line; while (getline(in, line)) { if (line.empty()) continue; stringstream ss(line); string token; int idx 0; int label 0; vectorfloat 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 和 gammaC 控制误分类惩罚越大越容易过拟合gamma 控制单个样本的影响半径越大越容易把训练样本圈成孤岛。我通常先跑一轮粗糙网格搜索C 从 0.1 到 100 按对数取 5 个值gamma 从 0.001 到 0.1 取 5 个值25 组组合各做 5 折交叉验证选平均准确率最高的那组。PtrSVM trainSVM(const Mat samples, const Mat labels) { PtrSVM 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)); PtrTrainData data TrainData::create(samples, ROW_SAMPLE, labels); model-train(data); return model; }逻辑说明TrainData::create把样本和标签封装成 OpenCV 训练接口需要的数据结构ROW_SAMPLE表示每一行是一个样本。setTermCriteria设了最大迭代 1000 次和精度 1e-6防止训练不收敛。参数说明上面代码里的C10.0, gamma0.01是我这份数据上的较优值。你换了自己的图像采集环境这两个值必须重新搜一遍直接抄会翻车。如果类别数量不平衡还要在train之前调setClassWeights给样本少的类别更高权重。3.4 模型保存、加载与单张推理训练完成后模型存到文件部署时加载。OpenCV 的 SVM 模型序列化很简单一个save一个load就够。推理时最容易被坑的是特征对齐训练时用 985 维推理时也必须 985 维顺序还不能变。// 推理输入单张牌面输出类别编号 int predictTile(const PtrSVM model, const Mat tile) { vectorfloat 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); PtrSVM loadedModel SVM::load(mahjong_svm.xml);逻辑说明predictTile先把牌面图片转成特征向量再包装成CV_32F的 1×985 矩阵丢给model-predict。这里有个细节feat是本地的vectorfloat构造Mat时传的是feat.data()只要feature和feat在同一个作用域内使用就没问题。save出来的 XML 文件里含所有支持向量换机器部署时记得一起拷贝。单张 985 维特征在 SVM 下预测耗时是毫秒级我实测单线程每秒能处理 500 张以上远高于摄像头采集速度性能不是瓶颈。4. 避坑排查麻将牌识别最常见的五个翻车现场做这类视觉项目真正耗时间的从来不是写代码而是排查那些“训练好好的、一上线就出问题”的诡异现象。这一章我把踩过的坑按“现象 → 原因 → 解决”整理出来基本覆盖这个项目里大多数能遇到的翻车点。4.1 牌面反光让绿色“发”被识别成白板现象训练集里“发”牌准确率很高换到实际洗牌机上一半的“发”被 SVM 判成白板。原因牌面覆膜在 LED 灯下产生镜面反射高光区域的颜色饱和度趋近 0HSV 空间里 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 PtrSVM model, const vectorMat frames) { mapint, 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用mapint, int统计每个类别获得的票数遍历 3 到 5 帧后出现次数不小于 2 的类别胜出。阈值设为 2 是因为 3 帧时 2 票就是绝对多数5 帧时还能容忍两帧误判。配套还有一个动作我从头坚持到尾——看混淆矩阵。34 类全量跑一遍混淆矩阵太大我只挑最容易混的 5 对五筒对七筒、一条对一筒、六万对九万、东对西、中对发。每类拿 100 张图丢进模型统计误判去向。如果某个数字超过 5%我就把这两张牌的 25 维像素占比特征拉出来逐一比对看是不是网格分布太像。遇到过“一条”和“一筒”反复互判原因就是 5×5 网格里两者的图案都集中在中央三行最后把网格改成 7×7空间分辨率上去了这对就分开了。整套工程代码我按上面流程整理过特征提取、SVM 训练、推理例程各一个文件解压后按 README 的顺序编译运行就能复现这个识别流程。那次把“五筒”连续误判成“七筒”的事故就是因为偷懒只测了单帧没做投票也没看混淆矩阵。从那以后我每次训练完都强制走一遍“多帧投票 易混淆对核查”先看对角线谁掉队再看谁把谁拽走。这个习惯让我在麻将识别上少熬了不少夜。希望帮到你。本文还有配套的精品资源点击获取
返回列表