【機械学習の前処理】OpenCVを使って文字位置を切り出して色調整してみた

2022.07.28

こんちには。

データアナリティクス事業本部機械学習チームの中村です。

今回は画像の機械学習をするための前処理をOpenCV(Python)でやってみたブログになります。

前提

以下のような名前「中村」を書いた画像をデータとして取得しました。

この一つ一つの「中村」に対して、画像の機械学習システムで処理したいと考えています。

とりあえずざっくりの切り出しを手動で行ってみました。

しかしこのまま使用するにはいくつかの課題があります。

  • 縦横のpixelサイズを統一する必要がある。
    • 後段の機械学習(ここではAmazon Lookout for Vision)は固定サイズしか処理できない。
  • 明るさ(コントラスト)の正規化
    • 撮影日や場所によって証明度合いが微妙に異なるため、悪影響の可能性がある。

これらをOpenCVの前処理で対応していきます。

サイズ統一

エッジ検出

まずはサイズ統一ですが、最も単純に考えると中心付近を固定サイズで切り出すことでも解決できます。

しかしその場合は、以下のように文字が中心にこなかったり、文字サイズがデータにより変わってしまいます。

ですので、エッジ検出を使って文字位置を特定した後にある程度余白をもって切り出す方法を検討しました。

input_path = pathlib.Path('./input')
output_path = pathlib.Path('./output-boxed')
output_path.mkdir(parents=True, exist_ok=True)

output_path_debug = pathlib.Path('./output-boxed-debug')
output_path_debug.mkdir(parents=True, exist_ok=True)

PADDING = 15

for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

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

    # 2値化
    _, im_bw = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 輪郭検出
    contours, hierarchy = cv2.findContours(im_bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # 画像内の輪郭の端を探す
    edge_l, edge_r, edge_t, edge_b = 1e10, 0, 1e10, 0
    for i in range(len(contours)):
        x, y, w, h = cv2.boundingRect(contours[i])

        edge_l = x   if edge_l > x   else edge_l
        edge_r = x+w if edge_r < x+w else edge_r
        edge_t = y   if edge_t > y   else edge_t
        edge_b = y+h if edge_b < y+h else edge_b

    # padding + limitter
    edge_t = edge_t - PADDING if edge_t - PADDING > 0            else 0
    edge_b = edge_b + PADDING if edge_b + PADDING < img.shape[0] else img.shape[0]
    edge_l = edge_l - PADDING if edge_l - PADDING > 0            else 0
    edge_r = edge_r + PADDING if edge_l + PADDING < img.shape[1] else img.shape[1]

    # デバッグのためboxを画像内に描画
    img_con = img.copy()
    img_con = cv2.rectangle(img_con, (edge_l, edge_t), (edge_r, edge_b), (0, 255, 0), cv2.LINE_4)
    cv2.imwrite(str(output_path_debug.joinpath(p.name)), img_con)

    # 切り出し画像を出力
    img_boxed = img[edge_t:edge_b,edge_l:edge_r]
    cv2.imwrite(str(output_path.joinpath(p.name)), img_boxed)

切り出し位置は以下のようになりました。

リサイズ

切り出した状態では、まだ各サンプルの縦横が統一されていませんので、統一していきます。

input_path = pathlib.Path('./output-boxed')
output_path = pathlib.Path('./output-resized')
output_path.mkdir(parents=True, exist_ok=True)

# まずは最大幅width_maxを求める
width_max = 0
for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

    width_max = img.shape[1] if width_max < img.shape[1] else width_max

# これを最終的なリサイズ後の幅とする
width_target = width_max

# 関数定義
def scale_to_width(img, width):
    """
    アスペクト比を維持した状態で幅合わせする処理
    """
    h, w = img.shape[:2]
    height = round(h * (width / w))
    dst = cv2.resize(img, dsize=(width, height))
    return dst

# アスペクト比を維持した状態でwidth_maxに合わせた場合の最小の高さheight_minを求める。
height_min = 1e10
for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

    img_resize = scale_to_width(img, width_target)

    height_min = img_resize.shape[0] if height_min > img_resize.shape[0] else height_min

# これを最終的なリサイズ後の高さとする
height_target = height_min

# リサイズ実行
for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

    img_resize = scale_to_width(img, width_target)

    # 高さの差分は上下均等に割り当てる
    height_diff = img_resize.shape[0] - height_target
    img_resize = img_resize[height_diff//2:height_diff//2+height_min]

    # 出力
    cv2.imwrite(str(output_path.joinpath(p.name)), img_resize)

なお、各1文字の配置やアスペクト比によっては、高さを合わせる際に切れてしまう可能性があります。

その場合は、前節のPADDINGを増やすなどの調整が必要になります。

(この部分はコードで自動化することもできますが、今回はそこまで作りこんでいません)

以下が最終的な結果となりました。

明るさ(コントラスト)の正規化

minmax正規化

現状は単純な文字データになりますので、グレースケール変換後にminmax正規化しました。

input_path = pathlib.Path('./output-resized')
output_path = pathlib.Path('./output-minmax')
output_path.mkdir(parents=True, exist_ok=True)

# クリップ処理
def ct(img):
    """
    値を0-255にclipして、typeをuint8にする
    """
    return np.clip(img,0,255).astype(np.uint8)

def minmax(img):

    # グレースケールに変換(輝度算出のため)
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 輝度の最大最小を計算
    mx = np.max(img_gray)
    mn = np.min(img_gray)

    # minmax正規化
    img_gray = (img_gray - mn) / (mx - mn) * 255

    # クリップ処理
    img_gray = ct(img_gray)

    return img_gray

for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

    img_minmax = minmax(img)

    # 出力
    cv2.imwrite(str(output_path.joinpath(p.name)), img_minmax)

以下のように明るさが違うものが同等になることを確認できました。

ご参考:ヒストグラム平坦化

参考までに普通の写真などで使われるヒストグラム平坦化も試してみました。

今回のような単純な文字データについては、逆にムラがでたり色がついてしまうため、

今回は不採用にしました。

input_path = pathlib.Path('./output-resized')
output_path = pathlib.Path('./output-clahe')
output_path.mkdir(parents=True, exist_ok=True)

def clc(img, cl=2.0, gsize=8):

    # チャネル分割 
    b1, g1, r1 = cv2.split(img)

    # 平坦化
    clahe = cv2.createCLAHE(clipLimit=cl, tileGridSize=(gsize,gsize))
    b2 = clahe.apply(b1)
    g2 = clahe.apply(g1)
    r2 = clahe.apply(r1)

    return ct(cv2.merge((b2,g2,r2)))

for p in tqdm(input_path.glob('*.jpg'), total=len(list(input_path.glob('*.jpg'))), ascii=True):

    img = cv2.imread(str(p))

    img_clahe = clc(img)

    # 出力
    cv2.imwrite(str(output_path.joinpath(p.name)), img_clahe)

参考までに処理結果は以下です。

まとめ

いかがでしたでしょうか。

個人的には、前処理のやり方に色々あり、OpenCVはそれに対して様々な機能を提供してて便利だと感じました。

この前処理が後段の機械学習システムに対して効果があるかどうかはまた別問題ですが、

可視化して色々と目視でチェックしながら進める必要があるなと感じました。

参考記事