拍照怎麼搜題?(上)

/*
 * 寫在前面的兩句話:
 * 1、老王寫完上一篇《HTTPS到底是個啥玩意兒?》以後,就想再順勢寫幾篇技術乾貨,於是乎有了接下來這一篇文章。
 * 2、大家如果想看上一篇,以及未來的n篇,可以關注我的博客:http://zgwangbo.blog.51cto.com/ 或者手機關注微信:simplemain
 */

前一段時間幾個拍照搜題的軟件挺流行(比如:小猿搜題、作業幫、學霸君等),手機拍張照片,就能把考題的答案搜出來,完全不用去百度手敲。這可樂壞了莘莘學子們,不過不知道父母是什麼感受。

出於程序員那種職業的好奇心,同時也去評估一下做這個事情的難度和成本,老王用了兩週的時間做了一個簡單的研究並寫了一個demo程序,這裏分享給大家(注:由於研究時間不長,如有不正確的地方請專家們指正~)

拍照搜題的技術點主要由圖像識別和內容搜索組成,將拍照圖像中的文字或者圖形識別出來,再交給檢索系統,對已有的題目進行快速的搜索,找出最相似的題目。由於之前對搜索技術有過一定的瞭解(畢竟在狼廠幹過幾年,耳濡目染^_^),所以這次主要聚焦到圖像文本的識別上。同時,只是調研性質,所以爲了降低難度,我這次只做了英語的識別。

先給大家看看最後的效果。以下是隨便用手機拍的一段英文文章:

wKiom1cFJn-zE3ImAAMD4Ooi2k8847.png

最後識別出來以後,沒有做任何的單詞校正工作,效果如下:
=======================================
Three months after [he government stopped issulng (&%) or renewing permits for In[ernet cafes because of security (%#) concerns, some cafe owners are having flnanclal (ff%%) concerns of their own.
=======================================

後來,我的同事tt和yx可憐我,幫我做了兩個相關的單詞匹配算法,對單詞做校正,使得一些識別的不準的單詞得到校正,比如:issulng -> issuing。

好了,開始正題吧~



==== 處理流程 ====


整個過程大體分爲兩個階段:
1、圖像的處理。就是將我們拍的照片進行清理(有點類似於洗衣服),然後對圖像中的字符進行切分,爲字符的識別做準備。大概分成幾個過程,分別是:
a、灰度處理:將彩色圖片變成灰度圖
b、二值化:將灰度圖變成黑白圖
c、去噪:消除黑白圖上的噪點,讓圖看起來更乾淨
d、旋轉:對圖片進行順時針和逆時針旋轉,找到一個最佳水平位置
e、水平切割:對調整好水平位置的圖片進行一行一行的切割
f、垂直切割:對一行一行的圖片進行一列一列的切割,產出單個的字符。

2、圖像的識別。就是將上面的一個個單獨字符的圖片進行判別,看他到底是哪個字符。

你是不是感覺有點暈了呢?不錯,是我我也暈了,哈哈哈~
還記得我們的理念嘛?把複雜的問題,簡單的講清楚!
來吧,老王不會乾巴巴的講那些無聊的理論,看看實際怎麼處理的吧。


==== 圖像的處理 ====


第一步,我們拍照得到原圖。

wKioL1cFJ2WQt6-pAAMF5Krl1F0356.png

肉眼看,你覺得這個圖是不是黑白的?
回答“是”的同學,我負責任的告訴你,你的眼睛欺騙了你。可以用圖像處理軟件看看每一個像素,他們其實是彩色!!!
彩色有什麼問題麼?對於人眼來說,當然沒有。不過對於我們的程序來說,就是有問題的。他不知道哪個顏色是有效信息。所以,我們接下來的工作,就是對信息進行降維,將RGB(紅綠藍)的256*256*256種色值降維到2種,即:白+黑。這樣,我們的程序就能很輕鬆的判斷信息的有效性了。
好了,要實現白+黑,還得分兩步走:先灰,再黑白。

那麼,老王要問問題了:

問:什麼是黑色?
答:R = 0, G = 0, B = 0,也就是css中的#000000

問:什麼是白色?
答:R = 255, G = 255, B = 255,也即#ffffff

問:什麼是灰色?
答:誒……

老王也不知道明確的定義,但是老王知道是:R = G = B。也就是紅綠藍的色值相等的顏色。比如,我們經常在css中設置的值:#e0e0e0 #9c9c9c等等。

第二步,就是把上面拍的原始圖變成灰度圖。

wKioL1cFJ4-Bu-KgAANUemCSVIo325.png仔細跟上面的圖片對比一下,有什麼不一樣嘛?考驗像素眼的時候到了!如果眼睛還是看不出來,我做一張對比圖給大家看看:

wKiom1cFJwChSAvSAAMnht8ge2I730.png

看出區別來了嘛?如果還沒看出來,請用圖像處理軟件查看圖上下部分的每一個像素。

那麼,彩色圖是怎麼變到灰度圖的呢?
我們知道,每一個像素的顏色值可以由RGB三原色來表示,比如:紅色=RGB(255, 0, 0),黃色=RGB(255, 255, 0)。如果我們將每個像素的設置成一樣的,他就變成灰色了,比如:Color-Pixel(x, y) = RGB(r, g, b) => RGB(t, t, t),其中 t = r * k + g * p + b * q 並且k + p + q = 1,這樣的話,就可以把彩色變成灰白了。

這裏k、p、q的取值有很多很多種,一般說取0.11、0.59、0.3 或者 1/3、1/3、1/3。至於爲什麼,我就沒有去深究了~
我的算法裏,取的是1/3、1/3、1/3。

好了,有了灰色圖,我們怎麼把他變黑白呢?(裝B的說法:二值化)
算法有很多很多很多很多……

第三步,灰度圖的二值化。

我下載了一個軟件,上面列舉了n種二值化的方法

wKiom1cFJxnzlJBgAAOFGdTGjfA781.png每一種方法都有優缺點,沒有一種完美的解決方案。我自己做了灰度平均值、百分比閾值和雙峯波谷最小值等幾個算法,根據我的實驗效果,最後選擇了雙峯波谷最小值法。

這種算法是怎麼樣工作的呢?等我慢慢道來。
我們之前已經得到了灰度圖,他的RGB值:t=r=g=b。這樣我們就可以將RGB(r,g,b)值合併,用一個t表示。最終簡化成用1個Byte(8bits)來表示每一個像素的值,每個像素的值就會落在[0, 255]這樣一個閉區間上,如果我們用16進製表示,就是[00, ff]這樣一個區間。如果用放大鏡放大一個10*4個像素的圖片,就會像這樣:

00 00 00 00 00 00 00 00 00 00
ee 00 ee dd 4f 29 30 00 00 00
ff 10 32 ee 40 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00

好了,我們看看,值爲0的個數C(0)=29個, C(ee)=3, C(dd)=1, C(4f)=1, C(29)=1, C(30)=1, C(32)=1……
於是乎,如果我們統計完我們實驗那張圖片的0~255每個值的個數,會得到怎麼樣的一個結果呢?

wKioL1cFJ-GxE4-0AACdMehK7-c035.png用上述的方法,我把之前處理的那個灰度圖片的灰度值放到二維平面上,x軸表示0~255種色值,y軸表示每個色值的個數。仔細看,是否很容易看出有兩個波峯。這兩個波峯,就是這張灰度圖片上最重要的兩個色值:前景色+背景色。

一般來講,在文本照片的case裏,我們會認爲背景色的數值會比前景色多很多,所以較高的波峯,我們就認爲是背景色,而低的那個呢,我們認爲是前景色。在非文本的很多情況下,有可能出現多個波峯,這個時候,前景色有可能是多個色調,這種case就先不在這裏討論了。

好啦,有這樣一個數值投影,我們只要能找出前景色和背景色,就很好識別哪些是文字,哪些是背景了,對吧。從而,我們只要把前景標誌爲1,背景標誌爲0。我們就可以把一張w*h像素的彩色圖變成一個w*h的位圖。我們再在這張位圖上做算法,就輕鬆太多了。接下來的工作,就是怎麼樣用算法來找到前景色和背景色。

我們肉眼很容易看出,在x=18和x=135兩個點附近是兩個波峯,那裏對應的就是我們的前景和背景。不過,對於我們的程序來講,還不是那麼容易。我們需要再用算法來處理一下,讓程序能很容易的辨認出來。

我們用程序怎麼判斷一個點是波峯呢?就是他旁邊的兩個點值比他小,對不對。那旁邊的兩個點比他小,他就一定是波峯嘛?很明顯不是。比如我們上面的統計圖,很多毛刺的點,他們不是波峯,但是比周圍的點要大。那有沒有一種比較好的算法能快速的找到波峯呢?

其中有一種簡單粗暴但有效的方法,就是迭代平滑:每個點a[i] = (a[i-1] + a[i] + a[i+1]) / 3(當然,首尾兩個點單獨處理),如此反覆,至到找到只有兩個點(如果多個連續點值相同,則看成是一個點)比旁邊的點要大,或者最多執行K次(比如100次)。

我們來想想這個邏輯,我們不斷用旁邊點的值來修正一個點,那經過多次以後,這個點的值就會趨近於周圍兩個點。如果是一個突兀點,比如是一個高點,多次平均以後,一定和周圍兩個點的值相當,極限情況就是這幾個點相等,對吧。

wKiom1cFJ1XSDKg7AACOtDnRtk0812.png這就是我們迭代平滑以後的點,看起來是不是如絲般順滑啊,哈哈哈。
當然,如果迭代K次以後,也找不到這樣兩個點的話,我們就只能做個兼容方案:先找到最高點,然後再找距離這個點左右各p個點以外的次高點。我們就認爲這兩個點是前景和背景。當然,這個並不能證明他們就是,只是覺得他們最可能是。這個就沒有絕對的好,只能做技術上的折中。這個時候就是做實驗調參數了。

好了,有了波峯以後,我們總要給前背景做個區分,也就是,從那個點分開,把貼近背景的點認爲都是背景,把貼近前景的點,認爲都是前景。這個時候,我們就選擇了兩個波峯中間的波谷。(也有算法選擇他們之間的平均值)。具體到這個case,我們很容易就選擇到了62號點附近。

wKiom1cFJ2ujmC76AAB6STCtIxY764.png這樣,小於62的點,都是前景色,其餘的都是背景色。換句話說,也就是灰度圖像上,gray=r=g=b中值小於62的,都是前景色,我們把他們標識爲1,認爲是黑色。其餘的都是0,認爲是白色。看看效果吧:

wKioL1cFKHHR2atCAADX-ZQg4Xc002.png怎麼樣,是不是感覺一下就清爽了呢?
這樣,我們就把一張彩色圖,變成了黑白圖。
現在,我們的灰度像素圖,就變成了類似這樣的效果:

0 0 0 0 0 0 0 0 0 0
1 0 1 1 1 1 1 0 0 0
1 0 1 1 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0

完全就是我們熟悉的二進制的位圖,專業俗稱bitmap。哈哈哈哈~

好了,二值化做完了,接下來,我們需要做的,就是對黑白圖片上的一些干擾點進行優化,去掉這些干擾點(俗稱:噪點)。這個過程也叫去噪。

第四步,去噪點。
我們爲什麼要去掉噪點呢?我們把之前二值化以後的圖具備放大:

wKiom1cFJ9WDk_N-AABhqUZNJCY812.png大家可以看紅色剪頭所指,有很多零星的黑點。就像美女臉上出現了小黑癍,非常影響我們對美的追求一樣,會極大干擾到我們程序對於圖片的切割和識別。於是乎,我們要盡我們一切可能,把這些黑斑,去掉!!

常用的去噪方法很多很多。
最簡單的,即是大家在學算法數據結構中學到的DFS或者BFS(深度和廣度搜索)。我們對w*h的位圖先搜索所有聯通的區域(值爲1的,我們看起來是黑色的,連接起來的區域)。所有聯通區域算一個平均的像素值,如果某些聯通區域的像素值遠遠低於這個平均值,我們就認爲是噪點。然後用0代替他。

還有一些高級的算法,比如高斯去噪、椒鹽去噪等,用的是每個點上下左右8個點的平均值或者是求和以後的對比值來替代這個點,多次迭代來去噪。詳細的算法可以看看相關論文,也不是特別複雜。鑑於篇幅就只提到這裏。

去噪如果做的太厲害,就容易誤傷,把正常點當噪點去掉了(所謂的腐蝕),所以要注意取得一個平衡。

我的算法裏,上述方法都做了實驗,最後選擇了bfs去噪的方法。大家看看效果如下:

wKiom1cFJ-_gJ07jAACPjCt1ZSg768.png上面那些噪點基本上都被清理掉了。就像美女塗了美白的護膚品一樣,爽~

好了,對於圖像本身的清洗處理,我們基本上做的差不多了。
接下來,我們就要開始準備開始對圖像進行切割了。洗好的鴨子,終於要上菜板了~

第五步,旋轉調平。
對於用戶而言,拍照的時候不可能絕對的水平,所以,我們需要通過程序將圖像做旋轉處理,來找一個認爲最可能水平的位置,這樣切出來的圖,纔有可能是最好的一個效果。

那我們怎麼樣評估一個旋轉的角度是一個好的效果呢?

我們假定調整了一個角度alpha,如果把所有點向左投影,如果該行都是0,則計數加一。這樣,我們統計累加以後,找到調整角度以後,計數最大的那個角度。直白的說,就是,調整角度alpha,如果使得空行越寬越明顯,這樣的調整就是好的。

但是,由於旋轉操作是在做座標變換,是一個O(n^2)的算法,所以開銷很大,我們的調整最好是在一個小範圍內做調整,比如:-5°~ +5°之間,按0.1°爲最小單位做調整。這樣只需要100次,我們就可以找到一個相對比較滿意的值。

具體旋轉的算法,可以自己用座標變換來實現,也可以用各個語言提供的庫函數來做。我偷懶,用了java的系統庫函數,嘿嘿~

看看我們調整以後的效果:

wKiom1cFKA3AqDLvAADYOmeMJJg585.png我們放一個對比圖,因爲調整角度比較小,所有要仔細看哦~

wKioL1cFKNXwv8NIAAHlAGzXTT0800.png好了,圖像調整的水平了,我們就可以對圖像進行切割了。所以接下來,我們就先水平切割圖像,然後再對每一行進行垂直切割。最終產出一個個字符。

第六步,水平切割。
大家如果有寫過日記的話,就很清楚的感覺到,寫日記的時候,日記本的每一行都有一條水平線,用來保證我們寫的字的水平,對吧~ 現在我們就是要沿着每一條水平線,把我們的文字用剪刀剪成一行一行的。這就是水平切割。

還是老規矩,好不好,先看療效:

wKiom1cFKETjJP3bAADvZrGFp4U904.png這是我的程序,對二值化、去噪和旋轉調平以後的圖像做的水平切割的效果。沒一行的上邊緣畫了一條實線,下邊緣畫了一條虛線。在程序上,就是對應行號被標記爲一個水平區塊的開始和結束。

那具體是怎麼實現的呢?來吧,老王帶你繼續往下走。

首先,我們先在重複一下我們現在已經有的一個數據結構,是一張類似以下效果的w*h的位圖:

0 0 0 0 0 0 0 0 0 0
1 0 1 1 1 1 1 0 0 0
1 0 1 1 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0

接下來,我們將我們處理完的黑白圖片的位圖往y軸上投影,將所有值進行累加,這樣就能得到一個在y軸上的曲線(或直方圖)。

wKioL1cFKQyi2m8LAACvfY30DeU438.png大家應該感覺到了吧,沒一行值大的地方,就是前景色多的地方,值小的地方,就基本都是背景色。好了,這樣就好辦了。我們按照之前的方案,先做平滑,然後找波谷,從波谷切分。

wKioL1cFKSjzQFYnAACt1Ta6Abg824.png以上就是平滑以後的效果,是不是看着就爽很多了~ ^_^

然後我們就按之前的辦法,從波谷切分,這樣就得到一塊一塊的(鑑於篇幅,我就不再畫圖了哈~)。雖然有了這一塊一塊的,但是這一塊塊的東西還含有大部分的背景,我們需要將他們進行壓縮。直到每一塊的上邊緣線和下邊緣線緊貼我們的文本。

怎麼搞?其實也很簡單。我們將每一塊的投影值求一個平均,如果某一個點的值遠遠低於這個平均值,則認爲他是背景。這裏就用一個參數來調整。我這裏取值如下:
final double THRESHOLD = 0.5;
final double avg = Math.min((total / lastTroughIds.length) * THRESHOLD, 10);

只要投影值小於這個avg的,我們就把他當做背景。這樣,我們就將每一塊的背景行去掉了。

這樣是不是就結束了? No No No……

有一種case是這樣的:i jump
可以看到,i j特別多的這種,有可能上面那個點被單獨分隔成一行了,對不對。我們需要對前後比較小的區塊進行合併,然後讓他們形成一個整體。

做完這一些工作以後,基本上行就被劃開了(實際上還有很多細節工作要做,這裏就不贅述了。如果有興趣,我以後可以開源我的代碼~)

水平切開了以後,我們對於圖像的處理就完成90%了,最後就是垂直切分,把他們切成一個個單獨的字符塊。

第七步,垂直切割。
緊接上面,我們將得到的水平的切割行,進行垂直的切割處理。先看效果:

wKioL1cFKUKx3ESGAAFKxVdnxPE876.png那垂直切割和水平切割有什麼不一樣嘛?有一點不一樣:同一行的兩個字符往往挨的比較緊,有些時候會出現垂直方向上的重疊(比如:有些字體的Tj就會出現重疊),投影以後也不好切割,從而造成切割的時候出錯。就是這一點,使得我們的垂直切割比水平切割更難。

因此我在處理的時候,做了一些特殊的工作。老王先計算一下,這一行的所有字符大體的平均寬度,按道理,這一行的字符都該差不多寬(也有個別例外,但是按照2-8定律,我們處理好大多數的情況,有利於我們簡化問題的複雜度)。這樣,如果粘連的話,我們按照平均寬度的一個範圍,就大體上可以將他們在某一個薄弱的地方切割開。

那這些特殊的工作是怎麼做的呢?別急,待老王慢慢道來。

首先,我們可以看到絕大多數情況,字符還是基本獨立的。所以先把能連接的點,先連接起來,形成獨立的字符區塊。如果即使有粘連,也沒關係,我們留到後面來處理。要求位圖連通區塊,有很多算法,我們這裏就再次用到bfs(這些經典的算法真是屢試不爽),將圖的連通。比如:

wKioL1cFKWGipSFmAAMHDHn2n9c875.png


這個圖用bfs就可以跑出兩個大的連通區塊,左邊r部分和右邊e的部分。

是不是做完這一步就好了呢?當然不是。有一些字符還要做上下連通區域的合併。比如:

wKiom1cFKN7zeBSmAAONh3ND4AU496.png大家可以看到類似i、j這種字符,他們是分成上下兩個部分的,如果我們要識別他們,肯定是不能將他們拆分開的。而bfs算法又沒辦法將他們連通起來,怎麼辦呢?
我們將bfs產生出來的所有區塊往x軸上投影,如果出現上下重疊覆蓋較多的情況,我們就將他們做合併形成一個新的合併後的區塊,認爲他們就是連通的。

有了這樣一個個的區塊,我們就很好處理了。接着,我們把這些區塊算取他們的平均寬度。如果有些區塊寬度特別寬,比如超出平均寬度2-3倍,我們認爲有可能是兩個或者多個字符粘連在一起(比如有些字體的rm這兩個單詞就很容易拍照出來形成粘連),就可以在最薄弱的地方將他們切開,形成兩個或多個獨立的區塊;而有一些連續的區塊,每一個的寬度都特別小(只有平均寬度的幾分之一),並且他們的間隔也特別小(平均間隔的幾分之一),我們就認爲有可能他們原先是一個字符,但是在去噪的過程中被分開了(比如經常看到h、n這樣的字符,中間的那一塊特別薄,在拍的不是很清楚的情況下,去噪的時候被去掉了),我們就把這些區塊合併成一個區塊。

切分&合併完以後,我們一個個獨立的區塊,就是我們想要的一個個待識別的字符圖案了。前面做了這麼多的工作,就是爲了得到他們。

怎麼樣,老王說的還清楚嘛?
接下來的工作,就是對這些一個個獨立的圖案區塊做識別。

好了,這上半部分已經快7000個字了,老王已經鏖戰幾個深夜了。如果大家想繼續讀完下半部分,請人肉執行完以下代碼:

void next()
{
    大家可以關注老王的博客,手機也可以關注微信公衆號:simplemain   

    不願意打字盆友們,可以掃描二維碼關注哈~

wKiom1cFKSaT22QFAALXyy3s3d0770.jpg

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章