基于 AForge.Net 框架的扑克牌识别

发布时间:2023年12月27日

? Conmajia 2012, Nazmi Altun 2011
Init. 24 May 2012
SN: 125.1

本文为翻译文章,已获原作者 Nazmi Altun 授权。

下载资源:
源代码(148.6 KB)Demo(3.1 MB)

简介

(图片上的字:方块 4、方块 J、黑桃 2)

机器人搭配上扑克牌识别系统,即可在二十一点之类的扑克游戏中扮演荷官或者玩家的角色。实现这样的程序同样也是学习计算机视觉和模式识别的好路子。

本文涉及到关于 AForge.NET 框架的技术有二值化、边缘检测、仿射变换、BLOB 处理和模板匹配算法等。

需要注意的是,本文介绍的系统针对英美扑克设计,可能不适用于其他种类的扑克(如日本、朝鲜传统花牌——译者注)。当然,本文详述了扑克牌检测和识别的基本方法,具体的识别算法按特定的牌型特点据此修改即可。

这里有一个 演示视频

扑克检测

我们需要检测视频图像上的扑克对象,以便能进行下一步的识别。为了完成检测,我们会用一些图像滤镜对视频画面进行预处理。

第一步,将图像去色,即灰度化。去色是将彩色图像转换成 8bit 图像的一种操作。我们需要将彩色图像转换为灰度图像以便对其进行二值化。

我们把彩色图像转为灰度图像后,对其进行二值化。二值化是将灰度图像转换为黑白图像的过程。本文使用 Otsu 算法进行全局阈值化,代码如下。

// 复制原始图像
Bitmap temp= source.Clone() as Bitmap;
FiltersSequence seq = new FiltersSequence();
// 添加灰度滤镜
seq.Add(Grayscale.CommonAlgorithms.BT709);
// 添加二值化滤镜
seq.Add(new OtsuThreshold()); 
// 应用滤镜
temp = seq.Apply(source); 

其过程如下图。

(图片上的字:原始图像、灰度图像、二值/黑白图像)

有了二值图像后,就可以用 BLOB(图块)算法检测扑克牌了。我们使用 AForge.Net 的 BlobCounter 类完成这项任务。该类利用连通区域标记算法统计并提取出图像中的独立对象。

// 从图像中提取宽度和高度大于 150 的 blob
BlobCounter extractor = new BlobCounter();
extractor.FilterBlobs = true;
extractor.MinWidth = extractor.MinHeight = 150;
extractor.MaxWidth = extractor.MaxHeight = 350;
extractor.ProcessImage(temp);

执行完上述代码后,BlobCounter 类会滤掉宽度和高度不在 [150,350] 像素之间的图块。这有助于我们区分出图像中其他物体(如果有的话)。根据测试环境的不同,我们需要改变滤镜参数。例如,假设地面和相机之间距离增大,则图像中的扑克牌会变小。此时,我们需要相应的改变最小、最大宽度和高度参数。

现在,我们可以通过调用 extractor.GetObjectsInformation() 方法得到所有的图块信息(边缘点、矩形区域、中心点、面积、完整度,等等)。然而,我们只需要图块的边缘点来计算矩形区域中心点,并通过调用 PointsCloud.FindQuadriteralCorners 函数来计算。

foreach (Blob blob in extractor.GetObjectsInformation()) {
	// 获取扑克牌的边缘点
	List< IntPoint > edgePoints = extractor.GetBlobsEdgePoints(blob);
	// 利用边缘点,在原始图像上找到四角
	List< IntPoint > corners =  PointsCloud.FindQuadrilateralCorners(edgePoints);
}

(图片上的字:在图像上绘制边缘点、寻找每张扑克的边角点)

找到扑克牌的四角后,我们就可以从原始图像中提取出正常的扑克牌图像了。由上图可以看出,扑克牌可以横放。扑克牌是否横放是非常容易检测的。在扑克牌放下后,因为我们知道,牌的高度是大于宽度的,所以如果提取的图像宽度大于高度,那么牌必然是横放的。随后,我们用 RotateFlip 函数旋转扑克牌至正常位置。

注意,为了正确识别,所有的扑克应当具有相同的尺寸。不过,鉴于相机角度不同,扑克牌的尺寸是会变化的,这样容易导致识别失败。为了防止这样的问题,我们把所有变换后的扑克牌图像都调整为 200x300 像素大小。

// 用于从原始图像提取扑克牌
QuadrilateralTransformation quadTransformer = new QuadrilateralTransformation();
// 用于调整扑克牌大小
ResizeBilinear resizer = new ResizeBilinear(CardWidth, CardHeight);
foreach (Blob blob in extractor.GetObjectsInformation()) {
     // 获取扑克牌边缘点
     List<IntPoint> edgePoints = extractor.GetBlobsEdgePoints(blob);
     // 利用边缘点,在原始图像上找到四角
     List<IntPoint> corners =  PointsCloud.FindQuadrilateralCorners(edgePoints);
     Bitmap cardImg = quadTransformer.Apply(source); // 提取扑克牌图像

     if (cardImg.Width > cardImg.Height) // 如果扑克牌横放
		cardImg.RotateFlip(RotateFlipType.Rotate90FlipNone); // 旋转之
     cardImg =  resizer.Apply(cardImg); // 归一化(重设大小)扑克牌
       .....
}

(图片上的字:使用 QuariterialTransformation 类从原始图像提取出的扑克牌。该类基于每张牌的四个角进行变换)

到目前为止,我们已经找到了原始图像上每张扑克牌的四角,并从图像中提取出了扑克牌,还调整到统一的尺寸。现在,我们可以开始进行识别了。

识别扑克牌

有好几种用于识别的技术用于识别扑克牌。本文用到的是基于牌型(如扑克牌上的形状)及模板匹配技术。扑克牌的花色和大小是分开识别的。我们可以这样枚举:

public enum Rank {
	NOT_RECOGNIZED = 0,
	Ace = 1,
	Two,
	Three,
	Four,
	Five,
	Six,
	Seven,
	Eight,
	Nine,
	Ten,
	Jack,
	Queen,
	King
}
public enum Suit {
	NOT_RECOGNIZED = 0,
	Hearts,
	Diamonds,
	Spades,
	Clubs
}

我们还将创建如下的 Card 类来表示识别到的扑克牌。这个类包括了牌的大小、花色、提取到的扑克牌图像和其在原始图像上的四角点。

public class Card {
	// 变量
	private Rank rank; // 大小
	private Suit suit; // 花色
	private Bitmap image; // 提取出的图像
	private Point[] corners ;// 四角点
	// 属性
	public Point[] Corners { get { return this.corners; } }
	public Rank Rank { set { this.rank = value; } }
	public Suit Suit { set { this.suit = value; } }
	public Bitmap Image { get { return this.image; } }
	// 构造函数
	public Card(Bitmap cardImg, IntPoint[] cornerIntPoints) {
	    this.image = cardImg;
	    // 将 AForge.IntPoint 数组转化为 System.Drawing.Point 数组
	    int total = cornerIntPoints.Length;
	    corners = new Point[total];
	    for(int i = 0 ; i < total ; i++) {
	        this.corners[i].X = cornerIntPoints[i].X;
	        this.corners[i].Y = cornerIntPoints[i].Y;
	    }
	}
}

识别花色

标准的扑克牌有四种花色:黑桃、梅花、方块和红桃。其中方块和红桃是红色,黑桃和梅花是黑色。再有就是方块的宽度大于红桃,而梅花的宽度大于黑桃。这两个特点可以有助于我们识别花色。

识别颜色

首先,我们从识别颜色开始。正确识别出颜色,将帮助我们消除另外两种花色。我们将通过分析扑克牌图像的右上角来识别颜色。

public Bitmap GetTopRightPart() {
    if (image == null)
        return null;
    Crop crop = new Crop(new Rectangle(image.Width - 37, 10, 30, 60));
    return crop.Apply(image);
}

(图片上的字:裁剪扑克牌右上角、再次裁剪前次结果的底部)

裁剪了扑克牌右上角后,我们得到一张 30 x 60 像素的图像。但是该图像同时包含了花色和大小。因为我们只是分析花色,所以再次裁剪下半部分,得到 30 x 30 像素的图像。

现在,我们可以遍历图像中红色像素和黑色像素的总数。如果一个像素的红色分量比蓝色分量和绿色分量的总和还打,就可以认为该像素是红色。如果红、绿、蓝分量小于 50,且红色分量不大于蓝色和绿色分量和,则认为该像素是黑色。

关于处理 Bitmap 过程中的锁定与解锁,可以参见《GDI+ 位图读写速度测试》一文。——译者注

char color = 'B';
// 锁像素
BitmapData imageData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadOnly, bmp.PixelFormat);
int totalRed = 0;
int totalBlack = 0;
unsafe {
	// 统计红与黑
	try {
	   UnmanagedImage img = new UnmanagedImage(imageData);
	
	   int height = img.Height;
	   int width = img.Width;
	   int pixelSize = (img.PixelFormat == PixelFormat.Format24bppRgb) ? 3 : 4;
	   byte* p = (byte*)img.ImageData.ToPointer();
	   // 逐行
	   for (int y = 0; y < height; y++) {
	       // 逐像素
	       for (int x = 0; x < width; x++, p += pixelSize) {
	           int r = (int)p[RGB.R]; // 红
	           int g = (int)p[RGB.G]; // 绿
	           int b = (int)p[RGB.B]; // 蓝
	           if (r > g + b)  // 红 > 绿 + 蓝
	              totalRed++;  // 认为是红色
	           if (r <= g + b && r < 50 && g < 50 && b < 50) // 红绿蓝均小于50
	              totalBlack++; // 认为是黑色
	       }
	   }
	}
	finally {
	   bmp.UnlockBits(imageData); // 解锁
	}
}
if (totalRed > totalBlack) // 红色占优
color = 'R'; // 设置颜色为红,否则默认黑色
return color;

(图片上的字:(左边)总红色=82 总黑色=0;(右边)总红色=0 总黑色=80)

注意 .NET 的 Bitmap.GetPixel() 函数运行缓慢,所以我们使用了指针来遍历像素。

区分人物牌和数字牌

识别了颜色后,我们需要确定扑克牌是否是人物牌。人物牌的牌面为 J、Q、K。人物牌和数字牌之间有一个很突出的特点,即数字牌牌面有很多花色符号指示其大小,而人物牌很好辨认,其牌面有人物头像。我们可以简单的设定一个大个的花色形状来分析扑克,而不是对其使用复杂的模板匹配算法。这样,识别数字牌就可以变得更快。

为了找出一张扑克牌到底是人物牌还是数字牌非常简单。人物牌上面有大的人物图,而数字牌没有。如果我们对牌进行边缘检测和图块处理,找到最大图块,就可以从图块的大小上判断到底是人物牌还是数字牌了。

private bool IsFaceCard(Bitmap bmp) {
	FiltersSequence commonSeq = new FiltersSequence();
	commonSeq.Add(Grayscale.CommonAlgorithms.BT709);
	commonSeq.Add(new BradleyLocalThresholding());
	commonSeq.Add(new DifferenceEdgeDetector());
	Bitmap temp = this.commonSeq.Apply(bmp);
	ExtractBiggestBlob extractor = new ExtractBiggestBlob();
	temp = extractor.Apply(temp); // 提取最大图块
	if (temp.Width > bmp.Width / 2)  // 如果宽度大于整个牌的一般宽 
		return true; // 人物牌
	return false;  // 数字牌
}

我们不断的对扑克牌图像进行灰度变换、局部阈值化和边缘检测。注意我们使用局部阈值化而不是全局阈值化来消除照明不良的问题。

(图片上的字:原始扑克图像、灰度、Bradley 局部阈值、边缘检测、提取最大图块)

正如你所看到的,人物牌最大图块几乎和整张扑克牌一样大,很容易区分。

前面提到过,出于性能上的考虑,我们将使用不同的识别技术对人物牌和数字牌进行识别。对于数字牌,我们直接提取派上最大图块并识别其宽度和颜色。

private Suit ScanSuit(Bitmap suitBmp, char color) {
	Bitmap temp = commonSeq.Apply(suitBmp);
	// 提取最大图块
	ExtractBiggestBlob extractor = new ExtractBiggestBlob();
	temp = extractor.Apply(temp);
	Suit suit = Suit.NOT_RECOGNIZED;
	// 判断花色
	if (color == 'R')
	suit = temp.Width >= 55 ? Suit.Diamonds : Suit.Hearts;
	if (color == 'B')
	suit = temp.Width <= 48 ? Suit.Spades : Suit.Clubs;
	return suit;
}

(图片上的字:宽度 = 52 px、43 px、47 px、57 px)

上述测试最大误差 2 像素。一般来说,因为我们把扑克牌尺寸都调整到了 200 x 300 像素,所以测试的结果都会是相同的大小。

人物牌牌面上没有类似数字牌的最大花色图像,只有角上的小花色图。这就是为什么我们会裁剪扑克图像的右上角并对其应用模板匹配算法来识别花色。

二值化模板图像已经包含在了本文提供的下载资源中。

AForge.NET 提供了一个叫做 ExhaustiveTemplateMatching 的类实现穷尽模板匹配算法。该类对原始图进行完全扫描,用相应的模板对每个像素进行比较。尽管该算法的性能不佳,但我们只是用于一个小区域(30 x 60),也不必过于关心性能。

private Suit ScanFaceSuit(Bitmap bmp, char color) {
	Bitmap clubs, diamonds, spades, hearts; // 花色模板
	// 载入模板资源
	clubs = PlayingCardRecognition.Properties.Resources.Clubs;
	diamonds = PlayingCardRecognition.Properties.Resources.Diamonds;
	spades = PlayingCardRecognition.Properties.Resources.Spades;
	hearts = PlayingCardRecognition.Properties.Resources.Hearts;
	// 用0.8的相似度阈值初始化模板匹配类
	ExhaustiveTemplateMatching templateMatching = new ExhaustiveTemplateMatching(0.8f);
	Suit suit = Suit.NOT_RECOGNIZED;
	if (color == 'R') {
		if (templateMatching.ProcessImage(bmp, hearts).Length > 0)
			suit = Suit.Hearts; //匹配红桃
		if (templateMatching.ProcessImage(bmp, diamonds).Length > 0)
			suit = Suit.Diamonds; // 匹配方块
	}
	else {
		if (templateMatching.ProcessImage(bmp,spades).Length > 0)
			suit = Suit.Spades; // 匹配黑桃
		if (templateMatching.ProcessImage(bmp, clubs).Length > 0)
			suit = Suit.Clubs; // 匹配梅花
	}
	return suit;
}

(图片上的字:模板匹配?是/否)

当然,模板不能 100% 匹配样本,所以我们使用 0.8(80%)的相似度阈值。

识别大小

识别大小和识别花色类似,也是单独对人物牌和数字牌进行识别。由于数字牌可以只靠计算牌面上的花色图块数量就可以识别,而不用模板匹配,所以利用简单的图像滤镜就可以完成任务。

下面所示的 ScanRank 函数过滤小图块(小于 30 像素长或宽)并计算剩余的图块数。

private Rank ScanRank(Bitmap cardImage) {
	Rank rank = Rank.NOT_RECOGNIZED;
	int total = 0;
	Bitmap temp = commonSeq.Apply(cardImage); // 应用滤镜
	BlobCounter blobCounter = new BlobCounter();
	blobCounter.FilterBlobs = true;
	// 过滤小图块
	blobCounter.MinHeight = blobCounter.MinWidth = 30;
	blobCounter.ProcessImage(temp);
	total = blobCounter.GetObjectsInformation().Length; // 获取总数
	rank = (Rank)total; // 转换成大小(枚举类型)	
	return rank;
}

(图片上的字:边缘检测、过滤宽高小于 30 像素的图块、剩余图块总数为 10,即扑克的点数)

所以,数字牌不用模板匹配算法或是 OCR 即可识别。但是,对人物卡,我们需要再次使用模板匹配进行识别。

private Rank ScanFaceRank(Bitmap bmp) {
	Bitmap j, k, q; // 人物牌人物模板 4
	// 载入资源
	j = PlayingCardRecognition.Properties.Resources.J;
	k = PlayingCardRecognition.Properties.Resources.K;
	q = PlayingCardRecognition.Properties.Resources.Q;
	// 用0.75进行初始化
	ExhaustiveTemplateMatching templateMatchin =
		new ExhaustiveTemplateMatching(0.75f);
	Rank rank = Rank.NOT_RECOGNIZED;
	
	if (templateMatchin.ProcessImage(bmp, j).Length > 0) // J
		rank = Rank.Jack;
	if (templateMatchin.ProcessImage(bmp, k).Length > 0)// K
		rank = Rank.King;
	if (templateMatchin.ProcessImage(bmp, q).Length > 0)// Q
		rank = Rank.Queen;
	return rank;
}

由于识别难度较大,这次我们使用 0.75(75%)作为相似度阈值。

已知问题

本文给出的实例代码,只能识别分开的扑克牌,无法应对重叠牌。另一个已知问题是光线环境变化常造成识别错误。

结论

本文用到的图像用例来自 AForge.NET 框架。AForge.NET 为机器视觉和机器学习领域的开发者提供了大量有用的特性。对我来说,它同样非常简单。

本文还可提高,例如如何在牌还没有分放置的时候就进行识别。另一种提升是用这套系统做成 AI 二十一点玩家。

历史

7th, Oct., 2011: 初稿

许可

本文及附带的源文件代码和文件,遵循 CodeProject 网站开源许可(CPOL)

关于作者

Nazmi Altun,来自土耳其的软件开发者

? Conmajia 2012, Nazmi Altun 2011

文章来源:https://blog.csdn.net/conmajia/article/details/135247886
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。