65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 DotImage 进行光学标记识别

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2009年5月12日

CPOL

7分钟阅读

viewsIcon

79759

本文将带您了解如何使用 DotImage 中的图像处理功能识别标准表单上的标记。

引言

标准化考试使用答题卡可以轻松批改大量试卷。通常,这些工作由专门的机器完成,该机器可以扫描、识别和批改答题卡,但使用光学标记识别技术,我们可以通过普通的扫描仪和软件来模拟这一过程。本文将带您了解如何使用 DotImage 中的图像处理功能识别标准表单上的标记。

OMR 有五个基本步骤。

  1. 设计表单
  2. 为标记区域创建模板
  3. 对扫描图像进行二维变换,以正确对齐和调整大小
  4. 使用图像处理来突出标记
  5. 使用模板查找标记

设计表单

市场上有许多预打印的 OMR 表格可供您参考,但如果您想自行设计,原则很简单。您必须设计一个满足以下条件的表单:

  1. 软件可以轻松快速地对齐、移动和缩放,以便读取
  2. 软件可以轻松删除不感兴趣的部分,以便于处理

这是一个简单的示例(或查看/下载模板图像

设计表单时最重要的一点是使其易于后续处理。扫描表单后,我们必须确保其正确对齐和缩放,以便我们创建的模板能够与扫描图像匹配。

为了帮助实现这一点,我在顶部空白处的正中心放置了一个半英寸的黑色方块。这将很容易在后面找到,并且我模板中的所有坐标都将基于此方块的位置。其他常见技术是使用条形码或 OCR。如果您可以使用条形码读取软件,这可能是最佳选择,因为条形码旨在易于识别,并且条形码读取软件将为您提供在调整文档时要使用的确切位置和大小。当您无法保证扫描质量高时(顺便说一下,条形码读取作为 DotImage 的附加功能可在http://www.atalasoft.com/products/dotimage/barcode/获取),条形码也更具弹性。

然而,对于这个例子,展示您如何自行识别标记更有启发性——因为大多数预打印的表单都使用标记而不是条形码。

我还对气泡使用了“褪色”颜色(红色)。这将使我们以后可以轻松地将它们从图像中完全删除,从而更容易找到标记。

为标记区域创建模板

模板只是您要查找标记的区域的位置。您必须做几件事才能做到这一点。

  1. 选择您将扫描图像的 DPI。实际上可能有所不同(您将进行缩放),但您需要选择一个来获取像素坐标位置。我将使用 150 DPI。
  2. 选择一个您以后可以轻松找到的原点。我将使用我的黑色方块的中心。

在 150 DPI 下,正方形的中心位于 (637, 110),正方形的大小为 75 X 75。

每个红色圆圈大约 35 X 35,第一个位于 (155, 374)。向右移动到下一个气泡,增加 66.5 像素;向下移动到下一个气泡,增加 40.75 像素(两种情况都四舍五入)。

此模板可以表示为位置列表,但由于它如此规律,我们也可以用这段代码表示模板

static Point _markerStandardCenter = new Point(637, 110);
static Size _markerStandardSize = new Size(75, 75);
static Point _firstAnswerStandardLocation = new Point(155, 374);
static Size _firstAnswerStandardSize = new Size(35, 35);
static float _answerXDistance = 66.5f;
static float _answerYDistance = 40.75f;

Rectangle GetAnswerBubbleRect(int row, int col, Point markerCenter, float scale)
{
    // calculate the location of the first answer on the scaled image
    // first scale the standard offset from the standard center, 
    // then add it to the actual center on this image
    PointF firstAnswerPtScaled =
        new PointF(
         scale * (_firstAnswerStandardLocation.X - _markerStandardCenter.X) + 
           markerCenter.X,
         scale * (_firstAnswerStandardLocation.Y - _markerStandardCenter.Y) + 
           markerCenter.Y);

    // the answer bubble that we want is found by using the distance between 
    // the answers, scaled to this image size. The size of the bubble is the 
    // standard size multiplied by the scale.
    return new Rectangle(
        (int)(firstAnswerPtScaled.X + col * _answerXDistance * scale),
        (int)(firstAnswerPtScaled.Y + row * _answerYDistance * scale), 
        (int)(_firstAnswerStandardSize.Width * scale),
        (int)(_firstAnswerStandardSize.Height * scale)
    );
}

当我们找到标记时,我们将确定其中心和相对于我们标准的比例。鉴于此函数将告诉您给定答案气泡的矩形(在您的图像上)。

对扫描图像进行二维变换,以正确对齐和调整大小

我们需要确定三个二维变换,以便将我们的图像与模板所基于的标准图像匹配:旋转、平移和缩放。旋转图像最简单的方法是使用去斜算法。去斜算法查看图像并假定大多数线条应该位于 0 和 90 度,并返回图像偏离此角度的角度。

在 DotImage 中,我们可以用这段代码找到倾斜角度

double GetSkewAngle(AtalaImage img)
{
    AutoDeskewCommand cmd = new AutoDeskewCommand();
    cmd.ApplyToAnyPixelFormat = true;
    AutoDeskewResults res = (AutoDeskewResults)cmd.Apply(img);
    return res.SkewAngle;
}

并用这段代码旋转图像。

AtalaImage RotateImage(AtalaImage img, double angle)
{
    RotateCommand cmd = new RotateCommand(angle, Color.White);
    return cmd.Apply(img).Image;
}

要去除倾斜,获取角度并向相反方向旋转

private AtalaImage Deskew(AtalaImage img)
{
    double angle = GetSkewAngle(img);
    img = RotateImage(img, -angle);
    return img;
}

接下来你需要做的是找到标记。这无疑是一个简化的例子——在现实世界中,您可能需要根据图像使其更健壮。如果您不能依赖相对清晰的扫描,那么最好使用条形码或 OCR 来确定文档的真实比例和位移。

这是在图像的行上查找水平线段的代码

private bool IsDark(Color pixel)
{
    return pixel.GetBrightness() < .05;
}

private bool FindLine(AtalaImage img, int y, int markerSizeThreshold, 
        ref int left, ref int right)
{
    // loop through each pixel in the row looking 
    // for a line of length 'markerSizeThreshold'
    for (int x = 0; x < img.Width; ++x)
    {
        Color pixel = img.GetPixelColor(x, y);
        if (IsDark(pixel))
        {
            if (left == -1)
                left = x;
            right = x;
        }
        else
        {
            if (left != -1 && right - left > markerSizeThreshold)
            {
                return true;
            }
            else
            {
                // wasn't it
                left = -1;
                right = -1;
            }
        }
    }
    return false;
}

此代码将对小于 `markerSizeThreshold` 长度的微小斑点具有弹性。

使用它,我们可以通过首先找到一个线段,然后查看有多少行在基本相同的位置有一个线段来找到我们的盒子。

private Rectangle FindMarker(AtalaImage img)
{
    int numRowsToSearch = img.Height / 8; // marker is within this area
    int markerSizeThreshold = img.Width / 25; // marker is at least this big
    // eventual marker position
    int top = -1;
    int left = -1;
    int right = -1;
    int bottom = -1;
    
    // try to find the top of the marker, by looping through each row
    for (int y = 0; y < numRowsToSearch; ++y)
    {
        if (FindLine(img, y, markerSizeThreshold, ref left, ref right))
        {
            top = y;
            break;
        }
    }
    if (top == -1)
    {
        throw new Exception("Didn't find marker");
    }

    // find marker extents
    int expectedBottom = top + (right - left) + 10;
    bottom = expectedBottom;
    for (int y = top + 1; y < expectedBottom; ++y)
    {
        int l=-1, r=-1;
        if (FindLine(img, y, markerSizeThreshold, ref l, ref r))
        {
            if (l > right+5 || r < left-5)
            {
                throw new Exception("Marker not found");
            }
            if (l < left) left = l;
            if (r > right) right = r;
        }
        else if (y - top < markerSizeThreshold)
        {
            throw new Exception("Marker not big enough");
        }
        else
        {
            bottom = y;
            break;
        }
    }

    return new Rectangle(left, top, right - left, bottom - top);
}

此函数返回的矩形提供了我们执行平移和缩放变换所需的信息。由于我们知道标记的预期大小和位置,因此实际大小与预期大小的比率告诉我们比例,而与预期位置的偏移量告诉我们如何偏移我们的模板。

查找比例的代码是

private float GetImageScale(Rectangle markerActualLocation)
{
    return (((float)markerActualLocation.Width) / 
             ((float)_markerStandardSize.Width) +
           ((float)markerActualLocation.Height) / 
             ((float)_markerStandardSize.Height)) / 2.0f;
}

查找标记中心的代码是

private Point GetMarkerCenter(Rectangle markerActualLocation)
{
    return new Point(
        markerActualLocation.X + markerActualLocation.Width / 2,
        markerActualLocation.Y + markerActualLocation.Height / 2
    );
}

有了比例和中心的坐标,我们现在可以调用我们之前编写的 `GetAnswerBubbleRect()` 函数。

使用图像处理来突出标记

我们在设计表单时,故意为答案气泡使用了“褪色”颜色。这样做的原因是为了我们可以轻松地在图像中找到并删除它们。这是代码

private AtalaImage DropOut(AtalaImage img, Color color)
{
    ReplaceColorCommand cmdDropOutColor = 
        new ReplaceColorCommand(color, Color.White, .2);
    img = cmdDropOutColor.Apply(img).Image;

    ReplaceColorCommand cmdDropOutNearWhite = new 
        ReplaceColorCommand(Color.White, Color.White, .2);
    img = cmdDropOutNearWhite.Apply(img).Image;

    return img;
}

此函数会去除任何接近传入颜色或接近白色的像素。在 DotImage 中,`ReplaceColorCommand` 对象会自动将一种颜色替换为另一种颜色——构造函数的第三个参数是一个容差(介于 0 和 1 之间),表示需要与该颜色有多接近才能被替换。

使用模板查找标记

要找出哪些气泡被填充,我们需要遍历一列中的所有气泡,找出它们在图像中的位置,然后查看该位置的像素,看看气泡是否看起来被填充。由于我们已经去除了该区域的红色,因此很容易找到被填充的气泡。首先我们需要查看答案气泡上的矩形并计算深色像素的数量

private bool IsFilledIn(AtalaImage img, Rectangle rect)
{
    // find the number of pixels at each brightness in an area
    Histogram hist = new Histogram(img, rect);
    int[] histResults = hist.GetBrightnessHistogram();

    // count the dark ones
    int numDark = 0;
    for (int h = 0; h < histResults.Length; ++h)
    {
        if (IsDark(Color.FromArgb(h, h, h))) {
            numDark += histResults[h]; 
        }
    }

    // if over a third are dark, then this bubble is filled in
    if (numDark > (rect.Width * rect.Height / 3))
        return true;

    return false;
}

可以使用 `Histogram` 对象获取区域中像素的统计信息。在这种情况下,我们正在获取亮度直方图,它返回一个数组,其中包含我们传入的矩形区域中每个亮度级别 (0-255) 的像素数。我们可以使用我们编写的相同 `IsDark()` 函数来查找标记。如果答案区域中深色像素的数量超过总面积的三分之一,我们返回 true 以指示气泡被填充。

要阅读答题卡,我们只需遍历每一列和每一行,查找被填充的气泡

private String ReadAnswerBubbles(AtalaImage img, float scale, Point markerCenter)
{
    String name = "";

    // loop through each column, trying to find the letter that is filled in
    int numCols = 15;
    int numRows = 26;
    for (int c = 0; c < numCols; ++c)
    {
        for (int r = 0; r < numRows; ++r)
        {
            Rectangle rect = GetAnswerBubbleRect(r, c, markerCenter, scale);
            if (IsFilledIn(img, rect))
            {
                name += (char)('A' + r);
                break;
            }
        }
    }
    return name;
}

要将所有这些步骤组合在一起,请使用以下函数

private string GetAnswer(AtalaImage img)
{
    // Deskew the image
    img = Deskew(img);

    // find the marker so that we can scale and position the template
    Rectangle markerActualLocation = FindMarker(img);
    float scale = GetImageScale(markerActualLocation);
    Point markerCenter = GetMarkerCenter(markerActualLocation);

    // remove the answer bubbles (that are this shade of red: #D99694)
    img = DropOut(img, Color.FromArgb(0xD9, 0x96, 0x94));

    // read the answer bubbles
    return ReadAnswerBubbles(img, scale, markerCenter);
}

所以,如果我现在使用这张图片

并对其调用 `GetAnswer()`,它将返回“ATALASOFT”。我已包含此图像和模板,以便您可以进行操作。要获取 DotImage 的免费评估版,请访问http://www.atalasoft.com/products/dotimage

Archives(归档)

© . All rights reserved.