はじめに
カメラ画像から操作を直接推論する End-to-End(E2E)の自動運転 を、手のひらサイズのミニカーで再現しました。
- 人間がキーボードで操縦して「画像と操作のペア」を集める(Behavioral Cloning)
- PyTorchでCNNを学習させる
- 推論結果をBluetoothでArduinoに送り、モーターを動かす
- Arduino側に、AIとは独立したフェールセーフを2つ持たせる
この記事では、そのまま動かせるコード一式 と、組んでみてわかった技術的な注意点をまとめます。
実車の開発現場との比較や、作ってみて感じたことは、ブログに書いています。
動作の様子
システム構成
重いAI処理を行う上位のPCと、モーターを制御する下位のマイコンを分けています。実車の「SoC(AI処理)」と「MCU(車両制御)」の分離をまねた構成です。
スマホカメラ ──Wi-Fi(IP Webcam)──▶ PC(Python / PyTorch)
│ F / L / R / S の1文字
▼ Bluetooth(HC-05)
Arduino UNO ──▶ L298N ──▶ DCモーター×2
▲
超音波センサー(HC-SR04)
| 役割 | 実車では | ミニカーでは |
|---|---|---|
| 認知 | 車載カメラ | スマホのカメラ |
| 判断 | AI処理用のSoC(ADAS ECU) | PC上のPython(PyTorch) |
| 命令の伝送 | CAN、車載Ethernet | Bluetooth(HC-05) |
| 操作 | 車両制御用のECU(パワトレ、ブレーキのECU) | Arduino UNO |
| 安全の見張り役 | 冗長センサー、安全監視 | 超音波センサー(HC-SR04) |
部品
| 役割 | 部品 |
|---|---|
| MCU | Arduino UNO R3 |
| 上位の判断 | PC(GPUがあれば学習が速い) |
| カメラ | スマートフォン(IP Webcamアプリで映像を配信)+スマホマウント |
| フェールセーフ用センサー | 超音波センサー HC-SR04 |
| 通信 | Bluetoothモジュール HC-05 |
| モーター | モータードライバー L298N + DCモーター×2 |
| モーター用電源 | 単3電池4本の電池ボックス |
配線
①HC-05 ②Arduino UNO ③L298N ④HC-SR04(車体の前方)
配線の注意点
- モーターの電源はArduinoと分ける。 起動時の突入電流で電圧が下がり(ブラウンアウト)、Arduinoが再起動を繰り返す原因になります。
- GNDは共通にする。 電源を分けても、Arduino・L298N・電池のGNDはつなぎます。
- HC-05のRXは3.3V。 ArduinoのTX(5V)からは、抵抗で分圧してつなぎます。
- 書き込み時はHC-05を外す。 0・1番ピンにつないだままだと、スケッチを書き込めません。
- モーターのノイズ対策。 DCモーターの端子に0.1μFのコンデンサを入れると、誤動作が減ります。
コード
PC側は4つのファイル、Arduino側は1つのスケッチです。
.
├── e2e_common.py # モデル・前処理・接続設定(共通)
├── data_collection.py # Phase 2:データ収集
├── train_model.py # Phase 3:学習
├── inference.py # Phase 4:推論と制御
└── minicar_mcu.ino # Arduino:命令の受信とフェールセーフ
必要なライブラリは次のとおりです。
pip install torch torchvision opencv-python pyserial pandas pillow
Arduino:命令の受信と、独立したフェールセーフ
// --- Arduino側:命令の受信と、独立したフェールセーフ ---
const int TRIG = 9, ECHO = 10; // 超音波センサー HC-SR04
const int IN1 = 4, IN2 = 5, IN3 = 6, IN4 = 7; // モータードライバー L298N
const int STOP_DISTANCE_CM = 20; // これより近いと強制停止
const unsigned long CMD_TIMEOUT_MS = 300; // 命令がこの時間届かなければ停止
char command = 'S';
unsigned long lastCmdMs = 0;
void setup() {
Serial.begin(9600); // HC-05は0・1番ピン(ハードウェアシリアル)に接続
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(IN1, OUTPUT); pinMode(IN2, OUTPUT);
pinMode(IN3, OUTPUT); pinMode(IN4, OUTPUT);
drive(LOW, LOW, LOW, LOW);
}
long readDistanceCm() {
digitalWrite(TRIG, LOW); delayMicroseconds(2);
digitalWrite(TRIG, HIGH); delayMicroseconds(10);
digitalWrite(TRIG, LOW);
long us = pulseIn(ECHO, HIGH, 25000); // 25msで打ち切る(初期値の1秒だと制御が止まる)
if (us == 0) return -1; // 反射が返ってこない
return us / 58; // 往復の時間 → 距離[cm]
}
void drive(int a, int b, int c, int d) {
digitalWrite(IN1, a); digitalWrite(IN2, b);
digitalWrite(IN3, c); digitalWrite(IN4, d);
}
void loop() {
// 1. 上位(Python)からの命令を受け取る
while (Serial.available() > 0) {
command = Serial.read();
lastCmdMs = millis();
}
// 2. フェールセーフ①:障害物が近ければ、上位の命令を無視して停止
long d = readDistanceCm();
if (d > 0 && d < STOP_DISTANCE_CM) {
drive(LOW, LOW, LOW, LOW);
return;
}
// 3. フェールセーフ②:通信が途絶えたら停止(ウォッチドッグ)
if (millis() - lastCmdMs > CMD_TIMEOUT_MS) {
drive(LOW, LOW, LOW, LOW);
return;
}
// 4. 上位の判断どおりに動かす(左右は配線によって逆になることがある)
switch (command) {
case 'F': drive(HIGH, LOW, HIGH, LOW); break; // 前進
case 'L': drive(LOW, LOW, HIGH, LOW); break; // 左旋回(右のモーターだけ回す)
case 'R': drive(HIGH, LOW, LOW, LOW); break; // 右旋回(左のモーターだけ回す)
default: drive(LOW, LOW, LOW, LOW); break; // 停止
}
}
ポイントは次の3つです。
- フェールセーフ①(障害物): 20cm以内に物があれば、AIが「前進」と言っていても止まります。
- フェールセーフ②(通信の途絶): 命令が0.3秒届かなければ止まるウォッチドッグです。これがないと、Bluetoothが切れた瞬間の命令が「前進」なら走り続けます。
-
pulseIn()のタイムアウト: 初期値(1秒)のままだと、反射が返らないたびに制御ループが止まります。25msで打ち切っています。
共通部品
学習時と推論時で前処理がずれると精度が落ちるので、1つのファイルにまとめています。CNNはNVIDIAのPilotNetを小さくした構成です。
# --- 共通部品 (e2e_common.py):モデル・前処理・接続設定 ---
import torch.nn as nn
from torchvision import transforms
STREAM_URL = "http://192.168.x.x:8080/video" # IP Webcamの映像配信URL
BT_PORT = "/dev/cu.HC-05" # macOSの例(Windowsは "COM5" など)
LABELS = ["F", "L", "R"] # 前進・左・右
class E2E_Model(nn.Module):
"""NVIDIAのPilotNetを小さくした構成のCNN。画像から3つの操作のどれかを出す"""
def __init__(self):
super().__init__()
self.conv_layers = nn.Sequential(
nn.Conv2d(3, 24, kernel_size=5, stride=2), nn.ReLU(),
nn.Conv2d(24, 36, kernel_size=5, stride=2), nn.ReLU(),
nn.Conv2d(36, 48, kernel_size=5, stride=2), nn.ReLU(),
nn.Flatten(),
)
self.fc_layers = nn.Sequential(
nn.Linear(48 * 27 * 37, 100), nn.ReLU(), # 240×320の入力で 27×37 になる
nn.Linear(100, len(LABELS)),
)
def forward(self, x):
return self.fc_layers(self.conv_layers(x))
# 学習時と推論時で、必ず同じ前処理を使う
preprocess = transforms.Compose([
transforms.Resize((240, 320)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
Phase 2:データ収集
キーボード(W/A/D/S)で操縦しながら、映像と操作を保存します。keyboard ライブラリはmacOSで管理者権限が必要になることが多いので、OpenCVのキー入力を使っています。
# --- Phase 2:データ収集 (data_collection.py) ---
# W:前進 / A:左 / D:右 / S:停止 / Q:終了(映像のウィンドウを選択した状態で押す)
import os
import time
import cv2
import serial
from e2e_common import BT_PORT, STREAM_URL
os.makedirs("dataset/images", exist_ok=True)
log_file = open("dataset/driving_log.csv", "a") # 追記:何回かに分けて集められる
cap = cv2.VideoCapture(STREAM_URL)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 古いフレームをためこまない
bt = serial.Serial(BT_PORT, 9600, timeout=0)
time.sleep(2)
KEYS = {ord("w"): "F", ord("a"): "L", ord("d"): "R", ord("s"): "S"}
action = "S"
frame_count = len(os.listdir("dataset/images")) # 続きの番号から保存する
try:
while True:
ok, frame = cap.read()
if not ok:
continue
cv2.imshow("Data Collection", frame)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
break
action = KEYS.get(key, action) # 押されたキーで操作を切り替える
# Arduinoへ毎フレーム送る(ラジコンとして動かしつつ、生存確認を兼ねる)
bt.write(action.encode())
# 停止中以外の「画像と操作」のペアを保存する
if action != "S":
img_name = f"frame_{frame_count:06d}.jpg"
cv2.imwrite(f"dataset/images/{img_name}", frame)
log_file.write(f"{img_name},{action}\n")
frame_count += 1
finally:
bt.write(b"S")
log_file.close()
cap.release()
bt.close()
cv2.destroyAllWindows()
データ集めのコツ
- コースから外れかけた位置から「立て直す運転」も記録する。うまく走った映像だけだと、AIはずれたときの戻り方を知りません。
- 時間帯や照明を変えて集める。
Phase 3:学習
# --- Phase 3:学習 (train_model.py) ---
import pandas as pd
import torch
import torch.nn as nn
from PIL import Image
from torch.utils.data import DataLoader, Dataset, Subset
from torchvision import transforms
from e2e_common import LABELS, E2E_Model, preprocess
# 照明の違いに強くするため、学習時だけ明るさ・色をランダムに揺らす
augment = transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.3)
class DrivingDataset(Dataset):
def __init__(self, csv_file, img_dir, train):
self.data = pd.read_csv(csv_file, names=["image", "label"])
self.img_dir = img_dir
self.train = train
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
name, label = self.data.iloc[idx]
image = Image.open(f"{self.img_dir}/{name}").convert("RGB")
if self.train:
image = augment(image)
return preprocess(image), LABELS.index(label)
train_data = DrivingDataset("dataset/driving_log.csv", "dataset/images", train=True)
val_data = DrivingDataset("dataset/driving_log.csv", "dataset/images", train=False)
print("ラベルの内訳:", train_data.data["label"].value_counts().to_dict()) # 偏りを確認する
n_val = max(1, len(train_data) // 5) # 2割を検証用に取り分ける
order = torch.randperm(len(train_data)).tolist()
train_set, val_set = Subset(train_data, order[n_val:]), Subset(val_data, order[:n_val])
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
val_loader = DataLoader(val_set, batch_size=32)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = E2E_Model().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
for epoch in range(10):
model.train()
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
loss = criterion(model(images), labels)
loss.backward()
optimizer.step()
# 学習に使っていないデータで、正解率を確かめる
model.eval()
correct = 0
with torch.no_grad():
for images, labels in val_loader:
preds = model(images.to(device)).argmax(1)
correct += (preds == labels.to(device)).sum().item()
print(f"Epoch {epoch + 1}: loss {loss.item():.4f}, 検証の正解率 {correct / n_val:.1%}")
torch.save(model.state_dict(), "e2e_model.pth")
print("e2e_model.pth に保存しました")
- 学習時だけ
ColorJitterで明るさや色を揺らし、照明の変化に強くしています。 - 2割を検証用に分けて、学習に使っていないデータで正解率を確認します。
Phase 4:推論と制御
# --- Phase 4:推論と制御 (inference.py) ---
import time
import cv2
import serial
import torch
from PIL import Image
from e2e_common import BT_PORT, LABELS, STREAM_URL, E2E_Model, preprocess
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = E2E_Model().to(device)
model.load_state_dict(torch.load("e2e_model.pth", map_location=device))
model.eval()
cap = cv2.VideoCapture(STREAM_URL)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
bt = serial.Serial(BT_PORT, 9600, timeout=0)
time.sleep(2)
try:
while True:
ok, frame = cap.read()
if not ok:
break
t0 = time.perf_counter()
# OpenCVの画像(BGR)を、学習時と同じ形のテンソルに変換する
rgb = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
x = preprocess(rgb).unsqueeze(0).to(device)
# AIによる判断
with torch.no_grad():
action = LABELS[model(x).argmax(1).item()]
bt.write(action.encode()) # 毎フレーム送る=Arduinoへの生存確認にもなる
ms = (time.perf_counter() - t0) * 1000
cv2.putText(frame, f"{action} {ms:.0f} ms", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) # 判断と処理時間を表示
cv2.imshow("Inference", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
bt.write(b"S") # どんな終わり方でも、最後に停止命令を送る
cap.release()
bt.close()
cv2.destroyAllWindows()
- 毎フレーム命令を送ることが、Arduinoのウォッチドッグへの生存確認にもなります。
- 1フレームの処理時間を画面に表示するので、自分の環境の遅延を測れます。
組んでみてわかった注意点
1. 遅延は継ぎ目ごとに積み重なる
カメラ → Wi-Fi → 推論 → Bluetooth → Arduino の各段階で遅れが生じます。特に次の2つは見落としやすいです。
-
OpenCVのバッファ: 映像をためこむので、処理が追いつかないと古いフレームで判断します。
CAP_PROP_BUFFERSIZEを1にしています(バックエンドによっては効かないことがあります)。 -
pulseIn()のブロッキング: 前述のとおり、タイムアウトを短くしないと制御ループ全体が止まります。
2. 照明が変わるだけで精度が落ちる(ドメインシフト)
学習時と走行時で部屋の明るさが違うだけで、判断が大きくぶれます。データ集めの段階で照明を変えることと、学習時の ColorJitter が有効です。
3. 正解率の高さにだまされる(データの偏り)
コースを走ると、ラベルの大半が「F(前進)」になります。たとえば7割が前進なら、常に「前進」と答えるだけで正解率は約7割 になり、カーブで曲がれないモデルでも数字は良く見えます。学習スクリプトでラベルの内訳を表示しているのはこのためです。カーブのデータを増やす、左右反転した画像を足す(LとRを入れ替える)などで偏りをならします。
おわりに
数千円の部品でも、「AIの判断を、遅延のある通信越しに、物理的なモーターへ渡す」という実車と同じ構造の難しさが現れます。フェールセーフをAIとは独立した下位のマイコンに持たせる設計は、ミニカーでも実車でも変わらない原則です。
実車の開発現場との比較は、ブログに書いています。

