這裡示範如何在 Python 中以 OpenCV 自動偵測與切除掃描影像的白色邊緣,並修正傾斜的文件影像。
最近我正在整理大量的舊書籍,想要將書籍的每一頁都掃描成 PDF 電子檔,方便保存下來,之前已經開發完自動化的文字辨識的程式了,接下來我要處理掃描圖檔的空白邊緣修正問題,以下是問題的敘述與程式開發流程紀錄。
問題背景
由於我手上的書籍數量真的太多了,而且年代都很久遠,很多還有破損的狀況,時常在翻頁時破損的頁面還會整頁掉下來,所以在掃描時我都會把掃描的紙張大小調大一點,以免有些頁面歪掉沒掃到,掃出來的圖檔大約會像這樣,周圍會有大片的空白區域。
另外很多老書籍的印刷不是很好(油墨很淡等問題),還有很多是手抄本的書,直接看紙本都不容易辨識,所以我再掃描的時候,是使用全彩照片的方式來掃,盡量保留書籍最原始的樣貌,而掃描的解析度當然就使用最高的 600 dpi,所以一頁 A4 紙張大小的圖,掃起來就要 3 MB 左右。
因為圖實在太大了,所以在網站上我只放縮小的圖作為示範,以下是一些範例圖檔。
掃出來圖檔除了以原始檔保存之外,我希望將它們製作成線上的資料庫,讓人可以透過網路查閱原始書籍的樣貌,所以這裡就要想辦法將掃出來的圖檔整理過,把空白區域去除,讓儲存空間可以節省一些,並且若有書籍在掃描時有傾斜的話,也要自動修正,方便使用者瀏覽。
結合色彩飽和度與明度篩選區域
首先我們要想辦法把書籍在照片中的位置偵測出來,方法有很多種,例如使用邊緣偵測、像素的亮度門檻值等,但是我的掃描圖檔常會出現陰影(尤其是厚度比較厚的書),書本紙張的顏色有時候比較暗、有時候比較亮,所以單純以邊緣或像素門檻值的方式並不好處理。
還好我這邊大部分的書籍都是陳年的舊書,紙張都偏黃、甚至咖啡色,也就是說書籍紙張是彩色的,背景是黑白的,而書籍紙張通常比背景暗一些,所以可以使用結合色彩飽和度與明度的方式,找除門檻值來篩選區域。
首先載入一些需要用到的 Python 模組,並將圖檔讀取進來,做一些前處理:
import sys import cv2 import numpy import imutils from matplotlib import pyplot from scipy.stats import gaussian_kde # 讀取圖檔 img = cv2.imread('image.jpg') # 使用縮圖以減少計算量 img_small = imutils.resize(img, width=640) # 在圖片周圍加上白邊,讓處理過程不會受到邊界影響 padding = int(img.shape[1]/25) img = cv2.copyMakeBorder(img, padding, padding, padding, padding, cv2.BORDER_CONSTANT, value=[255, 255, 255])
由於我的原始圖檔解析度非常高,所以這裡另外做一張縮圖,可以加速後續的某些運算,另外在原圖周圍加上一些白邊,讓後續在處理圖像時,比較不會有超出邊界的問題。
接著將影像從 BGR 轉為 HSV 色彩空間,並將飽和度與明度取出來,以 sv_ratio
這個自訂的比例混合:
# 轉換至 HSV 色彩空間 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) hsv_small = cv2.cvtColor(img_small, cv2.COLOR_BGR2HSV) # 取出飽和度 saturation = hsv[:,:,1] saturation_small = hsv_small[:,:,1] # 取出明度 value = hsv[:,:,2] value_small = hsv_small[:,:,2] # 綜合飽和度與明度 sv_ratio = 0.8 sv_value = cv2.addWeighted(saturation, sv_ratio, value, 1-sv_ratio, 0) sv_value_small = cv2.addWeighted(saturation_small, sv_ratio, value_small, 1-sv_ratio, 0)
這裡就是要想辦法找一個恰當的比例,讓混合的結果可以分判出書籍與背景,在調整的時候請把圖形畫出來看會比較直覺:
# 除錯用的圖形 pyplot.subplot(131).set_title("Saturation"), pyplot.imshow(saturation), pyplot.colorbar() pyplot.subplot(132).set_title("Value"), pyplot.imshow(value), pyplot.colorbar() pyplot.subplot(133).set_title("SV-value"), pyplot.imshow(sv_value), pyplot.colorbar() pyplot.show()
畫出來的圖形會類似這樣,簡單來說就是要讓最右方的飽和度與明度混合值(SV-value)圖形可以清楚區分出前景與背景:
我一開始在開發程式時,其實只用飽和度,但是後來發現掃描的圖檔有些邊緣雖然是接近黑色,但是飽和度卻很高,容易影響主體,所以最後決定綜合飽和度與明度,這樣會更準確一些。
決定門檻值
門檻值的決定是一很關鍵的問題,這裡我先用 Kernel Density Estimator 計算出 SV-value 的分佈函數,預期其它應該會出現雙峰的分佈,再找出中間的區域最小值作為門檻值:
# 使用 Kernel Density Estimator 計算出分佈函數 density = gaussian_kde(sv_value_small.ravel(), bw_method=0.2) # 找出 PDF 中第一個區域最小值(Local Minimum)作為門檻值 step = 0.5 xs = numpy.arange(0, 256, step) ys = density(xs) cum = 0 for i in range(1, 250): cum += ys[i-1] * step if (cum > 0.02) and (ys[i] < ys[i+1]) and (ys[i] < ys[i-1]): threshold_value = xs[i] break
這裡的關鍵參數在 gaussian_kde
的 bw_method
,調大一點的話,曲線比較平滑,反之就會震盪的比較厲害,若能讓分佈呈現清楚的雙峰型態是最好的,這部份其實最好也配合除錯的圖形來調整(關於直方圖的繪製,可參考直方圖的教學):
# 除錯用的圖形 pyplot.hist(sv_value_small.ravel(), 256, [0, 256], True, alpha=0.5) pyplot.plot(xs, ys, linewidth = 2) pyplot.axvline(x=threshold_value, color='r', linestyle='--', linewidth = 2) pyplot.xlim([0, max(threshold_value*2, 80)]) pyplot.show()
畫出來的圖形會類似這樣,這一步就是要找出最適當的門檻值(紅色虛線),讓前景與背景可以區分開來:
有時候 SV-value 的原始分佈根本就不是雙峰的樣子,這種狀況下會找不出適合的門檻值,若發生這種情況可以回去觀察看看 SV-value 的圖形,調整 sv_ratio
的比例來嘗試產生雙峰的分佈。
就我在開發程式時的測試經驗,比較新的書(紙張比較白)很比較不容易產生雙峰分布,所以如果想要處理新書的掃描圖片,可能要修改演算法,找一個更合適的方式來偵測書本的區域。
套用門檻值篩選區域
使用上面決定的門檻值篩選出書籍的區域,通常篩選出來的區域會有一些雜訊,所以我們會使用 OpenCV 的 Morphological Transformations 去除細小的雜訊:
# 以指定的門檻值篩選區域 _, threshold = cv2.threshold(sv_value, threshold_value, 255.0, cv2.THRESH_BINARY) # 去除微小的雜訊 kernel_radius = int(img.shape[1]/100) kernel = numpy.ones((kernel_radius, kernel_radius), numpy.uint8) threshold = cv2.morphologyEx(threshold, cv2.MORPH_OPEN, kernel) threshold = cv2.morphologyEx(threshold, cv2.MORPH_CLOSE, kernel) # 除錯用的圖形 pyplot.imshow(threshold, "gray") pyplot.show()
產生出來的 threshold 會類似這樣,白色部份就是選取的部份,而黑色部份則是排除的部份:
這裡在中間的部份還有許多空洞,是否要用其他演算法把它填滿都可以,在這裡如果不填也沒關係,因為我們只會使用到面積最大塊的區域。
產生等高線
接著使用門檻值得套用結果,以 OpenCV 所提供的功能產生等高線,並繪製除錯用的圖形:
# 產生等高線 _, contours, hierarchy = cv2.findContours(threshold, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) # 建立除錯用影像 img_debug = img.copy() # 線條寬度 line_width = int(img.shape[1]/100) # 以藍色線條畫出所有的等高線 cv2.drawContours(img_debug, contours, -1, (255, 0, 0), line_width) # 找出面積最大的等高線區域 c = max(contours, key = cv2.contourArea) # 找出可以包住面積最大等高線區域的方框,並以綠色線條畫出來 x, y, w, h = cv2.boundingRect(c) cv2.rectangle(img_debug,(x, y), (x + w, y + h), (0, 255, 0), line_width) # 嘗試在各種角度,以最小的方框包住面積最大的等高線區域,以紅色線條標示 rect = cv2.minAreaRect(c) box = cv2.boxPoints(rect) box = numpy.int0(box) cv2.drawContours(img_debug, [box], 0, (0, 0, 255), line_width) # 除錯用的圖形 pyplot.imshow(cv2.cvtColor(img_debug, cv2.COLOR_BGR2RGB)) pyplot.show()
這裡我們用藍色線條畫出所有的等高線,以綠色方框包住面積最大的等高線區域,並且嘗試在各種角度,以最小的方框包住面積最大的等高線區域,以紅色線條標示:
傾斜修正
我們使用紅色的方框框住書本的位置之後,就可以取得它的傾斜角度,接下來就可以將圖片進行旋轉,修正圖片傾斜問題:
# 取得紅色方框的旋轉角度 angle = rect[2] if angle < -45: angle = 90 + angle # 以影像中心為旋轉軸心 (h, w) = img.shape[:2] center = (w // 2, h // 2) # 計算旋轉矩陣 M = cv2.getRotationMatrix2D(center, angle, 1.0) # 旋轉圖片 rotated = cv2.warpAffine(img_debug, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT) img_final = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT) # 除錯用的圖形 pyplot.imshow(cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB)) pyplot.show()
這裡我們同時對除錯用的圖片以及原圖做旋轉,讓兩張圖同步處理,而在程式開發時只要看除錯用的圖片即可。
經過旋轉之後的圖形,紅色的方框看起來應該要是正的:
裁切邊緣影像
確認旋轉後的影像沒問題的話,接下來就要把紅色方框之外的邊緣裁切掉,作法是將原本的紅色方框座標,使用相同的旋轉矩陣進行轉換,計算出旋轉後的新座標位置,然後依據新的座標來裁切:
# 旋轉紅色方框座標 pts = numpy.int0(cv2.transform(numpy.array([box]), M))[0] # 計算旋轉後的紅色方框範圍 y_min = min(pts[0][0], pts[1][0], pts[2][0], pts[3][0]) y_max = max(pts[0][0], pts[1][0], pts[2][0], pts[3][0]) x_min = min(pts[0][1], pts[1][1], pts[2][1], pts[3][1]) x_max = max(pts[0][1], pts[1][1], pts[2][1], pts[3][1]) # 裁切影像 img_crop = rotated[x_min:x_max, y_min:y_max] img_final = img_final[x_min:x_max, y_min:y_max] # 除錯用的圖形 pyplot.imshow(cv2.cvtColor(img_crop, cv2.COLOR_BGR2RGB)) pyplot.show()
裁切之後,結果會像這樣:
這裡在將紅色方框旋轉之後,其實應該要再確認一次方框是否有落於繪圖區域之外,不過由於實務上掃描文件時,我們都會盡量讓書本放正,所以通常旋轉角度不大,加上我們在一開始就有先在周圍增加空白區域,所以通常紅色方框都不太會跑出去。
如果確認這個處理結果問題,就可以畫出正式的原圖處理結果:
# 完成圖
pyplot.imshow(cv2.cvtColor(img_final, cv2.COLOR_BGR2RGB))
pyplot.show()
這樣就完成掃描圖檔的基本修正了。
完整 Python 指令稿
由於這理的程式碼實在是太多了,所以我把所有的程式碼整理一下,放在 auto_crop_20180307.py.gz,執行方式為:
python3 auto_crop_20180307.py image.jpg
執行後會直接畫出六張圖,方便快速開發與除錯:
上面我所提供的好幾張範例掃描檔都可以直接用這個程式來處理,有興趣的人可以自行下載使用,大部分的圖直接跑就可以有很不錯的結果,而其中有幾張需要調整一下參數。
參考資料:danvk.org、PyImageSearch、Learn OpenCV、StackOverflow
Johnny
Sorry, I run your tool in Windows 10, I got the MemoryError.
Corrupt JPEG data: 5563 extraneous bytes before marker 0xd0
Traceback (most recent call last):
File “auto_crop_20180307.py”, line 83, in
img_debug = img.copy()
MemoryError
steve8000818@gmail.com
灰色的怎么处理?效果不行