Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

OpenCVで台形補正したい!

と思ってネットサーフィンしてたんですが、以下のような問題が。

  • 二値化する時のパラメータの根拠って何?
  • 出力座標の値の根拠って何?
  • そもそも四角形じゃない場合どうなるの?

どこに転がってるサンプルコードも共通でこんな感じでした。
定数で書かれてて、コメントもなかったら、何の根拠でその数字なのか分からないよ...
(サンプルコードだから許せるけど、仕事だったら白目になるやつ)

参考にさせてもらったコード

OpenCVを使って画像の射影変換をしてみるwithPython
こちら拝借させていただきました。
勝手に使って申し訳ありません、、、

とりあえず最終形

import sys
import cv2
import numpy as np


# ファイル名取得
if len(sys.argv) < 2:
    print("Usage: $ python " + sys.argv[0] + " sample.jpg")
    exit()
filename = sys.argv[1]

# ファイル読込み
img = cv2.imread(filename)
size = img.shape[0] * img.shape[1]

# グレースケール化
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 入力座標の選定
best_white = 0
best_rate = 0.0
best_approx = []
dict_approx = {}
for white in range(10, 255, 10):
    # 二値化
    ret, th1 = cv2.threshold(gray, white, 255, cv2.THRESH_BINARY)

    # 輪郭抽出
    image, contours, hierarchy = cv2.findContours(th1, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    # 面積が以下の条件に満たすものを選定
    # 角の数が4つ、1%以上、99%未満
    max_area = 0
    approxs = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        epsilon = 0.1 * cv2.arcLength(cnt, True)
        tmp = cv2.approxPolyDP(cnt, epsilon, True)
        if 4 == len(tmp):
            approxs.append(tmp)
            if size * 0.01 <= area\
            and area <= size * 0.99\
            and max_area < area:
                best_approx = tmp
                max_area = area
    if 0 != max_area:
        rate = max_area / size * 100
        if best_rate < rate:
            best_rate = rate
            best_white = white
    dict_approx.setdefault(white, approxs)

if 0 == best_white:
    print("The analysis failed.")
    exit()

# 出力座標の計算(三平方の定理)
r_btm = best_approx[0][0]
r_top = best_approx[1][0]
l_top = best_approx[2][0]
l_btm = best_approx[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

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

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

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

# 結果出力
print("Best parameter: white={} (rate={})".format(best_white, best_rate))

cv2.imwrite("_corrected." + filename, dst)

src = cv2.imread(filename)
cv2.drawContours(src, dict_approx[best_white], -1, (0, 255, 0), 3)
cv2.imwrite("_detail.{}".format(filename), src)

検証

まずは使用画像

使用画像は以下の通り。
使用させていただいた名刺の画像も拝借しました。
(たびたび申し訳ありません...)

meishi.jpeg

白背景
kenketsu_card.jpg
kenketsu_point.jpg

黒背景(後からやるけど、白背景だと解析が上手くいかなかったので黒背景も用意しておいた)
kenketsu_card_black.jpg
kenketsu_point_black.jpg

二値化する時のパラメータの根拠って何?

該当コードはこちら。

# 二値化
ret, th1 = cv2.threshold(gray, white, 255, cv2.THRESH_BINARY)

どこのサンプルコードもwhiteの部分がベタ書きされてました。
ここの値を変えると輪郭抽出の結果が変わってきます。
暗めの画像だったり、背景と同化してたり、逆に背景と真逆の色になってると、その都度変えないといけません。

人力で設定するの面倒くさい。

とりあえず、このwhiteの部分を10ずつカウントアップして、どういう値が取れるのか検証してみました。
5枚とも全部やったんですが、今回は傾向が顕著に見えた「名刺」だけ置いておきます。

#
# format
# 白 : 面積 (比率)
#
$ python correctionKeystone.py meishi.jpeg 
10 : 478601.0 (99.70854166666666%)
20 : 478601.0 (99.70854166666666%)
30 : 478601.0 (99.70854166666666%)
40 : 478599.0 (99.70812500000001%)
50 : 478591.0 (99.70645833333333%)
60 : 478543.5 (99.6965625%)
70 : 478411.5 (99.66906250000001%)
80 : 478012.5 (99.5859375%)
90 : 476445.5 (99.25947916666667%)
100 : 52.5 (0.010937500000000001%)
110 : 79.0 (0.016458333333333335%)
120 : 102.0 (0.021249999999999998%)
130 : 90.5 (0.018854166666666665%)
140 : 92.0 (0.01916666666666667%)
150 : 107.5 (0.022395833333333334%)
160 : 110.0 (0.022916666666666665%)
170 : 115.0 (0.02395833333333333%)
180 : 66103.5 (13.771562500000002%)
190 : 65999.5 (13.749895833333333%)
200 : 65905.0 (13.730208333333332%)
210 : 129.0 (0.026875%)
220 : 133.0 (0.027708333333333335%)
230 : 5.0 (0.0010416666666666667%)
240 : 2.0 (0.0004166666666666667%)
250 : 0 (0.0%)

white=30
_detail.30.meishi.jpeg

white=180
_detail.180.meishi.jpeg

white=240
_detail.240.meishi.jpeg

傾向として見えてきたのは、以下の通りです。

  • 値が小さすぎると、輪郭補正の時に外郭を取ってしまって使い物にならない
  • 値が大きすぎると、輪郭補正の時に何も引っかからない

とりあえず考えられる対策としては、以下の通りです。

  • 輪郭面積が、画像サイズに対して99%以上だと無視
  • 輪郭面積が、画像サイズに対して1%以下だと無視
  • 上記に該当しない輪郭面積で、最大のものを使用

出力座標の値の根拠って何?

該当コードはこちら。

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

どこのサンプルコードを見ても、max_xmax_yがベタ書きされてました。
出力する画像のサイズなので、適当に設定してればいいのかもしれないですが、「"適当"って何なんだ...」と感じてしまい:(

なので「元画像からトリミングした等倍のサイズを設定する」とします。

方法は簡単。
四角形の角それぞれの座標を使って、三平方の定理で算出します。
(こんなところで三平方の定理を使うと思ってなかったので、中学時代の旧友に会った感覚で懐かしかった)

# 
# とりあえず`best_approx`の中身の説明から。
# 
# @type
#   <class 'numpy.ndarray'>
# 
# @value(example)
#   [[[580 260]]  左下(x y)
#    [[452 141]]  左上(x y)
#    [[152 300]]  右上(x y)
#    [[230 467]]]  右下(x y)
# 

# 出力座標の計算(三平方の定理。詳しくは思い出すか調べてね。)
r_btm = best_approx[0][0]
r_top = best_approx[1][0]
l_top = best_approx[2][0]
l_btm = best_approx[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

そもそも四角形じゃない場合どうなるの?

対象コードはこちら。

tmp = cv2.approxPolyDP(cnt, epsilon, True)
if 4 == len(tmp):
  # 以下略

cv2.approxPolyDP(cnt, epsilon, True)で取れる値についてです。

これについては、輪郭それぞれの座標がNumpy配列で入ってます。
三角形なら3つ、四角形なら4つの座標が入ってます。
前章の「出力座標の値の根拠って何?」の例では、best_approxがこれに当たります。

やりたいのは台形補正で、台形は四角なので、3つ以下5つ以上は要りません。

さっきの「出力座標の値の根拠って何?」にも繋がりますが、

#仮に、取れた値が3(=三角形)だとして、

#三角形で切り出したのに、
pts1 = np.float32(approx)
#四角形に吐き出す
pts2 = np.float32([[max_x, max_y], [max_x, 0], [0, 0], [0, max_y]])

#という、訳分からない事が起きる

取りたい形に応じて、変化させていきましょう。

結果発表!!!!!(という名のオチ)

名刺

#参考にさせていただいた名刺画像は成功
$ python correctionKeystone.py meishi.jpeg 
Best parameter: white=180 (rate=13.771562500000002)

_corrected.meishi.jpeg

献血カード(白背景)

#撮影で影になったせいで、カードと背景が同化してしまい解析不能
$ python correctionKeystone.py kenketsu_card.jpg 
The analysis failed.

画像なし(解析不可能だったため)

献血カード(黒背景)

#ちゃんと取れたけど45度傾いた(台形補正はできたのでOK)
$ python correctionKeystone.py kenketsu_card_black.jpg 
Best parameter: white=110 (rate=31.378014081790123)

_corrected.kenketsu_card_black.jpg

献血ポイントカード(白背景)

#献血ちゃん...
$ python correctionKeystone.py kenketsu_point.jpg 
Best parameter: white=60 (rate=97.68636670524693)

_corrected.kenketsu_point.jpg

献血ポイントカード(黒背景)

#献血ちゃん.......
$ python correctionKeystone.py kenketsu_point_black.jpg 
Best parameter: white=90 (rate=26.30570023148148)

_corrected.kenketsu_point_black.jpg


OpenCV使った事たかったんですが、半日ほどやってみて、
失敗はしましたが、どうやって値を調査して検証して設定すればいいのかというのは見えてきたので、もう少し改良してみます。

台形補正自体は簡単で、問題は輪郭抽出だったから、輪郭抽出の精度を上げる必要があるね。
そのためには、画像の前処理するか、細かなパラメータ設定するか、他の手法を使うのか、、、

「こうやった方がいいよ!」とか「これはダメでしょ!」とかあれば、ぜひお声掛けくださいませ...

tyaunuakkia
Java / Ruby / Python / C# / 基本情報技術者 / 応用情報技術者
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away