這裡示範如何在 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_kdebw_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-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.orgPyImageSearchLearn OpenCVStackOverflow