opencvでスキャン後の余白カット

ImageMagickの-trimオプションでもいいかなあと思っていたんだけど、斜めにスキャンした画像の補正とか-fuzzの調整とか色々面倒。

と思いつつImageMagickを使ったりGIMPの台形補正(遠近法)ツールでシコシコやったりしてごまかしていたけど、どうやらopencvを使うとわりと簡単にできそうなのでやってみた。

最近は便利そうなライブラリはみんなPythonバインディングなのね。Perlがほしい。

「四角」「矩形検出」「python」「opencv」みたいなワードで検索するとたくさん情報やサンプルコードが出てくるので参考になります。

導入

チュートリアルのページなどを見つつ必要なモジュールをインストールする。

昔は違ったらしいけど、今はpipで簡単にインストールできる。

$ python -m pip install cv2
$ python -m pip install numpy
$ python -m pip install matplotlib

Pythonopencvは内部の数値計算にnumpyを使っていて、内部データがnumpyになっている。

numpyの使い方を覚えておくともっと効率よくプログラミングできそう。

matplotlibは特に必須ではないけど、画像やグラフを表示するときに使う。

あとチュートリアルのページに出てくるのでサンプルコードを動かすためにも入れておいたほうがよい。

コード

検索してみると、だいたいの流れは

  1. 画像の2値化
  2. 輪郭検出
  3. 台形補正

という感じ。

輪郭検出に使う画像が2値化画像なので、前もって画像を2値化しておく必要がある。

このやり方によって輪郭検出の精度がだいぶ変わってくるので、ここの調節が重要。

輪郭検出で四角形が検出できたら、その4頂点を使って台形補正を行えば余白削除&斜行補正のされた画像が得られる。

import sys
import numpy as np
import cv2
from matplotlib import pyplot as plt

def find_contours(img):
    image, contours, hierarchy = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    areas = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area > img.size*0.5 and area < img.size*0.95:
            epsilon = 0.1*cv2.arcLength(cnt, True)
            approx = cv2.approxPolyDP(cnt, epsilon, True)
            if len(approx) >= 4:
                areas.append(approx)

    img_c = cv2.drawContours(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), areas, -1, (0,0,255), 3)
    return img_c, areas

img = cv2.imread(sys.argv[1])
print("shape : " + str(img.shape))
print("size  : " + str(img.size))
print("dtype : " + str(img.dtype))

# statistics of margin
# H: 180*0.658 = 118.44, sigma=3.6
# S: 255*0.164 = 41.82, sigma=7.9
# V: 255*0.192 = 48.96, sigma=9.43

img_org = img
img     = cv2.GaussianBlur(img, (5, 5), 0)
img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
for n in range(30, 120, 5):
    mask_margin = cv2.inRange(img_hsv, (100,0,0), (120,0+n,0+n))
    r, area_margin = find_contours(mask_margin)
    print("find_contours :", n, "->", len(area_margin), "found")
    if area_margin:
        break

if not area_margin:
    print ("error: no contours found")
    sys.exit(0)

## sort contours topleft,topright,bottomleft,bottomright

area = area_margin[0]
area = sorted(area, key = lambda x: (x[0][0]+x[0][1]))  # x+y (x^2+y^2 is better?)
if area[1][0][0] < area[2][0][0]:
    tmp = area[1];
    area[1] = area[2]
    area[2] = tmp

print("contour:", area)

# 出力座標の計算(三平方の定理)

l_top = area[0][0]
r_top = area[1][0]
l_btm = area[2][0]
r_btm = area[3][0]

top_line   = (abs(r_top[0] - l_top[0]) ^ 2) + (abs(r_top[1] - l_top[1]) ^ 2)
btm_line   = (abs(r_btm[0] - l_btm[0]) ^ 2) + (abs(r_btm[1] - l_btm[1]) ^ 2)
left_line  = (abs(l_top[0] - l_btm[0]) ^ 2) + (abs(l_top[1] - l_btm[1]) ^ 2)
right_line = (abs(r_top[0] - r_btm[0]) ^ 2) + (abs(r_top[1] - r_btm[1]) ^ 2)
max_x = top_line  if top_line  > btm_line   else btm_line
max_y = left_line if left_line > right_line else right_line

print("width={}, height={}".format(max_x, max_y))

# 画像の座標上から4角を切り出す
pts1 = np.float32(area)
pts2 = np.float32([[0, 0], [max_x, 0], [0, max_y], [max_x, max_y]])

# 透視変換の行列を求める
M = cv2.getPerspectiveTransform(pts1, pts2)

# 変換行列を用いて画像の透視変換
img_cropped = cv2.warpPerspective(img_org, M, (max_x, max_y))

plt.subplot(1,3,1),plt.imshow(cv2.cvtColor(img_org, cv2.COLOR_BGR2RGB),'gray'),plt.title('ORIGINAL')
plt.subplot(1,3,2),plt.imshow(cv2.cvtColor(r, cv2.COLOR_BGR2RGB),'gray'),plt.title('mask')
plt.subplot(1,3,3),plt.imshow(cv2.cvtColor(img_cropped, cv2.COLOR_BGR2RGB),'gray'),plt.title('cropped')
plt.show()

結果


斜めにスキャンされた画像もキレイに余白が削除されてますね。

細かい話

解説というかメモ。

ガウシアンぼかし
img     = cv2.GaussianBlur(img, (5, 5), 0)

最初にガウシアンぼかしをかけて色を滑らかにしておくと後の処理がしやすい。
(5, 5)はカーネルサイズ。でかい画像はカーネルサイズを大きくするなど調節が必要そう。

2値化画像
def find_contours(img):
    image, contours, hierarchy = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    areas = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area > img.size*0.5 and area < img.size*0.95:
            epsilon = 0.1*cv2.arcLength(cnt, True)
            approx = cv2.approxPolyDP(cnt, epsilon, True)
            if len(approx) >= 4:
                areas.append(approx)

    img_c = cv2.drawContours(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), areas, -1, (0,0,255), 3)
    return img_c, areas

img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
for n in range(30, 120, 5):
    mask_margin = cv2.inRange(img_hsv, (100,0,0), (120,0+n,0+n))
    r, area_margin = find_contours(mask_margin)
    print("find_contours :", n, "->", len(area_margin), "found")
    if area_margin:
        break

多くのサイトではcv2.thresholdで画像を2値化する方法を紹介しているけど、今回の画像では今ひとつ上手くいかない。

普通に2値化しただけだと全く検出できない。

2値化のチューニング案としては、例えば色々な色空間のチャネルごとに輪郭検出をしてマスク画像を合成するというのがある。

確かに精度は良くはなったけど、名刺やポストイットみたいな単色の抽出ならともかく雑誌のページみたいな複雑な画像は難しいみたい。

上手くチャネル分解するとか、ガウシアンぼかしのチューニングをするとか、色々調整が必要っぽい。

面倒になったので、今回の目的に特化して「スキャナーの余白を削除する」を素直に実装することにした。

OpenCVのinRange()関数で画像が特定の色範囲に含まれているかどうかチェックできる。

この結果を2値画像として輪郭検出処理にかけることにする。

またRGB(OpenCVはBGRだけど)よりHSVの方が色検出に向いてるようなので、HSVに変換してからinRange()で余白を抽出する。

GIMPで余白部分の情報を取得して、パラメーターの参考にする。

# statistics of margin
# H: 180*0.658 = 118.44, sigma=3.6
# S: 255*0.164 = 41.82, sigma=7.9
# V: 255*0.192 = 48.96, sigma=9.43

なるほど。(100,0,0), (120,50,50) くらいにすれば良さそう。

この値も検出精度に関わるので、下限から少しずつ上げていって輪郭検出ができた時点で止めるというロジックにした。

色々試した結果、今回のデータだと大体S,V = 60くらいで検出できるようになる。

findContours()で輪郭の検出、approxPolyDP()で輪郭の多角形近似ができる。

検出した輪郭のうち、小さすぎたり大きすぎたり(画像全体を輪郭として抽出することがある)するものを除いて、近似後の頂点が4のものを四角形として保存する。

approxPolyDPのパラメーターはコピペ。。

領域(輪郭)の特徴 — OpenCV-Python Tutorials 1 documentation

epsilon = 0.1*cv2.arcLength(cnt,True)
approx = cv2.approxPolyDP(cnt,epsilon,True)
頂点のソート
area = area_margin[0]
area = sorted(area, key = lambda x: (x[0][0]+x[0][1]))  # x+y (x^2+y^2 is better?)
if area[1][0][0] < area[2][0][0]:
    area[1], area[2] = area[2], area[1]

切り取った画像のサイズ計算から射影変換の流れは以下のページをそのまま参考にした(コピペともいう)のだけど、そのまま使うと画像が回転したり反転したりする。

OpenCVで台形補正がしたかった話。応用編。

ので、あらかじめ頂点をソートする。numpyの関数でもっとスマートにできるのかもしれない。

  1. 原点からの距離が近いものが左上、遠いものが右下
  2. 残り2つはxの値が大きい方が右上、小さい方が左下

という感じで、左上、右上、左下、右下という順番に並び替える。

ところでPythonって変数のスワップをtmpみたいな一時変数なしに直接書いてOKなんですね。地味に便利。最近のLLは大体そうかな?

追記



見にくいけど、[130 55 55]のところで余白削除が不完全になってて台形でトリミングされてしまっている。

ので、領域が直角かどうかのチェックを加えることにした。

あと、あまり下の方からチェックしていくと変なのが検知されるので、もう少し大きな値からスタートすることにした。

何にしてもトリミングした後の目視確認は必要ですね・・・。

def angle(p1, p2, p3):
    p12 = p1[0] - p2[0]
    p23 = p3[0] - p2[0]

    dot = np.dot(p12, p23)
    norm_12 = np.linalg.norm(p12)
    norm_23 = np.linalg.norm(p23)
    cos = dot / (norm_12 * norm_23)
    rad = np.arccos(cos)
    theta = rad * 180 / np.pi
    return theta

def vars_from_rightangle(quad):
    # 01
    # 23
    r1 = angle(quad[0], quad[1], quad[3])
    r2 = angle(quad[1], quad[3], quad[2])
    r3 = angle(quad[3], quad[2], quad[0])
    r4 = angle(quad[2], quad[0], quad[1])
    var = 0
    for r in (r1, r2, r3, r4):
        var = var + pow((90-r), 2)
    print("r1={}, r2={}, r3={}, r4={}, var={}".format(r1, r2, r3, r4, var))
    return var
find_contours : [100   0   0] [130  50  50] -> 0 found
find_contours : [100   0   0] [130  55  55] -> 1 found
r1=92.19912728111747, r2=88.25982795073007, r3=89.18250765828346, r4=90.35853710986905, var=8.661202147533869
find_contours : [100   0   0] [130  60  60] -> 1 found
r1=89.5558975296218, r2=90.29483478368576, r3=89.81778811349452, r4=90.33147957319791, var=0.4272344328984021