【OpenCV+Python】画像の台形補正(射影変換)の実装

OpenCV
スポンサーリンク
ピーター
ピーター

PythonとOCRを使って、PDFからテキスト情報を抽出してみたい!

今回はPythonとOpenCVを使うことで、免許証のようなカードを撮影した画像を台形補正(射影変換)する実装をしました。その手順について、備忘録として記載します。



実装で考えた方針

今まで紹介した内容を組み合わせて進めたいと思います。

  1. 画像の取得
  2. 面積の大きい領域の抽出
    1. 二値化処理をして、輪郭を見えるようにする
    2. 輪郭抽出をして、大きい面積のものに絞る
  3. 領域端部座標の決定
    1. 重心の算出
    2. 重心から遠い4つの座標を算出
  4. 台形補正(射影変換)
  5. (おまけ)OCRした結果の確認

この流れで実装を組んで動かしていきます!

今回の画像では、2(領域抽出)と3(端部座標)が簡易的なもので対応できていますが、

色々な場面、画像の種類に対応させようとした場合、いずれも改善が必要になります。そのため、基本的な流れを把握して貰えばいいのかなと思います。

環境設定

基本インストール

pipでインストールしている場合のコマンド

### OpenCVのインストール
$ brew install opencv
$ pip install opencv-python==4.5.4.60
$ pip install opencv-contrib-python==4.5.4.60
$ pip install opencv-python-headless==4.5.4.60

###  EasyOCRのインストール
$ pip install easyocr==1.6.2

実際に環境構築していた際、このようなコメントが表示されましたので、OpenCV関係のバージョンは4.5.4.60に合わせています。

  SolverProblemError

  Because easyocr (1.6.2) depends on opencv-python-headless (<=4.5.4.60)
   and no versions of easyocr match >1.6.2,<2.0.0, easyocr (>=1.6.2,<2.0.0) requires opencv-python-headless (<=4.5.4.60).
  So, because image2txt-easyocr depends on both easyocr (^1.6.2) and opencv-python-headless (^4.7.0), version solving failed.

jupyterの場合のインストール

jupyterで動かす場合、pipでインストールしている場合のコマンド

pip install jupyter==1.0.0
pip install matplotlib==3.7.1

サンプルコード

今回はjupyter notebookで実装したままなので、そのまま紹介しています。

0. 対応モジュールの読み込み

### 画像処理用
import cv2
import numpy as np
import math

### OCR処理用
import easyocr

### jupyter表示用
import matplotlib.pyplot as plt
import copy
import json

1. 画像を読み込む

input_file_path = "sample.jpg"

img = cv2.imread(input_file_path)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img_tmp = copy.deepcopy(img)      ### 経過表示用(imgは台形補正で利用するため)
plt.imshow(img)
plt.show()

いい感じに斜めの写真を撮っています。画像を読み込む

2. 面積の大きい領域の抽出

2-1. 二値化処理をして、輪郭っぽいものを検出

今回の画像は、周りの光源からの写り込みも少ないため、単純な二値化処理を行います。画像内のヒストグラムでクラス間の分散が最大化する輝度を閾値とする大津の手法を使いました。

今回の方法では、輪郭が取れそうな状態になっていますね

### 大津の手法による二値化処理
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret,bin_img = cv2.threshold(gray_img, 10, 255, cv2.THRESH_OTSU)
plt.imshow(cv2.cvtColor(bin_img, cv2.COLOR_GRAY2BGR))
plt.show()

2-2. 輪郭抽出をして、大きい面積のものに絞る

次に、二値画像から輪郭抽出書を行い、その輪郭の中で面積が大きい領域を限定しました。今回の処理で輪郭が上手く抽出できていそうです。

### 規定以上の大きい領域を抽出する関数
def findLargeArea(bin_img, th_area: int = 10000):
    # 輪郭抽出
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    # 面積の大きいもののみ選別
    large_areas = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area > th_area:
            epsilon = 0.0001*cv2.arcLength(cnt, True)
            approx = cv2.approxPolyDP(cnt,epsilon, True)
            large_areas.append([area, approx])
    large_areas = sorted(large_areas, reverse=True, key=lambda data: data[0])
    return [ area[1] for area in large_areas]

### 最大領域の抽出
### 規定以上の大きい領域を抽出する関数を利用(findLargeArea)
def findMaxArea(bin_img, th_area: int = 10000):
    return findLargeArea(bin_img, th_area)[0]

max_areas = findMaxArea(bin_img)
cv2.drawContours(img_tmp,max_areas,-1,(0,255,0),3)

plt.imshow(img_tmp)
plt.show()

3. 領域端部座標の決定

今回の画像の中で、端部を決定したいのですが、丸い形状を上手く読み取るために輪郭の点数を多くしています。そのため、この中で一番端部として適した座標を単純に決められません。

今回は、領域の重心を算出し、その重心から遠い4座標をそれぞれの端部とする処理にしました。

そのため、①重心の算出、②一番遠い座標を決定が大まかな手順です。

3-1. 重心の算出

重心を求める手法がPythonにはあるので、その手法を活用します。参考にしたサイトの方法を利用しました。

### 重心を算出する関数
def calCenter(area):
    mu = cv2.moments(area)
    x  = int(mu["m10"] / mu["m00"])
    y  = int(mu["m01"] / mu["m00"])
    return x, y

x, y = calCenter(max_areas)

cv2.circle(img_tmp, (x,y), 4, 100, 2, 4)

plt.imshow(img_tmp)
plt.show()

3-2. 重心から遠い4つの座標を算出

重心から一番遠い座標が端部となりますが、その一番遠い座標の周りを端部と誤判定してはいけません。そのため、一番遠い座標が決定したら、その周辺座標は除外することで誤判定を抑制しました。

### 重心から遠い座標を中心に、近似する座標を除外する処理
### [out] max_point:dist_listの中で一番重心から遠い座標
def deleteNearPoint(dist_list, th_dist):
    dist_list   = sorted(dist_list, reverse=True, key=lambda data: data[0])
    _,far_point = dist_list[0]
    for n in reversed(range(len(dist_list))):
        cal_dist = np.linalg.norm(dist_list[n][1] - far_point)
        if th_dist > cal_dist:
            dist_list.pop(n)
            # print(len(dist_list))
    return far_point, dist_list

### 重心から遠い4座標を決定する関数
def detectPoint(area, x, y, th_coe: float = 0.75):
    out_points = []

    ### 重心からの距離を算出
    result_list = []
    center = np.array([x, y])
    for point in area:
        result_list.append([np.linalg.norm(center - point), point])

    ### 重心から一番遠い座標に近似する座標と判定する閾値の算出
    sort_list  = sorted(result_list, reverse=True, key=lambda data: data[0])
    max_dist,_ = sort_list[0]
    th_dist    = max_dist * th_coe
    while len(sort_list):
        far_point, sort_list = deleteNearPoint(sort_list, th_dist)
        out_points.append(far_point)
        # print(len(out_points))
    return out_points[:4]

c_points = detectPoint(max_areas, x, y)

for area in c_points:
    cv2.circle(img_tmp, (area[0]), 4, 100, 20, 4)
plt.imshow(img_tmp)
plt.show()

4. 台形補正(射影変換)

4つの座標が決定したら、抽出した座標がどの座標に対応するのか割り当てて、射影行列を算出しています。その行列を持ちいいた台形補正によって、画像の傾きを取り除きました。

def warpPerspective(in_rgb_img, points):
    try:

        # 変換前4点の座標 p1:左上 p2:左下 p3:右下 p4:右上
        points = sorted(points, key=lambda data: data[0][1])
        upside_point = sorted(points[:2], key=lambda data: data[0][0])
        downside_point = sorted(points[2:], key=lambda data: data[0][0])
        
        p1 = upside_point[0][0]
        p2 = downside_point[0][0]
        p3 = downside_point[1][0]
        p4 = upside_point[1][0]

        print("P1:", p1)
        print("P2:", p2)
        print("P3:", p3)
        print("P4:", p4)

        # 幅取得
        o_width_max = max(np.linalg.norm(p4 - p1), np.linalg.norm(p2 - p3))
        o_width_min = min(np.linalg.norm(p4 - p1), np.linalg.norm(p2 - p3))
        # 比率調整
        ratio    = o_width_max / o_width_min
        o_width  = math.floor(o_width_min * ratio)

        # 高さ取得
        o_height = max(np.linalg.norm(p2 - p1), np.linalg.norm(p4 - p3))
        o_height = math.floor(o_height * ratio)
        
        print("length_w:{}   length_h:{}".format(o_width, o_height))

        # 変換前の4点
        src = np.float32([p1, p2, p3, p4])

        # 変換後の4点
        dst = np.float32([[0, 0],[0, o_height],[o_width, o_height],[o_width, 0]])

        # 変換行列
        M = cv2.getPerspectiveTransform(src, dst)

        # 射影変換・透視変換する
        output = cv2.warpPerspective(in_rgb_img, M,(o_width, o_height))
    except Exception as e:
        print("error:{}".format(e.args))
        return None

    return output
daikei = warpPerspective(img, c_points)

plt.imshow(daikei)
plt.show()
ピーター
ピーター

今回の処理でいい感じに抽出できました(こういうカードは角が丸いので、完全な外を取れていませんが、変な歪みは少なそうです)

5. (おまけ)OCRした結果の確認

5.1 OCRの結果確認

reader = easyocr.Reader(['ja', 'en'])#日本語:ja, 英語:en

results = reader.readtext(daikei)
for result in results:
    print("text:", result[1], result[0])

判定結果

text: Rgkuten楽天銀行 [[204, 126], [696, 126], [696, 200], [204, 200]]
text: SECURITYCARD [[306, 221], [595, 221], [595, 261], [306, 261]]
text: 楽天銀行のお取引に使用する重要なカードです。大切にお取報いください。 [[34, 444], [701, 444], [701, 472], [34, 472]]
text: ※キャッシュカードではありません。 [[33, 470], [360, 470], [360, 502], [33, 502]]

5-2. 検出した領域の表示

def drawBoxImage(img, points):
    print("  dwar points:{}, {}, {}, {}".\
        format(tuple(points[0]), tuple(points[1]),\
               tuple(points[2]), tuple(points[3])))

    return cv2.polylines(img, [points], True, (255, 0, 0), thickness = 2)

def drawOCRResultImage(img, ocr_results):
    for oce_result in ocr_results:
        print(oce_result[1])
        ### OCRの座標情報はList型のため、numpyに変換する
        points = np.array(oce_result[0],dtype=np.int32)
        drawBoxImage(img, points)

drawOCRResultImage(daikei, results)
plt.imshow(daikei)
plt.show()
ピーター
ピーター

EasyOCRでは結果に影響しませんが、読み込んだ画像を後々、他の処理で利用する場合は、このような台形補正を活用できるのはいいと思います!

まとめ:Jupyter notebookで実装した場合

### 画像処理用
import cv2
import numpy as np
import math

### OCR処理用
import easyocr

### jupyter表示用
import matplotlib.pyplot as plt
import copy
import json
input_file_path = "sample.jpg"

img = cv2.imread(input_file_path)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img_tmp = copy.deepcopy(img)      ### 経過表示用(imgは台形補正で利用するため)
plt.imshow(img)
plt.show()
### 大津の手法による二値化処理
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret,bin_img = cv2.threshold(gray_img, 10, 255, cv2.THRESH_OTSU)
plt.imshow(cv2.cvtColor(bin_img, cv2.COLOR_GRAY2BGR))
plt.show()
### 規定以上の大きい領域を抽出する関数
def findLargeArea(bin_img, th_area: int = 10000):
    # 輪郭抽出
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    # 面積の大きいもののみ選別
    large_areas = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area > th_area:
            epsilon = 0.0001*cv2.arcLength(cnt, True)
            approx = cv2.approxPolyDP(cnt,epsilon, True)
            large_areas.append([area, approx])
    large_areas = sorted(large_areas, reverse=True, key=lambda data: data[0])
    return [ area[1] for area in large_areas]

### 最大領域の抽出
### 規定以上の大きい領域を抽出する関数を利用(findLargeArea)
def findMaxArea(bin_img, th_area: int = 10000):
    return findLargeArea(bin_img, th_area)[0]

max_areas = findMaxArea(bin_img)
cv2.drawContours(img_tmp,max_areas,-1,(0,255,0),3)

plt.imshow(img_tmp)
plt.show()
### 重心を算出する関数
def calCenter(area):
    mu = cv2.moments(area)
    x  = int(mu["m10"] / mu["m00"])
    y  = int(mu["m01"] / mu["m00"])
    return x, y

x, y = calCenter(max_areas)

cv2.circle(img_tmp, (x,y), 4, 100, 2, 4)

plt.imshow(img_tmp)
plt.show()
### 重心から遠い座標を中心に、近似する座標を除外する処理
### [out] max_point:dist_listの中で一番重心から遠い座標
def deleteNearPoint(dist_list, th_dist):
    dist_list   = sorted(dist_list, reverse=True, key=lambda data: data[0])
    _,far_point = dist_list[0]
    for n in reversed(range(len(dist_list))):
        cal_dist = np.linalg.norm(dist_list[n][1] - far_point)
        if th_dist > cal_dist:
            dist_list.pop(n)
            # print(len(dist_list))
    return far_point, dist_list

### 重心から遠い4座標を決定する関数
def detectPoint(area, x, y, th_coe: float = 0.75):
    out_points = []

    ### 重心からの距離を算出
    result_list = []
    center = np.array([x, y])
    for point in area:
        result_list.append([np.linalg.norm(center - point), point])

    ### 重心から一番遠い座標に近似する座標と判定する閾値の算出
    sort_list  = sorted(result_list, reverse=True, key=lambda data: data[0])
    max_dist,_ = sort_list[0]
    th_dist    = max_dist * th_coe
    while len(sort_list):
        far_point, sort_list = deleteNearPoint(sort_list, th_dist)
        out_points.append(far_point)
        # print(len(out_points))
    return out_points[:4]

c_points = detectPoint(max_areas, x, y)

for area in c_points:
    cv2.circle(img_tmp, (area[0]), 4, 100, 20, 4)
plt.imshow(img_tmp)
plt.show()
def warpPerspective(in_rgb_img, points):
    try:

        # 変換前4点の座標 p1:左上 p2:左下 p3:右下 p4:右上
        points = sorted(points, key=lambda data: data[0][1])
        upside_point = sorted(points[:2], key=lambda data: data[0][0])
        downside_point = sorted(points[2:], key=lambda data: data[0][0])
        
        p1 = upside_point[0][0]
        p2 = downside_point[0][0]
        p3 = downside_point[1][0]
        p4 = upside_point[1][0]

        print("P1:", p1)
        print("P2:", p2)
        print("P3:", p3)
        print("P4:", p4)

        # 幅取得
        o_width_max = max(np.linalg.norm(p4 - p1), np.linalg.norm(p2 - p3))
        o_width_min = min(np.linalg.norm(p4 - p1), np.linalg.norm(p2 - p3))
        # 比率調整
        ratio    = o_width_max / o_width_min
        o_width  = math.floor(o_width_min * ratio)

        # 高さ取得
        o_height = max(np.linalg.norm(p2 - p1), np.linalg.norm(p4 - p3))
        o_height = math.floor(o_height * ratio)
        
        print("length_w:{}   length_h:{}".format(o_width, o_height))

        # 変換前の4点
        src = np.float32([p1, p2, p3, p4])

        # 変換後の4点
        dst = np.float32([[0, 0],[0, o_height],[o_width, o_height],[o_width, 0]])

        # 変換行列
        M = cv2.getPerspectiveTransform(src, dst)

        # 射影変換・透視変換する
        output = cv2.warpPerspective(in_rgb_img, M,(o_width, o_height))
    except Exception as e:
        print("error:{}".format(e.args))
        return None

    return output
daikei = warpPerspective(img, c_points)

plt.imshow(daikei)
plt.show()
reader = easyocr.Reader(['ja', 'en'])#日本語:ja, 英語:en

results = reader.readtext(daikei)
for result in results:
    print("text:", result[1], result[0])
def drawBoxImage(img, points):
    print("  dwar points:{}, {}, {}, {}".\
        format(tuple(points[0]), tuple(points[1]),\
               tuple(points[2]), tuple(points[3])))

    return cv2.polylines(img, [points], True, (255, 0, 0), thickness = 2)

def drawOCRResultImage(img, ocr_results):
    for oce_result in ocr_results:
        print(oce_result[1])
        ### OCRの座標情報はList型のため、numpyに変換する
        points = np.array(oce_result[0],dtype=np.int32)
        drawBoxImage(img, points)

drawOCRResultImage(daikei, results)
plt.imshow(daikei)
plt.show()

コメント

ランキング

タイトルとURLをコピーしました