日曜技術者のメモ

趣味でやった事のメモ書きです。

Keras+TensorflowでRapsberry Piライントレーサー作ってみた

某所のライントレース大会向けに機械学習を使ったライントレーサーを作ってみた。
そろそろいいかなと思うのでblogで公開してみる。
完成した動作は↓を参照。


(なお、この動画の後は自分の影を黒と認識して机から落ちました・・・)

何分大会がある週の週末に思いついて一日で実装したので適当な実装箇所が
数多くあるが時間がなかったのでご容赦頂きたい。

本体解説

外観
f:id:ginnyu-tei:20180412223813p:plain

2階建てで下のプレートはタミヤダブルギヤボックスとモーターコントローラ(DRV8830)
上はRaspberry Pi3とRaspberry Piカメラ+スマートフォン用クリップレンズ (広角)
広角のクリップレンズはラズパイカメラの画角が狭くて足元が映らなかったのでその対策。
電源はモバイルバッテリーを使っている
f:id:ginnyu-tei:20180412223859p:plain

なぜ機械学習で実装しようと思ったか?

もともと機械学習を使うつもりはまったくなかった
機械学習は学習データを大量に用意するのが面倒だと思っていたからだ
なのでOpenCVの画像処理を使って↓の様に輪郭抽出と矩形取得を使って大会に持っていく予定だった。
f:id:ginnyu-tei:20180412223707p:plain
(上下反対なのは実装の都合上ラズパイカメラを逆さまに取り付けているだけで意図はないです。)
輪郭抽出するためカメラ画像を2値化する必要がある。それが下の画像
f:id:ginnyu-tei:20180412225731p:plain
発表資料にこの画像を貼り付けた時に
「あれ?2値化してしかも背景がない画像なら大量に自動生成して学習できるんじゃない?」
と大会の最終週にもかからず思いついてしまったので時間が全くないのにやってしまった。

学習データの自動生成

OpenCVの楕円描画関数を使った。
画像サイズ40x30(ラズパイだとこの画像サイズが限界だった) の中にランダムな楕円を描画して
楕円が右か左かの学習データを作成した。
5つ位描画してみたのが↓。若干怪しい画像もあるがまあ良しとした。
カメラの二値化画像と全く似ていないが結果は問題なく左右を認識できた。
これを10000データ自動生成した 。(学習結果を確認するために追加で+300生成)
f:id:ginnyu-tei:20180412230224p:plain

学習プログラム(Windows)

ラズパイでもWindows PCでも動く機械学習の環境を探した結果
言語はPythonでライブラリはTensorFlow+Kerasを使った。
↓はWindowsPCで実行した学習

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

import keras
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from keras import backend as K

from IPython.display import SVG
from keras.utils.vis_utils import model_to_dot

width = 40
height = 30
line_width = 8

#plt.figure(figsize=(50,40))

train_num = 10000
#train_num = 9
data_num = 300
x_train = np.zeros((train_num,height,width,1), dtype=np.uint8)
y_train = np.zeros((train_num), dtype=np.uint8)
x_test = np.zeros((data_num,height,width,1), dtype=np.uint8)
y_test = np.zeros((data_num), dtype=np.uint8)
#########学習データ生成
for i in range(2):
    if i == 0:
        loop_num = train_num
    else:
        loop_num = data_num
    j = 0
    while(j<loop_num):
        #背景作成
        image = np.zeros((height,width,1), dtype=np.uint8)
        #image.fill(255)
        #ランダム座標生成
        #rand_num = 1:左0:右
        rand_num = np.random.randint(0,2)
        rand_center = np.random.randint(0,10)
        if rand_num == 1:
            center = (rand_center,height)
        else:
            center = (width-rand_center,height)
        angle = (np.random.randint(0,60),np.random.randint(10,120))
        #print(center,angle)
        cv2.ellipse(image,center,angle,360,0,360,255,line_width)
        
        #真っ黒は削除
        if(np.max(image)==0):
            continue
        index = np.where(image == 255)
        #楕円の中心が偏っても楕円が画像中心を超える場合は左右逆にする
        if (index[0][0] == 0):
            #print(j,index)
            if (index[1][0] < width/2+line_width) & (rand_num==0):
                rand_num = 1
            elif(index[1][0] > width/2-line_width) & (rand_num==1):
                rand_num = 0
        if i == 0:
            x_train[j] = image.copy()
            y_train[j] = rand_num
            #画像チェック
            #image=cv2.cvtColor(image,cv2.COLOR_GRAY2RGB)
            #plt.subplot(190+j+1)
            #plt.title(str(rand_num) +" "+ str(index[0][0]) +" "+ str(index[1][0]))
            #plt.imshow(image)
        else:
            x_test[j] = image.copy()
            y_test[j] = rand_num
        j = j + 1
            
x_train = x_train / 255
x_test = x_test / 255

y_train = keras.utils.to_categorical(y_train, 2)
y_test = keras.utils.to_categorical(y_test, 2)

#NN構築
model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),activation='relu',input_shape=(height,width,1)))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(2, activation='softmax'))
model.summary()
model.compile(loss=keras.losses.categorical_crossentropy,optimizer=keras.optimizers.Adadelta(),metrics=['accuracy'])
#学習
model.fit(x_train, y_train,batch_size=128,epochs=12,verbose=1,validation_data=(x_test, y_test))

#NNと重みをファイル出力
json_string = model.to_json()
open('c:\cnn_model_40_30.json', 'w').write(json_string)
model.save_weights("c:\cnn_model_weights_40_30.hdf5")
#学習結果確認
score = model.evaluate(x_test, y_test, verbose=1)

print('Test loss:', score[0])
print('Test accuracy:', score[1])

↑の実行結果
tensorflow-gpu使ったのですぐに終了した

_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_3 (Conv2D)            (None, 28, 38, 32)        320       
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 26, 36, 64)        18496     
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 13, 18, 64)        0         
_________________________________________________________________
dropout_3 (Dropout)          (None, 13, 18, 64)        0         
_________________________________________________________________
flatten_2 (Flatten)          (None, 14976)             0         
_________________________________________________________________
dense_3 (Dense)              (None, 32)                479264    
_________________________________________________________________
dropout_4 (Dropout)          (None, 32)                0         
_________________________________________________________________
dense_4 (Dense)              (None, 2)                 66        
=================================================================
Total params: 498,146
Trainable params: 498,146
Non-trainable params: 0
_________________________________________________________________
Train on 10000 samples, validate on 300 samples
Epoch 1/12
10000/10000 [==============================] - 14s - loss: 0.3082 - acc: 0.8600 - val_loss: 0.2049 - val_acc: 0.8867
Epoch 2/12
10000/10000 [==============================] - 2s - loss: 0.1949 - acc: 0.9089 - val_loss: 0.1447 - val_acc: 0.9300
Epoch 3/12
10000/10000 [==============================] - 1s - loss: 0.1539 - acc: 0.9280 - val_loss: 0.1327 - val_acc: 0.9433
Epoch 4/12
10000/10000 [==============================] - 1s - loss: 0.1338 - acc: 0.9372 - val_loss: 0.1316 - val_acc: 0.9400
Epoch 5/12
10000/10000 [==============================] - 1s - loss: 0.1140 - acc: 0.9504 - val_loss: 0.1181 - val_acc: 0.9500
Epoch 6/12
10000/10000 [==============================] - 1s - loss: 0.1043 - acc: 0.9579 - val_loss: 0.1069 - val_acc: 0.9500
Epoch 7/12
10000/10000 [==============================] - 1s - loss: 0.0997 - acc: 0.9631 - val_loss: 0.0847 - val_acc: 0.9733
Epoch 8/12
10000/10000 [==============================] - 1s - loss: 0.0878 - acc: 0.9615 - val_loss: 0.1088 - val_acc: 0.9600
Epoch 9/12
10000/10000 [==============================] - 1s - loss: 0.0815 - acc: 0.9690 - val_loss: 0.0981 - val_acc: 0.9567
Epoch 10/12
10000/10000 [==============================] - 1s - loss: 0.0784 - acc: 0.9691 - val_loss: 0.0958 - val_acc: 0.9700
Epoch 11/12
10000/10000 [==============================] - 1s - loss: 0.0746 - acc: 0.9714 - val_loss: 0.0693 - val_acc: 0.9800
Epoch 12/12
10000/10000 [==============================] - 1s - loss: 0.0715 - acc: 0.9737 - val_loss: 0.0732 - val_acc: 0.9667
 32/300 [==>...........................] - ETA: 0sTest loss: 0.073219653219
Test accuracy: 0.966666666667

このCNN自体はKerasのサンプルコードの層をそのまま流用して
画像サイズが小さくなった分と出力層が右・左の2つになった分ネットワークを小さくしている。
半年前はこれにTensorBoard組み込んで学習結果確認をしていましたがそのコードを紛失しましたorz
各層の大きさは適当に決めました。時間がなかったので追い込んでいません。
今の結果を見る限りもっと小さくても良さそうです。

推論&モータ制御プログラム(raspbian)

RaspberryPiで動かしたコードを↓に載せる
学習したモデルと重みを読み込んで推論している。 推論した結果に適当な重みを付けて左右のモーター電圧を調整することで
左右に曲がるようになっている。

# -*- coding: utf-8 -*-
import cv2
import numpy as np

import keras
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from keras.models import model_from_json
from keras.preprocessing.image import load_img, img_to_array
import smbus
import time

i2c = smbus.SMBus(1)
#学習に使ったモデルを読み込み
json_string = open('./cnn_model_40_30.json', 'r').read()
model = model_from_json(json_string)
#学習した重みを読み込み
model.load_weights("./cnn_model_weights_40_30.hdf5")
model.compile(loss=keras.losses.categorical_crossentropy,optimizer=keras.optimizers.Adadelta(),metrics=['accuracy'])
model.summary()
print "eval start"
cap = cv2.VideoCapture(0)

try:
  while True:
    ret,frame = cap.read()
    #cv2.imwrite('orig.bmp', frame);
    frame = cv2.resize(frame,(40,30))
    frame = cv2.flip(frame, -1);
    #グレースケール→2値化
    img_gray = cv2.cvtColor(frame,cv2.COLOR_BGR2GRAY)
    ret, img_dst = cv2.threshold(img_gray,0,255,cv2.THRESH_BINARY_INV|cv2.THRESH_OTSU)
    img_dst /= 255
    #学習データからの予測
    #predicted[0][0]:Right  predicted[0][1]:Left
    predicted = model.predict(np.array([img_to_array(img_dst)]), 1, 0)
    print(predicted);
    r_motor = (int(predicted[0][1] * 3) << 2);
    l_motor = (int(predicted[0][0] * 3) << 2);
    #予測結果に適当に重みをつけてモーター制御
    if (r_motor == 0):
      r_motor = 0x00
    else:
      r_motor = 0x1A + r_motor

    if (l_motor == 0):
      l_motor = 0x00
    else:
      l_motor = 0x1A + l_motor
    #DRB8830の設定
    i2c.write_byte_data(0x63, 0x00, r_motor)
    i2c.write_byte_data(0x64, 0x00, l_motor)
except KeyboardInterrupt:
  print("get SIGINT")
#割り込み(Ctrl+C)でモーターを止めて終了
i2c.write_byte_data(0x63, 0x00, 0x00) #R
i2c.write_byte_data(0x64, 0x00, 0x00) #L

cap.release()
cv2.destroyAllWindows()

結果

上のツイートの通り一日で実装できた。
自分の影を黒認識するのは二値化部分の問題だと思うのでまあ、セーフ

反省点

真面目に(?)CNN構築するの初めてだったので各層のサイズや層の数などは適当にやったのが反省点
もっと小さくても左右2値なんだから認識出来る気がする。
それと今回は割り切って左右にしたが分類数を増やしてちょっと右とかかなり左とか入れたかった。

これ書いてて思ったが入力が2値なんだからCNNも2値化できるんじゃないかな?
やる気がでたらこれも実験してみたい。