からあげ博士の日常と研究と

博士課程を満期退学した人が好きなことを好きなままに書くところ。

pythonとOpenCVを使って画像から輪郭を抽出する ―― 100円玉に関する種々の画像解析

 こんにちは。からあげ博士(@phd_karaage)です。前回OpenCVを使った記事から相当な日数が経ってしまいました。100円玉を使った画像解析はこれくらいまではやりたいと思っていたところなので、ようやく終着点という感じでしょうか。もっと解析のやりようがあるとは思いますがとりあえずはここまで。

 という訳で今回はこの100円玉の輪郭を解析したいと思います。これまでの記事で紹介してきた100円玉の画像ですね。

www.phd-karaage.com

 前回の記事ではこの100円玉の画像を二値化することまで至りました。RGB画像のRだけ取り出して大津の二値化を適用すると結構綺麗に切り抜けるということが分かりましたね。もちろんこの画像では、という条件がありますが、皆さんは自分が解析したい対象に合わせてどの色を取り出すかなど考えてみるといいかもしれません。もっとも一番最初にやるべきはグレースケール画像でトライしてみることですが。

目次

データが得られたところから。

import cv2
import matplotlib.pyplot as plt

file_path = "E://DSC_1387.JPG"
img = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)

img_R = img[:, :, 2] ##BGR配列で読み込まれている
val_4, th_4 = cv2.threshold(img_R, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY)
plt.imshow(th_4)

 前回の記事ではこれで良好な二値化画像が得られたことが分かりましたね。実際に得られた100円玉の二値化画像はこんな感じ。

 そしてOpenCVで輪郭を抽出するときには、黒い背景から白い物体を抽出するということを行います。勘のいい方はお気づきですね。このままth_4を輪郭の解析に回すことはできませんね。この画像では背景が白(値が255)、物体が黒(値が0)となっています。という訳で逆転させてあげる必要があります。逆転するにはこんな感じのコードで行ける。

val_5, th_5 = cv2.threshold(img_R, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)
plt.imshow(th_5)

 cv2.threshold()の引数を変えてあげることで逆になったものを得ることができます。最後のcv2.THRESH_BINARYcv2.THRESH_BINARY_INVとしてあげればよいのです。実際の値もどのように書き換わったか見てみましょう。変数エクスプローラでth_5の中身をなんとなく見てみます。

 背景は0、物体が255となっていますね。なんとなくmatplotlibの結果を眺めてどの辺の座標が境界線か確認してみただけですが。

cv2.findContors()で輪郭検出

 輪郭検出の実際は、cv2.findContours()で行います。まさにそのままの名前ですね。輪郭を検出するぞという強い意志を感じます。

contours, hierarchy = cv2.findContours(th_5, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)

 ここでは何をやっているのかというと、th_5の輪郭を見つけてね。輪郭の階層構造は無視していいよ。輪郭の点はすべて取ってね。近似はしないぞ。ということをやっています。引数の中身は結構単純ですね。

 返り値は、contourshierarchyとなっていて、輪郭の点と、階層構造が返ってくることになります。今回階層構造は無視しているので以下のように書くこともできますね。

contours, _ = cv2.findContours(th_5, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)

 これは単純にhierarchyにあたる返り値は捨てるぞ、ということを意味しています。

OpenCVのバージョンに要注意

 どのバージョンだったか忘れましたが、このcv2.findCoontours()の返り値が変わっています。pythonでOpenCVを勉強している人なら必ず1回は見るであろうこのチュートリアルには過去の返り値のやり方が記載されています。

labs.eecs.tottori-u.ac.jp

 このページに記載されているcv2.findContours()の返り値はimage, contours, hierarchyで構成されていますが、いつの間にかimageにあたる返り値が削除されていました。バージョンを上げたタイミングで動かなくなって焦った記憶があります。

 少なくともここ1年以内くらいのタイミングでOpenCVを導入しているなら、このトラップに引っかかる可能性がありますのでご確認くださいませ。

輪郭を描きだす

 とりあえずcv2.findContours()を実行することで、輪郭情報がcontoursに保存されています。このcontoursにはほしい輪郭だけ入っているでしょうか?答えは否です。先ほど得られた輪郭を描きだしてみましょう。輪郭を描き込むには、cv2.drawContours()を使うことで実行が可能です。

import numpy as np
cnt_img = cv2.drawContours(np.zeros(img.shape), contours, -1, (0,255,0), 3)
plt.imshow(cnt_img)

 どの画像に描きこむか、そしてどの輪郭情報を用いるか、線の色と太さを引数で指定しています。今回輪郭情報はcontoursであり、-1ですべての輪郭を描きこんでほしいと引数で指定しています。そして輪郭の線は緑だと指定しています。

 今回は背景が黒の画像に描きこみたかったので、すべてが黒色の画像を用意しています。これは単純にnp.zeros(img.shape)ですべてが0でできた行列を、img.shapeの形で造ってほしいと指定しているだけです。

 そして得られた画像はこちら。

 円形の輪郭だけではなく、内部の輪郭、100円玉の構造がなんとなくわかる形で取得されていることが分かりますね。

 もちろんnp.zeros(img.shape)の中身をimgとしてあげることで、100円玉の画像に輪郭を描画することも可能です。

 いずれにせよこのままでは、100円玉の輪郭を抽出したとは言い切れませんね。

contoursの中身を精査する

 contoursの中身には輪郭を構成する全点が、リストの中に行列として格納されています。ということは、点が多い輪郭が外側の輪郭を表しているのではないかと考えることができますね。

 ということで、点の多い輪郭を無理やり抽出してみましょう。

cnt = np.asarray(0)

for i in range(len(contours)):
    contour_i = contours[i]
    contour_count = np.sum(contour_i.shape)
    if contour_count > np.sum(cnt.shape):
        cnt = contour_i

 かなり泥臭いことをやっています。そして多分これよりやりやすい手法はあるはず。

 なにをやっているかと言えば、初期値に中身のない行列を用意し、あとはcontoursの中身について、.shapeの和を取っては比較し続けるということをしています。

 それでは実際に得られた輪郭を描画してみてみましょう。

cnt_img = cv2.drawContours(np.zeros(img.shape), [cnt], -1, (0,255,0), 3)

plt.imshow(cnt_img)

 このように100円玉の外側の輪郭が取れていることが分かります。もちろんこの手法は二値化が上手く行っていること前提です。失敗していると変な外側の輪郭を取ってしまう、ということも考えられますね。

輪郭の特徴量を求める

 ここで輪郭が求まったところで、輪郭が示す領域の特徴量を見てみましょう。ここでは面積と周囲長です。ただしそれぞれは平方メートルや、メートル単位で返ってくる訳ではなく、ピクセル数で返ってくることに要注意です。当然ですね。ここには1ピクセルが何ミリorセンチメートルかという情報がありません。

area = cv2.contourArea(cnt)

print(area)

perimeter = cv2.arcLength(cnt, True)

print(perimeter)

 それぞれ専用の関数があります。cv2.contourAreacv2.arcLengthです。引数にそれぞれ輪郭の点を入れ、cv2.arcLengthでは、Trueを指定することで閉じた輪郭であることを示しています。

 このように値が返ってきます。これはピクセル数で返ってきていますから、もしスケールがあるならばそのスケールでさらに計算をしてあげることでスケールに補正された形で値を取ることができます。

各引数の解説はいつかやりたい。

 輪郭を抽出する一通りの方法を紹介しました。ただ、cv2.findContours()の引数なんかはもう少し詳細に説明できる部分があったりします。この部分はいつか説明できればいいなあと思っています。それはこの記事に追記するのか、独立な別記事を仕立てるのかはまだ考えてすらいない。