介绍
字形(或最常被称为光学字形)的识别是一个很重要的交叉主题,在许多不同领域都有应用。光学字形最流行的应用是增强现实,其中计算机视觉算法在视频流中找到它们,并用人工生成的对象替代,从而创建一个视图,该视图在现实世界中是一半真实,一半是虚拟物体。光学字形应用的另一个领域是机器人技术,其中字形可用于向机器人发出命令,或帮助机器人在某些环境中导航,其中字形可用于指示机器人方向。这是光学字形应用的不错的演示之一:
在本文中,我们将讨论用于光学字形识别的算法,这是迈向基于光学字形的所有应用程序的第一步。对于某些应用程序,这可能是第一步也是唯一的步骤,因为识别是唯一需要的步骤。但是对于其他人,例如3D增强现实,这仅仅是个开始。
为了进行算法原型设计和测试,我使用了A4Go应用程序,该应用程序是老周出图软件的一部分。通常,它确实简化了在许多图像上对该算法的测试,并允许将精力集中在概念本身上,而不是其他一些不需要的编码。
以下是我们旨在识别的一些标志符号的示例。所有字形均由一个正方形网格表示,该网格均等地划分为相同的行数和列数。网格的每个单元格都填充有黑色或白色。每个字形的第一行和最后一行/列仅包含黑色单元格,这会在每个字形周围创建黑色边框。我们还假设每个行和列都至少有一个白色单元格,因此不存在完全黑的行和列(除了第一个和最后一个)。所有这些字形都以以下方式打印在白纸上,即字形的黑色边框周围有白色区域(上面的A4Go图片显示了它们在打印时的外观)。
寻找潜在的字形
在进行字形识别之前,还有另一项任务需要首先解决-在图像中找到潜在的字形进行识别。任务的目的是找到所有看起来像字形的四边形区域-一个有望用于进一步分析和识别的区域。换句话说,我们需要在源图像中找到每个字形的4个角。碰巧,这项任务是整个字形搜索-识别项目中最困难的一项。
第一步很简单-我们将对 原始图像进行灰度处理,因为它会减少要处理的数据量,而且我们无论如何都不需要颜色信息。
接下来是什么?我们可以看到,所有字形都是对比度很强的对象-白纸上的黑色边框字形。因此,最有可能的好方向是搜索被白色区域包围的黑色四边形并进行分析。但是,如何找到它们?想法之一是尝试进行阈值处理,然后进行斑点分析以找到黑色四边形。当然,我们不会使用 带有预定义阈值的常规阈值,因为它不会给我们带来任何好处-我们根本无法为所有可能的光照和环境条件设置一个阈值。尝试阈值化 可能会产生一些好的结果:
正如我们在上面的图片中看到的那样,大津阈值法的工作做得很好-我们得到了黑色四边形,周围是白色区域。使用blob计数器,可以找到上述二进制图像上的所有黑色对象,进行一些检查以确保这些对象是四边形,等等。从这点开始,实际上一切都可以工作,但是可能一些问题。问题在于Otsu阈值处理适用于以上图像,而实际上适用于许多其他图像。但并非所有人都如此。这是其中一张图片,其中的图片无法正常运行,并且所有想法都失败了。
上图显示了全局阈值在某些光照/环境条件下效果不佳。因此,我们可能需要找到另一个想法。
如前所述,光学字形是相当对比度的对象-黑色字形被白色区域包围。当然,对比度可能会根据光线条件而变化,黑色区域可能会变亮,而白色区域可能会变暗。但是,除非我们的照明条件绝对不好,否则差异仍然应该足够大。因此,我们可以尝试查找图像亮度急剧变化的区域,而不是尝试查找黑色或白色四边形。这是边缘检测器的工作,例如差分边缘检测器:
为了摆脱图像亮度变化不大的区域,我们将进行阈值处理。这是我们开始的3个示例的样子:
正如我们在上面的图片中看到的那样,所有检测到的字形都由形成四边形的独立斑点表示。在光照条件不是很糟糕的情况下,所有这些字形的四边形都具有良好连接的边缘,因此它们实际上是用单个斑点表示的,使用斑点计数算法可以很容易地提取它们。
以下是不良照明条件的示例,其中阈值化和阈值边缘检测均无法产生任何可用于进一步的字形定位和识别的良好结果。
因此,我们决定进行边缘检测,因此这是代码的开头(我们将使用 UnmanagedImage 以避免对.NET托管图像进行额外的锁定/解锁):
// 1 - 灰度
UnmanagedImage grayImage = null;
if ( image.PixelFormat == PixelFormat.Format8bppIndexed )
{
grayImage = image;
}
else
{
grayImage = UnmanagedImage.Create( image.Width, image.Height,
PixelFormat.Format8bppIndexed );
Grayscale.CommonAlgorithms.BT709.Apply( image, grayImage );
}
// 2 - 边缘检测
DifferenceEdgeDetector edgeDetector = new DifferenceEdgeDetector( );
UnmanagedImage edgesImage = edgeDetector.Apply( grayImage );
// 3 - 阈值边缘
Threshold thresholdFilter = new Threshold( 40 );
thresholdFilter.ApplyInPlace( edgesImage );
现在,当我们拥有一个包含所有对象有效边缘的二进制图像时,我们需要处理由这些边缘形成的所有斑点,并检查是否有任何斑点可以代表字形的边缘。要遍历所有单独的blob,我们可以使用 BlobCounter:
// 创建和配置Blob计数器
BlobCounter blobCounter = new BlobCounter( );
blobCounter.MinHeight = 32;
blobCounter.MinWidth = 32;
blobCounter.FilterBlobs = true;
blobCounter.ObjectsOrder = ObjectsOrder.Size;
// 4 - 查找所有独立的Blob
blobCounter.ProcessImage( edgesImage );
Blob[] blobs = blobCounter.GetObjectsInformation( );
// 5 - 检查每个Blob
for ( int i = 0, n = blobs.Length; i < n; i++ )
{
// ...
}
从获得的二进制边缘图像可以看出,我们有很多边缘。但是,并非所有的物体都形成一个四边形的外观对象。我们只对看起来为四边形的斑点感兴趣,因为字形将始终由四边形表示,而无论其如何旋转。为了检查四边形,我们可以使用GetBlobsEdgePoints()收集blob的边缘点 ,然后使用 IsQuadrilateral()方法检查这些点是否可以形成四边形。如果不是,那么我们跳过该Blob,然后转到下一个。
ListedgePoints = blobCounter.GetBlobsEdgePoints( blobs[i] );
Listcorners = null;
// 它看起来像四边形吗?
if ( shapeChecker.IsQuadrilateral( edgePoints, out corners ) )
{
// ...
}
现在我们有了所有看起来像四边形的斑点。但是,并非每个四边形都是一个字形。正如我们已经提到的,字形具有黑色边框,并打印在白纸上。因此,我们需要检查一下,我们拥有的斑点内部是黑色,而外部是白色。或者,更正确地说,内部应该比外部更暗(因为照明可能会有所不同,并且检查完美的白色/黑色将不起作用)。
要检查blob内部是否比外部暗,我们可以使用GetBlobsLeftAndRightEdges() 方法获取blob的左右边缘点,然后计算blob外部和内部像素之间的平均亮度差。如果平均差异足够显着,则很可能我们有一个较暗的物体,周围有较亮的区域。
// 在左侧和右侧获取边缘点
ListleftEdgePoints, rightEdgePoints;
blobCounter.GetBlobsLeftAndRightEdges( blobs[i],
out leftEdgePoints, out rightEdgePoints );
// 从外部计算像素值之间的平均差
// 形状和内部
float diff = CalculateAverageEdgesBrightnessDifference(
leftEdgePoints, rightEdgePoints, grayImage );
// 检查平均差异,这表明外面的重量比多少轻
// 平均
if ( diff > 20 )
{
// ...
}
为了阐明计算斑点外部像素与内部像素之间的平均差的想法,让我们仔细看一下CalculateAverageEdgesBrightnessDifference()方法。对于Blob的左边缘和右边缘,该方法将构建两个点列表-位于边缘左侧一点的点列表和位于边缘右侧一点的点列表(假设3个像素远离边缘)。对于每个点列表,它使用Collect8bppPixelValues()收集与这些点相对应的像素值 方法。然后计算平均差-对于左侧斑点的边缘,它从边缘左侧(斑点外部)的像素值减去边缘右侧(斑点内部)的像素值;对于右斑点的边缘,它的作用相反。完成计算后,该方法将产生一个值,该值是斑点外部像素与内部像素之间的平均差。
const int stepSize = 3;
// 计算外部像素与外部像素之间的平均亮度差
// 以指定的左右边缘为边界的对象内部
private float CalculateAverageEdgesBrightnessDifference(
List leftEdgePoints,
List rightEdgePoints,
UnmanagedImage image )
{
// 创建点列表,这些点在边缘的左侧/右侧
List leftEdgePoints1 = new List( );
List leftEdgePoints2 = new List( );
List rightEdgePoints1 = new List( );
List rightEdgePoints2 = new List( );
int tx1, tx2, ty;
int widthM1 = image.Width - 1;
for ( int k = 0; k < leftEdgePoints.Count; k++ )
{
tx1 = leftEdgePoints[k].X - stepSize;
tx2 = leftEdgePoints[k].X + stepSize;
ty = leftEdgePoints[k].Y;
leftEdgePoints1.Add( new IntPoint(
( tx1 < 0 ) ? 0 : tx1, ty ) );
leftEdgePoints2.Add( new IntPoint(
( tx2 > widthM1 ) ? widthM1 : tx2, ty ) );
tx1 = rightEdgePoints[k].X - stepSize;
tx2 = rightEdgePoints[k].X + stepSize;
ty = rightEdgePoints[k].Y;
rightEdgePoints1.Add( new IntPoint(
( tx1 < 0 ) ? 0 : tx1, ty ) );
rightEdgePoints2.Add( new IntPoint(
( tx2 > widthM1 ) ? widthM1 : tx2, ty ) );
}
// 从指定点收集像素值
byte[] leftValues1 = image.Collect8bppPixelValues( leftEdgePoints1 );
byte[] leftValues2 = image.Collect8bppPixelValues( leftEdgePoints2 );
byte[] rightValues1 = image.Collect8bppPixelValues( rightEdgePoints1 );
byte[] rightValues2 = image.Collect8bppPixelValues( rightEdgePoints2 );
// 从外部计算像素值之间的平均差
// 形状和内部
float diff = 0;
int pixelCount = 0;
for ( int k = 0; k
{
if ( rightEdgePoints[k].X - leftEdgePoints[k].X > stepSize * 2 )
{
diff += ( leftValues1[k] - leftValues2[k] );
diff += ( rightValues2[k] - rightValues1[k] );
pixelCount += 2;
}
}
return diff / pixelCount;
}
现在是时候看看我们进行的两次检查的结果-四边形和斑点内外像素之间的平均差异。让我们突出显示所有通过这些检查的斑点的边缘,看看是否更接近字形位置的检测。
看一下上面的图片,我们可以看到我们所做的两次检查的结果确实是可以接受的-仅突出显示包含光学字形的斑点,而没有其他内容。可能会发生一些其他对象可能满足那些检查的情况,并且该算法可能会发现一些被白色区域包围的其他暗四边形。但是实验表明,这种情况很少发生。即使有时发生,仍然涉及进一步的字形识别步骤,该步骤可能会过滤“假”字形。因此,我们认为我们拥有相当不错的字形(或更准确地说是潜在字形)定位算法,并且可以进一步深入识别。
字形识别
现在,当我们有了潜在字形(四边形)的坐标时,就可以对其进行实际识别。可以开发一种算法,直接在源图像中进行字形识别。但是,让我们简化一下事情,从源图像中提取字形,因此对于每个潜在字形我们都有一个单独的正方形图像,仅包含字形数据。这可以使用QuadrilateralTransformation完成 。以下是从某些先前处理过的图像中提取的一些字形:
// 6 - 做四边形变换
QuadrilateralTransformation quadrilateralTransformation =
new QuadrilateralTransformation( quadrilateral, 100, 100 );
UnmanagedImage glyphImage = quadrilateralTransformation.Apply( image );
从上面的图片可以看出,光照条件可能会发生很大变化,某些字形可能与其他字形的对比度不一样。因此,我们可以 在此阶段使用Otsu脱钩法对字形进行二值化处理。
// otsu 阈值
OtsuThreshold otsuThresholdFilter = new OtsuThreshold( );
otsuThresholdFilter.ApplyInPlace( glyphImage );
在此阶段,我们准备进行最终的字形识别。存在多种可能的方式来执行此操作,例如形状识别,模板匹配等。尽管使用诸如形状识别之类的功能可能会有好处,但我发现它们对于识别满足约束条件的字形这样简单的任务而言过于复杂从一开始就制成。如前所述,我们所有的字形都由正方形网格表示,每个单元格都用黑色或白色填充。因此,在这种假设下,识别算法可以变得非常简单-只需将字形图像划分为单元格,然后检查单元格的平均(最常见)颜色是什么。
在讨论字形识别代码之前,让我们澄清一下将字形划分为单元格的方式。例如,让我们看一下下面的图片。在这里,我们可以看到字形是如何通过深灰线分为具有相同宽度和高度的5x5单元格的。因此,我们可以做的就是仅计算每个此类单元中的白色像素数量,并检查该数量是否大于单元面积的一半。如果更大,则我们假设该单元格由白色填充,相当于“ 1”。如果数字小于单元格面积的一半,则我们有一个黑色填充单元格,它对应于“ 0”。此外,我们可能会为每个单元格引入置信度级别-如果整个单元格都填充有白色或黑色像素,则我们对单元格的颜色/类型有100%的信心。然而,如果单元格具有60%的白色像素和40%的黑色像素,则识别置信度将下降到60%。当一个单元格用白色填充一半,一半用黑色填充时,置信度等于50%,这意味着我们完全不确定单元格的颜色/类型。
然而,利用上述方法,几乎不可能找到可以给出100%置信度的单元。从上图可以看出,字形定位,提取,阈值化等所有过程都可能导致一些缺陷-一些边缘单元可能还包含围绕字形的白色区域的一部分,但是某些内部单元应该是黑色可能包含由相邻的白色单元格等引起的白色像素。因此,我们可能会在单元格边界周围引入小间隙并将其排除在处理之外,而不是计算整个单元格区域内白色像素的数量。上面的图片演示了带间隙的想法-而不是扫描用深灰线突出显示的整个单元,而是扫描用浅灰线突出显示的较小的内部区域。
现在,当识别想法似乎很明确时,我们就可以实现它了。首先,代码将抛出提供的图像,并计算每个像元的像素值之和。然后,这些总和用于计算每个像元的充满度-一个充满白色像素的像元充满度。最后,单元格的充满度用于确定其类型(“ 1”-白色填充或“ 0”-黑色填充)和置信度。注意:使用此功能(方法)之前,用户必须设置字形大小以进行识别。
public byte[,] Recognize( UnmanagedImage image, Rectangle rect, out float confidence )
{
int glyphStartX = rect.Left;
int glyphStartY = rect.Top;
int glyphWidth = rect.Width;
int glyphHeight = rect.Height;
// 字形的像元大小
int cellWidth = glyphWidth / glyphSize;
int cellHeight = glyphHeight / glyphSize;
// 为每个单元格留出一定的间隔,不进行扫描
int cellOffsetX = (int) ( cellWidth * 0.2 );
int cellOffsetY = (int) ( cellHeight * 0.2 );
// 单元格的扫描大小
int cellScanX = (int) ( cellWidth * 0.6 );
int cellScanY = (int) ( cellHeight * 0.6 );
int cellScanArea = cellScanX * cellScanY;
// 每个字形单元格的汇总强度
int[,] cellIntensity = new int[glyphSize, glyphSize];
unsafe
{
int stride = image.Stride;
byte* srcBase = (byte*) image.ImageData.ToPointer( ) +
( glyphStartY + cellOffsetY ) * stride +
glyphStartX + cellOffsetX;
byte* srcLine;
byte* src;
// 对于所有字形的行
for ( int gi = 0; gi < glyphSize; gi++ )
{
srcLine = srcBase + cellHeight * gi * stride;
// 对于该行中的所有行
for ( int y = 0; y < cellScanY; y++ )
{
// 对于所有字形列
for ( int gj = 0; gj < glyphSize; gj++ )
{
src = srcLine + cellWidth * gj;
// 对于列中的所有像素
for ( int x = 0; x < cellScanX; x++, src++ )
{
cellIntensity[gi, gj] += *src;
}
}
srcLine += stride;
}
}
}
// 计算每个字形单元格的值并设置
// 字形的置信度以最小化单元格的置信度值
byte[,] glyphValues = new byte[glyphSize, glyphSize];
confidence = 1f;
for ( int gi = 0; gi < glyphSize; gi++ )
{
for ( int gj = 0; gj < glyphSize; gj++ )
{
float fullness = (float)
( cellIntensity[gi, gj] / 255 ) / cellScanArea;
float conf = (float) System.Math.Abs( fullness - 0.5 ) + 0.5f;
glyphValues[gi, gj] = (byte) ( ( fullness > 0.5f ) ? 1 : 0 );
if ( conf < confidence )
confidence = conf;
}
}
return glyphValues;
}
使用上面提供的功能,字形二值化之后的下一步看起来非常简单:
// 识别原始字形
float confidence;
byte[,] glyphValues = binaryGlyphRecognizer.Recognize( glyphImage,
new Rectangle( 0, 0, glyphImage.Width, glyphImage.Height ), out confidence );
在这个阶段,我们有一个2D字节数组,其中包含与字形图像的黑白单元相对应的“ 0”和“ 1”元素。例如,该函数应为上面显示的字形图像提供以下显示的结果:
0 0 0 0 0
0 1 1 0 0
0 0 1 1 0
0 0 1 0 0
0 0 0 0 0
现在,让我们做一些检查,以确保我们处理了满足开始时设置的约束的字形图像。首先,让我们检查置信度-如果它低于特定限制(例如0.6,对应于60%),那么我们跳过处理后的对象。如果字形不具有由黑色单元格构成的边框(如果字形数据在第一行/最后一行或最后一列中至少包含单个“ 1”值)或没有至少一个白色,则我们也将其跳过任何内部行或列中的单元格。
if ( confidence >= minConfidenceLevel )
{
if ( ( CheckIfGlyphHasBorder( glyphValues ) ) &&
( CheckIfEveryRowColumnHasValue( glyphValues ) ) )
{
// ...
// 进一步处理
}
}
这就是字形数据提取/识别。如果包含潜在字形的候选图像已通过所有步骤和检查,则似乎我们确实得到了字形。
将找到的字形与字形数据库匹配
尽管我们确实从图像中提取了字形数据,但这并不是字形识别任务的最后一步。处理增强现实或机器人技术的应用程序通常具有一个字形数据库,其中每个字形可能都有其自身的含义。例如,在增强现实中,每个字形都与要显示的虚拟对象相关联,而不是一个字形,但是在机器人应用程序中,每个字形都可以代表机器人的命令或方向。因此,最后一步是将提取的字形数据与字形数据库进行匹配,并检索与该字形相关的信息-它的ID,名称等。
为了成功完成字形匹配步骤,我们需要牢记字形可以旋转,因此将提取的字形数据与数据库中存储的字形进行一对一比较是行不通的。在字形数据库中找到匹配的字形,我们需要对提取的字形数据与数据库中的每个字形进行4次比较-将提取的字形数据与数据库的4种可能的旋转进行比较。
值得一提的另一件事是,数据库中的所有字形都应该是旋转变体,以便无论旋转如何都是唯一的。如果字形在旋转后看起来相同,则它是旋转不变字形。对于旋转不变的字形,我们无法确定其旋转角度,这对于增强现实之类的应用而言非常重要。另外,如果数据库中包含很少的旋转不变字形,则可能无法在数据库中找到正确的匹配字形;如果旋转其中一个,它们看起来可能相同。
下图显示了一些旋转变体和不变字形。字形(1)和(2)是旋转变体-如果旋转它们,它们将始终看起来不同。字形(3),(4)和(5)旋转不变-如果旋转,它们看起来将相同,因此无法检测其旋转角度。此外,我们可能还会看到字形(4)与字形(5)相同,只是被旋转了,因此字形数据库不应同时包含这两个字形(即使它是旋转变体字形)。
现在,当很明显所有字形都必须是旋转变体并且我们需要在数据库中对每个字形执行4个比较才能找到匹配项时,我们可以继续执行匹配例程。
public int CheckForMatching( byte[,] rawGlyphData )
{
int size = rawGlyphData.GetLength( 0 );
int sizeM1 = size - 1;
bool match1 = true;
bool match2 = true;
bool match3 = true;
bool match4 = true;
for ( int i = 0; i < size; i++ )
{
for ( int j = 0; j < size; j++ )
{
byte value = rawGlyphData[i, j];
// 不旋转
match1 &= ( value == data[i, j] );
// 旋转180度
match2 &= ( value == data[sizeM1 - i, sizeM1 - j] );
// 旋转90度
match3 &= ( value == data[sizeM1 - j, i] );
// 旋转270度
match4 &= ( value == data[j, sizeM1 - i] );
}
}
if ( match1 )
return 0;
else if ( match2 )
return 180;
else if ( match3 )
return 90;
else if ( match4 )
return 270;
return -1;
}
从上面的代码中可以看到,如果提供的字形数据与保留在data 变量(字形类的成员)中的数据不匹配,则该方法返回-1 。但是,如果找到匹配项,它将返回旋转角度(逆时针方向为0、90、180或270度),该旋转角度用于从我们匹配的原始字形中获取指定的字形数据。
现在,我们要做的就是遍历数据库中的所有字形,并检查从图像中提取的字形数据是否与数据库中的任何字形匹配。如果找到匹配项,则我们可以获取与匹配的字形关联的所有数据,并将其用于可视化,向机器人发出命令等。
这就是所有字形识别。现在该进行一个小型演示了,该演示演示了上述所有应用于视频Feed的代码(该代码使用边框突出显示已识别的字形并显示其名称)。
2D增强现实
现在,当我们有了字形识别功能时,我们可以尝试做一些有趣的事情。这次我们不会做3D增强现实,而是从2D增强现实开始。这样做并不难,因为我们拥有为此所需的一切。
我们需要做的第一件事是纠正字形的四边形(在字形本地化阶段,我们从IsQuadrilateral()调用中得到了那个字形)。正如已经提到的,我们从找到的四边形中提取的字形看起来可能与字形数据库中的字形不完全相同,但是可能会旋转。因此,我们需要以这种方式旋转四边形,以使从中提取的字形看起来与数据库中的字形完全相同。为此,我们需要使用在字形匹配阶段执行的CheckForMatching()调用提供的旋转角度:
if ( rotation != -1 )
{
foundGlyph.RecognizedQuadrilateral = foundGlyph.Quadrilateral;
// 旋转四边形的角
while ( rotation > 0 )
{
foundGlyph.RecognizedQuadrilateral.Add( foundGlyph.RecognizedQuadrilateral[0] );
foundGlyph.RecognizedQuadrilateral.RemoveAt( 0 );
rotation -= 90;
}
}
现在要完成2D增强现实,我们要做的就是将我们想要的图像放入校正后的四边形中。为此,我们使用BackwardQuadrilateralTransformation-与QuadrilateralTransformation相同,但不是从指定的四边形中提取图像,而是将另一个图像放入其中。
// 使用四边形变换将字形的图像放到字形上
BackwardQuadrilateralTransformation quadrilateralTransformation = new BackwardQuadrilateralTransformation( );
quadrilateralTransformation.SourceImage = glyphImage;
quadrilateralTransformation.DestinationQuadrilateral = lyphData.RecognizedQuadrilateral;
quadrilateralTransformation.ApplyInPlace( sourceImage );
那很快。之前提到过的2D增强现实无话可说。
结论
我真的希望本文中的信息将对所有从事与字形识别有关的项目或只是了解计算机视觉的人有用。我个人在该项目上学到了很多东西,希望以上所有内容不仅对我有用。
正如已经提到的,图像或视频帧中字形识别的复杂性不在于字形识别本身,而在于其定位。找到可能包含字形的四边形是最难的部分。我为此尝试了不同的方法,发现边缘检测似乎最有效。但是,在某些情况下(主要与不良的照明条件有关),该技术也会失效。因此,字形的本地化仍然停留在一个实际的领域,以供重新访问和改进。