薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

Pythonで画像の傾きを補正して水平にする

コピー機等でスキャンした画像データをよく見ると、ほんのわずかに傾いているものがあります。Windowsだと標準ソフトのフォトあたりを使うと、スライダーをマウスで動かしながら画像の回転を行うことができますが、角度の最小単位が1度ずつとなっていて微妙な操作は難しそうですし、何十枚もこの作業をするのは面倒です。そこで、今回は画像の傾きを補正するプログラムを書くことにしました。
なお、本記事の内容は「数字認識を使って棚卸を自動化するアプリケーションを作る」で行った処理のひとつとなっています。


補正の目的と方法

・目的
Excelで作成した棚卸記入用紙の画像データから数字をきれいに切り出すために、画像をできるだけ水平な状態にしておきたいと考え、画像のわずかな(1度以下の単位の)傾きを補正することにしました。

・方法
画像にあるマーカー(目印)となる部分の位置のズレをもとに傾いている角度を求め、その角度分逆回転することにより水平にします。

環境

  • Windows10 Pro
  • Anaconda3 / Python 3.7.6
  • OpenCV 4.5.1

参考までにスキャナーの設定も書いておきます。

  • A4用紙
  • ファイル形式:jpeg
  • 解像度:400×400dpi
  • カラー
  • 圧縮率:低

※解像度については高ければ良いというものではありませんが、ネットで調べたOCRソフトの解像度を参考にしました。目的が手書き数字認識なので、このことだけを考えれば実際にはここまで高い解像度は必要なかったと思います。

OpenCVのインストール

Windows環境でAnacondaは既にインストール済みとします。
Anaconda Promptを開いて以下のコマンドを入力します。

pip install opencv-python


テンプレートマッチングによるマーカーの位置の検出

まずは画像の中でマーカーとなる部分を決め、それをテンプレートとして作成します。

テンプレート画像の作成

テンプレートに用いるマーカーは何でも良いのですが、プログラムの精度を少しでも良くするために以下の点に留意しました。

  • 左右である程度離れている(近すぎない)もの
  • 画像の他の箇所に似ている部分がないもの
  • できれば左右同じでないもの

左右で同じものを使っても構いませんが、その場合は検出された位置が左右のどちらであるかを判定する必要があります。

私は用紙印刷時のページレイアウトの「ヘッダー編集」で左右に下のような文言を書き入れていたので、そこに二重丸を加えたものをマーカーとすることにしました。(緑で囲んだ部分)
f:id:enokisaute:20210112002706j:plain
このマーカー部分をペイント*1で切り取って保存し、templateフォルダを作って入れておきます。
<templateフォルダ>
f:id:enokisaute:20210108001458p:plain
なお、このテンプレート作成時の画像の解像度は、本番で用いる画像データのものと同じにしておきます。また、画像が水平であればマーカーのy座標は同じである、ということを前提として角度を計算するので、テンプレート画像の一方だけが上下の余白が大きかったりすると、補正の精度が悪くなってしまいます。もっとも、切り取り作業は人手でやるので、多少の誤差が入ることは避けられませんが。

OpenCVによるテンプレートマッチング

テンプレートマッチングでは、入力画像全体をスライドしながら比較していくことでテンプレート画像と同じ部分を見つけることができます。

先ほどテンプレート画像は入力画像と同じ解像度で作ると書きましたが、私が試したところ少々入力画像が縮小(80~90%)されていてもうまくいく場合もあるようです。また、これは角度についても同様で、入力画像に多少の回転があってもマッチングで同じように位置を検出することができました(でないと、やろうとしていることがうまくいかないはず)。しかし、わざわざ精度を落とすようなことはしたくないので、ここはけっこう気をつかいました。

コードと画像データ、テンプレート画像を以下のような配置にして以下のコードを実行します。(Windows環境ではパスの中に日本語が入っているとOpenCVの関数呼び出しでエラーになるので注意してください)

└── work
   ├── code.py
   ├── template
   │   ├── left_marker.jpg
   │   └── right_marker.jpg
   └── jpeg
       ├── A1.jpg
       │   ・
       │   ・
       └── ZX.jpg
<code.py>  

import glob, os
import cv2
 
def detect_marker(file, marker):
    # 画像ファイルとテンプレートをグレースケールで読み込む
    img = cv2.imread(file, 0)
    template = cv2.imread(marker, 0)
    # テンプレートマッチング
    result = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
    # 検出結果から領域の位置を取得. 類似度が最大のものを使用
    _, _, _, max_loc = cv2.minMaxLoc(result)
    # -------------------------------------------------
    # 検出部位を囲んで表示. 以下は確認用なので本番では消す
    w, h = template.shape[::-1]
    br = (max_loc[0] + w, max_loc[1] + h)  # template右下座標
    c_img = cv2.imread(file)               # カラーで読み込む
    # 長方形を描画(img, 左上座標, 右下座標, BGR, thickness)
    cv2.rectangle(c_img, max_loc, br, (0, 255, 0), 3)
    cv2.imshow('image', cv2.resize(c_img, (img.shape[1]//2, img.shape[0]//2)))   # 1/3の大きさで描画
    cv2.waitKey(0)
    cv2.destroyAllWindows()
    # -------------------------------------------------
    return max_loc
 
# 与えられたフォルダの全てのファイル(フルパス)をソートしてリストで返す
def get_all_files(dir_path):
    return sorted(glob.glob(dir_path))
 
 
if __name__ == '__main__':
    current_dir = os.path.dirname(os.path.abspath(__file__))
    jpg_dir = '/jpeg/*'     # ここにJPEGファイルを入れておく
 
    jpg_files = get_all_files(current_dir + jpg_dir)
 
    for jpg_file in jpg_files:
        print(jpg_file)
        # ----- 回転処理 -----
        left_pos = detect_marker(jpg_file, "./template/left_marker.jpg")
        right_pos = detect_marker(jpg_file, "./template/right_marker.jpg")
        print("left position: " + str(left_pos))
        print("right position: " + str(right_pos))

detect_marker関数ではjpegフォルダの入力画像に対して、与えられたテンプレート画像を検出した箇所のうち最も類似度が高いものの左上座標を返します。
また、上のコードでは確認のためその位置を矩形で囲んで表示するようにしています。

left position: (289, 131)
right position: (2864, 141)

<左テンプレート画像のマッチング>
f:id:enokisaute:20210112001603j:plain
<右テンプレート画像のマッチング>
f:id:enokisaute:20210112001816j:plain

補正する回転角度を求める

逆三角関数のひとつであるarctan(アークタンジェント)を使って求めます。入力として直角三角形の辺の比(タンジェント)を与えたときに、出力としてその角度を返す関数です。

mathモジュールにあるatan2(y, x)関数はy, xの両変数の正負の符号を踏まえた上での、点(x, y)方向の直線とx軸の正の向きとの間の角度(ラジアン)を返します。
この関数に上で求めた左右のマーカーの位置(座標)の差を下のように入力として与えてやると、左のマーカーを原点とした場合の右のマーカーの点とx軸の正方向とのなす符号付きの角度を返してくれます。
f:id:enokisaute:20210111215652p:plain

# 補正する回転角度(degree)を計算
def calc_rotation_angle(left_pos, right_pos):
    x = right_pos[0] - left_pos[0]
    y = right_pos[1] - left_pos[1]
    return math.degrees(math.atan2(y, x))

ここで注意なのですが、OpenCVの画像上の座標は左上を原点として右方向にx軸、下方向にy軸が伸びていくような座標となっていて、上図の座標とはy軸の向きが逆になっています。なので、今回のケースではatan2関数の入力のyにはマイナスを付けて与えたときに、上図の角度θが得られます。しかし、水平に補正するのに必要な回転角度は結局このθにマイナスが付いたもの(逆回転)なので、上のcalc_rotation_angle関数では補正する回転角度を直接得ていることになります。

また、今回の用途ではx=0(画像が90度とか-90度回転)や、x<0の場合は考えなくて良いのでatan2(y, x)ではなくatan(y/x)で事足りますが、プログラミングの実装ではゼロ除算や象限補正の点からatan2を用いるのが一般的なようで、こちらを使うことにしました。

画像の回転処理

画像の回転はcv2.warpAffine()関数で行います。このwarpAffineに渡す2番目のパラメータの変換行列はcv2.getRotationMatrix2Dで作成しますが、ここで先に求めた回転角度を渡しています。

def rotate_img(img, angle):
    # 画像サイズ(横, 縦)から中心座標を求める
    size = tuple([img.shape[1], img.shape[0]])
    center = tuple([size[0] // 2, size[1] // 2])
    # 回転の変換行列を求める(画像の中心, 回転角度, 拡大率)
    mat = cv2.getRotationMatrix2D(center, angle, scale=1.0)
    # アフィン変換(画像, 変換行列, 出力サイズ, 補完アルゴリズム)
    rot_img = cv2.warpAffine(img, mat, size, flags=cv2.INTER_CUBIC)
    return rot_img


プログラムの動作を確認する

では実際にこれらのプログラムで画像をうまく回転して水平にできるかどうかを試してみます。といっても、画像そのものを回転する処理はOpenCVの関数に丸投げしているので、ここでは補正するのに必要な回転角度が正しく得られるかどうかを見てみます。

テスト方法
以下の手順で行いました。

  1. 画像の左右にマーカー(テンプレート画像)をOpenCVを使って同じy座標(高さ)に貼り付ける(元画像自体が水平なのではありません)。
  2. 1の画像を-2.0~+2.0度の範囲で0.5度刻みで回転させる。
  3. プログラムを実行して補正する角度が2で回転させた角度の逆の符号で得られるかを確認する。

・結果

回転させた角度 実行結果 |実行結果 - 理論値|
-2.0
2.02569
0.02569
-1.5
1.49398
0.00602
-1.0
1.00293
0.00293
-0.5
0.51156
0.01156
0
0
0
0.5
-0.49109
0.00891
1.0
-1.00257
0.00257
1.5
-1.51443
0.01433
2.0
-2.02569
0.02569

表の数字は見やすいように適当に丸めています。
これが画像上では実際にどれくらいのズレとなるかを少し考えてみます。A4用紙を400×400dpiの解像度でスキャンすると、横縦でだいたい3300×4670ピクセルとなります。上の表で最も誤差が大きい0.02569のタンジェントは0.0004485、これに横幅の3300をかけると1.48と理論上では2ピクセルに満たないくらいのズレとなります。*2
回転させなかった場合の0を除いては、どれも理論値(回転させた角度の逆の符号)ぴったりとはいきませんでしたが、今回の目的には使えそうだということがわかりました。

写真で試してみる

最後に写真でもやってみます。やり方は同じで、回転させたい画像をjpegフォルダに置き、水平にするための目印となる部分を切り取ってテンプレート画像ファイルとして保存します。
f:id:enokisaute:20210111180449j:plain
上の画像の左右の目の部分をテンプレート画像として用いました。
f:id:enokisaute:20210111182603p:plain
このファイルをdetect_markerに渡して、プログラムを実行すると以下のように回転できます。

left position: (94, 77)
right position: (127, 67)
補正する回転角: -16.85839876773828

f:id:enokisaute:20210111180557j:plain
うまくできました。
でも、写真ではそもそも細かい単位での回転が必要となるようなことはあまり考えられませんし、ソフトを使った方がお手軽でトリミングも勝手にやってくれます。

参考

テンプレートマッチング — OpenCV-Python Tutorials 1 documentation、他
atan2 - Wikipedia
・今すぐ試したい! 機械学習・深層学習(ディープラーニング) 画像認識プログラミングレシピ



コード全文です。

import glob, math, os
import cv2
 
# JPEGフォルダの各画像の傾きを補正して水平にする
 
def rotate_img(img, angle):
    # 画像サイズ(横, 縦)から中心座標を求める
    size = tuple([img.shape[1], img.shape[0]])
    center = tuple([size[0] // 2, size[1] // 2])
    # 回転の変換行列を求める(画像の中心, 回転角度, 拡大率)
    mat = cv2.getRotationMatrix2D(center, angle, scale=1.0)
    # アフィン変換(画像, 変換行列, 出力サイズ, 補完アルゴリズム)
    rot_img = cv2.warpAffine(img, mat, size, flags=cv2.INTER_CUBIC)
    return rot_img
 
# 補正する回転角度(degree)を計算
def calc_rotation_angle(left_pos, right_pos):
    x = right_pos[0] - left_pos[0]
    y = right_pos[1] - left_pos[1]
    return math.degrees(math.atan2(y, x))
 
def detect_marker(file, marker):
    # 画像ファイルとテンプレートをグレースケールで読み込む
    img = cv2.imread(file, 0)
    template = cv2.imread(marker, 0)
    # テンプレートマッチング
    result = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
    # 検出結果から領域の位置を取得. 類似度が最大のものを使用
    _, _, _, max_loc = cv2.minMaxLoc(result)
    return max_loc
 
# 与えられたフォルダの全てのファイル(フルパス)をソートしてリストで返す
def get_all_files(dir_path):
    return sorted(glob.glob(dir_path))
 
 
if __name__ == '__main__':
    current_dir = os.path.dirname(os.path.abspath(__file__))
    jpg_dir = '/jpeg/*'     # ここにJPEGファイルを入れておく
 
    jpg_files = get_all_files(current_dir + jpg_dir)
 
    for jpg_file in jpg_files:
        print(jpg_file)
        # ----- 回転処理 -----
        left_pos = detect_marker(jpg_file, "./template/left_marker.jpg")
        right_pos = detect_marker(jpg_file, "./template/right_marker.jpg")
        print("left position: " + str(left_pos))
        print("right position: " + str(right_pos))
        angle = calc_rotation_angle(left_pos, right_pos)
        print("補正する回転角: " + str(angle))
        rotated_img = rotate_img(cv2.imread(jpg_file), angle)
        cv2.imwrite(jpg_file, rotated_img)  # 上書き保存
 

*1:Windowsに標準で入っているソフトです

*2:1ピクセルが最小単位なので、実際には端数は適当に処理されます。