
简介基于C的麻将牌分离与识别项目以云飞针图像为输入结合颜色直方图和25维像素占比两类特征通过SVM分类器完成每张牌的自动判读。项目适合图像处理、模式识别及C开发方向的小白或进阶学习者可作为毕业设计、课程设计或工程实训的参考原型。压缩包共2000个文件包含1958张麻将样本PNG图片、14个C源文件、11个头文件、3个UI界面文件及工程配置文件整体大小22.26MB目录组织便于按模块检索。目前已有95人学习/下载代码与数据配套完整能直观看到从图像分割到特征提取再到SVM识别的完整链路。读者可获得包含图像预处理、特征组合、SVM训练与识别、交互界面在内的完整参考实现理解颜色直方图与像素占比特征的构造方式并在此基础上自行调试、扩展识别策略适合作为入门图像分类的实战素材。1. 麻将识别从哪下手一张云飞针图怎么变成 SVM 可读的向量处理过图像识别项目的人都清楚真正卡的往往不是分类器而是「怎么把一张图拆成计算机能理解的数字」。这个基于 C 的麻将识别项目输入是一张包含多张牌的云飞针图像通常是一次性扫出来的整版牌面要完成两件事一是把每张麻将精确分离出来二是识别出每张牌的花色和点数。方案用了颜色直方图加 25 维像素占比做特征分类器采用 SVM。别小看这个组合它不依赖深度学习框架不要求 GPU纯 CPU 就能跑对工业场景里的快速落地很有参考价值。项目适合两类人一类是做图像分割和 SVM 特征工程入门的同学想看一套完整的 C 实现而不只是调 OpenCV 的 API另一类是实际有牌面识别需求、想找一个不依赖庞大模型库方案的从业者。接下来的内容会按数据准备、特征计算、SVM 训练、推理部署、常见坑的顺序完整拆开每段都给可抄的参数和代码。2. 图像预处理与单牌分离先让每一张牌各就各位2.1 云飞针图像的成像特点和预处理思路云飞针图像本质上是对麻将牌面的一次扫描或拍照结果特点是牌面排列相对规则但存在光照不均、牌间缝隙大小不一、个别牌有轻微旋转的问题。预处理目标是在进入特征提取之前保证每张牌被完整、独立地切出来并且方向基本统一。先读图转为灰度再做高斯模糊去除扫描噪点然后自适应阈值二值化。自适应阈值比固定阈值更稳——整版扫描图像经常一边亮一边暗固定阈值会出现部分牌面发白、部分牌面发黑的情况。cv::Mat src cv::imread(input.jpg); cv::Mat gray, blurred, binary; cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0); cv::adaptiveThreshold(blurred, binary, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 25, 10);这里ADAPTIVE_THRESH_MEAN_C表示用邻域均值做阈值基准25是邻域块大小10是减去的常量。块大小要根据牌面像素尺度调整如果单张牌在图中大约占 200×300 像素块大小 25 合适如果牌更大可以加大到 35 左右否则容易出现牌面内部纹路被当成背景。2.2 基于轮廓分析的单牌分割流程二值化之后用cv::findContours寻找连通域。这里有个细节不是每个轮廓都是一张牌扫描时牌之间的阴影、牌面反光都可能形成碎轮廓。第一轮过滤按面积过滤——过小的轮廓直接扔掉第二轮按轮廓的外接矩形宽高比过滤——规整麻将牌的宽高比大约在 0.65 到 0.8 之间这里实际指的是牌的横向宽度与纵向高度之比常规牌面宽度小于高度把比例明显不对的也排除。std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; cv::findContours(binary, contours, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); const double minArea 5000; // 可调按图像分辨率等比缩放 const double minRatio 0.60; const double maxRatio 0.85; for (const auto c : contours) { double area cv::contourArea(c); if (area minArea) continue; cv::Rect r cv::boundingRect(c); double ratio (double)r.width / r.height; if (ratio minRatio || ratio maxRatio) continue; cv::Mat roi src(r); // 保存 roi / 进一步处理 }RETR_EXTERNAL只取最外层轮廓适合牌面互不重叠的扫描场景。如果实际图像里牌与牌有粘连这个策略会失效那时需要改用分水岭或形态学腐蚀先断开连接。后面避坑章节会专门讲这个问题。轮廓过滤后建议对每个roi再做一次最小外接矩形cv::minAreaRect检测得到旋转角度。即便整体扫描图是摆正的个别牌也可能歪了几度后续特征提取对旋转敏感所以要么做仿射校正要么在特征设计时让特征本身具备一定旋转鲁棒性。3. 颜色直方图与 25 维像素占比特征工程的关键设计3.1 直方图为什么有效麻将牌的颜色区分度麻将牌的颜色组合很有限万条筒三色牌面配白底字牌的红中、发财、白板颜色固定。这意味着颜色信息本身就有很强的判别力。颜色直方图的思路是统计牌面区域每个颜色通道的分布而不是关心某个像素具体在哪。HSV 空间比 RGB 更适合作颜色特征。RGB 三个通道高度相关光照一变三个通道一起变HSV 的 H色相和 S饱和度受亮度干扰相对小。实际操作中只统计 H 和 S 通道并做二维直方图V 通道弃用或只算一个统计量。cv::Mat roiHSV; cv::cvtColor(roi, roiHSV, cv::COLOR_BGR2HSV); int hBins 30, sBins 32; int histSize[] { hBins, sBins }; float hRanges[] { 0, 180 }; float sRanges[] { 0, 256 }; const float* ranges[] { hRanges, sRanges }; int channels[] { 0, 1 }; cv::Mat hist; cv::calcHist(roiHSV, 1, channels, cv::Mat(), hist, 2, histSize, ranges, true, false); cv::normalize(hist, hist, 1, 0, cv::NORM_L1);hBins30和sBins32的选择是经验值。Bin 太细比如 60×64会引入噪声——同一个颜色的牌因为扫描色差分散到多个 bin相似度计算反而不稳定太粗又失去区分度。30×32 是试过多个组合后比较稳定的参数如果你要换数据集从 16×16 起步调每次翻倍找验证集准确率拐点。归一化方式选NORM_L1让直方图所有 bin 的和等于 1这样直方图表示的是颜色分布比例而不是像素数量跟牌面被切出来的大小无关。如果你对比两张牌的颜色相似度cv::compareHist配合相关系数或卡方距离都行但 SVM 训练时直方图通常直接展平成一维向量作为输入。3.2 25 维像素占比具体怎么算维度是什么25 维像素占比特征是对整张牌面做固定网格划分统计每个格子里前景非白色背景像素的占比。按 5×5 网格划分就是 25 个格子每个格子算出来一个 0 到 1 之间的比例值构成 25 维向量。乍一听很简单但这个特征对「牌的牌面结构」做了非常直接的压缩——同一个数字的刻印位置相对固定不同数字的刻印分布差别明显。实现上注意两点第一划分网格之前先对 ROI 做尺寸归一化统一缩放成比如 200×240 的固定大小避免牌的尺寸差异影响网格比例第二前景判断不直接用二值化结果因为牌面颜色本身有明暗更稳的做法是在灰度图上用「低于某个阈值的像素算前景」来统计或者结合牌面主色做反向掩码。cv::Mat roiResized; cv::resize(grayROI, roiResized, cv::Size(200, 240)); std::vectorfloat feature25(25, 0.0f); int gw roiResized.cols / 5; int gh roiResized.rows / 5; for (int gy 0; gy 5; gy) { for (int gx 0; gx 5; gx) { cv::Mat cell roiResized(cv::Rect(gx * gw, gy * gh, gw, gh)); double nonWhite 0.0; for (int y 0; y cell.rows; y) { for (int x 0; x cell.cols; x) { if (cell.atuchar(y, x) 120) { nonWhite 1.0; } } } feature25[gy * 5 gx] nonWhite / (gw * gh); } }阈值120是按灰度图的暗部占比统计的。如果你扫描出来的牌面整体偏亮或偏暗这个阈值要跟着动。我一般会在预处理阶段算一次全图灰度均值然后按均值浮动调整阈值。25 维特征的意义在于它把牌面的空间分布信息压缩成很小的向量适合跟直方图拼接后喂给 SVM。3.3 特征拼接与归一化别让 SVM 被某一维特征带偏颜色直方图展平后是 30×32960 维加上 25 维像素占比总共 985 维。两个特征的量纲不同直方图每维是概率值范围 0 到 1像素占比也是 0 到 1但分布特征差异很大。如果不做标准化直接训练SVM 的核函数计算距离时方差大的维度会主导相似度。通常的做法是对每个特征维度独立做零均值单位方差归一化。这里有个具体问题SVM 拟合时用训练集的均值和方差做归一化推理时也要用同一组统计量不能重新算。实操中把均值和方差存成文件SVM 模型存成另一个文件推理时先加载再做转换。// 特征归一化假设 features 是 cv::Mat每行一个样本 for (int col 0; col features.cols; col) { cv::Scalar mean, stddev; cv::meanStdDev(features.col(col), mean, stddev); for (int row 0; row features.rows; row) { features.atfloat(row, col) (features.atfloat(row, col) - mean[0]) / (stddev[0] 1e-6); } // 保存 mean[0] 和 stddev[0] 到文件 }1e-6是防止某维特征在所有样本里完全一样时除零报错。实际中如果某维标准差接近 0说明这维特征没有区分度可以直接丢掉。这一现象在前几张牌的格子全被白色背景占满时会出现属于正常情况不要硬留。4. SVM 训练与模型持久化从样本采集到可部署模型4.1 样本集怎么准备每张牌至少多少张标注要细到哪种程度SVM 是监督学习样本质量直接决定模型上限。牌的种类按完整麻将算大约 34 种万 1-9、条 1-9、筒 1-9、东南西北中发白每种牌至少准备 30 到 50 张样本。样本太少 SVM 很容易过拟合尤其是 RBF 核下样本太多又没必要毕竟特征维度只有 985线性核下几百个样本已经能取得不错效果。样本文件名推荐按类别_编号.jpg组织比如wan1_001.jpg、tiao3_014.jpg。训练脚本按前缀解析标签不要手动编辑一个巨大的标注文件——容易错行错了还不好查。如果你有牌背面的图直接丢掉SVM 不需要学反面。训练时做一次数据集划分80% 训练20% 验证。验证集不参与训练只看最终准确率。不要用训练集准确率评估模型——RBF 核下训练集准确率几乎是 100%没意义。4.2 训练代码与核函数选择线性核还是 RBF 核OpenCV 自带 SVM 实现cv::ml::SVM::create()创建对象设置核函数类型后直接训练。核函数选择上我通常先试线性核特征维度上千、样本数量几百的前提下线性核往往已经够用。RBF 核理论上能拟合更复杂的决策边界但需要调C和gamma两个超参数调不好更容易翻车。cv::Ptrcv::ml::SVM svm cv::ml::SVM::create(); svm-setType(cv::ml::SVM::C_SVC); svm-setKernel(cv::ml::SVM::RBF); svm-setC(10.0); svm-setGamma(0.01); svm-setTermCriteria(cv::TermCriteria(cv::TermCriteria::MAX_ITER, 10000, 1e-6)); // features: 归一化后的训练特征矩阵layout: ROW_SAMPLE // labels: 类别标签从 0 开始连续编号 cv::Ptrcv::ml::TrainData trainData cv::ml::TrainData::create(features, cv::ml::ROW_SAMPLE, labels); svm-train(trainData);C10和gamma0.01是两个起手值。C是误分类惩罚系数越大越不允许分错越容易过拟合gamma控制 RBF 核的作用半径越大决策边界越弯曲。经验做法是网格搜索C取{1, 10, 100}gamma取{0.001, 0.01, 0.1}在验证集上跑准确率取最高组合。如果样本量少每种不到 20 张优先考虑线性核。线性核没有 gamma 超参把 C 设默认值 1 通常就能得到不错结果而且线性核的泛化能力在特征维度已经足够表达信息时反而优于 RBF。4.3 多分类策略一对一还是一对多麻将识别是典型的多分类问题OpenCV 的 SVM 在C_SVC类型下处理多分类默认采用一对一策略即每两类之间训练一个分类器34 类就是 C(34,2)561 个二分类器。预测时每个二分类器投票得票最多的类胜出。一对一的好处是每个二分类器只需在两类样本上训练训练速度快、样本不均衡问题小。一对多每个分类器管一类 vs 其余所有类训练次数少但容易出现样本不均衡——比如「一筒」只有 50 个正样本其余 33 类 1650 个负样本分类器会偏向负类。OpenCV 里不需要你手动实现投票逻辑svm-predict()会自动处理一对一链式投票。但要注意如果某些类之间特征非常接近比如「五条」和「六条」在低分辨率下可能相似投票结果可能出现平票。遇到这种情况取所有投票中的最高支持率比取平票后第一个胜出更可靠。predict的默认行为是返回最大投票值对应的类如果想获得原始置信度做兜底可以在predict时传cv::ml::StatModel::RAW_OUTPUT。模型保存与加载一行搞定svm-save(mahjong_svm.xml); cv::Ptrcv::ml::SVM loadedSvm cv::ml::SVM::load(mahjong_svm.xml);注意保存时同时保存归一化参数模型文件里不包含这些信息。推荐用自定义的文本文件存均值和方差或者直接用一个二进制结构把归一化参数和 SVM 模型打包部署时只加载一个文件。5. 避坑与常见问题分割粘连、光照不均、特征失效排查5.1 现象两张牌粘连时被分割成一张现象处理扫描图时相邻两张麻将牌边缘紧贴findContours只找到一个大轮廓boundingRect把两张牌框在一起后续特征提取全乱。原因牌间缝隙小于最小像素间距二值化后两张牌的前景区域在形态学上连通成一个区域。解决对二值图做形态学腐蚀用一个 3×3 或 5×5 的结构元素腐蚀 1 到 2 次把连接处断开再找轮廓。腐蚀操作顺序要注意——先腐蚀再 findContours别先找轮廓再腐蚀。腐蚀之后每张牌的边缘都轻微收缩按 size 归一化即可弥补不影响特征。cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); cv::erode(binary, binary, kernel, cv::Point(-1, -1), 2);腐蚀 2 次是最常见的组合。如果腐蚀一次断不开宁可多腐蚀一次也别把结构元素改大——改大容易侵蚀牌面内部刻印图案。5.2 现象同一张牌不同扫描批次识别结果差异明显现象第一天扫描的样本训练出来的模型第二天换了一台扫描仪或改变光照后识别率骤降。原因颜色直方图对光照变化敏感HSV 空间虽然比 RGB 稳但光照突变时 H 通道仍会偏移。尤其扫描仪偏色时同一张牌的红色部分 H 值整体偏移十几个单位直方图从「红色集中区」移到另一块区域。解决训练样本里主动混入不同光照条件下的牌面图。每种牌准备样本时覆盖至少三种光照标准白光、偏黄灯光、偏暗环境。训练时把三种光照样本都放进去模型自然学到光照不变性。如果条件不允许退一步的做法是训练前对颜色直方图做平滑——相邻 bin 之间的值做一次均值滤波缓解色相偏移的影响。5.3 现象训练集准确率 99%验证集只有 80%现象模型训练完打印训练集准确率接近满分跑验证集直接掉一截。原因这是过拟合的典型表现。特征维度过高985 维而样本量不足时SVM 的 RBF 核会把每个训练样本都包进一个独立的超球区域模型记住样本本身而非类别规律。解决先检查样本量——每种牌少于 30 张优先补数据而不是调参再尝试降低模型复杂度——gamma值调小一个数量级比如从 0.1 调到 0.01让决策边界更平滑最后尝试线性核如果线性核验证集准确率达到 90% 以上就没必要用 RBF。另外一个容易被忽略的点检查训练集和验证集划分时是否按类别分层——如果某类牌的样本全在验证集里训练时没见过这类准确率自然崩用cv::ml::TrainData::create的shuffle参数打乱顺序再划分。5.4 现象某两类牌总是互相误判现象比如「三条」和「五条」之间互相识别错误其他类别都正常。原因细看这两类牌的 25 维像素占比特征会发现网格分布非常接近——刻印图案在 5×5 网格粗粒度下区分度不够。颜色直方图对形状相近的牌又帮不上忙。解决这属于特征分辨率不够。两种思路一种在 25 维特征基础上增加更细粒度特征比如把网格升级到 10×10 并追加 100 维或者加入牌的局部二值模式另一种是专门为易混淆类别补充困难样本——找这两类里最容易认错的样本各加 20 张重新训练。如果只有 34 类里有 3 到 4 对易混牌后者的投入产出比更高。5.5 现象推理速度比预期慢CPU 占用高现象单张整版图处理一次需要几百毫秒其中识别部分耗时占比最高。原因多分类 SVM 一对一策略在 34 类时一共要跑 561 个二分类器每个分类器都要做一次核函数计算。RBF 核的计算复杂度碰特征维度几百张牌累积起来耗时显著。解决第一优先检查是否每个分类器都使用了全部特征维度——降维到前 200 个主成分往往速度翻倍且准确率下降不多第二如果特征已经足够区分把核函数换成线性核线性核的推理速度会比 RBF 快一个量级第三把预测改为批量——检测出的牌统一收集成矩阵后一次predict避免循环里单张调用。6. 模型验证与调优落地点混淆矩阵、交叉验证与部署习惯模型训练完第一件事不是看总准确率而是看混淆矩阵。OpenCV 没有直接输出混淆矩阵的接口自己写一个小工具遍历验证集样本predict输出预测标签统计每个真实标签下预测标签的分布生成 34×34 的计数矩阵。你能一眼看出哪些类别互相打架、哪些类别自己都不稳定。交叉验证我一般用五折每折 80% 训练、20% 验证五轮后把结果合计。五折交叉验证不只是算平均准确率还要看每一折准确率的方差——方差大说明样本分配有问题或某些类样本太少比只看平均值的参考价值大得多。最后一步是端到端验证拿一张完整的云飞针图跑完整流程分割出所有牌逐张识别然后把识别结果按原始坐标映射回原图。这一步很关键——它验证的不只是模型还有分割模块和特征提取模块在真实场景下的协同表现。训练时数据都是干净的 ROI推理时拿到的 ROI 是分割算法在真实图像上切出来的两者可能有细微差异比如分割边界不够紧贴牌边特征值随之偏差端到端测试能暴露这类问题。我习惯在推理管线的最后加一个置信度输出接口。OpenCV 里设置svm-predict(samples, results, cv::ml::StatModel::RAW_OUTPUT)时得到的是每个类别的决策值而不是标签决策值的最大差值可以当置信度用。把这个值打印出来跟人工标注对比你能在测试阶段积累很多「模型为什么错」的经验。从那以后我每次跑麻将识别项目都会强制走一遍「先训→五折验证→端到端跑全图→人工抽检 50 张」的标准流程抽检不是看对不对而是记录所有模型输出不那么自信的样本攒起来补充训练集。这个习惯让项目的迭代效率高了很多希望帮到你。本文还有配套的精品资源点击获取