ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于传统图像处理与SVM的麻将牌识别:从分割到分类的完整工程实践

基于传统图像处理与SVM的麻将牌识别:从分割到分类的完整工程实践 简介本资源是一个基于C实现的麻将牌图像识别项目面向计算机视觉初学者与课程设计实践者解决真实场景下云飞针拍摄的麻将牌自动分离与分类问题。项目融合颜色直方图与25维像素占比双特征提取策略并采用SVM分类器完成136张标准麻将牌的精准识别适合作为毕设、大作业或工程实训的完整技术方案参考。压缩包共含2000个文件1958张标注PNG样本图像、14个核心CPP源码、11个头文件及UI/构建相关文件总大小22.26MB结构清晰涵盖图像预处理、特征计算、模型训练与GUI交互全流程。已有95人学习下载代码具备良好可读性与模块化设计包含qcolorhist_pixratio_feature.cpp等关键算法实现以及mainwindow、dialogmajiang等界面逻辑便于理解特征工程与SVM在实际图像识别任务中的落地细节。1. 项目缘起从“一锅粥”到“单兵作战”的麻将牌识别做图像识别项目最怕的就是目标“黏”在一起。去年我接了个活儿客户给了一堆用云飞针摄像头拍的麻将桌图像要求是把每一张牌都单独抠出来并且自动识别出它是“一万”还是“红中”。乍一看这活儿不难麻将牌规规矩矩的背景也相对干净。但真上手就发现坑不少牌和牌之间紧挨着甚至部分重叠光照不均匀导致同一种牌的颜色有差异有些牌比如“一条”和“一筒”在低分辨率下轮廓有点像。直接用现成的OCR或者模板匹配效果惨不忍睹要么切不开要么认不准。折腾了一圈最后定下的技术路线核心就三块分离、提特征、分类。分离用的是传统的图像处理技术把每张牌的矩形区域找出来特征提取上我用了颜色直方图和一种自己琢磨的“25维像素占比”特征来双保险分类器则选择了经典的SVM支持向量机。整个流程用C实现追求的就是一个快和稳。今天我就把这个项目的完整实现思路、关键代码和踩过的那些坑毫无保留地分享出来。如果你也在做类似的卡牌、票据或者特定目标的分割识别这套方法或许能给你省下不少折腾的时间。2. 麻将牌图像分割如何把“连体婴”精准切开拿到一张云飞针摄像头拍摄的整副麻将图像第一步也是最关键的一步就是把每一张牌单独“框”出来。这一步的准确性直接决定了后续特征提取和识别的成败。如果切分错了比如把两张牌切到了一起或者把一张牌切成了两半那后面特征再强、分类器再牛也白搭。2.1 预处理为轮廓检测铺平道路原始图像通常是彩色的并且可能包含噪声、光照阴影等干扰。直接处理效果不好我们必须先给它“美美容”。灰度化与二值化这是所有轮廓操作的基础。首先将彩色图像转为灰度图简化信息。接着采用**大津法Otsu‘s Method**进行自适应阈值二值化。这个方法的好处是能自动根据图像灰度分布找到一个最佳阈值把前景麻将牌和背景麻将桌布分开。在OpenCV里就是一行代码的事cv::threshold(grayImg, binaryImg, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU)。这一步之后麻将牌应该是白色的255背景是黑色的0。注意光照不均是大津法的天敌。如果拍摄时光线一边亮一边暗可能导致二值化后某些牌的局部和背景融在一起。在实际项目中我遇到过桌面有深色花纹的情况这会被误认为前景。一个补救措施是在二值化前先进行高斯滤波或中值滤波平滑噪声和细小纹理但滤波核不能太大否则会模糊牌的边缘。形态学操作二值化后的图像牌的边缘可能不连续或者牌面上的花纹比如“万”字的笔画会产生一些小孔洞。这会影响后续寻找完整外轮廓。这里我使用了闭运算先膨胀后腐蚀。膨胀可以填充牌内部的花纹孔洞并连接断开的边缘随后的腐蚀则能大致恢复牌的原始大小平滑边缘。OpenCV中的cv::morphologyEx函数配合cv::MORPH_CLOSE操作符可以轻松完成。结构元素我通常选用一个3x3或5x5的矩形核。// 示例预处理核心步骤 cv::Mat gray, binary, morph; // 1. 灰度化 cv::cvtColor(srcImage, gray, cv::COLOR_BGR2GRAY); // 2. 高斯模糊去噪 cv::GaussianBlur(gray, gray, cv::Size(5, 5), 0); // 3. 大津法二值化 cv::threshold(gray, binary, 0, 255, cv::THRESH_BINARY_INV | cv::THRESH_OTSU); // 这里用了THRESH_BINARY_INV让牌为白色方便找轮廓 // 4. 闭运算填充内部空隙 cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); cv::morphologyEx(binary, morph, cv::MORPH_CLOSE, kernel);2.2 轮廓查找与筛选找到真正的“牌”预处理后我们得到了一张干净的二值图接下来就是用OpenCV的findContours函数找出所有白色区域的轮廓。这里会找到一大堆轮廓包括真正的麻将牌也可能包括图像噪点、桌布上的污渍或者因为牌面花纹而误产生的内部轮廓。关键筛选逻辑我们需要根据麻将牌的先验知识来过滤。一副标准麻将牌的大小是固定的在图像中表现为像素尺寸在一定范围内形状是矩形长宽比在一定范围并且面积不会太小。面积筛选计算每个轮廓的面积contourArea。设置一个最小面积阈值比如500像素过滤掉灰尘级别的噪点。同时也要设置一个最大面积阈值防止把多张粘连的牌当成一个整体虽然闭运算减少了粘连但严重重叠时仍可能发生。矩形度与长宽比筛选用minAreaRect为每个轮廓找一个最小外接矩形。这个矩形能给我们提供中心点、尺寸width, height和旋转角度。对于平放在桌面的麻将其旋转角度应该接近0或180度允许小幅倾斜。更重要的是长宽比麻将牌的长宽比是固定的大约2:1。我们可以设定一个范围比如1.8到2.2来过滤掉圆形的骰子或者其他不规则物体。层次关系筛选findContours可以返回轮廓的层次信息。由于我们做了闭运算一张牌的外轮廓内部可能没有子轮廓孔洞被填上了所以我们可以选择只保留没有父轮廓的顶层轮廓这能有效排除牌面数字内部的轮廓。经过这几轮筛选保留下来的轮廓基本上就是一张张独立的麻将牌了。每个轮廓对应的最小外接矩形RotatedRect就是我们要的“边界框”。2.3 畸变校正与归一化让每一张牌“站好队”由于拍摄角度问题麻将牌在图像中可能是倾斜的旋转的矩形。为了后续提取统一、可比的特征我们需要把每一张牌都“扶正”并缩放到统一尺寸。仿射变换校正对于每个筛选后的RotatedRect我们可以获取它的四个顶点。然后我们定义目标矩形的四个顶点例如一个宽为100像素高为50像素的正矩形。使用getPerspectiveTransform计算透视变换矩阵再用warpPerspective将原图中倾斜的牌区域变换到我们定义的正矩形图像上。这样无论牌在原始图像中如何旋转我们得到的都是正面朝上、大小统一的牌面图像。// 示例提取并校正单张牌图像 std::vectorcv::Point2f srcPoints, dstPoints; cv::RotatedRect rect cv::minAreaRect(contour); rect.points(srcPoints.data()); // 获取旋转矩形的四个顶点 // 定义目标矩形归一化尺寸例如100x50 float width 100.0f; float height 50.0f; dstPoints.push_back(cv::Point2f(0, 0)); dstPoints.push_back(cv::Point2f(width, 0)); dstPoints.push_back(cv::Point2f(width, height)); dstPoints.push_back(cv::Point2f(0, height)); // 计算透视变换矩阵并应用 cv::Mat transformMat cv::getPerspectiveTransform(srcPoints, dstPoints); cv::Mat warpedCard; cv::warpPerspective(srcImage, warpedCard, transformMat, cv::Size(width, height));至此我们得到了一系列大小统一、姿态端正的单张麻将牌图像。这为后续稳定地提取特征打下了坚实的基础。这一步的稳定性是整个项目的基石我花了大量时间调整预处理和筛选参数以应对不同光照和拍摄条件下的图像。一个实用的技巧是可以写一个可视化调试工具把每一步处理的结果二值图、轮廓绘制、筛选后的框都显示出来方便参数调优。3. 特征工程教计算机“看”懂麻将牌图像分割出来之后计算机看到的依然只是一堆像素。我们需要从中提取出能够区分“一万”和“九万”、“东风”和“西风”的数学特征。特征提取是模式识别的灵魂特征选得好分类事半功倍。在这个项目中我采用了颜色直方图和25维像素占比两种特征进行融合形成对一张牌的综合描述。3.1 颜色直方图特征捕捉牌面的主色调麻将牌虽然花色多样但其颜色分布有很强的规律性。“万”、“条”、“筒”系列通常是单色如蓝色、绿色的数字或图案而“中发白”等字牌则有鲜明的颜色红、绿。颜色直方图能很好地刻画这种全局颜色分布。HSV色彩空间的优势我并没有在常见的RGB空间计算直方图而是转换到了HSV空间。原因在于HSV色调Hue饱和度Saturation明度Value将颜色信息H,S和亮度信息V分离开对光照变化主要是亮度V的变化更具鲁棒性。一张红色的牌在亮处和暗处其RGB值差异很大但它的色调H基本是稳定的。计算与量化将校正后的牌面图像从BGR转换到HSVcv::cvtColor(cardImg, hsvImg, cv::COLOR_BGR2HSV)。主要利用H和S两个通道。将H通道范围0-180和S通道范围0-255进行联合量化。例如将H均匀分成8个区间S均匀分成4个区间这样就得到了一个8x432维的联合直方图。使用cv::calcHist函数计算这个二维直方图。为了消除牌面大小对绝对像素数量的影响需要对直方图进行归一化使其总和为1。这样直方图的每一个bin值就代表了对应颜色区间在整张牌中所占的比例。// 示例计算HSV颜色直方图特征 cv::Mat hsvImg; cv::cvtColor(normalizedCard, hsvImg, cv::COLOR_BGR2HSV); // 分离H,S通道 std::vectorcv::Mat hsvPlanes; cv::split(hsvImg, hsvPlanes); cv::Mat hue hsvPlanes[0]; cv::Mat sat hsvPlanes[1]; // 设置直方图参数 int hBins 8, sBins 4; int histSize[] { hBins, sBins }; float hRanges[] { 0, 180 }; float sRanges[] { 0, 256 }; const float* ranges[] { hRanges, sRanges }; int channels[] { 0, 1 }; // 使用0(H)和1(S)通道 cv::MatND hist; cv::calcHist(hsvImg, 1, channels, cv::Mat(), hist, 2, histSize, ranges, true, false); cv::normalize(hist, hist, 1.0, 0.0, cv::NORM_L1); // L1归一化最终我们将这个32维的归一化直方图数据拉直reshape作为一个32维的特征向量。这个向量描述了这张牌的颜色构成。3.2 25维像素占比特征刻画形状与布局颜色特征虽然有效但对于区分颜色相近但图案不同的牌比如深蓝色的“一万”和“二万”就力不从心了。这时需要形状和纹理特征。我设计了一种简单但有效的“像素占比”特征。核心思想将归一化的牌面图像例如100x50划分成5x5的网格共25个格子。对于每个格子计算其中属于“前景”即牌面图案而非牌底色的像素比例。这相当于一个低分辨率的“形状模板”。如何定义“前景”这里需要一个针对麻将牌的二次分割。牌通常有底色如米白色、浅绿色和图案色。我们可以对牌面图像再次使用阈值分割例如在灰度图或某个颜色通道上或者利用颜色直方图中占比最大的颜色作为背景色进行掩码。更鲁棒的方法是使用聚类比如K-MeansK2将像素粗略分为背景类和前景类。计算流程对归一化牌面图像进行前景/背景分割得到二值图mask前景为255背景为0。将图像划分为5行5列。对于第i行第j列的格子计算mask在该格子区域内非零像素的数量再除以该格子的总像素数得到一个0到1之间的比值ratio[i][j]。将25个格子的ratio值按行优先顺序排列得到一个25维的特征向量。// 示例计算25维网格像素占比特征 cv::Mat grayCard, binaryMask; cv::cvtColor(normalizedCard, grayCard, cv::COLOR_BGR2GRAY); // 方法1自适应阈值 cv::threshold(grayCard, binaryMask, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU); // 方法2或使用固定阈值需根据牌底色调整 // cv::threshold(grayCard, binaryMask, 200, 255, cv::THRESH_BINARY_INV); int gridRows 5, gridCols 5; int cellHeight normalizedCard.rows / gridRows; int cellWidth normalizedCard.cols / gridCols; std::vectorfloat gridFeatures(25, 0.0f); int featureIdx 0; for (int r 0; r gridRows; r) { for (int c 0; c gridCols; c) { cv::Rect cellRect(c * cellWidth, r * cellHeight, cellWidth, cellHeight); cv::Mat cellMask binaryMask(cellRect); int foregroundPixels cv::countNonZero(cellMask); float ratio static_castfloat(foregroundPixels) / (cellWidth * cellHeight); gridFeatures[featureIdx] ratio; } }这个25维特征蕴含了牌面图案的粗略空间分布信息。“一万”的“一”字可能集中在中间一列而“九万”的九个点可能分布更广。它与颜色直方图特征形成了很好的互补。3.3 特征融合与向量化单一特征往往有局限性融合多特征能提升模型的判别能力。我将32维的颜色直方图特征和25维的像素占比特征**拼接Concatenate**在一起形成了一个57维的联合特征向量。注意由于两个特征的量纲和数值范围不同直方图值是比例在0~1像素占比也是0~1但分布可能不同直接拼接可能让某个特征在计算距离时占据主导。因此在送入分类器之前需要对这57维的整体特征向量进行标准化。我通常使用Z-score标准化减去均值除以标准差使得每个维度的均值为0方差为1。这一步对基于距离度量的分类器如SVM的RBF核至关重要。可以使用OpenCV的cv::normalize配合cv::NORM_L2或者在训练SVM时设置其param.svm_type为CvSVM::C_SVC并使用自动训练它会内部处理缩放。至此每一张麻将牌都被我们转化成了一个57维的、数字化的特征向量。这个向量就是这张牌的“数学身份证”接下来就交给SVM去学习和区分了。4. 分类器实战用SVM为麻将牌“验明正身”特征向量准备好了我们需要一个分类器来学习这些特征与麻将牌类别如“一万”、“东风”、“白板”等之间的映射关系。支持向量机SVM因其在小样本、高维度数据上的优秀性能成为了我的首选。它的核心思想是找到一个最优的超平面将不同类别的样本尽可能清晰且间隔最大地分开。4.1 SVM原理与OpenCV实现要点对于线性不可分的问题我们的57维特征空间大概率是非线性的SVM通过核函数Kernel Function将样本映射到更高维的空间使其在那个空间里线性可分。最常用的核函数是径向基函数RBF也叫高斯核。它能够处理非常复杂的非线性决策边界。在OpenCV中传统的SVM实现位于ml模块。虽然OpenCV 4.x更推荐新的cv::ml::SVM接口但其核心参数是一致的。训练一个SVM分类器主要涉及以下几个关键参数SVM类型我们选择C_SVC这是最常用的用于多类分类的SVM类型。核函数选择RBFcv::ml::SVM::RBF。参数C惩罚系数。C值越大对误分类的惩罚越重模型越倾向于找到一个完全分离所有训练样本的超平面但可能导致过拟合模型复杂。C值小则允许一些误分类模型更简单可能欠拟合。需要通过交叉验证来寻找最优值。参数GammaRBF核函数的参数。Gamma定义了单个训练样本的影响范围。Gamma值大影响范围小模型会更复杂容易过拟合Gamma值小影响范围大模型会更平滑可能欠拟合。同样需要调优。训练流程准备训练数据一个cv::Mat类型的特征矩阵trainData行数是样本数列数是特征维度57。一个cv::Mat类型的标签矩阵labels对应每个样本的类别如用0代表“一万”1代表“二万”等。创建SVM对象设置参数。调用train方法进行训练。保存训练好的模型svm-save(“mahjong_svm_model.xml”)以便后续加载使用。// 示例使用OpenCV训练SVM #include opencv2/ml.hpp // 假设 trainFeatures 是 cv::Mat (N x 57) trainLabels 是 cv::Mat (N x 1, int) 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 cv::TermCriteria::EPS, 1000, 1e-6)); // 开始训练 cv::Ptrcv::ml::TrainData trainData cv::ml::TrainData::create(trainFeatures, cv::ml::ROW_SAMPLE, trainLabels); svm-train(trainData); // 保存模型 svm-save(mahjong_svm_model.yml);4.2 样本收集、标注与数据增强机器学习数据为王。SVM虽然对小样本友好但足够多且高质量的训练样本是模型泛化能力的保证。样本收集最直接的方法就是手动拍摄大量包含各种麻将牌在不同位置、不同光照、不同角度下的图片。云飞针摄像头固定后可以自动连续拍摄我们从中分割出单张牌图像作为原始素材。一个类别如“一万”至少需要几十到上百个样本。数据标注这是一个枯燥但必须精确的过程。为每一张分割出来的牌图像手动打上正确的标签。可以写一个简单的标注工具显示图片键盘输入类别编码。标签文件通常是一个与图像列表对应的文本文件。数据增强为了提升模型的鲁棒性防止过拟合可以对训练样本进行数据增强。对于麻将牌识别有效的增强方式包括轻微旋转±5度模拟牌放置不绝对水平。平移在牌面图像内轻微移动内容。亮度/对比度微调模拟光照变化。添加椒盐噪声或高斯模糊模拟图像质量下降。重要心得数据增强的幅度不宜过大。例如旋转角度太大“东风”可能就认不出了亮度变化太剧烈颜色特征会完全失真。增强的目的是模拟真实场景中的小扰动而不是创造新样本。4.3 模型训练、评估与调参实战有了数据和代码就可以开始训练了。但直接训练往往得不到好结果需要一个迭代调优的过程。数据集划分将标注好的样本随机分为训练集约70%和测试集约30%。测试集绝对不能参与任何训练和调参过程它只用于最终评估模型泛化能力。网格搜索调参C和Gamma的最优值未知。采用网格搜索Grid Search配合K折交叉验证K-fold Cross-Validation。例如让C在[0.1, 1, 10, 100]Gamma在[0.001, 0.01, 0.1, 1]中组合对每一组(C, Gamma)在训练集上进行5折交叉验证计算平均分类准确率。选择平均准确率最高的一组参数。训练与验证用找到的最优参数在整个训练集上重新训练最终模型。测试评估用保存的测试集评估最终模型。计算混淆矩阵可以清晰看到模型在哪两个类别上容易混淆比如“六条”和“九条”。除了整体准确率还要关注每个类别的精确率和召回率。// 示例使用测试集进行预测并计算准确率 cv::Ptrcv::ml::SVM svm cv::ml::SVM::load(mahjong_svm_model.yml); cv::Mat testPredictions; svm-predict(testFeatures, testPredictions); int correctCount 0; for (int i 0; i testLabels.rows; i) { if (testPredictions.atfloat(i) testLabels.atint(i)) { correctCount; } } float accuracy static_castfloat(correctCount) / testLabels.rows; std::cout 测试集准确率: accuracy * 100 % std::endl;在我的实际项目中初始模型在测试集上准确率只有85%左右。通过分析混淆矩阵发现主要错误集中在“万”牌的数字识别上特别是“一”、“二”、“三”的简写体在图像模糊时容易误判。针对这个问题我做了两件事一是增加了这些易混淆类别的训练样本针对性数据采集二是在特征工程部分我微调了25维网格的划分方式针对数字区域增加了网格密度改为不均匀网格从而提取更精细的形状特征。经过几轮迭代最终将测试准确率稳定在了96%以上。5. 工程整合与性能优化打造一个可用的系统算法模块跑通后我们需要将其整合成一个稳定、高效、可用的系统。这涉及到流程串联、错误处理、性能优化和部署考量。5.1 完整流程串联与错误处理一个完整的识别流程应该像流水线一样自动运行输入图像-预处理与分割-单牌校正-特征提取-SVM分类-输出结果。我们需要用C将这些模块封装成函数或类。一个健壮的系统必须有良好的错误处理机制分割失败处理如果findContours后筛选到的矩形数量为0或异常多可能是预处理参数不适应当前图像。可以记录日志并尝试回退到另一组参数如调整二值化阈值或者直接返回识别失败。特征提取异常如果某张牌图像校正后严重扭曲或为空应跳过该张牌的特征提取并在结果中标记为“无效”。分类置信度SVM的predict函数返回的是类别标签但我们可以使用predict的重载版本或predict2取决于OpenCV版本来获取到决策边界的距离或概率估计需要训练时设置svm-setProbability(true)。可以设定一个置信度阈值低于该阈值的识别结果视为“不确定”需要人工复核。资源管理确保图像内存及时释放避免内存泄漏。使用cv::Mat的引用计数机制在函数间传递时尽量使用const cv::Mat。5.2 C代码层面的性能优化云飞针摄像头可能产生连续的视频流实时性要求高。C虽然快但不当写法也会成为瓶颈。避免重复计算与拷贝对于每张牌都要进行的操作如计算颜色直方图确保函数参数使用常量引用。像高斯滤波核、形态学操作核等可以预先创建好重复使用。cv::Mat的ROIcv::Mat roi image(rect)是共享数据的不拷贝要善用。多线程并行处理识别多张牌是典型的数据并行任务。可以使用C11的std::thread或者OpenMP对for循环进行并行化。例如将检测到的所有牌放入一个vector然后用多线程同时进行“校正-特征提取-分类”的流程。需要小心线程间数据竞争每个线程应有独立的输出容器。#pragma omp parallel for for (size_t i 0; i detectedCards.size(); i) { cv::Mat warped warpCard(detectedCards[i]); std::vectorfloat feature extractFeature(warped); int label svm-predict(feature); // 将结果存入线程安全的容器如预先分配好大小的vector通过索引i写入 results[i] label; }内存池与对象复用对于高频调用的函数内部创建的临时cv::Mat可能会频繁分配释放内存。可以考虑在类内部或函数外部复用一些工作内存但要注意线程安全。模型加载优化SVM模型文件.xml或.yml只需在程序初始化时加载一次应将其保存在全局或静态变量中避免每次识别都重复加载。5.3 部署与持续改进将算法封装成动态库.dll或.so或静态库提供给上层应用程序如C#、Python或Qt界面程序调用。设计清晰的API接口例如std::vectorMahjongCard RecognizeMahjong(const cv::Mat inputImage)。持续改进的循环收集错误案例系统上线后肯定会遇到识别错误的案例。建立一个机制如保存出错的原始图像和识别结果来收集这些“困难样本”。分析错误原因是分割问题牌没切开还是特征不够区分度比如“發”和“绿一色”的绿色部分太像或者是训练数据缺乏此类场景针对性优化根据分析结果扩充训练数据特别是错误样本或者调整特征提取方法例如对于容易混淆的“条”系列也许可以加入更精细的纹理特征如LBP甚至重新调整SVM参数。模型更新用新的、更大的数据集重新训练模型并滚动更新部署的模型文件。这个从算法原型到稳定系统的过程远比调出一个高准确率的模型要复杂但也正是工程价值的体现。通过这个项目我深刻体会到在工业级应用中算法的鲁棒性和系统的稳定性往往比单纯的识别率数字更重要。本文还有配套的精品资源点击获取
返回列表