【Python】画像上から紙の部分だけを抽出して保存する【OpenCV】
こんにちは、ソリューションSecの長谷川です。
今日はPythonとOpenCVライブラリを使って、画像から紙の部分だけを切り取る方法について書いてみたいと思います。
(別に新しい技術でもないので、すごく今更ではあるのですが・・・)
今回やりたいこと

これは私の名刺の画像ですが、例えばこのような名刺や紙の帳票などを
システムで保管する際に、周りの余計な余白を取り除きたいというケースがよくあるかと思います。
無論、スキャナを使って読み取ってくれればそんな手間は省けるのですが
業務でスマホを利用する機会も増えてきている昨今では、このような画像が扱われることも多いのではないでしょうか。
そのため、今回は先程の画像から周りを削除して、名刺の部分だけを抽出します。
PythonとOpenCVを使って処理をする
では、名刺を抽出するまでを順を追ってみていきます。
準備
具体的な抽出処理の前の準備段階です。
ここでは、画像の読み込み→リサイズをします。
リサイズはしてもしなくてもいいのですが、最終的に保存する画像サイズが
元のサイズよりも小さくしたい場合は、最初にリサイズをかけておくことで
各処理の実行時間を短くすることが出来ます。
img = cv2.imread("meishi.jpg")
# リサイズ
h,w = img.shape[:2]
if max(h, w) > 1920:
scale = 1920 / max(h, w)
new_w = int(w * scale)
new_h = int(h * scale)
img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
上記のコードでは、読み込んだ画像からh(高さ)とw(幅)を取得し
どちらかが1920pxを超えているようであれば、最大長を1920として
アスペクト比を保ったままリサイズしようとしています。
グレースケール変換
準備が出来たら画像をグレースケールに変換します。
# グレースケール変換
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv2.imwrite("step_1.jpg", img) # ← デバッグ用なのでなくても良い
このあとに出てくるエッジ検出をする処理では、画像上の「輝度の変化(勾配)」を利用するします。
そのため、カラー画像のままではRGBの各チャンネルでエッジの情報が異なり、適切なエッジを検出しにくくなります。
また、グレースケール画像は輝度のみを持つ単一のチャンネルで構成されているため、処理負荷が軽減されます。

ノイズ除去
次にガウシアンブラーでノイズを除去します。
# ノイズ除去
img = cv2.GaussianBlur(img, (3, 3), 0)
cv2.imwrite("step_2.jpg", img) # ← デバッグ用なのでなくても良い
画像上にはノイズ(細かい輝度の変化)が含まれていることが多いため
エッジの誤検出を減らすためにこの処理を入れます。

ぱっと見た感じは分かりづらいですが、拡大して比較すると結構違います。

エッジの検出
次はエッジの検出です。エッジとは画像内で明るさ(輝度)の急激な変化が起こる部分のことです。
簡単に言うと、物体の境界線や輪郭ですね。
# エッジ抽出
img = cv2.Canny(img, 0, 400, apertureSize=3)
cv2.imwrite("step_3.jpg", img) # ← デバッグ用なのでなくても良い

cv2.Cannyのしきい値は調整が必要だったりします。
以下の記事などを参考にしてみてください。
cv2.Canny(): Canny法によるエッジ検出の調整をいい感じにする – Qiita
https://qiita.com/Takarasawa_/items/1556bf8e0513dca34a19
エッジを膨張させる
次に先程検出したエッジを膨張させます。
これは、細かくて途切れた部分や小さな野路を他のエッジとつなげて大きなエッジに変換することができるためです。
# 膨張処理
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
img = cv2.dilate(img, kernel)
cv2.imwrite("step_4.jpg", img) # ← デバッグ用なのでなくても良い

輪郭を検出する
さて、エッジも検出できたところで輪郭を抽出していきます。
# 輪郭抽出
contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
temp_img = org_img.copy() # ← デバッグ用なのでなくても良い
cv2.drawContours(temp_img, contours, -1, (0, 255, 0), 3) # ← デバッグ用なのでなくても良い
cv2.imwrite("step_5_1.jpg", temp_img) # ← デバッグ用なのでなくても良い
デバッグ用のコードが多めですが、先程のエッジを検出した画像から
どのような輪郭が取得できたのかをわかりやすくするために入れてみました。
下の画像を見てもらうと分かるように、名刺のフチの部分から文字の部分まで輪郭が取得されています。

一番大きい四角形の輪郭だけを取得する
さて、ちょっとだけ複雑になっていきます。
rects = []
for contour in contours:
epsilon = 0.02 * cv2.arcLength(contour, True) # 誤差許容値(輪郭の周囲長の2%)
approx = cv2.approxPolyDP(contour, epsilon, True)
if len(approx) != 4:
continue
approx.reshape(-1, 2)
rects.append(approx)
# 最も大きい短形を抽出
rects = sorted(rects, key=cv2.contourArea, reverse=True)
temp_img = org_img.copy() # ← デバッグ用なのでなくても良い
cv2.drawContours(temp_img, rects, 0, (0, 255, 0), 3) # ← デバッグ用なのでなくても良い
cv2.imwrite("step_6.jpg", temp_img) # ← デバッグ用なのでなくても良い
for文で取得したすべての輪郭ごとの処理をしていきます。
epsilonは近似の精度を示しており、輪郭の周囲長の2%に設定しました。
次の行のcv2.approxPolyDPで、与えられた輪郭を多角形に近似しています。
詳しくは下記の記事を参照してください。
【Python・OpenCV】輪郭形状の近似(cv2.approxPolyDP)
https://www.codevace.com/py-opencv-approxpolydp/
そのあとのif文では頂点の数を判定しています。
今回、名刺は四角形なので、四角形のもの以外は無視しようとしていますね。
更にその後のapprox.reshape()で適切な形に変換しています。
そして、その後に取得された四角形の一覧(=rects)で一番大きなものをだけを描画したのが下記の画像です。

射影変換して保存する
さて、いよいよ最後なのですが、最後はちょっとコード量が多いです。
とりあえず見てもらいます。
# 座標をソート
sorted_by_y = sorted(rects[0], key=lambda p: p[0][1])
top_two = sorted_by_y[:2]
bottom_two = sorted_by_y[2:]
# 上側の2点をx座標の昇順でソート
top_two = sorted(top_two, key=lambda p: p[0][0])
top_left, top_right = top_two
# 下側の2点をx座標の昇順でソート
bottom_two = sorted(bottom_two, key=lambda p: p[0][0])
bottom_left, bottom_right = bottom_two
points = np.array([top_left, bottom_left, bottom_right, top_right], dtype=np.float32)
# 縦横の長さを求める
width = max(np.linalg.norm(top_left - top_right), np.linalg.norm(bottom_left - bottom_right))
height = max(np.linalg.norm(top_left - bottom_left), np.linalg.norm(top_right - bottom_right))
# 最大長を500pxにする
scale = 500 / max(width, height)
width = int(width * scale)
height = int(height * scale)
# 射影変換行列を求める
dst_points = np.array([
[0, 0],
[0, height],
[width, height],
[width, 0]
], dtype=np.float32) # `np.float32` に変換
# 射影変換行列を求める
M = cv2.getPerspectiveTransform(points, dst_points)
transformed = cv2.warpPerspective(org_img, M, (width, height))
cv2.imwrite("step_7.jpg", transformed)
先ほど最も大きい輪郭(四角形)を取得したのですが
この四角形の4角の座標というのは左上、右上、左下、右下というような順番に並んではいません。
なので、最初にY座標でソートして、上下に分けた後、X座標でソートして左右に分けています。
また、取得した四角形の座標から幅と高さを取得します。
これをしておかないとアスペクト比がおかしな画像ができあがります。
今回は最大長を500pxとして変換後のサイズを求めました。
さて、最後の射影変換ですが、ここでは先ほど取得した輪郭(四角形)が水平になっていない上に
写真で取った際は斜めの角度から撮られていることもあるので
長方形ではなく台形になっているかもしれません。
なので、台形状のものをきれいな長方形になるよう引き伸ばして上げる必要があります。
そんなこんなな処理がつらつら書かれていますが、この処理を行って
最終的に得られた画像が以下になります。

きれいな画像になりましたね。
最後に
いかがでしたでしょうか。
今回は画像から名刺の部分だけを抽出するところまででしたが
たとえばこれが帳票なら同じような処理をしたうえで、紙の部分だけを抽出し
それをさらにCloudVisionAPIなどのOCRにかけてテキストを取得し
さらにさらにテキストを取得したい項目の場所が座標として分かっているのであれば
その座標の範囲内にあるテキストを目的のものとして取得するみたいなことができます。
単純なOCRだけでは全部の文字が取得されてしまうので、例えば名前を取得したいのに
他の項目まで取ってきてしまうこともありますが、どの場所にあるというのがわかっていれば絞り込めますね。
今は便利なサービスがいっぱいあるので、あまり意識したことのない領域でしたが
改めて自分でやってみると、難しいことやってるなーと思いましました。
私にとってPythonがメインに扱う言語ではないというのもありますが
数学は苦手なので行列だとかなんだとかよく覚えていないからです・・・笑
ただ、面白い技術でもあるので、何かに活かしていけたらなと思います。
それでは今回はこのへんで。