このノートについて

ロボットアームを使ったピックアンドプレース作業に必要な画像中の物体の位置姿勢推定を手を動かしながら学習しましょう。画像処理でよく使う表色系変換、領域分割、ハフ変換などのテクニックについて解説します。

Written by Yosuke Matsusaka (MID Academic Promotions, Inc. @yosuke)

このノートはCreative CommonsのBY-SAライセンスで公開します。CC BY-SA

準備

このノートはipython notebookを使った手を動かしながら学べる教材になっています。Ubuntu上では

sudo apt-get install ipython-notebook python-matplotlib python-opencv
ipython notebook --pylab inline

と入力することで環境を立ち上げることができます。

ipython notebookを立ち上げて新規ノートを作成したら、まずグラフ描画と数式処理のライブラリを読み込みます。

In [1]:
import cv2
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

以下も入力してください。この部分はアニメーションGIFを表示するためのおまじないです。

In [2]:
# Part of code is taken from: http://nbviewer.ipython.org/url/jakevdp.github.io/downloads/notebooks/AnimationEmbedding.ipynb
from tempfile import NamedTemporaryFile

GIF_TAG = """<img src="data:image/gif;bogus=ABCDEF;base64,{0}">"""

def anim_to_html(anim):
    if not hasattr(anim, '_encoded_video'):
        with NamedTemporaryFile(suffix='.gif') as f:
            anim.save(f.name, writer='imagemagick', fps=4)
            video = open(f.name, "rb").read()
        anim._encoded_video = video.encode("base64")
    return GIF_TAG.format(anim._encoded_video)

animation.Animation._repr_html_ = anim_to_html

今回使う画像は以下のシミュレーションによって生成したものです。

In [3]:
from IPython.display import Image
Image(url="http://devrt.tk/build/repo/54237a608ec2f07bded7cf2d.gif")
Out[3]:

シミュレーション実行のために必要なデータと、ベルト上を流れる物体を仮想カメラから撮影した連続画像のデータを以下のレポジトリで公開しています。手を動かしたい方はgitでクローンしてダウンロードしてください。

git clone https://github.com/devrt/world-robot-picking.git
cd world-robot-picking
git submodule init
git submodule update

物体を撮影した連続画像を読み込みます。最終的には連続画像を処理するのですが、解説と検討については途中(4フレーム目)の静止画を使って行います。

In [4]:
imgs = []
for i in range(1, 10):
    imgs.append(cv2.imread('../cap%i.ppm' % i))
img = imgs[4]

グラフ領域を初期化します。このグラフ上で画像処理の結果を確かめていきます。

In [5]:
fig = plt.figure()
im = plt.imshow(imgs[0])

連続画像をアニメーションで表示すると以下のようになります。ベルトの上を複数の物体が右から左に流れていく様子がわかると思います。

In [6]:
i = 0
def animate(*args):
    global i, im, imgs
    im.set_array(imgs[i])
    i = i + 1
    return im,
animation.FuncAnimation(fig, animate, frames=8, blit=True)
Out[6]:

表色系の変換

これからベルト上を流れる物体の位置姿勢を画像処理によって認識していくわけですが、まずはベルトと物体を区別することを考えます。

今回は深度情報の得られない通常のカラーカメラを使うため、色を使って物体を区別します。

一言に「色」と言っても、その扱いは思ったより難しいことが多くあります。上のアニメーションを見てもそれぞれの物体によって色は異なりますし、物体の側面に影ができて色が変化しています。今回はシミュレータで生成した画像を使いますが、実際の画像認識では照明のムラからできるベルト面の色変化にも対応しなければなりません。

そのようなときに便利に使えるのが「表色系」です。通常のカラー画像は光の三原色である赤・緑・青の混合で色を表現する「RGB表色系」が使われますが、画像処理の場合、色相・彩度・明度で色を表現する「HSV表色系」がよく使われます。

今回は画像処理にOpenCVを使いますが、OpenCVでは以下の関数を使って表色系を変換することができます。

In [7]:
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)

変換後のHSV画像から「色相」を見てみます。各物体の色の特徴が出ています。元の画像からは気づきませんでしたがベルト面が少し赤みがかっていた(赤い物体と同色だった)ことが分かります。

In [8]:
plt.imshow(hsv[:, :, 0])
Out[8]:
<matplotlib.image.AxesImage at 0x7fe15a987dd0>

次は「彩度」です。地味な色のベルト面とそれに比べて鮮やかな物体の色の特徴がきれいに出ています。この特徴を使えば物体をうまく切り出すことができそうです。

In [9]:
plt.imshow(hsv[:, :, 1])
Out[9]:
<matplotlib.image.AxesImage at 0x7fe158242210>

最後に「明度」も見てみましょう。今回はこの情報は使いませんが、物体の面に応じた照明の反射の様子をきれいに見ることができます。

In [10]:
plt.imshow(hsv[:, :, 2])
Out[10]:
<matplotlib.image.AxesImage at 0x7fe158178550>

2値化と領域分割

HSV表色系の「彩度」を使うと物体をうまく切り出せそうなことがわかったので早速作業してみましょう。

物体を切り出すにはまずある閾値(今回は100)で画像を2値化します。元の画像は8bit色(256階調)なのですが、2値化処理を行うと文字通り2色になります。

In [11]:
ret, mask = cv2.threshold(hsv[:, :, 1], 100, 255, cv2.THRESH_BINARY)
plt.imshow(mask)
Out[11]:
<matplotlib.image.AxesImage at 0x7fe1580aead0>

2値化した画像を1(赤)になっている島ごとに切り分けます。このような処理のことを「領域分割」と言います。OpenCVでは以下の関数を使います。

In [12]:
help(cv2.findContours)
Help on built-in function findContours in module cv2:

findContours(...)
    findContours(image, mode, method[, contours[, hierarchy[, offset]]]) -> contours, hierarchy

OpenCVのfindContours関数には適用する領域分割アルゴリズムにより様々なオプションがあるのですが、今回は以下の標準的な設定を使います。

In [13]:
blob, idx = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

領域分割を行った結果を確認します。無事に3つの領域に分割できているようです。

In [14]:
shape(blob)
Out[14]:
(3,)

試しに2番目の領域を画像として表示してみましょう。

In [15]:
mask = np.zeros(img.shape[:-1], np.uint8)
cv2.drawContours(mask, blob, 1, 255, -1)
plt.imshow(mask)
Out[15]:
<matplotlib.image.AxesImage at 0x7fe14b7d8e50>

上記の切り出した画像を元の入力画像にマスクとして掛けてあげると、緑の物体だけを抜き出して表示することができます。

In [16]:
plt.imshow(cv2.bitwise_and(img, img, mask=mask))
Out[16]:
<matplotlib.image.AxesImage at 0x7fe14b70fe90>

1番目の領域は青の物体に対応しているみたいですね。

In [17]:
mask2 = np.zeros(img.shape[:-1], np.uint8)
cv2.drawContours(mask2, blob, 0, 255, -1)
plt.imshow(cv2.bitwise_and(img, img, mask=mask2))
Out[17]:
<matplotlib.image.AxesImage at 0x7fe14b651650>

領域分割ができると物体の様々な情報を得ることができるようになります。

領域の重心(=物体の画像内での座標)は以下のように求まります。

In [18]:
m = cv2.moments(blob[1])
x = m['m10']/m['m00']
y = m['m01']/m['m00']
x, y
Out[18]:
(475.51380117999344, 499.64567582792256)

今回は使いませんが、以下のようにすると物体の色を求めることもできます。「緑の箱だけをピックアップ」のようなタスクはこの情報を使うことで実現できます。

In [19]:
mean = cv2.mean(hsv, mask=mask)
mean
Out[19]:
(59.01709998255104, 191.94870005234688, 157.47077298900717, 0.0)

ハフ変換を用いた物体の姿勢推定

物体を切り出して位置を推定するところまではできましたがロボットアームで物体を安定的に把持することを考えると、物体の姿勢に合わせて手を伸ばしてあげる必要があります。

物体の姿勢推定を行う方法はいくつかありますが、ここではハフ変換を使った方法を紹介したいと思います。

ハフ変換を行うためにはまず物体のエッジ画像を用意します。

In [20]:
im_edge = np.zeros(img.shape[:-1], np.uint8)
cv2.drawContours(im_edge, blob, 1, 255, 2)

細めにエッジを出したほうが精度よくハフ変換できるためやや見づらいと思いますが、エッジ画像は以下のような画像です。

In [21]:
plt.imshow(im_edge)
Out[21]:
<matplotlib.image.AxesImage at 0x7fe14b57fad0>

「ハフ変換」は画像上の各点を想定している図形を構成するパラメータ空間(ハフ空間)に射影して、その射影された点の密度(「投票」とも言う)から元の図形を推定するアルゴリズムです。

今回は物体の4辺を構成する直線のパラメータを推定します。 OpenCVで直線を推定するハフ変換(投票数100以下で足切り)は以下のようにして実行できます。

In [22]:
lines = cv2.HoughLines(im_edge, 2, np.pi/180, 100)

パラメータから画像に戻すと以下のようになります。各辺の直線がきれいに推定できています。

In [23]:
# Part of code is taken from: http://opencv-python-tutroals.readthedocs.org/en/latest/py_tutorials/py_imgproc/py_houghlines/py_houghlines.html
im_out = np.zeros(img.shape, np.uint8)
for rho,theta in lines[0]:
    a = np.cos(theta)
    b = np.sin(theta)
    x0 = a*rho
    y0 = b*rho
    x1 = int(x0 + 1000*(-b))
    y1 = int(y0 + 1000*(a))
    x2 = int(x0 - 1000*(-b))
    y2 = int(y0 - 1000*(a))
    cv2.line(im_out, (x1,y1), (x2,y2), (255,255,0), 2)
plt.imshow(im_out)
Out[23]:
<matplotlib.image.AxesImage at 0x7fe14b4c9510>

推定された各直線の角度を見ると以下のようになっています。左方向と右方向の直線の角度がそれぞれ算出されているので、0.7前後の値と2.3前後の値の2群が観察できます。

In [24]:
thetas = lines[:, :, 1]
thetas
Out[24]:
array([[ 2.3561945 ,  0.73303831,  0.78539819,  0.73303831,  0.78539819,
         2.33874106,  2.32128787,  2.30383468,  2.3561945 ,  2.37364769,
         2.33874106,  2.30383468,  0.71558499,  2.32128787,  2.28638124,
         2.28638124,  2.37364769,  0.71558499,  2.39110112,  2.26892805,
         2.39110112,  2.26892805,  0.68067843]], dtype=float32)

ここでは$\pi/2$で閾値をとって0.7前後の群から姿勢を推定してみたいと思います。

今回はシミュレータで生成した画像を使うのでノイズが少ないのですが、実際のデータではもっと多くのノイズが出てきます。実際の画像処理はこれらノイズとの戦いです。ノイズの多いデータから代表値を計算する場合、平均値ではなく中央値を使うと多くの場合、良い結果を得ることができます。

In [25]:
theta = np.median(thetas[thetas < np.pi/2])
theta
Out[25]:
0.73303831

連続画像での処理と可視化

上記した処理を連続画像に適用してみましょう。

In [26]:
import copy

i = 0
def animate(*args):
    global i, im, imgs
    
    # convert to HSV color space and binarize
    hsv = cv2.cvtColor(imgs[i], cv2.COLOR_RGB2HSV)
    ret, mask = cv2.threshold(hsv[:, :, 1], 100, 255, cv2.THRESH_BINARY)
    
    # split into blobs
    blob, idx = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    
    im_out = copy.deepcopy(imgs[i])
    for j in range(0, shape(blob)[0]):
        try:
            # calc moment of the blob
            m = cv2.moments(blob[j])
            x = int(m['m10']/m['m00'])
            y = int(m['m01']/m['m00'])

            # calc attitude of the object by using hough transform
            im_edge = np.zeros(img.shape[:-1], np.uint8)
            cv2.drawContours(im_edge, blob, j, 255, 1)
            lines = cv2.HoughLines(im_edge, 2, np.pi/180, 30)
            thetas = lines[:, :, 1]
            theta = np.median(thetas[thetas < np.pi/2])

            # create and show annotated image
            cv2.circle(im_out, (x, y), 20, (255,255,0), 10)
            cv2.line(im_out, (x, y), (x + int(150*np.sin(theta)), y - int(150*np.cos(theta))), (255,255,0), 10)
        except:
            pass
    im.set_array(im_out)
    i = i + 1
    return im,

animation.FuncAnimation(fig, animate, frames=8, blit=True)
Out[26]:

赤い物体と緑の物体に関してはこれで十分な感じです。見切れている青い物体についても上手く推定できていますが、作った本人からすると少々危なっかしい感じです、、、。工場などの環境をコントロールできる現場であれば物体をきれいにカメラ角に収めるためのガイドを付けるなどの手あてをすることができますが、より広い応用を考えると不安定な推定結果の棄却を行うアルゴリズムも付加するとより安心できるでしょう(ノートの紙面が尽きてしまったので今回についてここまででお開きにしたいと思います)。

次回は画像処理による物体の位置姿勢推定の結果を使って、シミュレータ上のロボットアームで物体を把持してみます。

おまけ

今回使った色による背景の分離はテレビでよく見る「クロマキー合成」と全く同じ原理です(クロマキー合成では背景に彩度が低く分離しやすいクロマ色を使います)。

画像処理の歴史から言うと、この後、Viola-Jonesが起こした革命により、今回のような背景色を仮定した物体認識方法は過去の技術となってしまうのですが、画像処理の基本としておさえた上で次に進んでいきましょう。

In [27]:
img2 = np.ones(img.shape, np.uint8) * 255
cv2.circle(img2, (500, 400), 200, (100,100,100), 100)

i = 0
def animate(*args):
    global i, im, imgs, img2
    hsv = cv2.cvtColor(imgs[i], cv2.COLOR_RGB2HSV)
    ret, mask = cv2.threshold(hsv[:, :, 1], 100, 255, cv2.THRESH_BINARY)
    blob, idx = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    mask = np.zeros(img.shape[:-1], np.uint8)
    for j in range(0, shape(blob)[0]):
        cv2.drawContours(mask, blob, j, 255, -1)
    im.set_array(np.where(cv2.merge((mask,mask,mask)) == 255, imgs[i], img2))
    i = i + 1
    return im,

animation.FuncAnimation(fig, animate, frames=8, blit=True)
Out[27]: