传统CV实战:基于颜色直方图与网格特征的麻将牌SVM识别
发布时间:2026/9/4 19:02:22 锦皓数字建站

简介本资源是一个基于C实现的麻将牌图像识别项目面向计算机视觉初学者与课程设计实践者解决真实场景下云飞针拍摄的麻将牌图像自动分离与分类问题。项目融合颜色直方图与25维像素占比双特征提取策略结合SVM分类器完成136张标准麻将牌的精准识别适用于毕设、工程实训及AI入门实战。压缩包共2000个文件22.26MB含1958张标注PNG样本图像、14个核心CPP源码如qcolorhist_pixratio_feature.cpp、dialogmj152.cpp、11个头文件及UI界面资源结构完整模块划分清晰便于理解图像预处理、特征工程与SVM训练全流程。目前已有95人学习下载代码具备良好可读性与调试基础适合在OpenCVQt环境下动手复现、调参优化与功能扩展。1. 项目概述从一张“云飞针”到麻将牌的智能识别最近在整理一个老项目核心目标是从一张被称为“云飞针”的特殊麻将图像中把每一张牌都精准地“抠”出来并识别出它是什么牌。这听起来像是图像处理和模式识别领域一个挺经典的“目标检测分类”问题但麻将牌识别有其独特的挑战牌面图案复杂有“万、条、筒、字”等排列紧密背景和光照条件多变。这个项目没有直接上现在流行的深度神经网络而是采用了更经典、也更能体现算法基本功的计算机视觉方法先提取颜色直方图和25维像素占比两种特征然后用支持向量机SVM进行分类。整个过程用C实现对性能和可控性有很高的要求。这个项目非常适合想深入理解传统图像处理流程、特征工程以及机器学习模型应用的开发者。无论你是正在学习OpenCV和C还是对如何将理论上的SVM、直方图匹配落地到具体问题感兴趣这个案例都能提供一条清晰的路径。它不依赖庞大的数据集和GPU算力却能让你亲手搭建一个从图像输入到结果输出的完整系统理解每一个环节的“为什么”和“怎么做”。2. 核心思路与方案选型为什么是“特征工程SVM”在深度学习一统计算机视觉江湖的今天为什么还要选择颜色直方图和像素占比SVM这套“古典”组合拳这背后是基于项目约束和问题特性的深思熟虑。2.1 问题场景与约束分析首先“云飞针”图像通常指的是麻将牌散乱铺开的一幅俯拍图可能是在牌桌上也可能是在识别装置中。其核心特点包括目标密集且规整麻将牌是矩形通常排列整齐或略有倾斜但彼此独立为分割提供了可能。类内差异小类间差异显著同一种牌如“一万”的图案是固定的但不同牌之间的图案、颜色、结构差异很大。例如“一条”是绿色小鸟“一筒”是一个红色圆圈。光照与背景干扰拍摄环境的光照可能不均匀背景可能是深色绒布或木质桌面存在反光、阴影。实时性与资源限制项目若用于实际设备如自动麻将机、点数机需要在嵌入式设备或普通PC上实时运行要求算法轻量、高效。基于这些特点深度学习方法虽然强大但需要大量标注数据训练模型体积大推理耗资源且内部机制如同黑盒。而传统方法数据需求小模型可解释性强运算效率高更符合本项目对可控性和效率的要求。2.2 技术方案拆解两步走策略整个流程被清晰地分为两步牌面分割Detection从整张“云飞针”图像中定位并分离出每一张独立的麻将牌。单牌识别Classification对分割出的每一张牌的子图像提取特征并使用SVM模型判断其类别如“三万”、“东风”、“白板”。2.3 特征与分类器选型理由颜色直方图麻将牌面的颜色分布是其最直观的特征之一。“万子”偏红“条子”偏绿“筒子”偏蓝或深色风牌和箭牌则有特定的颜色组合。颜色直方图对图像的旋转、轻微形变不敏感能很好地捕捉这种全局颜色统计信息且计算速度快。25维像素占比这是为了捕捉牌面的纹理和结构信息。具体做法可能是将一张牌图像划分成5x5的网格共25个区域然后计算每个区域内特定颜色如二值化后的前景白色或黑色的像素数量占比。这个特征能有效描述图案的局部空间分布例如“九筒”的九个点分布与“一筒”的一个点分布在网格占比上会有天壤之别。支持向量机SVM在特征维度不高本例中颜色直方图假设为N维加上25维总共N25维通常不超过几百维的情况下SVM是一个非常强大的分类器。它擅长处理小样本、非线性问题通过核函数并且最终模型只依赖于少数“支持向量”模型简洁预测速度快非常适合本项目这种类别固定麻将牌共34种或更多取决于规则、每类样本有限的分类任务。注意这里的“25维像素占比”是一种自定义的网格统计特征非常具有针对性。它本质上是一种简化的局部特征描述子比计算全图的HOG方向梯度直方图或SIFT尺度不变特征变换要轻量得多且对麻将牌这种特定目标效果显著。3. 核心细节解析与实操要点理解了整体框架我们来深入每个环节看看具体怎么做以及有哪些容易踩坑的地方。3.1 图像预处理为分割扫清障碍拿到“云飞针”原图第一步不是直接分割而是预处理目的是增强牌与背景的对比度减少噪声。色彩空间转换通常先将BGR图像转为灰度图便于后续阈值化。但注意如果后续分割严重依赖颜色比如背景是绿色绒布牌是红色可能需要先在HSV或Lab色彩空间下处理特定颜色通道。滤波去噪使用高斯模糊或中值滤波消除图像传感器噪声和微小的纹理干扰。中值滤波对“椒盐噪声”特别有效能保持边缘。对比度增强如果图像整体偏暗或偏亮可以使用直方图均衡化如CLAHE限制对比度的自适应直方图均衡化来拉伸灰度范围让牌的边缘更清晰。实操心得预处理参数如高斯核大小、滤波强度需要根据你的具体图像调整。一个技巧是写一个简单的GUI用滑动条实时调整参数看效果找到最适合你那批图像的参数组合。OpenCV的createTrackbar函数可以很方便地实现这一点。3.2 牌面分割如何把每张牌“框”出来这是关键一步分割的准确性直接决定后续识别的输入质量。二值化通过阈值分割将图像变为黑白。可以使用全局阈值如cv::threshold或更鲁棒的自适应阈值cv::adaptiveThreshold。目标是让麻将牌成为前景白色背景为黑色。形态学操作二值化后牌面内部可能有图案导致的空洞牌与牌之间可能有粘连。通过闭运算先膨胀后腐蚀可以填充牌内部的小孔洞通过开运算先腐蚀后膨胀可以分离轻微的粘连并平滑边缘。这里需要仔细调整核的大小。轮廓查找使用cv::findContours函数找到所有白色区域的轮廓。这里要检索外部轮廓RETR_EXTERNAL并采用简化轮廓的近似方法如CHAIN_APPROX_SIMPLE。轮廓筛选与矫正面积筛选根据先验知识设定一个合理的面积范围过滤掉太小的噪声点和过大的无效区域。矩形度与长宽比计算轮廓的最小外接矩形cv::minAreaRect。麻将牌近似矩形其外接矩形的面积与轮廓面积之比矩形度应接近1长宽比也应在一个固定范围内例如1.5到2.5之间。透视矫正如果牌不是正对着摄像头会有透视变形。通过最小外接矩形获取四个角点然后使用cv::getPerspectiveTransform和cv::warpPerspective进行透视变换将其“拉正”为规整的矩形图像。常见问题光照不均会导致二值化效果差部分牌分割不出来。解决方法一是尝试自适应阈值二是可以在二值化前先进行背景建模或差分如果背景相对固定的话。3.3 特征提取从图像到数字向量对于每一张矫正后的单牌图像我们需要提取两种特征并拼接成一个特征向量。颜色直方图特征将图像从BGR转换到HSV色彩空间。HSV对光照变化比RGB更鲁棒。计算H色调和S饱和度通道的二维直方图。通常忽略V明度通道以减少光照影响。将直方图“展平”flatten成一个一维向量。为了减少维度并增强鲁棒性通常会对直方图进行归一化cv::normalize并使用较少的bin如H通道8个binS通道8个bin共64维。25维像素占比特征将单牌图像二值化得到只有牌面图案白色和牌底黑色的图像。将图像均匀划分为5行5列共25个网格。遍历每个网格计算网格内白色像素的数量然后除以网格的总像素数得到该网格的“白色像素占比”。这样就得到一个25维的向量描述了图案在牌面上的空间密度分布。实操心得25维特征的提取依赖于单牌图像二值化的质量。如果牌面反光导致图案断裂或者牌底色不纯这个特征就会不准。因此在提取该特征前可能需要对单牌图像再做一次精细的局部二值化或使用边缘检测来强化图案。3.4 特征融合与SVM模型特征拼接将归一化后的颜色直方图向量例如64维和25维像素占比向量直接拼接在一起形成一个89维6425的联合特征向量。这就是代表一张牌的“数字指纹”。数据准备收集足够多的样本每种牌至少几十张在不同光照、角度下提取它们的特征向量并打好标签如0代表“一万”1代表“二万”…形成训练数据集。SVM训练与调参使用OpenCV的cv::ml::SVM类。关键参数SVM::Type: 通常选择C_SVC用于多分类。SVM::KernelType: 线性核LINEAR速度快但如果特征与类别关系非线性效果可能不好。径向基函数核RBF更强大但需要调参。建议先从RBF开始。C(惩罚系数) 和gamma(RBF核参数)这两个参数至关重要。需要使用网格搜索Grid Search配合交叉验证来寻找最优值。OpenCV的SVM::trainAuto可以辅助完成。训练完成后保存模型文件.xml或.yml便于后续加载使用。注意事项在拼接特征前最好对两种特征分别进行标准化Standardization即减去均值除以标准差使所有维度都在相近的数值范围内避免某个特征因量纲大而主导训练过程。4. 完整实现流程与核心代码解析下面我将以C和OpenCV为例勾勒出核心步骤的代码框架。假设我们已经有了分割好的单牌图像cv::Mat singleTile。4.1 特征提取函数实现#include opencv2/opencv.hpp #include vector // 提取颜色直方图特征 (HSV空间2D直方图) std::vectorfloat extractColorHistogram(const cv::Mat tileImg, int hBins 8, int sBins 8) { cv::Mat hsv; cv::cvtColor(tileImg, hsv, cv::COLOR_BGR2HSV); // 定义色调和饱和度的范围 float hRanges[] {0, 180}; // OpenCV中H范围是0-180 float sRanges[] {0, 256}; const float* ranges[] {hRanges, sRanges}; int channels[] {0, 1}; // 使用H和S通道 cv::Mat hist; cv::calcHist(hsv, 1, channels, cv::Mat(), hist, 2, {hBins, sBins}, ranges, true, false); // 归一化并展平 cv::normalize(hist, hist, 1.0, 0.0, cv::NORM_L1); std::vectorfloat histFeatures; hist.reshape(1, 1).copyTo(histFeatures); // 展平为1行N列的行向量 return histFeatures; } // 提取25维像素占比特征 std::vectorfloat extract25GridFeature(const cv::Mat tileImg) { cv::Mat gray, binary; cv::cvtColor(tileImg, gray, cv::COLOR_BGR2GRAY); // 自适应阈值二值化突出牌面图案 cv::adaptiveThreshold(gray, binary, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, cv::THRESH_BINARY_INV, 11, 2); int gridRows 5; int gridCols 5; int tileH binary.rows; int tileW binary.cols; int gridH tileH / gridRows; int gridW tileW / gridCols; std::vectorfloat gridFeatures(25, 0.0f); int idx 0; for (int r 0; r gridRows; r) { for (int c 0; c gridCols; c) { cv::Rect gridRect(c * gridW, r * gridH, gridW, gridH); cv::Mat grid binary(gridRect); int whitePixels cv::countNonZero(grid); float ratio static_castfloat(whitePixels) / (gridW * gridH); gridFeatures[idx] ratio; } } return gridFeatures; } // 融合特征 std::vectorfloat extractCombinedFeature(const cv::Mat tileImg) { auto colorHist extractColorHistogram(tileImg); auto gridFeat extract25GridFeature(tileImg); std::vectorfloat combinedFeature; combinedFeature.reserve(colorHist.size() gridFeat.size()); combinedFeature.insert(combinedFeature.end(), colorHist.begin(), colorHist.end()); combinedFeature.insert(combinedFeature.end(), gridFeat.begin(), gridFeat.end()); return combinedFeature; }4.2 SVM模型的训练与保存void trainAndSaveSVM(const std::vectorstd::vectorfloat trainingData, const std::vectorint labels, const std::string modelPath) { // 转换数据格式 int numSamples trainingData.size(); int featureSize trainingData[0].size(); cv::Mat trainDataMat(numSamples, featureSize, CV_32FC1); cv::Mat labelsMat(numSamples, 1, CV_32SC1); for (int i 0; i numSamples; i) { for (int j 0; j featureSize; j) { trainDataMat.atfloat(i, j) trainingData[i][j]; } labelsMat.atint(i, 0) labels[i]; } // 创建并设置SVM cv::Ptrcv::ml::SVM svm cv::ml::SVM::create(); svm-setType(cv::ml::SVM::C_SVC); svm-setKernel(cv::ml::SVM::RBF); svm-setTermCriteria(cv::TermCriteria(cv::TermCriteria::MAX_ITER cv::TermCriteria::EPS, 1000, 1e-6)); // 自动训练寻找最优C和gamma svm-trainAuto(cv::ml::TrainData::create(trainDataMat, cv::ml::ROW_SAMPLE, labelsMat)); // 保存模型 svm-save(modelPath); std::cout SVM model saved to: modelPath std::endl; }4.3 整体推理流程主函数流程大致如下int main() { // 1. 加载云飞针图像 cv::Mat cloudImage cv::imread(cloud_needle.jpg); // 2. 预处理、分割、透视矫正得到一组单牌图像 vectorMat tiles std::vectorcv::Mat tiles segmentTiles(cloudImage); // 假设已实现分割函数 // 3. 加载训练好的SVM模型 cv::Ptrcv::ml::SVM svm cv::ml::SVM::load(mahjong_svm_model.xml); // 4. 对每张牌进行识别 std::vectorint predictions; for (const auto tile : tiles) { auto feature extractCombinedFeature(tile); cv::Mat featureMat(1, feature.size(), CV_32FC1, feature.data()); float response svm-predict(featureMat); predictions.push_back(static_castint(response)); } // 5. 输出或使用识别结果 (predictions 里是类别标签) // ... return 0; }5. 常见问题、优化策略与避坑指南在实际操作中你肯定会遇到各种各样的问题。下面是我在实现和调试过程中总结的一些典型问题及解决方案。5.1 分割失败牌没分开或分多了问题轮廓查找找到了几十上百个区域很多是噪声。排查检查二值化效果显示二值化后的图像看牌与背景是否分离清晰。如果效果差尝试调整阈值方法或先进行更有效的预处理如背景减除。调整形态学核大小开运算的核太小无法分离粘连太大会腐蚀牌体。通过滑动条动态调整观察轮廓数量的变化。严格面积和长宽比过滤根据实际牌的像素面积设置一个较窄的过滤区间。长宽比也要卡死排除掉圆形或长条形的噪声。技巧在开发阶段把每一步的中间图像灰度图、二值图、形态学操作后图都显示出来并实时绘制找到的轮廓和外接矩形这是最直观的调试方法。5.2 特征提取不稳定同一张牌在不同光照下特征差异大问题导致SVM分类准确率下降。优化策略颜色直方图使用HSV而非RGB在计算直方图前可以对图像进行简单的光照补偿比如直方图均衡化或者使用更鲁棒的颜色描述子如颜色矩。25维特征二值化是关键。可以尝试用Canny边缘检测代替全局二值化然后计算网格内的边缘像素占比这样对光照变化更不敏感。特征标准化务必在训练和预测前对整个特征向量进行标准化。使用训练集计算出的均值和标准差来标准化训练集和测试集。数据增强对训练集图像进行仿射变换轻微旋转、缩放、添加噪声、调整亮度和对比度模拟各种拍摄条件让模型更鲁棒。5.3 SVM分类准确率不高问题训练集上表现好测试集上差或者整体准确率达不到实用要求如95%。排查与调优检查特征是否有效手动查看一些错分样本的特征向量与正确样本对比看特征是否有明显区分度。可以考虑使用PCA主成分分析降维并可视化观察不同类别的样本在特征空间是否可分。SVM参数调优C和gamma是核心。一定要用交叉验证进行网格搜索。OpenCV的trainAuto是个不错的起点但它可能找不到全局最优可以手动在其推荐值附近进行更精细的搜索。核函数选择如果数据线性不可分线性核效果必然差。RBF核是默认的好选择。也可以尝试多项式核。类别不平衡如果某些牌如字牌样本少会导致对这些牌的识别率低。可以在SVM中为不同类别设置不同的权重cv::ml::SVM::setClassWeights或者对少数类样本进行过采样。考虑更复杂的特征或模型如果传统特征SVM的天花板就在90%那么可能需要引入更强大的特征如HOG、LBP或者考虑使用轻量级的深度学习模型如MobileNet、SqueezeNet进行微调。但在嵌入式场景下需要权衡精度和速度。5.4 实时性不达标问题处理一张“云飞针”图像耗时过长。性能优化算法层面减少图像处理分辨率在保证识别率的前提下将图像缩放至较小的固定尺寸如牌面图像缩放到64x128。优化特征维度评估颜色直方图bins数量是否可以减少如从8x8降到4x4或者尝试其他计算更快的特征。使用线性SVM核RBF核预测比线性核慢。如果线性核精度可接受优先使用线性核。工程层面使用Intel IPP或OpenCV的UMatOpenCV的UMat可以利用GPU或IPP进行加速。多线程处理牌与牌之间的识别是独立的可以很容易地并行化。使用C11的thread或 OpenMP 来并行处理分割出的多张牌。代码热点分析使用性能分析工具如gprof、Valgrind的Callgrind找出最耗时的函数针对性优化。避坑终极建议建立一个可视化调试管道。写一个程序能够依次显示原图、预处理后图、二值化图、轮廓图、分割出的每个牌、以及最终的识别结果。同时将提取的特征值打印或保存下来。当识别出错时通过这个管道可以迅速定位问题是出在分割、特征提取还是分类环节这是最高效的调试方式。这个基于C和传统视觉方法的麻将牌识别项目就像搭积木一样把图像预处理、分割、特征工程、机器学习模型串联起来。它可能没有深度学习模型那么“炫酷”和“全能”但在这个过程中你对图像像素的操纵、对特征意义的理解、对模型参数影响的体会是直接调包所无法比拟的。当你看到自己写的代码成功地从一堆麻将中认出“九莲宝灯”时那种成就感是实实在在的。希望这个详细的拆解能帮你少走弯路更快地搭建起属于自己的“牌局分析专家”。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。