传统OCR系统最重要的步骤就是特征提取,为了找出图像候选的文字区域的特征。特征提取第一步是特征设计,特征设计是非常麻烦的事情,需要大量人工对汉子的独有结构进行特征设计,并且做分类数据库。

下面是传统OCR计数框架图。

image-20231002003719243

并且,用人工设计的特征来训练字符识别模型,单一的特征在字体发生变化时,泛化能力显著下降,鲁棒性不够。它还非常依赖最后字符分割的结果,所以错误率很高。

在ocr识别系统中,所有步骤被大致分成了三个部分。首先是图像输入预处理,然后是图像分割,然后是汉字识别,当然也包括英文等其他识别,最后对识别结果进行处理。

图像预处理时,为了加快图像识别模块的处理速度,需要将彩色图像转换为灰度图像,减小图像矩阵占用的内存空间。由彩色图像转换为灰度图像的过程叫做灰度化处理,灰度图像就是只有亮度信息而没有颜色信息的图像,而且存储灰度图像只需要一个数据矩阵,矩阵中的每个元素都表示对应位置像素的灰度值。

通过拍摄,扫描等方式采集图像可能会有局部区域模糊,对比度偏弱等因素的影响,而图像增强可以用于图像对比度的调整,可以突出图像的重要细节!因此,采用图像灰度变换等方法可以有效增强图像对比度,提高图像中字符的清晰度。

对比度增强是典型的空域图像增强算法,这种处理只是逐点修改原始图像中每个像素的灰度值,不会改变图像中像素的位置,在输入像素与输出像素之间是一对一的映射关系。

图像可能在扫描过程中受到噪声干扰,为了提高识别模块的准确率,通常采用平滑滤波的方法(中值滤波,均值滤波)去噪。

一旦图像被定义为一种数据类型,并能够访问该图像的灰度值(即像素),我们用直方图来表示不同灰度的概率密度函数。图像直方图表示图像中各种灰度出现的频率。可以对直方图建模,使图像可以改变其对比度,被称为直方图均衡化(histogram equalization)。直方图建模对于以对比度变化的方式进行图像增强是一种非常有用的技术。直方图均衡化允许低对比度的图像区域获取更高的对比度。

5.10.2 OCR 实现自然图像中文本的识别

1.使用MSER检测器候选文本区域

这种检测器可以很好的找到文本区域。它适用于文本,因为文本是具有一致的颜色和高对比度,是一种稳定的强度配置文件。
MSER(Maximally Stable Extremal Regions)区域是图像处理和计算机视觉中的一种特征,用于检测和描述图像中的稳定区域。这些稳定区域通常具有以下特点:

稳定性:MSER区域是稳定的,这意味着它们在不同尺度和光照条件下都能保持相对不变。这使它们适合用于物体检测、跟踪和匹配等计算机视觉任务,因为它们对图像变化具有一定的鲁棒性。而文本是在图像中稳定的区域。

区域性质:MSER区域是连通的像素集合,通常表示图像中的一个区域或物体。它们可以是图像中的明亮或暗区域,具体取决于应用的背景和需求。文本通常以连通的形式存在于图像中。

灰度值稳定性:MSER区域的灰度值变化相对较小,因此它们在灰度值上具有一定的一致性。而文本通常以相对一致的灰度值显示在图像中。

区域的最大性:MSER区域是在满足一定条件下,具有最大稳定性的区域。这意味着它们在变化尺度下会保持不变,并且不会被更大或更小的区域完全包含。

MSER区域检测通常用于对象检测、图像分割、文字检测和物体跟踪等计算机视觉任务中。它们可以帮助识别图像中的显著区域,进而用于后续的分析和处理。MSER检测器在MATLAB等图像处理工具中提供了一种方便的方式来自动检测这些稳定区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%使用detectMSERFeatures函数找到图像中所有区域并且绘制这些结果。
%需要注意,它会在文本旁边检测到许多非文本区域
colorImage = imread('handicapSign.jpg');
I = rgb2gray(colorImage);%先转成灰度图像
%MSER检测区域
[mserRegions, mserConnComp] = detectMSERFeatures(I, 'RegionAreaRange', [200, 8000], 'ThresholdDelta', 4);
%RegionAreaRange指定要保留的MSER区域的面积范围。因为MSER将图像分割成许多不同的区域,每个区域都是一组连通的像素。这些区域的大小可以有很大的差异。
%[200, 8000]分别为最小最大面积条件,即MSER的面积至少为200像素,面积<200像素的MSER区域就会被过滤掉。有助于消除图像中非常小的噪音或者不相关的区域。
%8000排除了过大的MSER区域,以确保保留的区域适合于特定任务。
%ThresholdDelta控制MSER检测的敏感度,表示相邻像素之间的灰度值差异的阈值。在这里它被设置为4,意味着只有在相邻像素的灰度值差异>4时才会被认为是一个MSER区域
%mserConnComp包含了检测到的MSER区域的连通组件信息,可以用于后续处理,比如将检测到的区域连接成更大的区域或者执行其他操作。
figure;
imshow(I);%先显示原图
hold on;
plot(mserRegions, 'showPixelList', true, 'showEllipses', false);
title('MSER区域');
hold off;
%看图发现检测出1119个区域,就像python中的canny边缘检测一样。

效果如图所示:

image-20231006155415879

2.根据基本几何属性去除非文本区域

此时我们可以用基于规则的方法删除非文本区域。比如,利用文本的几何属性,可以使用阈值过滤掉非文本区域。或者可以使用机器学习方法训练文本分类器和非文本分类器。下面实例使用一种根据几何属性的方法过滤非文本区域,使用regionprops函数测量其中的一些属性,然后根据属性值去除非文本区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
%测量MSER属性,用regionproperties也就是regionprops
mserStats = regionprops(mserConnComp, 'BoundingBox', 'Eccentricity', 'solidity', 'Extent', 'Euler', 'Image');
%使用边框数据计算高宽比
%boundingbox计算每个区域的边界框,即包围区域的最小矩形框。
%eccentricity计算区域的离心率,为0为圆形,1为椭圆形。
%solidity计算区域的实心度,区域内含对象的占比。如果为0就是空的,只有边界。
%假设你有一个表示一个环形物体的区域。这个环形区域是空心的,也就是说,它的内部没
%有像素,只有边界上的像素形成了环形的形状。在这种情况下,这个区域的实心度会非常
%小,因为内部像素很少,大部分像素都在边界上。
%Extent它表示区域实际覆盖的像素数与该区域的最小外接矩形内的像素数之间的比率。
%当扩展度接近1时,表示区域比较接近于矩形形状
%Euler负值的欧拉数通常表示对象具有多个孔洞,而正值表示孔洞数量较少或没有孔洞。
%Image计算区域的图像,这可能是一个包含了该区域的像素的矩阵。
bbox = vertcat(mserStats.BoundingBox);%BoundingBox记录了边界框信息,坐标和宽高,行向量表示
%vertcat 用于将多个数组或矩阵在垂直方向上连接在一起,形成一个更大的数组或矩阵。
%bbox将包含所有MSER区域边界框信息,每行对应一个区域的边界框信息。
w = bbox(:, 3);
h = bbox(:, 4);%第一列是边界框的x坐标,第二列是y坐标,三列宽,四列高。
aspectRatio = w ./ h;
%确定数据的阈值,以确定要删除哪些数据(可能要针对其他图像调整这些阈值)
filterIdx = aspectRatio' > 3;%先转置使得宽高比变成行向量,在一行中。
%生成一个逻辑数组
filterIdx = filterIdx | [mserStats.Eccentricity] > .995;
%这是or运算,先算后面,当>0.995生成一个逻辑数组,然后与filterIdx比较,只要成立一个就是true
fiilterIdx = filterIdx | [mserStats.Solidity] < .3;
filterIdx = filterIdx | [mserStats.Extent] < 0.2 | [mserStats.Extent] > 0.9;
filterIdx = filterIdx | [mserStats.EulerNumber] < -4;
%删除区域
mserStats(filterIdx) = [];
mserRegions(filterIdx) = [];%这里不是删除数组,而是删除对象,mserRegions是对象组。
%显示剩余的区域
figure;
imshow(I);
hold on;
plot(mserRegions, 'showPixelList', true, 'showEllipses', false);
title('基于几何属性去除非文本区域效果');
hold off;

效果如图所示:

image-20231006160439180

3.根据笔画宽度变化去除非文本区域

首先,笔画宽度是对组成字符的曲线和线条的宽度的度量。我们用笔画宽度区分文本区域和非文本区域。在文本区域中,笔画宽度的变化不会很大,而非文本区域中笔画宽度变化较大。我们这里使用距离变换和二进制细化操作实现这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
%%获取区域的二进制图像,并且填充它以免在笔画宽度计算期间产生边界效果。
%二值图像即黑色是0,白色是1.
regionImage = mserStats(6).Image;%这里你可以打开这个结构体看一下,其中的Image都是二值形式。
regionImage = padarray(regionImage, [1 1]);%padarray默认使用0值在图像周围填充1行1列
%所以由原来的19*13变成了21*15
%计算图像笔画宽度
distanceImage = bwdist(~regionImage);
%bwdist 函数计算的是从前景(对象)到背景(背景)的距离。然而有的时候输入的前景和背景的表示方式与标准相反
%这就需要取反操作。再应用bwdist计算前景到背景的距离。
skeletonImage = bwmorph(regionImage, 'thin', inf);%细化边界区域生成骨架图像。
strokeWidthImage = distanceImage;
strokeWidthImage(~skeletonImage) = 0;%将非骨架部分(边界之外的元素)置0。

%在笔画宽度图像旁边显示区域图像
figure
subplot(1,2,1);
imagesc(regionImage);
title('区域图像');
subplot(1,2,2);
imagesc(strokeWidthImage);%里面就已经不是2值了,可以看作灰度级别。
title('笔画宽度图像');

其中有一个重要函数

bwdist:bwdist(BW) computes the Euclidean distance transform of the binary image BW. For each pixel in BW, the distance transform assigns a number that is the distance between that pixel and the nearest nonzero pixel of BW.

可以看出我们要得到一个新的图像,其中的每个像素都是之前图像中,该像素到最小非0像素的距离(也就是默认白色是1,代表背景,黑色是0,代表文字),也就是黑色0到白色1的距离,所以白色的部分没有改变,而是黑色的背景部分改变了。而我们实际上重要的文字部分是二值中1,也就是白色部分,我们要计算白色到黑色的距离,就要先对图像二值取反,用~,如代码中所示。

之后变变成了1的部分计算到0的部分的最小距离,如图所示,这就是distanceImage了。

image-20231008013930017

可以看出斜边最小时用勾股定理,其余就是线段最短。

然后就是对bwmorph函数的解释,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
%ocr function explanation
BW = imread('circles.png');
figure
subplot(2,2,1);
imshow(BW);

BW2 = bwmorph(BW, 'remove');
subplot(2,2,2);
imshow(BW2);

BW3 = bwmorph(BW, 'skel', Inf);%取出基本骨架。
subplot(2,2,3);
imshow(BW3);

BW4 = bwmorph(BW, 'thin', inf);%即取出基本骨架再去噪,无穷次。称为细化,即把边界变薄。
subplot(2,2,4);
imshow(BW4);

四张图如图所示

image-20231008015830591

下图是strokeWidthImage经过skeletonImage过滤的图像。

image-20231008021100363

可以看到就剩下最后的笔画。

输出结果如图:

image-20231008021142908

下面把strokeWidthImage中那一竖线,取出成矩阵。

1
2
3
4
5
6
7
8
%图中发现笔画宽度图像在大部分区域几乎没有变化,表明该区域更可能是文本区域,
%因为构成该区域的线和曲线宽度都类似,这是人类可读文本的共同特征。
%(也就是像素离边界的距离差不多,说明这里就是一个笔画的中心部分,才能达到)
%为了利用一个阈值来使用笔画宽度去除非文本区域,必须将整个区域的变化量量化为一个单一的度量。
strokeWidthValues = distanceImage(skeletonImage);
%在距离图像distanceImage中取出skeletonImage内像素的距离值,就是把我们strokeWidthImage中那一竖线写成矩阵。
strokeWidthMetrics = std(strokeWidthValues) / mean(strokeWidthValues);
%算出归一化标准差了。

如图:

image-20231008022004693

然后可用阈值删除非文本区域。当然,阈值应该随着不同字样的图像调整优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
%%阈值笔画宽度变化度量
strokeWidthThreshold = 0.4;
strokeWidthFilterIdx = strokeWidthMetric > strokeWidthThreshold;
%大于阈值的我们认为是笔画,留下来
%下面用for循环处理所有区域,然后显示使用笔画宽度变化删除非文本区域结果。
for j = 1:numel(mserStats)%遍历了每一个小区域!所以有的strokeWidthMetric会大于阈值,有的会小于。
regionImage = mserStats(j).Image;
regionImage = padarray(regionImage, [1 1], 0);%用0值填充周围一行一列。
distanceImage = bwdist(~regionImage);%计算笔画宽度
skeletonImage = bwmorph(regionImage, 'thin', inf);%取出骨架再去噪。
strokeWidthValues = distanceImage(skeletonImage);%得到笔画矩阵
strokeWidthMetric = std(strokeWidthValues) / mean(strokeWidthValues);%进行标准差归一化处理
strokeWidthFilterIdx(j) = strokeWidthMetric > strokeWidthThreshold;
end
%根据笔画宽度变化去除区域
mserRegions(strokeWidthFilterIdx) = [];
mserStats(strokeWidthFilterIdx) = [];%因为文本区域笔画变化不大,所以标准差超过0.4的都去除了。
%显示剩余区域
subplot(2,2,3);
imshow(I);
hold on;
subplot(2,2,4);
plot(mserRegions, 'showPixelList', true, 'showEllipses', false);
title('基于笔画宽度变化去除非文本区域后的效果图');

效果如图所示:

image-20231009234413514

4.合并文本区域以获得最终检测结果

此时,我们已经得到的检测结果均由单个文本字符组成,要将这些结果用于识别任务(如ocr),单个文本字符就要合并为单词或者文本行,这让人们能够识别图像中具体的单词,因为单词比单个字符承载更多有意义的信息。

比如,识别一个字符串”TEXT”与单个字符{“T”, “E”, “X”, “T”},显然如果没有正确的顺序,那么单词的意思就会丢失。

将单个字符区域合并为单词或者一行文本的办法是先找到一个相邻的文本区域,然后在这些区域周围形成一个边框。为了找到相邻的文本区域,可以用区域道具扩展之前计算的边框。

这使得相邻文本区域的边框重叠,从而使属于同一单词或者文本行的文本区域形成重叠的边框链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
%获取所有区域的边框
%boundingbox包含了边框左上角坐标x,y宽度和高度!
bboxes = vertcat(mserStats.BoundingBox);%把矩阵纵向连起来,所以每一行对应一个区域边框的信息。
%将边框格式转换为[xmin ymin xmax ymax]格式
xmin = bboxes(:, 1);
ymin = bboxes(:, 2);
xmax = xmin + bboxes(:, 3) - 1;%第三列是宽度
ymax = ymin + bboxes(:, 4) - 1;
%因为边界是从1开始计数。
%将边框扩展一小部分。
expansionAmount = 0.02;
xmin = (1 - expansionAmount) * xmin;
ymin = (1 - expansionAmount) * ymin;
xmax = (1 + expansionAmount) * xmax;
ymax = (1 + expansionAmount) * ymax;

%将边框剪切到图像边界内
xmin = max(xmin, 1);
ymin = max(ymin, 1);%因为边界是从1开始计数。
xmax = min(xmax, size(I, 2));%size(I, 2)返回了图像的宽度,也就是边界不能大于图像宽度
ymax = min(ymax, size(I, 1));%同理不大于图像高度,这样保证了在原灰度图像内。
%显示扩展的边框
expandedBBoxes = [xmin ymin xmax-xmin+1 ymax-ymin+1];%现在的边框。
IExpandedBBoxes = insertShape(colorImage, 'rectangle', expandedBBoxes, 'LineWidth',3);
%insertShape在图像上绘制函数,其实类似模板匹配,这里画一个长方形,我们已经给出了自己的边框信息
figure;
imshow(IExpandedBBoxes);
title('扩展边框文本');
hold off;

现在重叠的边框可以合并到一起,这样可以形成围绕单个单词或者文本行的单个边框。为此,我们要计算所有边框之间的重叠比率,这相当于计算所有文本区域之间的距离,从而可以通过寻找非零重叠比率来找到相邻文本区域的组。一旦得到成对重叠比率,就使用一个图来寻找所有由非零重叠比率”连接“的文本区域。(这句话很别扭)

我们下面合并这些检测单独文本的文本框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
%使用bboxOverlapRatio函数计算所有展开边框的成对重叠比率。
%使用graph函数查找所有连接的文本区域。
overlapRatio = bboxOverlapRatio(expandedBBoxes, expandedBBoxes);%重叠区域与他们总区域之比
n = size(overlapRatio, 1);%获取矩阵overlapRatio的行数。
%因为我们可以看到在变量列表中,xmin表示宽度,而y表示高度,而高度和宽度都有1031个数
%也就是说一共有1031个边框!
%所以遍历计算每对的重叠比例!也就是第一个边框和第一个,和第二个。。。一直到和1031个!
%形成1031*1031的矩阵!
overlapRatio(1:n+1:n^2) = 0;%把对角线上和自己的重叠比例设置为0
%创建图
g = graph(overlapRatio);%创建了一个图,其中节点表示边界框,边表示两个边界框的关联程度,重叠程度。
componentIndices = conncomp(g);%把一定重叠程度的边界框连接起来,形成一些大的组合
%componentIndices即为大的组合的索引,这里文本区域分成了3组。
xmin = accumarray(componentIndices', xmin, [], @min);%
ymin = accumarray(componentIndices', ymin, [], @min);
% @指示accumarray函数在组合内的所有值上执行最小值操作。
%这些代码的目的是计算每个连通分量内所有边界框的最小x坐标,也就是为表示大边界框找到坐标。
xmax = accumarray(componentIndices', xmax, [], @max);
ymax = accumarray(componentIndices', ymax, [], @max);%根据最小尺寸和最大尺寸合并盒子
%使用[x, y, 宽度,高度]格式组成合并的边框
textBBoxes = [xmin, ymin, xmax-xmin+1 ymax-ymin+1];
%最后在显示最终结果之前,通过删除仅由一个文本区域组成的边框来抑制假文本检测。
%由于文本通常是成组(单词和句子)的,所以这样删除的不太可能是实际文本的隔离区域。
numRegionsInGroup = histcounts(componentIndices);%3组每一组分别包含的文本区域个数
textBBoxes(numRegionsInGroup == 1, :) = [];
%显示最终文本结果
ITextRegion = insertShape(colorImage, 'rectangle', textBBoxes, 'LineWidth',3);
%用最后得到的大边界框把文本区域框出来!
figure;
imshow(ITextRegion);
title('检测到文本');

注意,这里我们解释一下componentIndices,这是一个行向量,例如,假设 componentIndices 如下所示:

1
componentIndices = [0, 1, 1, 0, 1];

这表示有 5 个文本区域,分别属于三个组别。numRegionsInGroup 的计算如下:

1
numRegionsInGroup = histcounts(componentIndices);

在这个例子中,numRegionsInGroup 将是一个长度为 2 的向量,其中第一个元素表示组别 0 的文本区域数量,第二个元素表示组别 1 的文本区域数量。所以numRegionsInGroup维数就是组数2,两个分量分别是2和3也就是两个组分别包含的文本区域数量。

如果 numRegionsInGroup 的某个元素为 1,那么对应的组别中只包含一个文本区域。在 textBBoxes(numRegionsInGroup == 1, :) = []; 这行代码中,就会删除 textBBoxes 中那些属于只包含一个文本区域的组别的文本框。

最后提取文字即可。

1
2
3
4
%使用ocr技术识别检测到的文本
ocrtxt = ocr(I, textBBoxes);
display([ocrtxt.Text]);
%大功告成!

但是MSER方法我们也可以看出,单单根据检测器候选文本区域是不行的,还需要根据场景,比如英文固定字体的字符检测,满足一些几何性质,我们就可以根据几何性质来去除非文本区域。所以加入了针对文本情景的特殊特征操作之后,才可以提高文本检测的精确度,所以操作复杂度较高,而单单的MSER检测器很难有高准确率。