1.ゼロからの自動運転:フルスクラッチCNNで挑むラジコン走行
42東京 というプログラミングの学校に在籍しています。今回、自動運転ラジコンを作成し、コース3周のタイムを競う特別課題に挑戦しました。我々のチームは、Deep Learningに興味があったことから、ゼロから作るDeep-Learningをもとに画像認識の畳み込みニューラルネットワーク(CNN) を機械学習ライブラリーを使わないで実装してみました。
42東京は、冬と夏に一風変わった入学試験を行っています。面白いところですし、24時間好きな時間に滞在できる場所が新宿にできますし、何より学費無料ですので興味ある方はぜひWebサイトをチェックしてみてください
さて、自動運転ラジコンレース、毎回、抽選となる人気課題ですが、
- ラジコンのシャーシはどうする。既製品でいくか、回路の勉強のために自作するか?
- 頭脳はどうするか。ラズパイを使うか、またはアルディーノ?
- センサーは、距離センサーやLIDAR、カメラなどから何を選ぶか?
- 自動運転も、ルールベースか、機械学習を選ぶか?
など、多くの選択肢があり、チームの興味ある分野を、ハードとソフトの両面から深掘りできる懐の深い楽しい課題だと思いました。おすすめですので、次回開催時には、ぜひ申し込んで見てください。
本記事では、ゼロから作るDeep-Learningのコードをラジコンへ適用するための注意点だけカバーします。ニューラルネットワークの理論面や詳細についてはゼロから作るDeep-Learningを参照ください
この様な、楽しくかつ勉強になる場を設定していただき、かつパーツ購入の予算までつけていただきましたこと、42東京やスポンサーのみなさまに、厚く御礼申し上げます!
2.システム構成と選定パーツ
総額5万円を上限とする縛りの中で、以下のパーツを選択しました。
| 名称 | 役割 | コメント |
|---|---|---|
| タミヤTT-02 | シャーシ | 多くのチームが使用していた定番。コースに対しての能力は過剰なほど速度がでる。ショートカットを攻めるのなら、もっと小型のラジコンの方が有利かも |
| タミヤ ファインスペック2.4G 電動RCドライブセット | 電動セット | ステアリングサーボ(TSU-03)やESC(TBLE-04S)などを含む。結局プロポ、無線受信機は利用しなかったので、性能のいいESCとサーボを単品で買った方がおすすめ |
| HOBBYWING QuicRUN-WP-1060 | ESC | PWM信号の初期値設定などを自動でやってくれるのでおすすめ。タミヤTBLE-04Sは設定が手動で大変だったため、こちらを推奨 |
| Raspberry Pi 4 Model B (4GB) | ラズパイ | ラズパイ5も出ていたが、ラズパイ4で性能的には十分だった |
| サインスマート 広角 魚眼レンズ | カメラ | 2000円強と安かったが十分だった |
| PCA9685 | PWM制御 | ラズパイのI2C信号を受けて、サーボモーターなどをPWM駆動。ラズパイ直結より安定するらしい |
| Logicool Gamepad F310 | コントローラー | ラズパイとプロポの相性がわるかったので、途中で追加購入。XInput信号が出るので、pygameモジュールで簡単に信号が取れる。有線なので教師データ作成のRUN時はいい運動になりました |
他に付随して必要なものとしては以下:
- ラズパイ電力供給用のモバイルバッテリー
- SDカード
- メスメスのジャンパー線(4本)
- タミヤのユニバーサルプレート(見た目は良くないが、使いやすい)
- 100円ショップで小物類
3.ゼロから作る自動運転ラジコン用ニューラルネットワーク
実装の進め方としては、以下の段階を踏んで進めました。
- 自動運転ラジコンのフレームワーク
donkeycarにて、機械学習と自動運転が動くことを確認 - ラジコンのコントロールや機械学習などを
pythonで実装。畳み込みネットワークについては、プロトタイプとしてkerasを使用
参考: プロトタイプとして作成したKerasモデル
import keras
from keras import layers
class CarModel:
@staticmethod
def build_model(
input_shape: tuple = (160, 120, 3),
output_type: str = 'continuous'
) -> keras.Model:
"""
入力: カメラ画像
出力: ステアリング値 + スロットル値
"""
model = keras.Sequential([
# 入力層
layers.Input(shape=input_shape),
# CNN ブロック 1
layers.Conv2D(32, (3, 3), padding='same', activation='relu'),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.2),
# CNN ブロック 2
layers.Conv2D(64, (3, 3), padding='same', activation='relu'),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.2),
# CNN ブロック 3
layers.Conv2D(128, (3, 3), padding='same', activation='relu'),
layers.MaxPooling2D((2, 2)),
layers.Dropout(0.2),
# 全結合層
layers.Flatten(),
layers.Dense(256, activation='relu'),
layers.Dropout(0.3),
layers.Dense(128, activation='relu'),
# 出力層 (マルチタスク学習)
# - steering: [-1, 1] を出力
# - throttle: [0, 1] を出力
layers.Dense(2, name='output'), # [steering, throttle]
])
return model
- 最終的にフルスクラッチでCNNを実装
- 自作CNNの構成
3層の畳み込み層と2層の全結合層で実装しました。
- 自作CNNの構成
構成: Conv -> Relu -> Pool (x3回)
-> Flatten -> Affine -> Relu -> Dropout -> Affine -> MSE
実行環境については、機械学習を行うホストPCにはAppleシリコンのMac、ラズパイには、debian bookwarm 64bitを使用しました。
ゼロから作るDeep-Learning本とラジコン学習の違い
「ゼロから作るDeep-Learning」では、主にMNIST(手書き数字画像)の分類問題を扱いますが、自動運転ラジコンはカメラ画像から操作量(ステアリング・スロットル)を導き出す回帰問題です。
この違いにより、ニューラルネットワークの実装において以下の工夫・変更しました。
1. 入力データの4次元テンソル化と im2col
MNISTのようなグレースケール画像は $(N, 28*28 = 784)$ のような2次元配列(バッチサイズ × 画素数)として扱うことが多いですが、ラジコンのカメラ画像はカラー(RGB)とchannelは3つであり、畳み込み層(Convolution)で空間情報を維持する必要があります。
そのため、入力データを $(N, C, H, W)$ の4次元テンソルとして扱うパイプラインを構築しました。
- $N$: バッチサイズ
- $C$: チャンネル数 (RGB=3)
- $H$: 高さ (120px)
- $W$: 幅 (160px)
具体的には、データローダー (loader.py) にて、PILで読み込んだ画像 $(H, W, C)$ を転置させて $(C, H, W)$ の形式に変換しています。
# pc-app/loader.py
# 画像読み込み後、正規化
img = np.array(img_pil)
normalized_img = normalize(img)
# ... (中略) ...
# (N, H, W, C) -> (N, C, H, W) へ転置
images_nchw = images_tensor.transpose(0, 3, 1, 2)
im2colの実装
畳み込み演算を高速な行列積(ドット積)に変換するために、4次元データを2次元行列に展開する im2col (image to column) を実装しました。 特に、長方形(非正方形)のフィルターでも正しく動作するように実装を調整しています。
# cnn/src/cnn/util.py
def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
# ... (中略) ...
# 6次元配列として展開用の箱を用意
col = np.zeros((N, C, filter_h, filter_w, OH, OW))
for y in range(filter_h):
y_max = y + stride * OH
for x in range(filter_w):
# フィルター内の各画素に対応する画像データを一括で抜き出す
x_max = x + stride * OW
col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]
# (N, C, FH, FW, OH, OW) -> (N, OH, OW, C, FH, FW)
transposed_col = col.transpose(0, 4, 5, 1, 2, 3)
# 最終的に2次元行列 (N*OH*OW, C*FH*FW) に変形
reshaped_col = transposed_col.reshape(N * OH * OW, -1)
return reshaped_col
この実装により、単純な for 文による畳み込み計算よりも高速に、かつ numpy のブロードキャスト機能を活用して効率的に学習を進めることができました。
2.出力層の設計:分類から回帰へ
ここが最大の違いです。「0〜9のどれか?」を当てる分類から、「ハンドルを何度切るか?」という連続値を当てる回帰へ、ネットワークの出口(出力層)を再設計しました。
| MNIST (本) | 自動運転ラジコン (今回) | |
|---|---|---|
| 問題設定 | 分類問題 (Classification) | 回帰問題 (Regression) |
| 出力ニューロン数 | 10個 (数字のクラス数) | 2個 (ステアリング, スロットル) |
| 活性化関数 | Softmax | 恒等写像 (なにもしない) |
| 損失関数 | 交差エントロピー誤差 | 二乗和誤差 (MSE) |
コード上では、出力層に活性化関数(SigmoidやReLUなど)を挟まず、Affine層の出力をそのまま推論値として扱っています。これにより、$-1.0$ 〜 $1.0$ のような負の値も自然に出力可能になります。
# cnn/src/cnn/network.py
self.AffineLayers = [
Flatten(),
Affine(input_size=flatten_dim, output_size=hidden_size, name="Affine1"),
Relu(),
Dropout(0.5),
# 最終層:活性化関数を通さず、値をそのまま出力する
Affine(input_size=hidden_size, output_size=output_size, name="Affine2"),
]
3.損失関数:二乗和誤差 (MSE) の実装
正解データ(人間が操作した値)と、AIが予測した値のズレを評価するために、二乗和誤差 (Mean Squared Error) を実装しました
# cnn/src/cnn/layers.py
class MeanSquaredError:
def forward(self, y: np.ndarray, t: np.ndarray) -> float:
self.t = t
self.y = y
batch_size = self.y.shape[0]
# 誤差の計算
self.loss = 0.5 * np.sum((self.y - self.t) ** 2) / batch_size
return self.loss
def backward(self, dout: float = 1.0) -> np.ndarray:
batch_size = self.t.shape[0]
# 勾配: (予測値 - 正解値) / バッチサイズ
dx = (self.y - self.t) / batch_size
return dx
この実装により、「ハンドルを右に切りすぎた」「アクセルが弱すぎた」といった誤差が数値としてネットワークに逆伝播され、適切な操作を学習できるようになります。
4. パラメータークラスの導入
ゼロから本のサンプルコードでは、重み(W)やバイアス(b)をディクショナリで管理することが多いのですが、正直ごちゃごちゃしており分かりにくいと思いました。よって、Parameterクラス(cnn/src/cnn/parameter.py)を導入しました。
# cnn/src/cnn/parameter.py (構造の概念)
class Parameter:
def __init__(self, data: np.ndarray, name: str):
self.data = data # 重み本体
self.grad = None # 算出された勾配
4.リポジトリについて
-
ルートリポジトリ
ホストPC(学習用)とラズパイ(推論・ドライブ用)ともに、git cloneして入れる -
car-app
ラズパイ内でcdしmakeで使用。学習のための走行や、推論しての走行などを担当 -
pc-app
ラズパイでの走行データ(画像+スロットル値+ステアリング値)を持ってきて、ホストPCで機械学習を行う -
畳み込みニューラルネットワーク
car-appとpc-appともに使うネットワークの実装。
layers.pyなどの部品を元に、network.pyで畳み込み層を実装。
5.終わりに
AIの進歩は本当にすごいですね、自動運転ラジコン用のコードもLLMがなければ完成させられなかったと思います(この記事も半分以上はAI製)。ただ、今回、自己実装(車輪の再発明)により、これまでブラックボックスの魔法のように感じていた機械学習の初歩がよく理解できました。ゼロからDeep-Learningはとてもおすすめな本なのですが、作成するものが0〜9の手書き数字判別機だけだと、どうしても学習モチベーションの維持も難しいかもしれません。対して、ラジコンカーという目に見えるものが、自分のニューラルネットワークで学習して、教えた通り動いたときはすごく感動しました。画像認識は、ニューラルネットワークの最初の一歩のCNNで実装可能ですし、推論結果が物理的な挙動としてすぐにフィードバックされることから学習のモチベーションも上がると思うので、ぜひ挑戦してみてください。
備忘録:来年も出場できたなら、以下のことに挑戦
- 距離センサーなども投入して、画像と距離データで学習させたい
- モーターをブラシレスにして、ブレーキのデータも学習につかいたい
- GPUなどをつかって機械学習の速度を上げてみたい
参考文献
- donkeycar documentation
- 斎藤 康毅, 『ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装』, オライリー・ジャパン, 2016.: 1巻目の内容が、ちょうど画像認識の畳み込みネットワークです
- Chainer チュートリアル