基于图像视觉词汇的文本分类方法(完整项目)

一年多以前我脑子一热,想做一款移动应用:一款给学生朋友用的“错题集”应用,可以将错题拍照,记录图像的同时,还能自动分类。比如拍个题目,应用会把它自动分类为"物理/力学/曲线运动"。当然,这个项目其实不靠谱,市场上已经有太多“搜题”类应用了。但过程很有趣,导致我过了一年多,清理磁盘垃圾时,还舍不得删掉这个项目的“成果”。

这个项目,核心要解决的问题就是文本分类。所以最初想到的方案是先 OCR 图片转文本,然后分词,再计算 tf-idf,最后用 SVM 分类。但这个方案的问题是:开源 OCR 普遍需要自己训练,且需要做大量的优化、调校和训练,才能在中文识别上有不错的效果,加上图像上还会有公式、几何图形,这些特征也会决定分类,这又提高了对 OCR 的要求。所以我最终选择的方案是,不使用 OCR,而是直接从图像中寻找有区分性的、鲁棒的特征,作为视觉词汇。之后再通过传统文本分类的方法,训练分类器。 下面将展示整个训练过程,训练的样本来自《2016 B版 5年高考3年模拟:高考理数》,并手工标注了14个分类,每个分类下约50个样本,每个样本为一个题目, 图像为手机拍摄。

本文中大部分算法库来自numpy、scipy、opencv、skimage、sklearn。

  1. 预处理

为了获取到稳定的特征,我们需要对图像进行预处理,包括调整图像大小,将图像缩放到合适尺寸;旋转图像,或者说调整成水平;二值化,去除色彩信息,产生黑白图像。

1.1. 调整图像大小

调整的目的是为了让图像中文字的尺寸保持大致相同的像素尺寸。这里做了一个简单假设,即:图像基本是一段完整的文本,比如一个段落,或者一页文档,那么不同的图像中,每行文本的字数相差不会很大。这样我就可以从我所了解的、少得可怜的图像工具库里找到一个工具了:直线拟合。即通过拟合的直线(线段)长度与图像宽度的比例,调整图像的大小。下图为两张不同尺寸图像,经过多次拟合+调整大小后的结果,其中红色算法检查到的直线(线段)。

下面是使用 opencv 直线拟合的代码:

# Canny算法提取边缘特征, image是256灰度图像

image = cv2.Canny(image, 50, 200)
# 霍夫线变换提取直线

lines = cv2.HoughLinesP(image, 2, math.pi / 180.0, 40, numpy.array([]), 50, 10)[0]

1.2. 图像二值化

二值算法选用skimage.filters.threshold_adaptive局部自适应阀值的二值化), 试下来针对这种场景,这个算法效果最好,其他算法可以去scikit-image文档了解。下图为全局阀值和局部自适应阀值的效果对比:

相关代码如下:

# 全局自适应阀值binary_global = image > threshold_otsu(image)
binary_global = numpy.array(binary_global, 'uint8') * 255

binary_global = cv2.bitwise_not(binary_global)
 #反转黑白# 局部自适应阀值

adaptive = threshold_adaptive(image, 41, offset=10)
adaptive = numpy.array(adaptive, 'uint8') * 255

adaptive = cv2.bitwise_not(adaptive) 
 #反转黑白

1.3. 旋转图像

从第一步获取到的直线,可以计算出图像的倾斜角度,针对只是轻微倾斜的图像,可以反向旋转进行调整。由于可能存在干扰线条,所以这里取所有直线倾斜角度的中值比平均值更合适。下图展示了图像旋转跳转前后的效果:

相关代码如下:

# 先计算所有线条的角度angles = []for line in lines:
    x = (line[2] - line[0])
    y = (line[3] - line[1])
    xy = (x ** 2 + y ** 2) ** 0.5
    if 0 == xy:        continue
    sin = y / xy
    angle = numpy.arcsin(sin) * 360. / 2. / numpy.pi
    angles += [angle]    # 计算中值

angle = numpy.median(angles)
# 旋转图像

image = ndimage.rotate(image, angle)

2. 提取特征

这里的思路是,首先通过形态学处理,可以分割出文本行(的图像),再从文本行中分割出词汇(的图像),然后从"词汇"中提取特征。但这里的需要克服的困难是:

  1. 很多汉字分左右部,容易被错分,比如你好, 可能被分割成以4块图像:
  2. 独立的“字”并不适合于文本分类,还需能学习出词汇。

针对以上问题的解决方案是:

  1. 将小的图像块进行组合,组合后的新图像块和原来的小块图像一起作为原始图像的特征,如你好将得到10个特征:你女你好尔女尔好
  2. 得益于上面的方案,词汇信息也被保留了下来,所以第二个问题也就解决了,同时增加了算法的鲁棒性。

下面将介绍具体实现。

2.1. 提取文本行

由于预处理过程中已经将样本的图像尺寸基本调整一致,所以可以比较容易的利用形态学的处理方法,分割出文本行。过程如下:

# cv2.Canny 可提取边缘,并去除噪点# image为调整过大小,但没有调整水平和二值化的图像

# 二值化后会影响 cv2.Canny 算法效果,所以这里用还没有二值化的图片

image = cv2.Canny(image, 100, 200)
# 二值化后调整水平image = ndimage.rotate(image, slope)
# 进行四次膨胀和腐蚀操作# 水平方向膨胀和腐蚀,联通字与字之间的空间

# 垂直方向做较小的膨胀和腐蚀,填补行内的空隙

image = cv2.dilate(image, cv2.getStructuringElement(cv2.MORPH_RECT, (40, 3)))
image = cv2.erode(image, cv2.getStructuringElement(cv2.MORPH_RECT, (40, 3)))
image = cv2.erode(image, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)))
image = cv2.dilate(image, cv2.getStructuringElement(cv2.MORPH_RECT, (6, 5)))

下图展示了每一步的变化:

接下来可以利用scipy库中的measurements.label方法,标记出不同的的区域,下图展示了标注后的效果,不同区域以不同的灰度表示。

相关代码如下:

# image 为上一步形态学处理后的图像

image = 1 * (image > 64)
 # 只保留灰度>64的区域,可以去除一些躁点

labeled, count = measurements.label(image)
 # labeled为一个和图像尺寸一致的矩阵,矩阵中每个元素的值即这个像素位置所属的区域索引

# count为区域数量figure()
gray()
imshow(labeled)
show()

接下来根据标记的区域,可从图像中裁剪出每行的数据,如下图:

相关代码如下:

def bounding_box(src):
    '''
    矩阵中非零元素的边框
    '''
    B = numpy.argwhere(src)    if B.size == 0:        return [0, 0, 0, 0]
    (ystart, xstart), (ystop, xstop) = B.min(0), B.max(0) + 1
    return [xstart, ystart, xstop - xstart, ystop - ystart]    def clip_lines(image, labeled, count)
    lines = []        for i in range(1, count + 1):
        temp = image.copy()
        temp[labeled != i] = 0
        box = bounding_box(temp)
        x, y, w, h = box
        data = temp[y:y + h, x:x + w]
        lines.append(data)  
    return lines

2.2. 提取特征(视觉词汇)

裁剪出单行文本图像后,我们可以将图像中各列的像素的值各自累加,得到一个一纬数组,此数组中的每个局部最小值所在的位置,即为文字间的空隙。如下图所示,其中蓝色线为像素值的累加值,绿色线为其通过高斯滤波平滑后的效果,红色线为最终检测到的分割点。

详细过程见下面代码:

# 1. 将图像中每一列的所有像素的值累加orisum = image.sum(axis=0) / 255.0

# 2. 累加后的数组通过高斯滤波器做平滑处理,减少干扰

filtered = filters.gaussian_filter(orisum, 8)
# 3. 找出拐点(上升转下降、下降转上升的点)trend = False 
 # False 下降,True上升preval = 0  
# 上一个值points = []  # 拐点pos = 0for i in filtered:   
 if preval != i:       
 if trend != (i > preval):
            trend = (i > preval)
            points += [[pos if pos == 0 else pos - 1, preval, orisum[pos]]]
    pos = pos + 1
    preval = i 
# 4. 下降转上升的拐点即为分割点
  ... 代码略 ... 

将单行的图像按上述方法获取的分割点进行裁剪,裁剪出单个字符,然后再把相邻的单个字符进行组合,得到最终的特征数据。组合相邻字符是为了使特征中保留词汇信息,同时增加鲁棒性。下图为最终获得的特征信息:

本文中使用的所有样本,最终能提取出约30万个特征。

2.3. 选择特征描述子

选择合适的特征描述子通常需要直觉+运气+不停的尝试(好吧我承认这里没有什么经验可分享),经过几次尝试,最终选中了HOG(方向梯度直方图)描述子。HOG 最让人熟悉的应用领域应该是行人检测了,它很适合描述钢性物体的边缘特征(方向),而印刷字体首先是刚性的,其次其关键信息都包含在边缘的方向上,所以理论上也适合用 HOG 描述。更多关于HOG的介绍请点击这里

https://link.jianshu.com/?t=http://scikit-image.org/docs/dev/auto_examples/features_detection/plot_hog.html#sphx-glr-auto-examples-features-detection-plot-hog-py

下图为文字图像及其 HOG 描述子的可视化:

代码如下:

# 提取边缘canny = cv2.Canny(numpy.uint8(img), 50, 200)
# 计算描特征描述子desc, hog_image = hog(
    canny, 
    orientations=6, 
    pixels_per_cell=(4, 4), 
    cells_per_block=(2, 2), 
    visualise=True)

3. 训练词汇分类器

对词汇进行人工标注工作量太大,所以最好能做到自动分类。我的做法是先聚类,再基于聚类的结果训练分类器。但有个问题,主流的聚类算法中,除了 K-Means 外,其他都不适合处理大量样本(目前有30万+样本),但 K-Means 在这个场景上聚类效果不佳,高频但不相关的词汇容易被聚成一类,而 DBSCAN 效果很好,但样本数一多,所需时间几何级增长(在我的机器上,超过两万个样本就需要耗费数个小时)。下图来自sklearn 文档,对各聚类算法做了比较:

2017/09/21 修改:原此处选择的聚类方法(即先使用先用 K-Means 做较少的分类然后对每个分类单独使用 DBSCAN 聚类并单独训练 SVC 分类器),准确率保持在70%左右,很难提高,故改用了下面描述的新方法。

为解决这一问题,我的做法是: 1. 先对每类样本下的词汇用 DBSCAN 聚类(约1万个词汇样本),得到一级分类。 2. 聚类后,计算每个一级分类的中心,然后以所有中心为样本再用DBSCAN聚类,得到二级分类。完成后,原一级分类中心的新分类,即代表其原一级分类下所有元素的分类。

聚类的过程为,使用前面提取的 HOG 特征,先 PCA 降纬,再 DBSCAN 聚类。这里注意,计算二级分类时,PCA应使用全局样本计算。

分类器使用SGDClassifier,原因是其支持分批计算,不至于导致内存不足。

本文中使用的样本,最终得到3000+词汇类型。下图为分类效果,其中每一行为一个分类:

4. 训练文本分类器

有了词汇分类器,我们终于可以识别出每个文本样本上所包含的词汇了(事实上前面步骤的中间过程也能得到每个样本的词汇信息),于是我们可以给每个样本计算一个词袋模型(即用每个词出现的次数表示一篇文本),再通过池袋模型计算TF-IDF模型(即用每个词的 TF*IDF 值表示一篇文本),并最终训练 SVM 分类器。下面展示了此过程的主要代码:

Fitting the classifier to the training set
done in 0.034s
score : 0.918639053254Predicting on the test set
done in 0.004s
               precision    recall  f1-score   support

    物理-电学-静电场       1.00      0.67      0.80         3
   物理-力学-互相作用       0.56      1.00      0.71         5
  物理-机械振动和机械波       0.83      1.00      0.91         5
   物理-电学-电磁感应       0.71      1.00      0.83         5
   物理-电学-恒定电流       1.00      1.00      1.00         5
   物理-力学-曲线运动       0.88      0.78      0.82         9
 物理-机械能及其守恒定律       0.62      0.56      0.59         9
        物理-光学       1.00      0.50      0.67         
2物理-力学-万有引力与航天       1.00      0.75      0.86         4
 物理-力学-牛顿运动定律       0.62      0.71      0.67         7
   物理-电学-交变电流       1.00      1.00      1.00         1
     物理-电学-磁场       1.00      0.25      0.40         4
        物理-热学       1.00      1.00      1.00         
2物理-力学-质点的直线运动       0.86      0.86      0.86         7

  avg / total       0.81      0.78      0.77        68

[[2 0 0 0 0 0 1 0 0 0 0 0 0 0]
 [0 5 0 0 0 0 0 0 0 0 0 0 0 0]
 [0 0 5 0 0 0 0 0 0 0 0 0 0 0]
 [0 0 0 5 0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 5 0 0 0 0 0 0 0 0 0]
 [0 1 0 0 0 7 0 0 0 0 0 0 0 1]
 [0 2 0 0 0 1 5 0 0 1 0 0 0 0]
 [0 0 0 0 0 0 0 1 0 1 0 0 0 0]
 [0 0 0 0 0 0 1 0 3 0 0 0 0 0]
 [0 1 0 0 0 0 1 0 0 5 0 0 0 0]
 [0 0 0 0 0 0 0 0 0 0 1 0 0 0]
 [0 0 0 2 0 0 0 0 0 1 0 1 0 0]
 [0 0 0 0 0 0 0 0 0 0 0 0 2 0]
 [0 0 1 0 0 0 0 0 0 0 0 0 0 6]]

测试集上正确率 81%,召回率 78%。个别分类正确率较低,可能是因为样本数太少,另外训练过程大多使用默认参数,若进行细致调校,应该还有提高空间。

https://www.jianshu.com/p/f774e273a883


原文发布于微信公众号 - 大数据挖掘DT数据分析(datadw)

原文发表时间:2018-03-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏ATYUN订阅号

赫尔辛基大学AI基础教程:神经网络是如何构建的(5.2节)

正如我们前面所说,神经元是非常简单的处理单元。在第4章讨论了线性和逻辑回归之后,神经网络的基本技术细节可以被看作是同一个思路的变种。

963
来自专栏iOSDevLog

逻辑回归

1313
来自专栏AI科技大本营的专栏

AI从入门到放弃:BP神经网络算法推导及代码实现笔记

作为AI入门小白,参考了一些文章,想记点笔记加深印象,发出来是给有需求的童鞋学习共勉,大神轻拍!

992
来自专栏AI研习社

Quora Question Pairs 竞赛冠军经验分享:采用 4 层堆叠,经典模型比较给力

AI 研习社按:今天要介绍的比赛是 Quora Question Pairs,该比赛的目的是将具有相同意图的问题正确配对。最近本次竞赛的冠军在 Kaggle 社...

39111
来自专栏北京马哥教育

Python环境下的8种简单线性回归算法

GitHub 地址:https://github.com/tirthajyoti/PythonMachineLearning/blob/master/Linea...

990
来自专栏人工智能头条

AI从入门到放弃:BP神经网络算法推导及代码实现笔记

822
来自专栏机器之心

教程 | 如何通过牛顿法解决Logistic回归问题

选自TLP 机器之心编译 参与:Nurhachu Null、黄小天 本文介绍了牛顿法(Newton's Method),以及如何用它来解决 logistic 回...

3215
来自专栏大数据挖掘DT机器学习

支持向量机SVM入门详解:那些你需要消化的知识

出自:嘉士伯的Java小屋 http://www.blogjava.net/ (一)SVM的八股简介 支持向量机(Support Vector Machine)...

3288
来自专栏IT派

机器学习中导数最优化方法(基础篇)

1. 前言 熟悉机器学习的童鞋都知道,优化方法是其中一个非常重要的话题,最常见的情形就是利用目标函数的导数通过多次迭代来求解无约束最优化问题。实现简单,codi...

35313
来自专栏AI科技大本营的专栏

机器学习决策树的分裂到底是什么?这篇文章讲明白了!

作者 | Prashant Gupta 译者 | AI100(rgznai100) 在实际生活中,树的类比如影随形。事实证明,树形结构对于机器学习领域同样有着广...

35911

扫描关注云+社区