从字形识别到增强现实






4.96/5 (81投票s)
本文描述了一种识别静态图像和视频中光学字形的算法,然后展示了它在3D增强现实中的应用。
目录
引言
字形(或更常称作光学字形)识别是一个非常交叉的课题,在不同领域都有应用。光学字形最流行的应用是增强现实,其中计算机视觉算法在视频流中找到它们,并用人工生成的对象代替,创造出一种半真实半虚拟的视图——真实世界中的虚拟对象。光学字形应用的另一个领域是机器人学,其中字形可用于向机器人发出命令,或帮助机器人在字形可用于指示机器人的环境中导航。
在本文中,我们将讨论光学字形识别算法,这是所有基于光学字形的应用的第一步。然后,我们将从字形识别转向2D,最后转向3D增强现实。
对于那些在阅读所有细节之前更喜欢先看看这是什么的人,这里有一个小视频总结了所做的工作
![]() |
必备组件
本文后续讨论的所有图像处理算法都基于 AForge.NET 框架。对它有一些了解不会有坏处,但这不是必需的,因为该框架提供了文档和示例。为了算法原型设计和测试,我使用了框架中的 IPPrototyper 应用程序。像往常一样,它确实简化了在多幅图像上测试算法的过程,并允许专注于想法本身,而不是其他不必要的编码。
![]() |
下面是我们旨在识别的一些字形的样本。所有字形都由一个正方形网格表示,网格等分为相同数量的行和列。网格的每个单元格都填充了黑色或白色。每个字形的第一行和最后一行/列只包含黑色单元格,这在每个字形周围创建了一个黑色边框。我们还假设每行和每列至少有一个白色单元格,因此没有完全黑色的行和列(除了第一行和最后一行)。所有这些字形都打印在白纸上,使得字形的黑色边框周围有白色区域(上面的IPPrototypes图片显示了它们打印出来的样子)。
![]() |
寻找潜在的字形
在进入字形识别之前,还需要首先解决另一个任务——在图像中找到要识别的潜在字形。这个任务的目标是找到所有可能看起来像字形的四边形区域——一个足以进行进一步分析和识别的有前途的区域。换句话说,我们需要在源图像中找到每个字形的4个角。碰巧这个任务是整个字形搜索-识别部分中最难的一个。
第一步很简单——我们将对原始图像进行灰度化处理,因为它会减少要处理的数据量,而且我们也不需要此任务的颜色信息。
接下来是什么?正如我们所见,所有字形都是对比度很高的物体——白纸上的黑色边框字形。因此,最可能的方向是寻找被白色区域包围的黑色四边形并进行分析。但是,如何找到它们呢?一个想法是尝试进行阈值处理,然后进行斑点分析以找到黑色四边形。当然,我们不会使用预定义阈值的常规阈值处理,因为它不会给我们任何东西——我们根本无法为所有可能的光线和环境条件设置一个阈值。尝试Otsu阈值处理可能会产生一些好的结果
![]() | ![]() |
![]() | ![]() |
正如我们在上面的图片中看到的,Otsu阈值处理做得相当好——我们得到了被白色区域包围的黑色四边形。使用斑点计数器,可以在上面的二值图像中找到所有黑色对象,执行一些检查以确保这些对象是四边形,等等。从这一点开始,一切都有可能奏效,但是可能会有一些问题。问题是Otsu阈值处理对上面的图像有效,并且对许多其他图像也有效。但并非对所有图像都有效。这里有一张图像,它没有按预期工作,整个想法都失败了。
![]() | ![]() |
上图显示全局阈值处理在某些照明/环境条件下效果不佳。因此我们可能需要寻找另一种方法。
正如已经提到的,光学字形是对比度很高的物体——被白色区域包围的黑色字形。当然,对比度可能会根据光照条件而变化,黑色区域可能会变亮,但白色区域可能会变暗。但除非光照条件非常差,否则差异仍然应该足够大。因此,与其尝试寻找黑色或白色四边形,我们不如尝试寻找图像亮度急剧变化的区域。这是边缘检测器的工作,例如差分边缘检测器
![]() |
为了摆脱图像亮度变化不明显的区域,我们将进行阈值处理。以下是它与我们开始的3个样本的对比情况
![]() | ![]() |
![]() |
正如我们在上面的图片中看到的,所有检测到的字形都由一个独立的、形成四边形的斑点表示。在照明条件不完全糟糕的情况下,所有这些字形的四边形都具有良好连接的边缘,因此它们确实由一个单一的斑点表示,这很容易通过斑点计数算法提取出来。
下面是光照条件不佳的一个例子,在这种情况下,Otsu阈值处理和阈值边缘检测都无法产生任何可用于进一步字形定位和识别的良好结果。
![]() | |
![]() | ![]() |
因此我们决定采用边缘检测,代码的开头如下(我们将使用UnmanagedImage来避免.NET托管图像的额外锁定/解锁)
// 1 - grayscaling
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 - Edge detection
DifferenceEdgeDetector edgeDetector = new DifferenceEdgeDetector( );
UnmanagedImage edgesImage = edgeDetector.Apply( grayImage );
// 3 - Threshold edges
Threshold thresholdFilter = new Threshold( 40 );
thresholdFilter.ApplyInPlace( edgesImage );
现在,我们有了一个包含所有对象重要边缘的二值图像,我们需要处理这些边缘形成的所有斑点,并检查是否有任何斑点可能代表一个字形的边缘。要遍历所有独立的斑点,我们可以使用BlobCounter
// create and configure blob counter
BlobCounter blobCounter = new BlobCounter( );
blobCounter.MinHeight = 32;
blobCounter.MinWidth = 32;
blobCounter.FilterBlobs = true;
blobCounter.ObjectsOrder = ObjectsOrder.Size;
// 4 - find all stand alone blobs
blobCounter.ProcessImage( edgesImage );
Blob[] blobs = blobCounter.GetObjectsInformation( );
// 5 - check each blob
for ( int i = 0, n = blobs.Length; i < n; i++ )
{
// ...
}
从我们得到的二值边缘图像中可以看出,我们有很多边缘。但并非所有边缘都形成一个四边形形状的对象。我们只对四边形形状的斑点感兴趣,因为字形无论如何旋转,都将始终由一个四边形表示。为了检查四边形,我们可以使用GetBlobsEdgePoints()收集斑点的边缘点,然后使用IsQuadrilateral()方法检查这些点是否可以形成一个四边形。如果不能,我们就跳过该斑点并转到下一个斑点。
List<IntPoint> edgePoints = blobCounter.GetBlobsEdgePoints( blobs[i] );
List<IntPoint> corners = null;
// does it look like a quadrilateral ?
if ( shapeChecker.IsQuadrilateral( edgePoints, out corners ) )
{
// ...
}
好的,现在我们有了所有看起来像四边形的斑点。然而,并非每个四边形都是字形。正如我们已经提到的,字形有一个黑色边框,并且打印在白纸上。所以我们需要检查我们拥有的斑点内部是黑色,但外部是白色。或者,更准确地说,内部应该比外部暗得多(因为光照可能不同,检查完美的黑白将不起作用)。
为了检查斑点内部是否比外部暗,我们可以使用GetBlobsLeftAndRightEdges()方法获取斑点的左右边缘点,然后计算斑点外部和内部像素的平均亮度差。如果平均差足够显著,那么我们很可能有一个被较亮区域包围的深色对象。
// get edge points on the left and on the right side
List<IntPoint> leftEdgePoints, rightEdgePoints;
blobCounter.GetBlobsLeftAndRightEdges( blobs[i],
out leftEdgePoints, out rightEdgePoints );
// calculate average difference between pixel values from outside of the
// shape and from inside
float diff = CalculateAverageEdgesBrightnessDifference(
leftEdgePoints, rightEdgePoints, grayImage );
// check average difference, which tells how much outside is lighter than
// inside on the average
if ( diff > 20 )
{
// ...
}
为了阐明计算斑点外部和内部像素平均差值的思想,让我们仔细看看 CalculateAverageEdgesBrightnessDifference() 方法。对于斑点的左右边缘,该方法构建了两个点列表——一个点列表略微位于边缘左侧,另一个点列表略微位于边缘右侧(假设距离边缘3个像素)。对于每个点列表,它使用 Collect8bppPixelValues() 方法收集与这些点对应的像素值。然后它计算平均差值——对于斑点的左边缘,它从边缘左侧(斑点外部)像素值中减去边缘右侧(斑点内部)像素值;对于斑点的右边缘,它执行相反的差值。计算完成后,该方法会产生一个值,该值是斑点外部和内部像素的平均差值。
const int stepSize = 3;
// Calculate average brightness difference between pixels outside and
// inside of the object bounded by specified left and right edge
private float CalculateAverageEdgesBrightnessDifference(
List<IntPoint> leftEdgePoints,
List<IntPoint> rightEdgePoints,
UnmanagedImage image )
{
// create list of points, which are a bit on the left/right from edges
List<IntPoint> leftEdgePoints1 = new List<IntPoint>( );
List<IntPoint> leftEdgePoints2 = new List<IntPoint>( );
List<IntPoint> rightEdgePoints1 = new List<IntPoint>( );
List<IntPoint> rightEdgePoints2 = new List<IntPoint>( );
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 ) );
}
// collect pixel values from specified points
byte[] leftValues1 = image.Collect8bppPixelValues( leftEdgePoints1 );
byte[] leftValues2 = image.Collect8bppPixelValues( leftEdgePoints2 );
byte[] rightValues1 = image.Collect8bppPixelValues( rightEdgePoints1 );
byte[] rightValues2 = image.Collect8bppPixelValues( rightEdgePoints2 );
// calculate average difference between pixel values from outside of
// the shape and from inside
float diff = 0;
int pixelCount = 0;
for ( int k = 0; k <leftEdgePoints.Count; 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 - do quadrilateral transformation
QuadrilateralTransformation quadrilateralTransformation =
new QuadrilateralTransformation( quadrilateral, 100, 100 );
UnmanagedImage glyphImage = quadrilateralTransformation.Apply( image );
![]() |
正如我们从上图中看到的,光照条件可能会有很大的变化,有些字形可能不像其他字形那样具有对比度。因此,我们可以在此阶段使用Otsu阈值处理对字形进行二值化。
// otsu thresholding
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;
// glyph's cell size
int cellWidth = glyphWidth / glyphSize;
int cellHeight = glyphHeight / glyphSize;
// allow some gap for each cell, which is not scanned
int cellOffsetX = (int) ( cellWidth * 0.2 );
int cellOffsetY = (int) ( cellHeight * 0.2 );
// cell's scan size
int cellScanX = (int) ( cellWidth * 0.6 );
int cellScanY = (int) ( cellHeight * 0.6 );
int cellScanArea = cellScanX * cellScanY;
// summary intensity for each glyph's cell
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 all glyph's rows
for ( int gi = 0; gi < glyphSize; gi++ )
{
srcLine = srcBase + cellHeight * gi * stride;
// for all lines in the row
for ( int y = 0; y < cellScanY; y++ )
{
// for all glyph columns
for ( int gj = 0; gj < glyphSize; gj++ )
{
src = srcLine + cellWidth * gj;
// for all pixels in the column
for ( int x = 0; x < cellScanX; x++, src++ )
{
cellIntensity[gi, gj] += *src;
}
}
srcLine += stride;
}
}
}
// calculate value of each glyph's cell and set
// glyphs' confidence to minim value of cell's confidence
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;
}
有了上面提供的函数,字形二值化后的下一步看起来很简单
// recognize raw glyph
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 ) ) )
{
// ...
// further processing
}
}
这就是字形数据提取/识别的全部内容。如果包含潜在字形的候选图像通过了所有这些步骤和检查,那么我们似乎真的得到了一个字形。
将找到的字形与字形数据库匹配
尽管我们从图像中提取了字形数据,但这并不是字形识别任务的最后一步。处理增强现实或机器人技术的应用程序通常有一个字形数据库,其中每个字形可能都有自己的含义。例如,在增强现实中,每个字形都与一个虚拟对象相关联,以便代替字形显示,但在机器人应用程序中,每个字形可能代表机器人的命令或方向。因此,最后一步是将提取的字形数据与字形数据库匹配,并检索与该字形相关的信息——其ID、名称以及其他任何信息。
为了成功完成字形匹配步骤,我们需要记住字形可以旋转,因此将提取的字形数据与数据库中存储的字形进行一对一比较将不起作用。在字形数据库中查找匹配的字形时,我们需要对提取的字形数据与数据库中的每个字形进行4次比较——将提取的字形数据的4种可能旋转与数据库进行比较。
另一个需要提及的重要事项是,数据库中的所有字形都应该是旋转变体的,以便无论旋转如何都具有唯一性。如果一个字形在旋转后看起来相同,那么它就是一个旋转不变的字形。对于旋转不变的字形,我们无法确定它们的旋转角度,这对于增强现实等应用程序非常重要。此外,如果数据库中包含几个旋转不变的字形,并且其中一个旋转后可能看起来相同,那么可能无法在数据库中找到正确的匹配字形。
下图展示了一些旋转变体和旋转不变的字形。字形(1)和(2)是旋转变体——如果它们旋转,它们将始终看起来不同。字形(3)、(4)和(5)是旋转不变的——如果旋转,它们将看起来相同,因此不可能检测它们的旋转角度。我们还可以看到字形(4)实际上与字形(5)相同,只是旋转了,因此字形数据库不应同时包含它们。
![]() |
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];
// no rotation
match1 &= ( value == data[i, j] );
// 180 deg
match2 &= ( value == data[sizeM1 - i, sizeM1 - j] );
// 90 deg
match3 &= ( value == data[sizeM1 - j, i] );
// 270 deg
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度),该角度用于从我们匹配到的原始字形中获取指定的字形数据。
现在,我们所需要做的就是遍历数据库中的所有字形,并检查我们从图像中提取的字形数据是否与数据库中的任何字形匹配。如果找到匹配项,那么我们就可以获取与匹配字形关联的所有数据,并将其用于可视化、向机器人发出命令等。
这就是字形识别的全部。现在是时候进行一个小演示了,它展示了所有上述代码应用于视频流(代码用边框突出显示识别出的字形并显示它们的名称)。
![]() |
2D增强现实
现在,字形识别已经成功,是时候更进一步尝试一些2D增强现实了。由于我们已经拥有所需的一切,所以这并不难做到。
我们需要做的第一件事是纠正字形的四边形(我们在字形定位阶段调用 IsQuadrilateral() 得到的结果)。正如已经提到的,我们从找到的四边形中提取的字形可能与字形数据库中的字形不完全相同,但可能已旋转。因此,我们需要以这样一种方式旋转四边形,使从中提取的字形与数据库中的字形完全相同。为此,我们需要使用字形匹配阶段我们进行的 CheckForMatching() 调用提供的旋转角度
if ( rotation != -1 )
{
foundGlyph.RecognizedQuadrilateral = foundGlyph.Quadrilateral;
// rotate quadrilateral's corners
while ( rotation > 0 )
{
foundGlyph.RecognizedQuadrilateral.Add( foundGlyph.RecognizedQuadrilateral[0] );
foundGlyph.RecognizedQuadrilateral.RemoveAt( 0 );
rotation -= 90;
}
}
现在,为了完成2D增强现实,我们所需要做的就是将我们想要的图像放入经过校正的四边形中。为此,我们使用BackwardQuadrilateralTransformation——与QuadrilateralTransformation相同,但它不是从指定的四边形中提取图像,而是将另一幅图像放入其中。
// put glyph's image onto the glyph using quadrilateral transformation
BackwardQuadrilateralTransformation quadrilateralTransformation =
new BackwardQuadrilateralTransformation( );
quadrilateralTransformation.SourceImage = glyphImage;
quadrilateralTransformation.DestinationQuadrilateral = glyphData.RecognizedQuadrilateral;
quadrilateralTransformation.ApplyInPlace( sourceImage );
![]() |
那真是太快了。在所有之前提到的内容之后,2D增强现实就没什么好说的了。所以让我们看另一个演示……
![]() |
姿态估计
很明显,3D增强现实不像2D增强现实那么简单。要在字形上方放置一个3D对象,仅仅知道字形四个角的坐标是不够的。相反,需要知道字形在真实世界中的3D中心坐标(平移)以及它绕X/Y/Z轴的旋转角度。因此,在进一步深入3D增强现实之前,我们需要找到确定字形真实世界3D姿态的方法。
有许多关于3D姿态估计的论文发表,描述了不同的算法。其中最流行似乎是POSIT算法,它很容易理解和实现。该算法在Daniel F. DeMenthon和Larry S. Davis的论文“Model-Based Object Pose in 25 Lines of Code”中有所描述。
POSIT算法的目的是估计物体的3D姿态,包括绕X/Y/Z轴的旋转和沿X/Y/Z轴的平移。为此,算法需要一些物体点的图像坐标(最少4个点——正是我们拥有的角点数量)。然后它需要知道这些点的模型坐标。这意味着用于姿态估计的物体模型是已知的,因此我们知道模型中对应点的坐标(是的,我们知道)。最后,算法需要用于拍摄物体的相机的有效焦距。
我们可以轻松地收集POSIT算法工作所需的所有信息。然而,该算法有一个限制,这使得它对我们来说有点无用——该算法是为非共面情况设计的。换句话说,用于姿态估计的模型点不能全部位于同一平面上。不幸的是,这正是我们遇到的情况。由于字形是平面的,因此无法使用POSIT估计它们的姿态。
幸运的是,研究人员并没有止步于POSIT,而是提出了一个扩展算法,即共面POSIT。它本质上是相同的POSIT,但适用于共面情况。该算法的描述可以在Oberkampf、Daniel F. DeMenthon和Larry S. Davis撰写的论文“Iterative Pose Estimation using Coplanar Feature Points”中找到。至于实现,我们将使用AForge.NET框架中的CoplanarPOSIT类。
假设我们想估计字形的姿态,如下图所示(其角点用不同颜色突出显示以便后续参考)
![]() |
首先,让我们从用于姿态估计的点的图像坐标开始。上图显示了黄色、蓝色、红色和绿色四点。这些点的坐标是(所有坐标都相对于图像中心;Y轴正方向从中心到顶部;图像原始大小为640x480)
- (-77, 48) - 黄色;
- (44, 66) - 蓝色;
- (75, -36) - 红色;
- (-61, -58) - 绿色。
现在我们需要获取这些点的模型坐标。假设我们将坐标系原点设置在字形中心,字形位于XZ平面上,并且我们使用左手坐标系,其中Z轴远离观察者,X轴向右,Y轴向上。因此,如果我们的实际字形大小为113毫米,例如,那么其模型定义应如下所示
- (-56.5, 0, 56.5) - 黄色;
- (56.5, 0, 56.5) - 蓝色;
- (56.5, 0, -56.5) - 红色;
- (-56.5, 0, -56.5) - 绿色。
我们需要的最后一件事情是有效焦距。图像宽度可以作为其一个很好的近似值。由于示例源图像的大小是640x480,我们取有效焦距等于640。现在我们准备使用以下代码来估计字形的姿态
// define model of glyph with side length equal to 113 mm
Vector3[] modelPoints = new Vector3[]
{
new Vector3( -56.5f, 0, 56.5f ),
new Vector3( 56.5f, 0, 56.5f ),
new Vector3( 56.5f, 0, -56.5f ),
new Vector3( -56.5f, 0, -56.5f ),
};
// define image points
AForge.Point[] imagePoints = new AForge.Point[]
{
new AForge.Point( -77, 48 ),
new AForge.Point( 44, 66 ),
new AForge.Point( 75, -36 ),
new AForge.Point( -61, -58 ),
};
// create instance of pose estimation algorithm
CoplanarPosit coposit = new CoplanarPosit( modelPoints, 640 );
// estimate pose of the object
Matrix3x3 rotationMatrix;
Vector3 translationVector;
coposit.EstimatePose( imagePoints, out rotationMatrix, out translationVector );
由于本文的主题不涉及3D变换矩阵、透视投影等,我们不会深入探讨如何解释计算出的变换矩阵。相反,我们只看一小段代码,它使用获得的旋转矩阵和变换向量——我们将在字形上方放置X/Y/Z轴,以查看3D姿态估计的准确性
// model used to draw coordinate system's axes
private Vector3[] axesModel = new Vector3[]
{
new Vector3( 0, 0, 0 ),
new Vector3( 1, 0, 0 ),
new Vector3( 0, 1, 0 ),
new Vector3( 0, 0, 1 ),
};
// transform the model and perform perspective projection
AForge.Point[] projectedAxes = PerformProjection( axesModel,
// create tranformation matrix
Matrix4x4.CreateTranslation( translationVector ) * // 3: translate
Matrix4x4.CreateFromRotation( rotationMatrix ) * // 2: rotate
Matrix4x4.CreateDiagonal( new Vector4( 56, 56, 56, 1 ) ), // 1: scale
imageSize.Width );
...
private AForge.Point[] PerformProjection( Vector3[] model,
Matrix4x4 transformationMatrix, int viewSize )
{
AForge.Point[] projectedPoints = new AForge.Point[model.Length];
for ( int i = 0; i < model.Length; i++ )
{
Vector3 scenePoint = ( transformationMatrix *
model[i].ToVector4( ) ).ToVector3( );
projectedPoints[i] = new AForge.Point(
(int) ( scenePoint.X / scenePoint.Z * viewSize ),
(int) ( scenePoint.Y / scenePoint.Z * viewSize ) );
}
return projectedPoints;
}
当我们有了3D模型的投影点后,我们只需要绘制它。
// cx and cy are coordinates of image's centre
using ( Pen pen = new Pen( Color.Blue, 5 ) )
{
g.DrawLine( pen,
cx + projectedAxes[0].X, cy - projectedAxes[0].Y,
cx + projectedAxes[1].X, cy - projectedAxes[1].Y );
}
using ( Pen pen = new Pen( Color.Red, 5 ) )
{
g.DrawLine( pen,
cx + projectedAxes[0].X, cy - projectedAxes[0].Y,
cx + projectedAxes[2].X, cy - projectedAxes[2].Y );
}
using ( Pen pen = new Pen( Color.Lime, 5 ) )
{
g.DrawLine( pen,
cx + projectedAxes[0].X, cy - projectedAxes[0].Y,
cx + projectedAxes[3].X, cy - projectedAxes[3].Y );
}
![]() |
POSIT和共面POSIT算法对用户来说唯一的区别在于,共面POSIT算法提供了两种物体姿态估计——对于算法的共面版本,方程组有两个解。检查哪种姿态估计更好的唯一方法是将两种估计的变换都应用于模型,执行透视投影,并与提供的图像点进行比较。导致相似图像点的姿态估计被认为是最好的。注意:所有这些都由共面POSIT算法的实现自动完成,因此它提供了最佳估计。但是,如果用户需要,替代估计也可用(请参阅CoplanarPosit类的文档)。但我们稍后会回到它……
3D增强现实
现在我们已经掌握了所有必要的知识,是时候将它们整合起来,以实现3D增强现实,将虚拟3D对象放置在真实字形之上。
3D渲染
首先要决定的是使用哪个库/框架进行3D渲染。对于这个增强现实项目,我决定尝试Microsoft的XNA框架。**注意**:由于本文主要内容与XNA无关,因此不会包含XNA的初学者介绍。
由于XNA框架主要面向游戏开发,其与WinForms应用程序的集成从其发布之初就并非一帆风顺。最初的想法是XNA管理整个游戏窗口、图形和输入/输出。然而,自那时以来情况有所改善,现在有官方示例展示了XNA与WinForms应用程序的集成。遵循这些XNA示例和教程中的一些,将在某个时候清楚地看到,渲染一个小模型的简单代码可能看起来像这样
protected override void Draw( )
{
GraphicsDevice.Clear( Color.Black );
// draw simple models for now with single mesh
if ( ( model != null ) && ( model.Meshes.Count == 1 ) )
{
ModelMesh mesh = model.Meshes[0];
// spin the object according to how much time has passed
float time = (float) timer.Elapsed.TotalSeconds;
// object's rotation and transformation matrices
Matrix rotation = Matrix.CreateFromYawPitchRoll(
time * 0.5f, time * 0.6f, time * 0.7f );
Matrix translation = Matrix.CreateTranslation( 0, 0, 0 );
// create transform matrices
Matrix viewMatrix = Matrix.CreateLookAt(
new Vector3( 0, 0, 3 ), Vector3.Zero, Vector3.Up );
Matrix projectionMatrix = Matrix.CreatePerspective(
1, 1 / GraphicsDevice.Viewport.AspectRatio, 1f, 10000 );
Matrix world = Matrix.CreateScale( 1 / mesh.BoundingSphere.Radius ) *
rotation * translation;
foreach ( Effect effect in mesh.Effects )
{
if ( effect is BasicEffect )
{
( (BasicEffect) effect ).EnableDefaultLighting( );
}
effect.Parameters["World"].SetValue( world );
effect.Parameters["View"].SetValue( viewMatrix );
effect.Parameters["Projection"].SetValue( projectionMatrix );
}
mesh.Draw( );
}
}
上面的代码与完整的AR渲染会有多大不同?实际上,差别不会太大。上面的代码只缺少两点才能实现一些增强现实:1)绘制真实场景而不是用黑色填充;2)为虚拟对象使用适当的世界变换矩阵(缩放、旋转和平移)以放置在字形上。就是这样——只有两点。
对于增强现实场景,我们需要渲染真实世界的图片——来自摄像头、文件或任何其他来源的视频,其中包含一些要识别的光学字形。在不深入探讨视频采集/读取细节的情况下,我们可以假设每个新的视频帧都以.NET的Bitmap形式提供。显然,XNA框架对GDI+位图不太关心,并且不提供渲染它们的方法。因此,我们需要一个工具方法,它允许将Bitmap转换为XNA的2D纹理进行渲染
// Convert GDI+ bitmap to XNA texture
public static Texture2D XNATextureFromBitmap( Bitmap bitmap, GraphicsDevice device )
{
int width = bitmap.Width;
int height = bitmap.Height;
Texture2D texture = new Texture2D( device, width, height,
1, TextureUsage.None, SurfaceFormat.Color );
BitmapData data = bitmap.LockBits( new Rectangle( 0, 0, width, height ),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb );
int bufferSize = data.Height * data.Stride;
// copy bitmap data into texture
byte[] bytes = new byte[bufferSize];
Marshal.Copy( data.Scan0, bytes, 0, bytes.Length );
texture.SetData( bytes );
bitmap.UnlockBits( data );
return texture;
}
一旦包含当前视频帧的位图被转换为XNA的纹理,它就可以在渲染3D模型之前进行渲染,这样这些模型就可以放置在某个真实世界图片之上,而不是黑色背景。唯一需要注意的是,在进行2D渲染后,需要恢复XNA图形设备的某些状态,这些状态在2D和3D图形之间共享,但被纹理渲染出于其目的而改变。
// draw texture containing video frame mainSpriteBatch.Begin( SpriteBlendMode.None ); mainSpriteBatch.Draw( texture, new Vector2( 0, 0 ), Color.White ); mainSpriteBatch.End( ); // restore state of some graphics device's properties after 2D graphics, // so 3D rendering will work fine GraphicsDevice.RenderState.DepthBufferEnable = true; GraphicsDevice.RenderState.AlphaBlendEnable = false; GraphicsDevice.RenderState.AlphaTestEnable = false; GraphicsDevice.SamplerStates[0].AddressU = TextureAddressMode.Wrap; GraphicsDevice.SamplerStates[0].AddressV = TextureAddressMode.Wrap;
最后也是最重要的一点是,确保渲染模型的尺寸、位置和旋转与真实世界中字形的姿态和位置相对应。此时,所有这些都不复杂,因为在上一章中已经描述过了。现在我们只需要将它们全部结合在一起。
将光学字形从现实世界带入虚拟世界
如上所述,共面POSIT算法提供了估计的旋转矩阵和平移向量。大致如下:
// estimate pose of the object
Matrix3x3 rotationMatrix;
Vector3 translationVector;
coposit.EstimatePose( imagePoints, out rotationMatrix, out translationVector );
当我们知道字形的旋转和平移时,我们可以更新XNA部分,利用这些信息将3D模型放置到正确的位置,并使用适当的旋转和大小。这是代码的一部分(从初始XNA代码示例复制),它计算XNA渲染的模型世界矩阵——我们只需要更改这部分即可完成增强现实场景,因为我们已经拥有所有其余部分
...
Matrix world = Matrix.CreateScale( 1 / mesh.BoundingSphere.Radius ) *
rotation * translation;
...
有人可能会认为,将 AForge.NET 框架的矩阵/向量转换为 XNA 的矩阵就足以让一切正常工作。然而并非如此。尽管 XNA 使用列主矩阵表示法,而 AForge.NET 框架使用行主表示法,但这并不是需要注意的主要区别。我们需要注意的事实是,XNA 使用的坐标系与姿态估计代码使用的坐标系不同。XNA 使用右手坐标系,其中 Z 轴从原点指向观察者,X 轴和 Y 轴分别指向右和上。在这种坐标系中,增加对象的 Z 坐标会使其更接近观察者(相机),从而使其在投影屏幕上看起来更大。然而,在现实世界中,情况恰恰相反——对象的 Z 坐标越大,意味着它离观察者越远。这被称为左手坐标系,其中 Z 轴指向远离观察者,X/Y 轴具有相同的方向(右/上)。因此,我们需要将字形的估计姿态坐标从左手坐标系转换为右手坐标系。
将真实世界坐标转换为XNA坐标的第一部分是取反对象的Z坐标,这样真实世界中越远的对象,在XNA场景中就越深。第二部分是转换对象的旋转角度——取反绕X和Y轴的旋转。
还有一件重要的事情——我们需要缩放XNA的3D模型。正如我们上面看到的,我们用毫米描述了字形模型。因此,姿态估计算法也用毫米估计了字形的平移。这将导致模型的Z坐标设置为约 -200,当字形距离相机约20厘米时,如果模型的原始尺寸很小,这将使3D模型在XNA场景中显得微小。所以我们所需要做的就是缩放3D模型,使其具有与字形尺寸“可比”的大小。
将所有这些放在一起,将上述代码行(计算XNA对象的全球矩阵)替换为以下代码
float modelSize = 113;
// extract rotation angles from the estimated rotation
float yaw, pitch, roll;
positRotation.ExtractYawPitchRoll( out yaw, out pitch, out roll );
// create XNA's rotation matrix
Matrix rotation = Matrix.CreateFromYawPitchRoll( -yaw, -pitch, roll );
// create XNA's translation matrix
Matrix translation = Matrix.CreateTranslation(
positTranslation.X, positTranslation.Y, -positTranslation.Z );
// create scaling matrix, so model fits its glyph
Matrix scaling = Matrix.CreateScale( modelSize );
// finally compute XNA object's world matrix
Matrix world = Matrix.CreateScale( 1 / mesh.BoundingSphere.Radius ) *
scaling * rotation * translation;
好了,就这样——增强现实完成了。将所有上述代码放在一起,我们应该得到一个像这样的XNA屏幕
![]() |
幕后的一些事情
尽管以上所有内容足以实现3D增强现实,但仍有一些值得提及的事情。其中一件事与字形角点检测中的“噪声”有关。如果您仔细查看上面展示的一个视频(字形识别和2D增强现实),您可能会注意到,在某些情况下,某些字形的角点可能会出现一种抖动(移动一两个像素),尽管整个字形应该是静态的。这种字形抖动效应可能由不同因素引起——视频流中的噪声、光照中的噪声、视频压缩的伪影等。所有这些因素都会导致字形角点检测中出现小错误,这些错误在连续的视频帧之间可能会有几个像素的差异。
这种类型的字形抖动对于只需要字形检测/识别的应用程序来说不是问题。但在增强现实应用程序中,这种小错误可能会导致一些不必要的视觉效果,看起来不美观。正如在之前的视频中可以看到的,字形坐标的一个像素变化已经在2D增强现实中造成了抖动画面。在3D增强现实中,情况会更糟,因为几个像素的微小变化将导致3D姿态估计略有不同,这将使3D模型抖动得更厉害。
为了消除上述导致AR模型抖动的角点检测噪声,可以实现字形坐标跟踪。例如,如果字形所有8个角点坐标的最大变化是2个像素或更多,则认为字形正在移动。否则,当最大变化仅为1个像素时,将其视为噪声并使用字形的先前坐标。还可以进行另一项检查,即计算位置变化超过1个像素的角点数量。如果只有一个这样的角点,则也将其视为噪声。这个规则是基于这样的假设:字形很难以这样一种方式旋转,以至于在透视投影后只有一个角点会改变其位置。
另一个可能导致某些3D增强现实伪影的问题与使用共面POSIT算法进行3D姿态估计有关。正如算法描述中所述,其数学可能得出两种有效的3D姿态估计(从数学角度来看是有效的)。当然,两种估计都会进行检查,以了解其效果如何,并为每种估计计算误差值。然而,两种估计的误差值都可能相当小,并且在某个视频帧上,错误的估计可能会得到更低的误差(同样是由于噪声和角点检测的不完美)。这可能会在增强现实中产生糟糕的效果,即3D模型在大多数时间都显示正确,但有时其姿态会突然变为完全不同的样子。
上述3D姿态估计误差也可以通过跟踪字形姿态来处理。例如,如果最佳估计姿态的误差值是替代姿态误差值的两倍(或更多),那么这种姿态总是被认为是正确的。但是,如果两种姿态的误差值差异很小,则跟踪算法会选择看起来更接近前一视频帧上检测到的字形姿态的姿态。
(注:上述跟踪例程的代码示例在文章中省略,可在GRATF项目的完整源代码中找到)
最终结果
现在是时候观看3D增强现实的最终视频了,其中包含了所有的噪声抑制和3D姿态校正……
![]() |
结论
从字形识别算法原型阶段到最终的3D增强现实,我花了一段时间才完成这个项目。但我必须承认,我非常享受这个过程,学到了很多东西,特别是考虑到大部分工作都是从零开始完成的——只是对算法进行头脑风暴,在互联网上寻找零散的知识等等。可以更快完成吗?当然。对我来说,这只是一个业余项目,在时间允许的情况下进行。
尽管为了使其工作已做了很多工作,但仍有更多需要继续改进的地方。例如,其中一个关键领域是字形检测/识别。目前,如果字形移动过快,以至于当前照明条件和摄像机曝光时间无法捕捉,算法可能会无法检测到字形。在这种情况下,字形图像会变得模糊,难以进行任何识别。3D姿态估计算法还可以进一步改进。当然,关于字形跟踪还有很多可以做。例如,可以计算字形沿3个轴的移动/旋转速度和加速度,这可以用于制作一些不错的3D游戏和效果。
目前,所有已完成的工作都已作为开源项目发布。GRATF项目包含两个主要部分:1) 字形定位、识别和姿态估计算法库,以及 2) 字形识别工作室应用程序,该应用程序展示了所有实际操作,包括2D/3D增强现实。由于核心算法被封装到库中,因此可以轻松地将其集成到需要仅字形识别或更多增强现实功能的其他应用程序中。
我真诚地希望这篇文章能找到它的读者,这个项目能找到它的用户,这样这些工作就能被重用和扩展,带来新的酷炫应用。或者至少,它能对所有那些开始字形识别项目或只是学习计算机视觉的人有所帮助。