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

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

手書き数字データに前処理を行う

前回「画像から手書き数字を切り出す - 薬剤師のプログラミング学習日記」で自分で用意した画像データから手書き数字の部分だけを切り出すことができました。あとはこの手書き数字を画像識別モデルに読ませて数字認識をさせたいところですが、切り出した画像データにはノイズ(意味のない特徴量)が入っていたり、認識させたい数字が画像の端に寄っていたりするものがあります。
そこでよりモデルの精度を高めるために、切り出した数字画像に前処理*1を行いました。


前処理の方針

最終的にはMNISTデータで学習したモデルに手書き数字の予測をさせたいので、MNIST画像に近い形を目指します。
<MNIST画像データセットの例>
f:id:enokisaute:20210101114414p:plain

具体的には、
・画像にある数字以外の部分を取り除く
・数字の大きさをだいたい同じにする
・数字の位置を画像の中心に補正する
を行います。

f:id:enokisaute:20210226000219p:plain
また、白紙(数字が書かれていない)と判定されたものはこの段階で削除しておきます。

画像をグレースケールで読み込んで2値化する

まずは画像から数字の輪郭を抽出しますが、OpenCVのチュートリアルにもあるように以下の点に留意します。

  • 精度よく抽出するために2値化画像を用いる。
  • OpenCVの輪郭検出は黒い背景から白い物体の輪郭を検出すると仮定している。
  • cv2.findContours() 関数は入力画像を変える処理である。

画像をグレースケールで読み込み→平滑化(ぼかし)→2値化→輪郭抽出と処理を進めていきます。グレースケール画像は輝度(明るさの度合い)のみで表現したもので、2値化画像はこのグレースケールよりもさらに情報を落として白黒の2色のみにして特徴量を際立たせたものです。

img_gray = cv2.imread(png_file, cv2.IMREAD_GRAYSCALE)
img_blur1 = cv2.GaussianBlur(img_gray, (11, 11), 0)
img_inv = cv2.threshold(img_blur1, 245, 255, cv2.THRESH_BINARY_INV)[1]

f:id:enokisaute:20210216004031j:plain
グレースケールで読み込んだ画像を2値化する前に、平滑化(ぼかし)処理をしています。元画像は字のかすれや枠線を消すためのマスク処理の影響で数字部分は「す」が入ったような状態ですが、画像をぼかすことでこのようなノイズをある程度除去することができます。
どのフィルタを使うかによっても輪郭抽出の結果は変わってきますが、ここではガウシアンフィルタcv2.GaussianBlur() を使いました。

2値化にはOpenCVの cv2.threshold()関数を使用します。第1引数はグレースケール画像、第2引数はしきい値、第3引数は最大値を指定し、第4引数はcv2.THRESH_BINARY_INVとすることでピクセル値がしきい値よりも大きければ黒(0)を割り当て、しきい値よりも小さければ白(255)を割り当てて、白黒の画像に変換しています。
試行錯誤の結果、しきい値を245としましたが事前のマスク処理で背景を白にしていたので、ほぼ「背景色でないところ」がしきい値という意味になります。事前の平滑化処理の影響もあって元の数字よりもやや太めではありますが、はっきりとした2値化画像が得られました。

輪郭の抽出

cv2.findContours() 関数を用いて輪郭を抽出します。

contours, hierarchy = cv2.findContours(
        img_inv, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

第1引数に入力(2値化)画像、第2引数の輪郭の抽出モードは最も外側の輪郭のみを抽出、第3引数の輪郭の近似方法は輪郭を圧縮して直線の端点のみを保持するよう指定しています。戻り値はPythonのリストとして出力される輪郭と、輪郭の階層情報の2つ*2ですが今回は階層情報は使いません。

画像中の輪郭一つ一つを正確に抽出するという意味では、事前の平滑化処理でカーネルのサイズをあまり大きくしない方が良かったのですが、数字がひとつの輪郭で抽出されず2つ以上の輪郭になっては後々都合が悪かったので(後述)、ある程度大きなサイズとしました。下図はガウシアンフィルタのカーネルを(5, 5)と(11, 11)で輪郭を抽出したときの比較です。
f:id:enokisaute:20210215005128p:plain

大津の二値化

ここから抽出した輪郭を使って、元画像を処理していきます。2値化画像はMNIST画像と比べると、数字が太すぎてやや不自然に見えます。実際、字の太さもモデルの精度には影響が大きいようでしたでこの2値化画像を使うということはしません(輪郭抽出のためだけに使用)。
というわけで、最初に読み込んだグレースケール画像をもう一度平滑化→2値化処理して、こちらを最終的に残す画像とします。

img_blur2 = cv2.GaussianBlur(img_gray, (5, 5), 0)
img_inv2 = cv2.threshold(img_blur2, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]

今度もガウシアンフィルタを使いますが、輪郭抽出のための平滑化よりもカーネルのサイズは小さくして(大きくすると太くなり過ぎるので)、2値化処理では大津の二値化を使用しました。2値化関数のしきい値には0を指定し、第4引数にはcv2.THRESH_OTSU も一緒に追加します。

大津の二値化は画像のヒストグラムにおける分布が双峰性を持つような画像において、その分離度が大きくなるように閾値の決定を自動的に行う処理です。

# グレースケール画像ヒストグラムの描画
fig, ax = plt.subplots()
hist_gr, bins = np.histogram(img_blur2.ravel(), 256, [0, 256])
ax.plot(hist_gr, '-b')
ax.set_xlim(0, 255)
ax.set_ylim(-10, 200)
ax.set_xlabel('pixel value')
ax.set_ylabel('number of pixels')
ax.set_title('グレースケール画像のヒストグラム')
plt.show()

f:id:enokisaute:20210217173526j:plain
(右は双峰性を持つヒストグラム。OpenCVチュートリアルより引用)
今回の数字画像(上図左)のように双峰性を持たない場合は、あまり良い結果が得られないとのことですが・・、どの画像も一律のしきい値よりも画像ごとに決定した方がよいかと思ってやってみたところ、識別モデルの精度もこちらの方が良かったので採用しました。

抽出した輪郭を使って画像の必要な部分のみを残す

数字そのものの輪郭である(はずの)最大の輪郭矩形の中に余計な部分が入っていれば、数字画像だけを残そうとして矩形を切り取っても余計な部分も一緒に切り取られてしまいます。
f:id:enokisaute:20210220185426j:plain
一定のサイズ以下の輪郭は除く、としていても数字が入っている矩形の中に入り込んでいればどうしようもありません。また、平滑化フィルタをかけることである程度ノイズを消すことができるとはいえ、大きなノイズを消そうとすると必要な部分にもフィルタの影響が強く出てしまうため無理があります。

そこで、輪郭領域が最大のもの以外は背景色で埋めてしまう、という方法を取ることにしました。

def fill_unnecessary_area(img, cntrs, back_color=0):
    # img内の輪郭cntrsを背景色で埋める
    for c in cntrs:
        x, y, w, h = cv2.boundingRect(c)
        img[y:(y + h), x:(x + w)] = back_color
    return img

呼び出し側はこちらです

    # 輪郭の中での領域面積が最大のものを取得
    max_area_idx = np.argmax([cv2.contourArea(c) for c in contours])
    # 最大面積の輪郭をcontoursから取り出してリストからは削除しておく
    max_area = contours.pop(max_area_idx)   
    tmp_img = fill_unnecessary_area(img_inv2, contours)  # 最大面積(数字)以外の矩形を埋める

抽出した輪郭のリストcontoursから最大の領域面積を持つインデックスを取得します。必要になるのはこのインデックスの値だけなので、次にpop()を使って値を取得し、contoursからその要素を削除しておきます。最後に、残ったcontoursを関数に渡して背景色で埋めています。
上図の「4」の例では次のようになります。
f:id:enokisaute:20210220193126p:plain
しかしこの方法は「数字に離れているパーツはない」ということが前提になっています。先に「5」が2つの輪郭で抽出された例を示しましたが、このように数字部分の輪郭が2つ以上で抽出されてしまうと、処理後は欠損した数字画像となってしまうので注意が必要です。

輪郭抽出前のガウシアンフィルタのカーネルのサイズを(11, 11)とやや大きく設定したのは数字の輪郭を複数にさせないためです。これくらいのサイズでガウシアンフィルタをかけておけば、少々離れた部分があったとしても数字そのものをひとつの輪郭として捉えることができました。
f:id:enokisaute:20210220213304p:plain


白紙画像の判定

画像の切り出しではループを回して画像の特定の領域から機械的に切り取っていっただけなので、数字が書かれていない枠の画像もあります。これは識別モデルで読む必要がないので、数字が書かれていないと判定した画像については、returnでNoneを返すようにして削除します。

def preprocess(png_file, blank=253, min_size=300,
                         padding=3, new_size=(88, 88)):

    img_gray = cv2.imread(png_file, cv2.IMREAD_GRAYSCALE)

    if np.sum(img_gray) / img_gray.size >= blank:
        return None
    # ----- 画像の輪郭を抽出する(省略) -----
    # 輪郭の中での領域面積が最大のものを取得
    max_area_idx = np.argmax([cv2.contourArea(c) for c in contours])  
    max_area = contours.pop(max_area_idx)   

    if cv2.contourArea(max_area) < min_size:
        # 最大面積の輪郭がmin_size未満なら削除
        return None

画像が”白紙”と判定するケースは2つとしました。1つは最初に画像を読み込んだときに全画素の平均が定数blank(253)以上だったとき、2つ目は輪郭中の最大の領域面積がmin_sizeより小さかったときです。

モルフォロジー変換

次は2値化画像にフィルタをかけて収縮(Erosion)、膨張(Dilation)といった処理を行います。
対象画像が白黒の2値化画像でデータが1か0しかないとすると、

  • 膨張処理:対象画像とフィルタでOR演算を行う。片方に1があれば出力は1。
  • 収縮処理:対象画像とフィルタでAND演算を行う。両方が1の場合のみ出力が1。

この2つを組み合わせるオープニング、クロージングという処理もあります。オープニングは収縮の後で膨張、クロージングは膨張の後で収縮させる処理で、どちらもノイズの除去に効果を発揮します。

この処理の後で指定したサイズにリサイズしますが、リサイズ(縮小する場合)後にこの処理を行うと数字部分が潰れてしまうため、先にしておく必要があります。
今回は膨張を2回した後で収縮という順にフィルタのサイズを変えてやってみました。いろいろ試してみましたが、これも結果の画像をMNISTで学習したモデルに読ませてみて決めました。

def morph_transformation(img):
    # ----- モルフォロジー変換 -----
    kernel_1 = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
    ret_img1 = cv2.dilate(img, kernel_1, iterations=2)  # 膨張
    kernel_2 = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))
    ret_img2 = cv2.erode(ret_img1, kernel_2)  # 収縮
    return ret_img2

処理による(輪郭で切り取った状態の)画像の変化を示しておきます。左から順にコードの処理を進めていったときの様子です。
f:id:enokisaute:20210222014419p:plain
白い数字部分の中の小さな黒い点が埋められているのが確認できます。

矩形の縦横比を保ったままリサイズする

MNISTの公式ページTHE MNIST DATABASE of handwritten digitsによると、『NISTの元の白黒(バイレベル)画像は、アスペクト比を維持しながら20×20ピクセルボックスに収まるようにサイズ正規化されています。』とあります。
ここでも同じように、取得した数字の輪郭の矩形のアスペクト比は変えずに、矩形の長い方の辺の長さが画像の大きさ(ここでは元々の大きさに近い88×88としています)のだいたい70%くらいになるよう*3調整してリサイズします。

# 輪郭抽出した矩形の縦横比を変えない最大の辺の長さ(横, 縦)を返す
def get_maxrect_size(w, h, side_length):
    size = round(side_length * 0.75)
    aspect_ratio = w / h
    if aspect_ratio >= 1:
        return size, round(size / aspect_ratio)
    else:
        return round(size * aspect_ratio), size
 
# 抽出した矩形のパラメータ(x, y, w, h)にpad分余白を持たせる
def padding_position(x, y, w, h, pad):
    return x - pad, y - pad, w + pad * 2, h + pad * 2

    tmp_img = fill_unnecessary_area(img_inv2, contours) 
    x, y, w, h = cv2.boundingRect(max_area)
 
    if x >= padding and y >= padding:
        x, y, w, h = padding_position(x, y, w, h, padding)
 
    # ----- モルフォロジー変換 -----
    tmp_img = morph_transformation(tmp_img)
    # ----- 矩形の縦横比を保ったままリサイズする -----
    cropped = tmp_img[y:(y + h), x:(x + w)]
    new_w, new_h = get_maxrect_size(w, h, new_size[0])
    new_cropped = cv2.resize(cropped, (new_w, new_h))

コードは画像の不要な部分を背景色で埋めたところからです。cv2.boundingRect()で輪郭の外接矩形の左上の位置(x, y),横と縦のサイズ(w, h)を取得した後、切り取り時に少し余裕を持たせるためx, y, w, hを新たに計算(padding分ゆとりを持たせるだけ)しています。なお、このpadding_position()関数は参考ページのものを使わせていただきました。
後は縦横比を求め、長い方の辺が画像(new_size)の大きさの70%くらい*4になるよう縦横の長さを求めて、リサイズしています。
f:id:enokisaute:20210221012732p:plain

画像の重心を平行移動させる

先ほどのMNISTのページでは『ピクセルの重心を計算し、この点が28x28フィールドの中心に位置するように画像を平行移動することで、画像を28x28画像の中心に配置しました』という記述があります。
ここではcv2.moments() 関数で2値化した数字画像の重心を求めて、その重心を画像の中心へ移動する、という方法で行いました。
f:id:enokisaute:20210225011746p:plain

def move_to_center(img, new_size):
    m = cv2.moments(img)
    # 重心
    cx = int(m['m10'] / m['m00'])
    cy = int(m['m01'] / m['m00'])
    # 移動量の計算
    tx = new_size[1] / 2 - cx
    ty = new_size[0] / 2 - cy
    # x軸方向にtx, y軸方向にty平行移動させる
    M = np.float32([[1, 0, tx], [0, 1, ty]])
    dst = cv2.warpAffine(img, M, new_size)
    return dst

関数には矩形で切り取った数字画像、new_sizeを与えます。

CNNではある程度の位置のズレに対してはロバスト(頑健)ですが、ただ単に『切り取った画像を上下左右から同じ距離の位置におく』という方法と比較すると、MNISTで学習したモデルにおいては精度に大きな差がみられました。

完成

結果の画像を実際に目で見て確認したかったので、元画像からそれほど変わらない大きさの88×88ピクセルになるよう前処理を施しました。MNISTで学習したモデルにはこれを28×28ピクセルにリサイズした画像(<前処理の方針>の画像)を読ませることになります。
f:id:enokisaute:20210225001930p:plain
次回はこれを識別モデルに読ませてみたいと思います。
www.yakupro.info


参考

機械学習のためのOpenCV入門 - Qiita
OpenCV-Python チュートリアル(画像の平滑化、画像のしきい値処理、OpenCVにおける輪郭(領域)、モルフォロジー変換)
カラー図解 Raspberry Piではじめる機械学習 基礎からディープラーニングまで (ブルーバックス)
・機械学習のための「前処理」入門


以下のコードの関数は今回の記事に関係のある部分だけ載せていますが、それ以外は過去記事
Pythonで画像の傾きを補正して水平にする
画像から手書き数字を切り出す
をご参照ください。プログラムもある程度大きなものになってきました。モジュールに分割したほうがいいかもしれません。

import glob, math, os
import numpy as np
import cv2
 
def fill_unnecessary_area(img, cntrs, back_color=0):
    # img内の輪郭cntrsを背景色で埋める
    for c in cntrs:
        x, y, w, h = cv2.boundingRect(c)
        img[y:(y + h), x:(x + w)] = back_color
    return img
 
# 抽出した矩形のパラメータ(x, y, w, h)にpad分余白を持たせる(Qiita [機械学習のためのOpenCV入門]より)
def padding_position(x, y, w, h, pad):
    return x - pad, y - pad, w + pad * 2, h + pad * 2
 
def morph_transformation(img):
    kernel_1 = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
    ret_img1 = cv2.dilate(img, kernel_1, iterations=2)  # 膨張
    kernel_2 = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))
    ret_img2 = cv2.erode(ret_img1, kernel_2)  # 収縮
    return ret_img2
 
# 輪郭抽出した矩形の縦横比を変えない最大の辺の長さ(横, 縦)を返す
def get_maxrect_size(w, h, side_length):
    size = round(side_length * 0.75)
    aspect_ratio = w / h
    if aspect_ratio >= 1:
        return size, round(size / aspect_ratio)
    else:
        return round(size * aspect_ratio), size
 
def move_to_center(img, new_size):
    m = cv2.moments(img)
    # 重心
    cx = int(m['m10'] / m['m00'])
    cy = int(m['m01'] / m['m00'])
    # 移動量の計算
    tx = new_size[1] / 2 - cx
    ty = new_size[0] / 2 - cy
    # x軸方向にtx, y軸方向にty平行移動させる
    M = np.float32([[1, 0, tx], [0, 1, ty]])
    dst = cv2.warpAffine(img, M, new_size)
    return dst

 
def preprocess(png_file, blank=253, min_size=300, padding=3, new_size=(88, 88)):
 
    img_gray = cv2.imread(png_file, cv2.IMREAD_GRAYSCALE)
 
    if np.sum(img_gray) / img_gray.size >= blank:
        # 白紙の場合は削除
        return None
 
    # ----- 画像の輪郭を抽出する -----
    img_blur1 = cv2.GaussianBlur(img_gray, (11, 11), 0)
    img_inv = cv2.threshold(img_blur1, 245, 255, cv2.THRESH_BINARY_INV)[1]
    contours, hierarchy = cv2.findContours(
        img_inv, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
    # ----- 最大面積の輪郭以外は背景色で埋める -----
    img_blur2 = cv2.GaussianBlur(img_gray, (5, 5), 0)
    img_inv2 = cv2.threshold(img_blur2, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
     
    max_area_idx = np.argmax([cv2.contourArea(c) for c in contours])    # 輪郭の中での領域面積が最大のものを取得
    max_area = contours.pop(max_area_idx)   # 最大面積の輪郭をcontoursから取り出して削除しておく
 
    if cv2.contourArea(max_area) < min_size:
        # 最大面積の輪郭がmin_size未満なら削除
        return None
 
    tmp_img = fill_unnecessary_area(img_inv2, contours)
    x, y, w, h = cv2.boundingRect(max_area)
 
    if x >= padding and y >= padding:
        x, y, w, h = padding_position(x, y, w, h, padding)
 
    # ----- モルフォロジー変換 -----
    tmp_img = morph_transformation(tmp_img)
    # ----- 矩形の縦横比を保ったままリサイズする -----
    cropped = tmp_img[y:(y + h), x:(x + w)]
    new_w, new_h = get_maxrect_size(w, h, new_size[0])
    new_cropped = cv2.resize(cropped, (new_w, new_h))
    # ----- 重心を画像のセンターへ移動 -----
    dst_img = move_to_center(new_cropped, new_size)
    return dst_img
 
 
if __name__ == '__main__':
    current_dir = os.path.dirname(os.path.abspath(__file__))
    jpg_dir = '/jpeg/*'    # スキャンした画像データを入れておく
 
    # jgpファイルをソートしてリストに取得
    jpg_files = get_sorted_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")
        angle = calc_rotation_angle(left_pos, right_pos)
        img = rotate_img(cv2.imread(jpg_file), angle)
        # ----- 画像中の数字の切り出し処理 -----
        pos_array = detect_makers_position(img, './template/square.jpg')
        img = delete_grid(img)  # 枠線の消去
        # 切り出したファイルを保存するためpngフォルダを作成する
        output_path = make_output_dir('clipped_png', jpg_file)
        png_files = clip_num(img, pos_array)

        for f_name, png in png_files.items():
            png_file = os.path.join(output_path, f_name)
            cv2.imwrite(png_file, png)
            # ----- 前処理 -----
            processed_png = preprocess(png_file)
            if processed_png is None:
                os.remove(png_file)     # 白紙と判定された画像は削除
            else:
                cv2.imwrite(png_file, np.float32(processed_png))

*1:前回記事でも背景をマスク処理で除くなど前処理に含まれるものもありましたが

*2: OpenCV 4以降では戻り値が3つから2つに変更されています。

*3:MNIST画像は28×28なので約70%で20×20

*4:paddingでゆとりを持たせたため×0.75としました。