OpenCVを利用した矩形検出の試行錯誤_エッジ検出・適応的閾値処理

前記事で示したOpenCVを用いた矩形検出の改善案のうち、ここでは下記2案の内容について記載する。
改善案3 エッジ検出を使う
改善案4 適応的閾値処理を使う

環境

Python 3.7.8
OpenCV-Python 4.5.1.48

改善案3 エッジ検出を使う

案の概要

画像の輝度が急激に変化している箇所をエッジ(今回だと輪郭)として捉える処理を行い、エッジのみになった画像に対して輪郭検知を行うという案。
エッジとみなすのは周辺と比べ値が急激に変化している箇所なので、微分した値が大きい箇所をエッジとみなす形で処理が行われる。調べると、エッジ検出としてはSobelフィルタ、Laplacianフィルタ、Cannyフィルタなどがよく使われているようだったが、ここではCannyフィルタを利用した場合の結果を示す。

案の実践

エッジ検出した結果に対して輪郭の検知を行った。 一見うまくいくように見えるが、実際にはその後矩形のみに絞る処理がうまくいかず、1枚もレシートを検出できなかった。これは、レシートの外形を輪郭が囲っているように見えるがそれらはひとつながりの線になっておらず、別々の矩形と捉えられているからと思われる。
エッジ検出の拡大

そこで輪郭の検知前にノイズ処理(ここではモルフォロジー変換)を追加し、検出したエッジを単純化(膨張処理)することにした。 概ねレシートの輪郭を捉えられているように見えるが、一部途切れているなどしていて、その後レシートの矩形のみに絞る処理をすると1枚のレシートしか検出できなかった。
そこで、エッジの膨張させる程度をもう少し強くしてみる。 これだと3枚ともレシートを検出できた。
検出結果を見ると、レシートを囲む2重線のうち、内側のみ矩形と判断されていた。これは、外側のレシートを囲む線は背景のノイズを拾った線とつながるなどしていて、矩形と判定されなかったことによる。
一方、内側のレシートを囲む線はレシートの印字の輪郭線とつながっていないため、矩形と判断できた。今回の膨張の程度だと問題ないが、もう少し大きくすると今度はレシートの印字の検知の範囲とレシートを囲む内側の線がくっついてしまい、うまく矩形検出されなくなることが予想される。
輪郭検知の拡大

※ モルフォロジー変換を膨張でなくクロージング(エッジをいったん膨張させて他の細かいエッジと結合させた後、膨張を戻す)にすれば上記の問題が解決できるのではと考え試してみた。しかし、以下の結果となり、最終的にレシートの矩形はうまくいかなかった。

案の評価

レシートの矩形検出自体は行えるが、上記のようにモルフォロジー変換の強さを各画像に合わせ調整する必要がありそうなので、任意の画像に対応するのは難しいだろう。

コード

案3の一連の処理を行う際に書いたコードを以下に示す。

※ 案3に特有の部分
エッジ処理(Canny法)は cv2.Canny で行っている。
Pythonbinary_img = cv2.Canny(gray_img, 100, 200) # 100はminVal、200はmaxVal
# 画素値の微分値が maxVal 以上であればエッジとみなす  
# 画素値の微分値が minVal 以下であればエッジではないとみなし除外する
# 画素値の微分値が二つの閾値の間の場合、エッジと区別された画素(maxVal以上)につながっていればエッジとみなし,そうでなければエッジではないとみなし除外する
モルフォロジー変換(膨張)は cv2.dilate で行っている。
kernel = np.ones((30,30), np.uint8) # 処理の際参照する領域のサイズ
dilation = cv2.dilate(img, kernel, iterations=1) # iterationsは処理回数

一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def binarize(img):
    """画像を2値化する
    """
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    binary_img = cv2.Canny(gray_img, 100, 200)
    plot_img(binary_img, 'binary_img')
    binary_img = binary_img.astype('uint8')
    return binary_img


def noise_reduction(img):
    """ノイズ処理(膨張)を行う
    """
    kernel = np.ones((30,30), np.uint8)
    dilation = cv2.dilate(img, kernel, iterations=1)
    plot_img(dilation, 'dilation')
    return dilation


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.01*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img):
    """矩形検出までの一連の処理を行う
    """
    binary_img = binarize(img)
    noise_reduction_binary_img = noise_reduction(binary_img)
    contours = find_contours(noise_reduction_binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')


input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file)

改善案4 適応的閾値処理を使う

案の概要

前記事で示した現在の検出手順では、画像全体の画素値を対象として計算し2値化の閾値を決めていた。適応的閾値処理は画像全体ではなく、画像中の小領域ごとに閾値を計算する方法になる。そのため、領域ごとに光源環境が変わるような画像であっても限られた領域内の画素を対象とすることで、画像全体を対象とした場合よりも良い結果が得られる。

案の実践

OpenCVで用意されている適応的閾値処理の関数として cv2.adaptiveThreshold がある。以下の2引数を動かして変化を確認した。
・Block Size: 閾値計算時に対象にする小領域の大きさ(奇数とする)
・C: 計算された閾値から引く定数

Block Sizeを動かす(Cは2に固定)
Cを動かす(Block Sizeは101に固定)
上記結果をみて、Bloce Size=255、C=2でやってみた。 レシート3枚拾えているが、余計な背景も矩形として拾ってしまっている。
輪郭検知の段階でかなりノイズが多いことが要因と考え、ノイズ処理(中央値フィルタ)を追加してみた。 今度はレシートのみ過不足なく拾えている。

案の評価

上記のように結果を見ながら閾値を調整したので、画像によってはレシートの矩形検出がうまく行えない恐れがある。

コード

案4の一連の処理を行う際に書いたコードを以下に示す。

※ 案4に特有の部分
適応的閾値処理の関数として cv2.adaptiveThreshold を利用している。
binary_img = cv2.adaptiveThreshold(gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 255, 2)
# 第2引数:輝度値の最大値(今回255)
# 第3引数:閾値計算の方法(今回のADAPTIVE_THRESHOLD_GAUSSIAN_Cだと小領域で閾値を計算する方法にガウス分布による重み付けをした平均値を使うことになる)
# 第4引数:閾値処理の種類(今回のTHRESH_BINARYだと閾値より小さい範囲は黒大きい範囲は白に変換する)
# 第5引数:閾値計算時に対象にする小領域の大きさ(今回255としている)
# 第6引数:計算された閾値から引く定数
ノイズ処理(中央値フィルタ)として cv2.medianBlur を利用している。
median = cv2.medianBlur(img, 9) # 9はカーネルサイズ(中央値を計算する対象とする範囲)
一連の処理を行い、検出結果を画像で返すところまでのコード
import cv2
import numpy as np
from matplotlib import pyplot as plt


def binarize(img):
    """画像を2値化する
    """
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    binary_img = cv2.adaptiveThreshold(gray_img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 255, 2)
    plot_img(binary_img, 'binary_img')
    return binary_img


def noise_reduction(img):
    """ノイズ処理(中央値フィルタ)を行う
    """
    median = cv2.medianBlur(img, 9)
    plot_img(median, 'median')
    return median


def find_contours(img):
    """輪郭の一覧を得る
    """
    contours, _ = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    return contours


def approximate_contours(img, contours):
    """輪郭を条件で絞り込んで矩形のみにする
    """
    height, width, _ = img.shape
    img_size = height * width
    approx_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        area = cv2.contourArea(cnt)
        if arclen != 0 and img_size*0.02 < area < img_size*0.9:
            approx_contour = cv2.approxPolyDP(cnt, epsilon=0.05*arclen, closed=True)
            if len(approx_contour) == 4:
                approx_contours.append(approx_contour)
    return approx_contours


def draw_contours(img, contours, file_name):
    """輪郭を画像に書き込む
    """
    draw_contours_file = cv2.drawContours(img.copy(), contours, -1, (0, 0, 255, 255), 10)
    plot_img(draw_contours_file, file_name)


def plot_img(img, file_name):
    """画像の書き出し
    """
    plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    plt.title(file_name)
    plt.show()
    cv2.imwrite('./{}.png'.format(file_name), img)


def get_receipt_contours(img):
    """矩形検出までの一連の処理を行う
    """
    binary_img = binarize(img)
    binary_img = noise_reduction(binary_img)
    contours = find_contours(binary_img)
    approx_contours = approximate_contours(img, contours)
    draw_contours(img, contours, 'draw_all_contours')
    draw_contours(img, approx_contours, 'draw_rectangle_contours')


input_file = cv2.imread('/path/to/example.jpg')
get_receipt_contours(input_file)