© 版权所有 野比 2012
原文地址:点击查看
作者:Nazmi Altun
下载源代码 - 148.61 KB
下载demo - 3.1 MB
介绍
(图片上的字:方块4,方块J,黑桃2)
用机器人配上扑克牌识别系统,就可以在二十一点一类的扑克游戏中扮演荷官或是人类玩家的角色。实现这样的程序同样也是学习计算机视觉和模式识别的好途径。
本文涉及到的AForge.NET框架技术有二值化、边缘检测、仿射变换、BLOB处理和模板匹配算法等。
需要注意的是,这篇文章和文中介绍的系统是针对英美扑克设计的,可能不适用于其他种类的扑克。然而,本文描述了扑克的检测和识别的基本方法。因此,具体的识别算法需要根据扑克牌型特点而加以变化。
这里有一个视频演示。
(已经搬运到优酷——野比)
http://v.youku.com/v_show/id_XNDAxNzQyOTYw.html
© 版权所有 野比 2012
扑克检测
我们需要检测图像(指采集到的视频画面,下同——野比注)上的扑克对象,以便能进行下一步的识别。为了完成检测,我们会用一些图像滤镜对视频画面进行处理。
第一步,将图像去色(即灰度化——野比注)。去色是将彩色图像转换成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]像素之间的斑点(blob,即图块blob,图像中的独立对象。以下将改称图块——野比注)。这有助于我们区分出图像中其他物体(如果有的话)。根据测试环境的不同,我们需要改变滤镜参数。例如,假设地面和相机之间距离增大,则图像中的扑克牌会变小。此时,我们需要相应的改变最小、最大宽度和高度参数。
现在,我们可以通过调用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); // 归一化(重设大小)扑克牌 ..... }
(图片上的字:使用QuadriteralTransformation类从原始图像提取出的扑克牌。该类利用每张牌的四角进行变换。)
到目前为止,我们已经找到了原始图像上每张扑克牌的四角,并从图像中提取出了扑克牌,还调整到统一的尺寸。现在,我们可以开始进行识别了。
© 版权所有 野比 2012
识别扑克牌
有好几种用于识别的技术用于识别扑克牌。本文用到的是基于牌型(如扑克牌上的形状)及模板匹配技术。扑克牌的花色和大小是分开识别的。我们可以这样枚举:
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; } } }
© 版权所有 野比 2012
识别花色
标准的扑克牌花色有四种:黑桃、梅花、方块和红桃。其中方块和红桃是红色,黑桃和梅花是黑色。再有就是方块的宽度大于红桃,而梅花的宽度大于黑桃。这两个特点可以有助于我们识别花色。
识别颜色
首先,我们从识别颜色开始。正确识别出颜色,将帮助我们消除另外两种花色。我们将通过分析扑克牌图像的右上角来识别颜色。(作者强调过,本文基于他所选用的具体的扑克牌型,和印刷、牌面设计有关——野比注)
public Bitmap GetTopRightPart() { if (image == null) return null; Crop crop = new Crop(new Rectangle(image.Width - 37, 10, 30, 60)); return crop.Apply(image); }
(图片上的字:裁剪 扑克图像右上角、再次裁剪前次图像的底部)
裁剪了扑克牌右上角后,我们得到一张30x60像素的图像。但是该图像同时包含了花色和大小。因为我们只是分析花色,所以再次裁剪下半部分,得到30x30像素的图像。
现在,我们可以遍历图像中红色像素和黑色像素的总数。如果一个像素的红色分量比蓝色分量和绿色分量的总和还打,就可以认为该像素是红色。如果红、绿、蓝分量小于50,且红色分量不大于蓝色和绿色分量和,则认为该像素是黑色。
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;
注意.NET的Bitmap.GetPixel()
函数运行缓慢,所以我们使用了指针来遍历像素。
区分人物牌和数字牌
识别了颜色后,我们需要确定扑克牌是否是人物牌。人物牌的牌面为J、Q、K。人物牌和数字牌之间有一个很突出的特点,即数字牌牌面有很多花色符号指示其大小,而人物牌很好辨认,其牌面有人物头像。我们可以简单的设定一个大个的花色形状来分析扑克,而不是对其使用复杂的模板匹配算法。这样,识别数字牌就可以变得更快。
为了找出一张扑克牌到底是人物牌还是数字牌非常简单。人物牌上面有大的人物图,而数字牌没有。如果我们对牌进行边缘检测和图块(BLOB)处理,找到最大图块,就可以从图块的大小上判断到底是人物牌还是数字牌了。
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; // 数字牌 }
所以我们不断的对扑克牌图像进行灰度变换、局部阈值化和边缘检测。注意我们使用局部阈值化而不是全局阈值化来消除照明不良的问题(即消除光线变换时,相机的自动白平衡造成的屏幕忽明忽暗现象——野比注)。
(图片上的字(上下牌相同):原始扑克图像,灰度化,布拉德利局部阈值化,边缘检测,提取最大图块)
正如你所看到的,人物牌最大图块几乎和整张扑克牌一样大,很容易区分。
前面提到过,出于性能上的考虑,我们将使用不同的识别技术对人物牌和数字牌进行识别。对于数字牌,我们直接提取派上最大图块并识别其宽度和颜色。
private Suit ScanSuit(Bitmap suitBmp, char color) { Bitmap temp = commonSeq.Apply(suitBmp); //Extract biggest blob on card ExtractBiggestBlob extractor = new ExtractBiggestBlob(); temp = extractor.Apply(temp); //Biggest blob is suit blob so extract it Suit suit = Suit.NOT_RECOGNIZED; //Determine type of suit according to its color and width if (color == 'R') suit = temp.Width >= 55 ? Suit.Diamonds : Suit.Hearts; if (color == 'B') suit = temp.Width <= 48 ? Suit.Spades : Suit.Clubs; return suit; }
上述测试最大误差2像素。一般来说,因为我们把扑克牌尺寸都调整到了200x300像素,所以测试的结果都会是相同的大小。
人物牌牌面上没有类似数字牌的最大花色图像,只有角上的小花色图。这就是为什么我们会裁剪扑克图像的右上角并对其应用模板匹配算法来识别花色。
在项目资源文件中有二值化模板图像。(参见项目源代码——野比注)
AForge.NET还提供了一个叫做ExhaustiveTemplateMatching的类,实现了穷尽模板匹配算法。该类对原始图进行完全扫描,用相应的模板对每个像素进行比较。尽管该算法的性能不佳,但我们只是用于一个小区域(30x60),也不必过于关心性能。
private Suit ScanFaceSuit(Bitmap bmp, char color) { Bitmap clubs, diamonds, spades, hearts; // 花色模板 4 // 载入模板资源 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%)作为相似度阈值。
已知问题
本文的实现,只能识别分开的扑克牌(没有重叠——野比注)。另一个已知问题是光线环境变化常造成识别错误。
© 版权所有 野比 2012
结论
本文用到的图像用例来自AForge.NET框架。AForge.NET为机器视觉和机器学习领域的开发者提供了大量有用的特性。对我来说,它同样非常简单。
本文还可提高,例如如何在牌还没有分放置的时候就进行识别。另一种提升是用这套系统做成AI二十一点玩家。
历史
* 7th, Oct., 2011: 初稿
许可
本文及附带的源文件代码和文件,遵循代码计划网站开源许可(CPOL)
关于作者
Nazmi Altun
Softeare Developer
Turkey
© 版权所有 野比 2012
(全文完)