JetRacerの自動走行の仕組み

JetRacerとは

Nvidiaが公開しているJetsonNanoを用いた自動走行カープロジェクトがあります。 github.com

前回の記事ではこちらの作り方を説明しました。

yhrscoding.hatenablog.com

作った当時はとりあえずサンプルのJupyter Notebookをただ実行するだけで動かしていただけでした。 今回こちらで自動走行ができる仕組みについて少し調べてみました。

基本的なラジコンの仕組み

まず自動走行する以前に、JetRacerの改造前のラジコンについて考えてみます。

車本体の位置を制御することに関して、ラジコンのコントローラー(プロポ)からステアリング(Steering、進行方向)・スロットル(Throttle、進行速度)の2つの信号が車側の受信機に届き、値に応じて車に取り付けられている2つのモーターの動きを制御しています。

f:id:hygradme:20191208031003j:plain:w300 f:id:hygradme:20191208031014j:plain:w300

ラジコンの自動走行

ラジコンを自動走行させるには基本的には人間に変わり、何らかのセンサーを使い状況を認識し、判断を行いステアリング・スロットルを適切に制御することで自動走行を実現します。

現在の標準のJetRacerではスロットルはあらかじめ指定した固定値を常に維持し、車正面に搭載のカメラから得られた情報を元にステアリングの値を変化させることでコースに沿った走行が実現しています。
ちなみにJetRacerでスロットルを状況に応じて自動で操作するということは可能で、方法はいくつか考えられます(前に参加した大会ではステアリング角に応じた速度の値を決めることでコーナーでは低速、直線では高速にする改良を行い、タイムを大幅に縮めることができました)

スロットル制御については以下の記事でもmasato-kaさんが触れられていますので、興味のある方は参考にしてください。 masato-ka.hatenablog.com

カメラ画像からからステアリング角を決める仕組み

上で述べたように、自動走行には人間の目に変わりコースの形状・車の位置などを認識し、それに応じた制御をする必要があります。
こちらは実際の自動車の自動運転では車両前方につけたカメラで白線認識を行ったり、奥行きまで認識できるセンサー(Lidar、ステレオカメラ、ミリ波レーダー)などを用い、車の空間における位置関係を把握しステアリング・スロットルの判断を行うということが行われていると思われます。白線認識を行ったり、LidarなどのセンサーをJetRacerに搭載することも可能ですが、システムが複雑になったり、重量・価格などがネックになってしまいます。

もっともシンプルに自動走行を実現するには通常のWebカメラのようなカメラ1つ(単眼カメラと呼ばれる)からステアリングを直接推定することです。 そんなこと可能なのかと思われるかもしれませんが、近年人気のディープラーニングを使って、「画像を入力->ステアリング角を出力」を行ったという論文をNvidiaが数年前に発表しました。

End to End Learning for Self-Driving Cars (2016) https://images.nvidia.com/content/tegra/automotive/images/2016/solutions/pdf/end-to-end-dl-using-px.pdf

詳細は省きますが、内容としては「道路を走行中の画像」と「その画像を撮影した時点での車のステアリング角」のペアのデータを大量に収集し、CNNというニューラルネットワークにその関係を学習させ、走行中にその時点での画像から直ちにその時点で取るべきステアリング角を推定し実際にステアリング角を変化させるということを行っています。

f:id:hygradme:20191208042121p:plain:w400
画像とステアリング角を使ってネットワークを学習し推論する

JetRacerの自動走行もこの論文と同様の原理で自動走行しています。 唯一異なる点は論文のように実際の走行時のステアリング角を学習のターゲットとせず、各画像に付与した車が向かうべきターゲットピクセル座標(x,y)を使って正しいステアリング角を擬似的に作り出している点です。

具体的に見ていきましょう。

上記のレポジトリ内の jetracer/notebooks/interactive_regression.ipynb を順に実行することでデータ収集、学習を行うことができますが、実行して画像を収集していく過程で、TASKという変数名で指定した名前から始まるフォルダが、jetracer/notebooks内に作成されています。 そのフォルダ内のapex内に画像ファイルが以下のようなファイル名で保存されていると思います。 99_109_xxxxxxxxxxxx.jpg

このファイル名先頭の2つの数字が画像を保存する際にクリックしたピクセル座標x,yに対応しています。

なおこのファイルの保存の処理はnotebooks/xy_dataset.py内のXYDatasetクラスのsave_entry関数で行われています。

class XYDataset(torch.utils.data.Dataset):
    (中略)
    def save_entry(self, category, image, x, y):
        category_dir = os.path.join(self.directory, category)
        if not os.path.exists(category_dir):
            subprocess.call(['mkdir', '-p', category_dir])
            
        filename = '%d_%d_%s.jpg' % (x, y, str(uuid.uuid1()))
        
        image_path = os.path.join(category_dir, filename)
        cv2.imwrite(image_path, image)
        self.refresh()

filenameを x_y_uuid.jpgという形式にしてフォルダに保存していることがわかります。 jupyter notebook上のカメラ画像の座標をクリックするごとにこの関数が呼ばれ、画像とターゲットのピクセル座標をファイル名内に入れて保持します。

ネットワークをトレーニングする際にこの画像とファイル名を読み込んで学習を行います。 なお実際にネットワークから出力するx,yの値はピクセル座標ではなく、画像サイズを元の値から割って得られる -1 から1の値です。

なおこの処理は上の画像を保存する処理が入っているnotebooks/xy_dataset.py内に記述されており、

class XYDataset(torch.utils.data.Dataset):
    (中略)
    def __getitem__(self, idx):
        ann = self.annotations[idx]
        image = cv2.imread(ann['image_path'], cv2.IMREAD_COLOR)
        image = PIL.Image.fromarray(image)
        width = image.width
        height = image.height
        if self.transform is not None:
            image = self.transform(image)
        
        x = 2.0 * (ann['x'] / width - 0.5) # -1 left, +1 right
        y = 2.0 * (ann['y'] / height - 0.5) # -1 top, +1 bottom
        
        if self.random_hflip and float(np.random.random(1)) > 0.5:
            image = torch.from_numpy(image.numpy()[..., ::-1].copy())
            x = -x
            
        return image, ann['category_index'], torch.Tensor([x, y])

の中の

 x = 2.0 * (ann['x'] / width - 0.5) # -1 left, +1 right
 y = 2.0 * (ann['y'] / height - 0.5) # -1 top, +1 bottom

でターゲットのピクセル座標ann['x']、ann['y'] がそれぞれ-1 ~ 1 に変換された x,yになっていることがわかるかと思います。

お気づきかもしれませんが、このターゲットのピクセル座標を予測しても、実際のステアリング角とは異なるので、通常は予測結果をそのままラジコンのステアリング角には使用できません。

JetRacerではこの予測したピクセル座標(-1~1に正規化)のxの値をそのままステアリング角とみなします。ステアリング角も-1~1の範囲に正規化されているため、例えばもっとも左にステアリング角を取るとき(steering = -1)はx=-1となり、もっとも右にステアリング角を取るとき(steering = 1)はx=1となります。

f:id:hygradme:20191208145724p:plain:w400
画像内のピクセル座標とステアリング角の対応付け

このxの値をそのままステアリング角とみなすというのはやや極端かと思いますが、カメラが車体進行方向に対してまっすぐに取り付けられているとするならば、画像の真ん中を境にマークの位置に応じて左右のステアリング値に直接変換するというのはもっとも簡単で手軽な方法の1つだと思います。

実際の画像を見ていただくとわかりますが、真ん中から左側をクリックするときはステアリングは左、右側をクリックするときはステアリングは右になっていそうなことがわかると思います。 f:id:hygradme:20191208152754j:plain:w350 f:id:hygradme:20191208152822j:plain:w350

ピクセル座標の値をステアリング角とみなして、実際にラジコンにステアリング指示を行う処理はjetracer/notebooks/road_following.ipynbの最後のセルに記述されています。

import numpy as np

STEERING_GAIN = 0.75
STEERING_BIAS = 0.00

car.throttle = 0.15

while True:
    image = camera.read()
    image = preprocess(image).half()
    output = model_trt(image).detach().cpu().numpy().flatten()
    x = float(output[0])
    car.steering = x * STEERING_GAIN + STEERING_BIAS

の中の

    x = float(output[0])
    car.steering = x * STEERING_GAIN + STEERING_BIAS

の部分が、学習済みモデルに画像を入れて出力されたピクセル座標のxの値を取り出し、ステアリング角(car.steering)に変換している部分です。 実際の処理ではSTEERING_GAINという値をかけることで、ステアリングの値を多少小さめに指示する操作を行っています。こちらの値は使用するラジコンの最大ステアリング角等を見て調整することでより性能の良い自動走行を実現できると思われます。 またSTEERING_BIASはステアリング値に一定値を常に加える処理ですので、カメラが車体本体から左右にずれていたり、重力の軸周りに回転してしまっている場合あるいはラジコンのステアリング機構自体の左右へのズレを補正するためのもので、そういうズレがなければ通常STEERING_BIAS=0.00で問題ないと思います。

ピクセル座標のyの値は標準のJetRacerでは使用しておりませんが、こちらは画像をクリックしてアノテーションを行うときのことを考えると、奥行き方向に関連する情報を持っている可能性があると思いますので(コース上をクリックするとするならば視界の先をどれだけ見渡せるかで速度を出せるかどうかの目安にできる?)、速度(throttle)に関する制御に使えるのではないかと考えられます。

以上で簡単ですが、JetRacerの自動走行の仕組みについて自分の理解の範囲で解説させていただきました。 勘違いや、JetRacerの自動走行の仕組みについてより詳しい背景等ご存知でしたら是非ご指摘願います。