OpenCV: 2値化

前回・前々回と引き続き、OpenCVを触っていきます。

2値化

画像はチャネル (グレー画像であれば1チャネル, RGB画像であれば3チャネル) ごとに階調を持っており、一般的に256階調になります。

これを2階調、つまり白・黒に変換する処理のことを2値化と言います。画像中の特定の際立った情報を抽出したいときに用いられ、特に文字認識タスクなどの前処理としてよく登場します。

変換処理は2段階で行います。

  1. 画像をグレースケール画像に変換
  2. 各画素に対して、閾値以上であれば白 (=255) 、閾値未満であれば黒 (=0) に変換する

このしきい値をどうやって決めるかによって、大きく2パターンに分類されます。 - 画像中で唯一の閾値を用いる (大局的閾値) - 画像中の画素ごとに異なる閾値を用いる (適応的閾値)

まず、今回使う画像を読み込み、グレースケールに変換します。

import numpy as np
import matplotlib.pyplot as plt
import cv2

# サンプル画像
input_file_path = "./yagi.jpg"

# 画像のロード
img = cv2.imread(input_file_path)
img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)  # グレースケールで読み込み
print(img.shape)  #  (400, 400)

f:id:ohke:20190817155636p:plain

大局的閾値による2値化

大局的閾値の場合、cv2.thresholdを使います。

ここでは閾値を127 (2番目の引数) に設定してます。

  • 3番目の引数 (maxval) は、変換後の最大値
  • 4番目の引数 (type) は、変換処理の種類でいくつか用意されてます
    • cv2.THREASH_BINARY_INVとすると白黒反転します
_, output = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)

全体的に暗めの写真なので、127で切ると少し暗い画像になります。

f:id:ohke:20190817160251p:plain

画素値の非ストルグラムをみますと、多くの画素は127より小さいことがわかります。
このように全体的に暗い (明るい) などで、画素値の偏りが大きい場合があり、固定の閾値ではうまく欲しい情報を抽出できなくなります。画素値の分布をプロットしてみるとよくわかります。

f:id:ohke:20190817161248p:plain

画素値の分布を考慮して閾値を決めるアルゴリズムの1つに、大津の2値化があります。
ある閾値で2つのクラスに分類したときに、クラス内の分散が最小となる閾値を選択する方法です。画素値の分布に双峰性があると仮定してます。

cv2.thresholdの3番目の引数で、通常のcv2.THRESH_BINARYcv2.THRESH_OTSUを加えることで、大津のアルゴリズムで閾値を設定してくれます。

1番目の返り値が設定された閾値で、87となってます。上の分布を見ると、たしかに2つの山のちょうど谷間で設定できてそうです。

th, output = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(th)  # 87.0

出力された画像を見ると、明るくなって目などは縁取られてます。

f:id:ohke:20190817161810p:plain

適応的閾値による2値化

同一の画像内でも、光の当たり具合などによってうまく欲しい情報を取り出せないことがあります。
大津の2値化画像を見ると、ヤギのおでこのあたりの輪郭に比べると、顎部分の輪郭はそれほど明確ではないです。地面の色が明るく、明暗差が小さいためです。

適応的閾値では、変換対象画素の周辺 (正方形領域) の画素値の分布によって閾値を決めます。これによって同一画像内 (より正確には全ての画素) で異なる閾値によって2値化します。

OpenCVではcv2.adaptiveThresholdによって提供されています。

  • 2番目の引数 (adaptiveMethod) で、閾値の計算方法を指定しています
    • cv2.ADAPTIVE_THRESH_MEAN_C: 周辺画素の平均値を閾値にする
    • cv2.ADAPTIVE_THRESH_GAUSSIAN_C: ガウス窓で重み付けされた値で平均値を計算されます
      • 近い画素ほど重要な情報であるため
  • 4番目の引数 (blockSize) で、閾値の計算で用いる周辺領域のサイズを指定
    • blockSize × blockSizeが周辺領域となる
  • 5番目の引数 (C) は閾値を下げる定数となっています (0以上の値が一般的)
output = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 101, 2
)

顎の部分に着目していただいても分かる通り、全体的に明暗差が強調され、輪郭や毛並みがはっきり現れるようになっています。

f:id:ohke:20190817164710p:plain

周辺領域が狭すぎると、全体的にギザギザした画像になります。101x101 -> 11x11で狭めたのが下の画像です。

output = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY,11, 0
)

f:id:ohke:20190817164741p:plain

引数Cを設定した例です。閾値が-5されますので、全体的に明るくなってます。

output = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 101, 5
)

f:id:ohke:20190817165206p:plain

cv2.ADAPTIVE_THRESH_GAUSSIAN_Cを試した例です。遠くの画素の影響を受けづらくなりあますので、周辺領域を狭くしたときに近いギザギザも見られます。

output = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 101, 0
)

f:id:ohke:20190817165354p:plain

まとめ

OpenCVでいろいろな2値化を試しました。

OpenCV: Optical Flowで物体の動きをトラックする

先週に引き続き、OpenCVを使っていきます。

Optical Flow

Optical Flowを使って、物体の動きを検出していきます。OpenCVのチュートリアルと↓の記事を参考にしました。

Optical Flowは動画から切り出されたフレーム画像間の特徴点の差分 (= 動き) をベクトル化することで、主に動画内の物体の動きのトラッキングなどに用いられます。
車が走っている動画1から2枚のフレームを切り出したとき、車の動きを左下へのベクトルとして表現します。

Optical Flowには2つのポイントがあります。

  1. 特徴点をどうやって検出するか
  2. フレーム間の特徴点の一致をどうやって判別するか

Optical Flowの特徴点の検出では、Shi-Tomasiのコーナ検出がよく使われるそうです。 (提案論文2のタイトルもそのものズバリの"Good Features to Track"です。ストレート。)

ベースとなっているのはHarris3のコーナ検出です (Harrisのコーナ検出についてはこちらのPDFの解説が詳しいです) 。Harrisのコーナ検出では最後に得られる2つの固有値が大きい画素 (複数のエッジ = コーナ) として判定しています。Shi-Tomasiはこの閾値の計算方法が異なるだけです。

特徴点の一致を判定する方法として、今回はLucas-Kanade法4を使いました。以下の条件に基づいて、フレーム前後で特徴点をトラックします。こちらのスライドが参考になります。

  • 特徴点の周囲の画素も同様に動く (空間的整合性) と仮定している
  • ピラミッド (解像度が1, 1/2, 1/4, ... となる画像の集合) を使って、大きな動きも小さな動きに変換することで追随させる

実装

それでは実装していきます。最初に動画をロードします。

  • OpenCVではVideoCaptureクラスで動画ファイルを扱います
import numpy as np
import matplotlib.pyplot as plt
import cv2
from google.colab.patches import cv2_imshow  # Google Colaboratory用

# NHKクリエイティブライブラリの動画
# https://www2.nhk.or.jp/archives/creative/material/view.cgi?m=D0002060316_00000
file_path = "D0002060316_00000_V_000.mp4"

# 動画ファイルのロード
video = cv2.VideoCapture(file_path)

特徴点抽出

最初のフレームで特徴点を抽出します。動画は150フレーム目から210フレーム目まで使用します (合計60フレーム) 。

  • setメソッドで、1つ目の引数に定数CAP_PROP_POS_FRAMES、2つ目の引数に取得したいフレームインデックスを指定
  • Shi-TomashiのアルゴリズムはgoodFeaturesToTrackメソッドで実装されてます
    • パラメータはコメントのとおりです
# 150フレームから210フレームまで5フレームごとに切り出す
start_frame = 150
end_frame = 210
interval_frames = 5
i = start_frame + interval_frames

# 最初のフレームに移動して取得
video.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
ret, prev_frame = video.read()

# グレースケールにしてコーナ特徴点を抽出
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_RGB2GRAY)

feature_params = {
    "maxCorners": 200,  # 特徴点の上限数
    "qualityLevel": 0.2,  # 閾値 (高いほど特徴点数は減る)
    "minDistance": 12,  # 特徴点間の距離 (近すぎる点は除外)
    "blockSize": 12  # 
}
p0 = cv2.goodFeaturesToTrack(prev_gray, mask=None, **feature_params)

# 特徴点をプロットして可視化
for p in p0:
    x,y = p.ravel()
    cv2.circle(prev_frame, (x, y), 5, (0, 255, 255) , -1)
    
cv2_imshow(prev_frame)

抽出後の画像を見ると傾向がわかります。

  • 文字 (左上) や木や枝はコーナーが多いので、そういった部分は特徴点として抽出されやすくなってます
  • 自動車もボンネットとバンパーの間、フェンダの折目など、カーブしている部分が抽出されてます

f:id:ohke:20190810215828p:plain
150フレーム目 (最初のフレーム)

Optical Flow

最後にOptical Flowの実装です。

  • Lucas-Kanade法はcalcOpticalFlowPyrLKで実装されてます
    • 前後のフレームと前のフレームの特徴点を渡すと、後のフレームにおいて対応する各特徴点のインデックスが返されます
      • 対応する特徴点の有無は2つ目の返り値で判定 (0:無い, 1:ある)
    • パラメータはコメントのとおりです
# OpticalFlowのパラメータ
lk_params = {
    "winSize": (15, 15),  # 特徴点の計算に使う周辺領域サイズ
    "maxLevel": 2,  # ピラミッド数 (デフォルト0で、2の場合は1/4の画像まで使われる)
    "criteria": (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03)  # 探索アルゴリズムの終了条件
}

# 可視化用
color = np.random.randint(0, 255, (200, 3))
mask = np.zeros_like(prev_frame)

for i in range(start_frame + interval_frames, end_frame + 1, interval_frames):
    # 次のフレームを取得してグレースケールにする
    video.set(cv2.CAP_PROP_POS_FRAMES, i)
    ret, frame = video.read()
    frame_gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
    
    # OpticalFlowの計算
    p1, status, err = cv2.calcOpticalFlowPyrLK(prev_gray, frame_gray, p0, None, **lk_params)
    
    # フレーム前後でトラックが成功した特徴点のみを
    identical_p1 = p1[status==1]
    identical_p0 = p0[status==1]
    
    # 可視化用
    for i, (p1, p0) in enumerate(zip(identical_p1, identical_p0)):
        p1_x, p1_y = p1.ravel()
        p0_x, p0_y = p0.ravel()
        mask = cv2.line(mask, (p1_x, p1_y), (p0_x, p0_y), color[i].tolist(), 2)
        frame = cv2.circle(frame, (p1_x, p1_y), 5, color[i].tolist(), -1)
    
    # 可視化用の線・円を重ねて表示
    image = cv2.add(frame, mask)
    cv2_imshow(image)

    # トラックが成功した特徴点のみを引き継ぐ
    prev_gray = frame_gray.copy()
    p0 = identical_p1.reshape(-1, 1, 2)

トラックした結果は以下です。

180フレーム目 (特徴点抽出から30フレーム経過後) を見ると、自動車の特徴点は自動車とともに動いていますが、風景の特徴点は動かずそのままであることがわかります。

f:id:ohke:20190810192503p:plain
180フレーム目

さらに30フレームが経過した210フレーム目を見ると、今度は自動車と風景の特徴点が重なり、異なるところにトラックされている点が多いことがわかります。また自動車の動きから外れてしまった特徴点なども見られます。
ある程度のフレーム数が経過したら特徴点を取り直す、といった工夫が必要なようです。

f:id:ohke:20190810192610p:plain
210フレーム目

まとめ

今回はOpenCVを使ってOptical Flowで物体の動きをトラッキングしました。


  1. NHKクリエイティブ・ライブラリーの動画を使用しました。https://www2.nhk.or.jp/archives/creative/material/view.cgi?m=D0002060316_00000

  2. Shi, Jianbo & Tomasi, Carlo. (2000). Good Features to Track. Proceedings / CVPR, IEEE Computer Society Conference on Computer Vision and Pattern Recognition. IEEE Computer Society Conference on Computer Vision and Pattern Recognition. 600. 10.1109/CVPR.1994.323794.

  3. Chris Harris and Mike Stephens. A combined corner and edge detector. In Proc. of Fourth Alvey Vision Conference. 1988.

  4. B. D. Lucas, T. Kanade, et al. An iterative image registration technique with an application to stereo vision. In IJCAI, 1981.

OpenCV: 特徴点抽出とマッチング

お仕事で初めて画像処理システムの開発に携わってます。
基本的なツールとしてOpenCVについて知っておいた方が良さそうですので、自分用のメモとしてトピックごとに整理していこうと思います。

OpenCV

言わずと知れたコンピュータビジョンのOSSライブラリですね。(そう言っておきながら、自分はほとんど使ったことがなかったのですが。)

opencv.org

インストールは多少ハマるかも知れません。こちら↓の記事などを参考にしてくださいませ。ちなみにGoogle Colaboratoryだとデフォルトでインストールされてます。

画像のロード

準備としてサンプル画像をロードします。

import numpy as np
import matplotlib.pyplot as plt
import cv2

# サンプル画像
input_file_path = "yagi.jpg"
output_file_path = "tmp.jpg"

# 画像のロード
img = cv2.imread(input_file_path)
print(img.shape)  # (400, 400, 3)

# 画像の表示
def display(img, output_file_path=output_file_path):
    cv2.imwrite(output_file_path, img)
    plt.imshow(plt.imread(output_file_path))
    plt.axis('off')
    plt.show()
    
display(img)

いつものアイコンですね。

f:id:ohke:20190803133350p:plain

特徴点抽出

特徴点抽出はその名の通り、画像の中から"特徴的な"ポイントを抽出するアルゴリズムです。使われる特徴としては角 (コーナー) が多いようですが、輝度の勾配なども使われるそうです。

コーナーを検出して特徴点とするアルゴリズムの1つに、SIFT (scale-invariant feature transformation, 提案論文) があります。
従来の手法 (HarrisやShi-Tomasiなど) と比較して、拡大・縮小しても1つのコーナーとして検出される頑健さを持ってます。チュートリアル (SIFT (Scale-Invariant Feature Transform)の導入 — OpenCV-Python Tutorials 1 documentation) に詳しい説明があります。

  1. M x Nの元画像をM/2 x N/2, M/4 x N/4, ... と低解像度にした画像 (オクターブ) を生成します (参考)
    • こうしてできたオクターブの組み合わせをガウシアンピラミッドと呼びます
  2. 各オクターブに複数のσ (σ, kσ, ...) のガウシアンフィルタ (≒ぼかし) をかけて差分を取ります (difference of Gaussians: DoG)
    • 輪郭抽出に近い働きをします
  3. 各差分画像の画素の極大値をキーポイント (=特徴点) 候補とします
    • 同一画像の近傍8画素 + オクターブの上下18画素で極大値となる画素を選択します
  4. エッジや低コントラストの候補を閾値で除外して最終的なキーポイントを決定します

OpenCVでの実装ですが、今回はSIFTの改良であるAKAZEを使います。

  • SIFTではエッジもぼかしてしまっていたものを、非線形フィルタで特徴抽出することで精度を改善したKAZE
  • KAZEを高速化したAKAZE (Accelarated KAZE)

AKAZEについてはこちら↓が参考になります。

実装ではグレースケール画像からdetectAndComputeメソッドでキーポイントと特徴量記述子を得ています。キーポイントの近傍画素のヒストグラムを持った特徴量となっており、この後のマッチングで使用します。

# グレースケール変換
from_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)

# 特徴点抽出 (AKAZE)
akaze = cv2.AKAZE_create()
from_key_points, from_descriptions = akaze.detectAndCompute(from_img, None)

# キーポイントの表示
extraceted_img = cv2.drawKeypoints(from_img, from_key_points, None, flags=4)
display(extraceted_img)

画像に検出された特徴点をマッピングしたのが下の図です。目の端や口元、角の根元などが特徴点として抽出されていることがわかります。(ちょっとエモい。)

f:id:ohke:20190803134137p:plain

マッチング

2枚の画像の特徴点を対応付けることをマッチングと言います。マッチングができると、同じ物体であることの認識や物体の動きの検出などが可能になります。

2枚の画像から得られた特徴量記述子の距離を計算し、最も近いものを同一のキーポイントとする方法があります。OpenCVではBFMatcherでそれができます。こちらもチュートリアル(特徴点のマッチング — OpenCV-Python Tutorials 1 documentation) に詳しく記載があります。
BFMatcher_createでは2つの引数を取ります。1つ目は距離の計算方法で、AKAZEの場合バイナリ値となるのでハミング距離を指定してます。2つ目の引数はcrossCheckで、Trueにすると互いの距離が最も近いキーポイント同士のみが紐付けられるようになります (デフォルトFalseでは、一方のキーポイントから見ると最も近いが、他方のキーポイントから見ると他のキーポイントの方が近いという非対称が起こります) 。

def match(from_img, to_img):
    # 各画像の特徴点を取る
    from_key_points, from_descriptions = akaze.detectAndCompute(from_img, None)
    to_key_points, to_descriptions = akaze.detectAndCompute(to_img, None)
    
    # 2つの特徴点をマッチさせる
    bf_matcher = cv2.BFMatcher_create(cv2.NORM_HAMMING, True)
    matches = bf_matcher.match(from_descriptions, to_descriptions)
    
    # 特徴点を同士をつなぐ
    match_img = cv2.drawMatches(
        from_img, from_key_points, to_img, to_key_points, 
        matches,  None, flags=2
    )
    
    return match_img, (from_key_points, from_descriptions, to_key_points, to_descriptions, matches)

元画像を変形してキーポイントをマッチングした結果を示します。

同じ画像

f:id:ohke:20190803235322p:plain

リサイズした画像

f:id:ohke:20190803235345p:plain

回転した画像

f:id:ohke:20190803235407p:plain

一部をクロッピングした画像

f:id:ohke:20190803235425p:plain

同じ画像であれば変形が入ったとしても概ねマッチングできているようです。

まとめ

今回はOpenCVを使って特徴点抽出とマッチングを行いました。