一種基於視覺辭彙的文本分類方法

一年多以前我腦子一熱,想做一款移動應用:一款給學生朋友用的「錯題集」應用,可以將錯題拍照,記錄圖像的同時,還能自動分類。比如拍個題目,應用會把它自動分類為"物理/力學/曲線運動"。當然,這個項目其實不靠譜,市場上已經有太多「搜題」類應用了。但過程很有趣,導致我過了一年多,清理磁碟垃圾時,還捨不得刪掉這個項目的「成果」,所以乾脆回收利用一下,寫篇文章圈圈粉(源碼在這裡),歡迎 Star。

這個項目,核心要解決的問題就是文本分類。所以最初想到的方案是先 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) * 255binary_global = cv2.bitwise_not(binary_global) #反轉黑白# 局部自適應閥值adaptive = threshold_adaptive(image, 41, offset=10)adaptive = numpy.array(adaptive, uint8) * 255adaptive = 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的介紹請點擊這裡。下圖為文字圖像及其 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 文檔,對各聚類演算法做了比較:

為解決這一問題,我選擇了分級聚類的方法,先用 K-Means 做較少的分類,比如1000類,然後對每個分類單獨使用 DBSCAN 聚類並單獨訓練 SVC 分類器。

3.1. 一級分類

第一級的分類中沒有使用 HOG 描述特徵,而是自己定義了一種簡單的描述子。即以圖像每列的有效像素比和跳躍次數描述一張圖像。如下圖:

其中圖像 Original、Skeletonize、White count、Jumps 分別為原始圖像、骨架提取後的圖像、基於骨架圖計算的每列白色像素的比例,基於骨架圖計算的每列顏色跳躍的次數。相關代碼如下:

# 提取骨架圖image = skeletonize(numpy.array(image) / 255.)# 顏色統計跳躍次數jumps = []h, w = image.shapefor i in range(0, w): y = numpy.where(image[:, i] != 0)[0] if len(y) == 0: jumps.append(0) else: jump = 0 last = 0 for i in y: if last != i: jump += 1 last = i + 1 if last != h: jump += 1 jumps.append(jump)# 每列跳躍頻率 jumps = numpy.array(jumps, float) * 2. / hjumps = filters.gaussian_filter(jumps, 2) # 高斯濾波處理 # 每列白色像素的比例 th = (h - img.sum(axis=0)).astype(float) / h th = filters.gaussian_filter(th, 2) # 高斯濾波處理

然後對所有視覺辭彙樣本做 K-Means 聚類,K-Means 即可用於聚類也可用於分類。下圖是聚類後,其中一個分類下的樣本:

/lv1_words_3 2.png

相關代碼:

cluster = MiniBatchKMeans( n_clusters=1000, verbose=1, max_no_improvement=None, reassignment_ratio=1.0) cluster.partial_fit(features)

3.2. 二級分類

針對前面 K-Means 聚類(分類)的的結果,對每個分類使用 DBSCAN 單獨聚類。由於 DBSCAN 無法用於分類,所以還需要針對 DBSCAN 的結果訓練分類器,這裡我們使用線性 SVC 分類器。二級分類我們得到約1萬個類型(辭彙)。下圖為其中一個 K-Means 分類聚類後的效果,每行表示一個二級類型:

代碼如下:

# 先使用 PCA 對樣本特徵降緯norm = preprocessing.Normalizer()features = norm.fit_transform(features)pca = RandomizedPCA(n_components=70, whiten=True)features = pca.fit_transform(features)# 聚類cluster = DBSCAN(6, min_samples=3)cluster.fit(features)# 用聚類的結果訓練 SVC 分類器trainX = X[cluster.labels_ != -1]trainY = cluster.labels_[cluster.labels_ != -1]param_grid = {C: [1e3, 5e3, 1e4, 5e4, 1e5], gamma: [0.0001, 0.0005, 0.001, 0.005, 0.01, 0.1], }gscv = GridSearchCV( SVC(kernel=linear, probability=True), param_grid=param_grid)svc = gscv.fit(X_train, y_train)

4. 訓練文本分類器

有了辭彙分類器,我們終於可以識別出每個文本樣本上所包含的辭彙了(事實上前面步驟的中間過程也能得到每個樣本的辭彙信息),於是我們可以給每個樣本計算一個詞袋模型(即用每個詞出現的次數表示一篇文本),再通過池袋模型計算TF-IDF模型(即用每個詞的 TF*IDF 值表示一篇文本),並最終訓練 SVM 分類器。下面展示了此過程的主要代碼:

# bow 保存每篇文本的詞袋模型# labs 保存每篇文本類型 # 計算 tfidf 模型tfidf = TfidfTransformer(smooth_idf=True, sublinear_tf=True, use_idf=True)datas = numpy.array(tfidf.fit_transform(bow).toarray()) # 使用 PCA 降緯pca = RandomizedPCA(n_components=20, whiten=True).fit(datas) # 樣本數據20%用於測試,80%用於訓練X_train, X_test, y_train, y_test = train_test_split(data, labs, test_size=0.2, random_state=42)# 訓練SVM print("Fitting the classifier to the training set")param_grid = {C: [1e3, 5e3, 1e4, 5e4, 1e5], gamma: [0.0001, 0.0005, 0.001, 0.005, 0.01, 0.1], }gscv = GridSearchCV(SVC(kernel=linear, probability=True), param_grid=param_grid)svc = gscv.fit(X_train, y_train)print("Best estimator found by grid search:")print(svc.best_estimator_)print "score :", svc.score(data, labs) # 測試訓練結果print("Predicting on the test set")y_pred = svc.predict(X_test)print(classification_report(y_test, y_pred, target_names=target_names))print(confusion_matrix(y_test, y_pred))

5. 結束

此項目完整代碼及樣本數據可點擊這裡下載。任何想在實際項目中使用此方法的朋友請注意,以上方法目前只在一個樣本庫中測試過,在其他樣本庫中表現如何還不知道,但願沒把你帶坑裡。


推薦閱讀:

Perceptual loss for Real time Style Transfer and Super-Resolution 論文閱讀
從零開始實現KNN分類演算法
基於不平衡樣本的推薦演算法研究
Day6-《The Introduction of Statistical Learning》學習筆記
機器學習演算法如何調參?這裡有一份神經網路學習速率設置指南

TAG:機器學習 | 計算機視覺 | 圖像處理 |