1. はじめに(AI学習のきっかけ)
- 大好きなゲーム『SnowRunner』を題材にAIを勉強したい!
- 実行環境:macOS Tahoe 26.5.2
- 相棒のAI:まずは一番身近だった「Microsoft Copilot」を選択。
SnowRunnerとは?
トラックやオフロードSUVなどで悪路を指定された荷物を目的地に運んだり、災害で流された車両や機械を探したりするゲームです。
今回はSwitch2版の4K画像を使います。
2. 提案された3つのアプローチと、最初の一歩
Copilot先生から提示された3つの難易度:
- 車種判別(分類) ➡️ 「これ画面に映ってるの何?」(★今回はこれに決定!)
- 位置特定(物体検出) ➡️ 「画面のどこに車がある?」
- マップ上の位置推定 ➡️ 「背景からして、ここミシガンのどこ?」
3. 車種判別の5ステップと「Teachable Machine」の罠
開発のステップ
- ステップ1:プレイ動画を撮影(4K)
- ステップ2:動画から1秒ごとに静止画を抽出(FullHD)
- ステップ3:フォルダ分けしてラベル付け(各150枚程度)
- ステップ4:ノーコードで簡単な「Teachable Machine」で学習
- ステップ5:Copilotに書いてもらったPythonコードで推論実行
ステップ1:
プレイ動画を撮りながら、途中、左右が開けた場所で、車両をぐるりと回るように視野を動かしていろいろな角度から車を撮影しました。
ステップ2:
ffmpegで動画から1秒ごとに静止画(FullHD)を抽出。
// 4Kの長い動画から切り出す部分を指定して4Kの短い動画に切り出して、その動画からFHDの静止画を切り出す(2段階)
ffmpeg -ss 00:06:28 -i input4k.mp4 -t 00:00:40 -c copy clip01.mp4
ffmpeg -i clip01.mp4 -vf "scale=1920:1080,fps=1" frames/frame_0%04d.jpg
// 4Kの長い動画から切り出す部分を指定して、直接FHDの静止画を切り出す(1段階でOK)
ffmpeg -ss 00:05:10 -i input4k.mp4 -t 00:02:30 -vf "scale=1920:1080,fps=1" frames/frame_%04d.jpg
ステップ3:
フォルダーに分けてラベル付け。
次のようなフォルダー構成にしました。トラックには「アオリ付き荷台(Sideboard)」というフレーム装備を明記。
dataset/
├── Chevrolet_CK1500/
├── Fleetstar_7020A_Sideboard/
├── GMC_MH9500_Sideboard/
└── Scout_800/
実際に各フォルダーの内容は次のようになります。(トリミングされています。)




ステップ4:
Teachable Machineで学習させて、学習したモデルデータをダウンロードします。
「使ってみる」→「画像プロジェクト」→「標準の画像モデル」→それぞれのクラスを定義してそれぞれの画像をアップロード→トレーニング→モデルをエクスポート→TensorflowタブでSavedmodel形式を選んで「モデルをダウンロード」。

ダウンロードしたconverted_savedmodel.zipを解凍すると、次のようなフォルダ構成になっています。
converted_savedmodel/
├── labels.txt ← クラス名のラベル
└── model.savedmodel/ ← モデルデータのフォルダ(内容を変更しないこと!)
├── assets/
├── saved_model.pb
└── variables/
ステップ5:
推論するコード(classify.py)は最終的なコードを最後に載せます。
対象にした4車種と「フレーム装備」の標準化
カスタマイズで見た目が変わる対策として、ルールを固定:
- スカウト車(ノーマル状態):Chevrolet CK1500 / Scout 800
- トラック車(アオリ付き荷台装備):GMC MH9500 / Fleetstar 7020A
4. 【トラブル発生】認識率が半分!?AI先生たちのバトンリレー
試行錯誤①:Copilotの提案「もっと車を大きく映そう」
一律で画面中央やや下をトリミングするコードを追加 ➡️ 多少改善するも、なぜかScout 800に誤認識されまくる。

緑の枠内でトリミングしています。
rawフォルダー内の画像を上の画像のように一律でトリミングしてtrimmedフォルダーに保存するコード(trimming.py)です。推論コード内にも同じトリミングを行う部分があります。
import cv2
import glob
import os
input_dir = "raw"
output_dir = "trimmed"
os.makedirs(output_dir, exist_ok=True)
for path in glob.glob(input_dir + "/*.jpg"):
img = cv2.imread(path)
h, w, _ = img.shape
# 画面中央を基準に車体が写っている領域を切り出す
x1 = int(w * 0.2)
x2 = int(w * 0.8)
y1 = int(h * 0.3)
y2 = int(h * 0.9)
crop = img[y1:y2, x1:x2]
cv2.imwrite(os.path.join(output_dir, os.path.basename(path)), crop)
試行錯誤②:Gemini先生のファインプレー!コードの致命的バグを発見
原因はまさかの「色の反転」と「正規化の計算ミス」。
- 誤:BGR(Python標準) ➡️ 正:RGB(Teachable Machine標準)への変換漏れ。
- AIモデルの前提条件にコードが合っていなかったことが判明!
試行錯誤③:用途に合わせたAIの使い分け
Gemini先生の提案が少し難易度高め(PyTorchや本格的な環境構築)になりかけたため、Teachable Machineの親玉である「Google AI」に相談役をチェンジ。より実践的な機能拡張へ。
5. 機能拡張と、驚きの最終結果!
正解ラベルと比較して、フレームごとの「正答率」を自動算出する機能を推論コードに追加。最終的な推論コードは最後にあります。
📊 最終評価レポート
- 総評価フレーム数: 3,179枚
- 全体正答率 (Overall Accuracy): 91.95%
車種別の結果:
- 🚗 Chevrolet CK1500: 99.31% (ほぼ完璧!)
- 🚛 GMC MH9500: 99.68% (ほぼ完璧!)
- 🚙 Scout 800: 87.77% (黄色い車体がGMCに見えた?)
- 🚚 Fleetstar 7020A: 84.40% (GMCの荷台と見間違えた?)
6. まとめと次のステップ
- 身近なゲームを使うことで、モチベーションを保ったまま画像認識の基本(データ前処理、カラーチャネル、評価ログ)が学べた!
- AIはそれぞれ得意分野(コード生成、デバッグ、検索)があるので、複数を使い分けるのが正解。
- 次は「位置特定(物体検出)」か、YOLOを使った本格的な学習にチャレンジしてみたい。
📝 著作権・免責事項
本プロジェクトで使用しているゲーム画像および動画は、ゲーム『SnowRunner』(© Saber Interactive / Focus Entertainment)のものを使用しています。著作権はすべて権利所有者に帰属します。
車種判別に入力した動画
プレイ動画から各車種ごとに学習用とは違う部分を切り出したシーンを単純につないだものです。
最終的な推論コード
以下は最終的な推論コード(classify.py)です。正解ファイル(gt.txt)とかレポートファイル(accuracy_report.csv)とかを入出力しているので複雑になっています。
import tensorflow as tf
import numpy as np
import cv2
import sys
import csv
from collections import defaultdict
MODEL_PATH = "model.savedmodel"
VIDEO_PATH = "snowrunner.mp4"
USE_CAMERA = False
LABELS_PATH = "labels.txt"
GT_PATH = "gt.txt"
CONF_THRESHOLD = 0.5
# --- labels.txt をロード ---
try:
with open(LABELS_PATH, "r", encoding="utf-8") as f:
class_names = [line.strip() for line in f.readlines() if line.strip()]
except FileNotFoundError:
print(f"エラー: {LABELS_PATH} が見つかりません。")
sys.exit(1)
# --- モデルのロード ---
try:
model = tf.keras.layers.TFSMLayer(MODEL_PATH, call_endpoint="serving_default")
except Exception as e:
print(f"モデルの読み込みに失敗しました: {e}")
sys.exit(1)
# --- ビデオキャプチャの設定 ---
cap = cv2.VideoCapture(0 if USE_CAMERA else VIDEO_PATH)
if not cap.isOpened():
print("エラー: ビデオソースを開けませんでした。")
sys.exit(1)
# --- 出力動画・FPSの設定 ---
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
fps = cap.get(cv2.CAP_PROP_FPS)
if fps <= 0 or np.isnan(fps):
fps = 30.0
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter("result.mp4", fourcc, fps, (width, height))
# --- 正解ファイル (gt.txt) のパース関数(修正完了) ---
def parse_gt_file(file_path, fps):
timeline = []
try:
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
# 最初に見つかるスペースで、タイムスタンプ(左)と中身(右)に2分割
parts = line.split(maxsplit=1)
if len(parts) < 2:
continue
time_str = parts[0]
# parts[1] の文字列からダブルクォーテーションをきれいに削除
label_name = parts[1].replace('"', '').strip()
# タイムスタンプをフレーム数に変換 (hh:mm:ss:ff または hh:mm:ss)
time_parts = time_str.split(":")
hh = int(time_parts[0])
mm = int(time_parts[1])
ss = int(time_parts[2])
ff = int(time_parts[3]) if len(time_parts) > 3 else 0
total_frames = int(((hh * 3600) + (mm * 60) + ss) * fps) + ff
timeline.append((total_frames, label_name))
except FileNotFoundError:
print(f"警告: 正解ファイル {file_path} が見つかりません。正誤判定はスキップします。")
return None
timeline.sort(key=lambda x: x[0])
return timeline
gt_timeline = parse_gt_file(GT_PATH, fps)
def get_current_gt(frame_idx, timeline):
if not timeline:
return "N/A"
current_label = "不明"
for start_frame, label in timeline:
if frame_idx >= start_frame:
current_label = label
else:
break
return current_label
# --- CSVレポートの準備 ---
csv_file = open("accuracy_report.csv", "w", newline="", encoding="utf-8")
csv_writer = csv.writer(csv_file)
csv_writer.writerow(["Frame", "Timestamp(sec)", "AI_Prediction", "Confidence", "Ground_Truth", "Is_Correct"])
# --- 集計用カウンターの初期化 ---
total_evaluated_frames = 0
correct_predictions_count = 0
class_total_counts = defaultdict(int)
class_correct_counts = defaultdict(int)
print("処理を開始します。'q' キーで終了します。")
frame_count = 0
try:
while True:
ret, frame = cap.read()
if not ret:
break
h, w, _ = frame.shape
x1, x2 = int(w * 0.2), int(w * 0.8)
y1, y2 = int(h * 0.3), int(h * 0.9)
frame_trimmed = frame[y1:y2, x1:x2]
if frame_trimmed.size == 0:
frame_count += 1
continue
# --- 前処理 ---
img_rgb = cv2.cvtColor(frame_trimmed, cv2.COLOR_BGR2RGB)
img = cv2.resize(img_rgb, (224, 224))
img = img.astype(np.float32)
img = (img / 127.5) - 1.0
img = np.expand_dims(img, axis=0)
# --- 推論 ---
pred_dict = model(img)
output_key = list(pred_dict.keys())[0]
pred = pred_dict[output_key].numpy()
idx = np.argmax(pred)
confidence = pred[0][idx]
# --- AIラベル判定 ---
if confidence < CONF_THRESHOLD:
ai_label = "不明"
else:
ai_label = class_names[idx] if idx < len(class_names) else "不明"
# --- 正解データとの照合 ---
gt_label = get_current_gt(frame_count, gt_timeline)
is_correct = (ai_label == gt_label)
status_text = "OK" if is_correct else "NG"
# --- 全体集計の更新 ---
total_evaluated_frames += 1
if is_correct:
correct_predictions_count += 1
# --- 車種別集計の更新 ---
class_total_counts[gt_label] += 1
if is_correct:
class_correct_counts[gt_label] += 1
# --- CSVへの書き出し ---
timestamp_sec = frame_count / fps
csv_writer.writerow([frame_count, f"{timestamp_sec:.2f}", ai_label, f"{confidence:.4f}", gt_label, status_text])
# --- 画面描画 ---
text_ai = f"AI: {ai_label} ({confidence:.2f})"
text_gt = f"GT: {gt_label} [{status_text}]"
text_x, text_y = 60, 180
cv2.rectangle(frame, (text_x - 20, text_y - 140), (text_x + 2700, text_y + 160), (0, 0, 0), -1)
cv2.putText(frame, text_ai, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 4, (255, 255, 255), 3)
color_status = (0, 255, 0) if is_correct else (0, 0, 255)
cv2.putText(frame, text_gt, (text_x, text_y + 120), cv2.FONT_HERSHEY_SIMPLEX, 4, color_status, 3)
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
out.write(frame)
cv2.imshow("SnowRunner AI - Evaluation", frame)
frame_count += 1
if cv2.waitKey(1) == ord('q'):
break
finally:
cap.release()
out.release()
if total_evaluated_frames > 0:
incorrect_predictions_count = total_evaluated_frames - correct_predictions_count
overall_accuracy = (correct_predictions_count / total_evaluated_frames) * 100
print("\n" + "="*30)
print(" OVERALL SUMMARY")
print("="*30)
print(f"Total Frames Evaluated: {total_evaluated_frames}")
print(f"Correct Predictions: {correct_predictions_count}")
print(f"Incorrect Predictions: {incorrect_predictions_count}")
print(f"Overall Accuracy: {overall_accuracy:.2f}%")
print("\n" + "="*30)
print(" CLASSWISE ACCURACY REPORT")
print("="*30)
csv_writer.writerow([])
csv_writer.writerow(["--- Overall Summary ---"])
csv_writer.writerow(["Metric", "Value"])
csv_writer.writerow(["Total Frames Evaluated", total_evaluated_frames])
csv_writer.writerow(["Correct Predictions", correct_predictions_count])
csv_writer.writerow(["Incorrect Predictions", incorrect_predictions_count])
csv_writer.writerow(["Overall Accuracy (%)", f"{overall_accuracy:.2f}"])
csv_writer.writerow([])
csv_writer.writerow(["--- Classwise Accuracy Report ---"])
csv_writer.writerow(["Class Name", "Total Frames", "Correct", "Incorrect", "Accuracy (%)"])
for cls in sorted(class_total_counts.keys()):
total = class_total_counts[cls]
correct = class_correct_counts[cls]
incorrect = total - correct
accuracy = (correct / total) * 100
print(f"Class: {cls:<30} | Total: {total:<5} | Correct: {correct:<5} | Incorrect: {incorrect:<5} | Accuracy: {accuracy:.2f}%")
csv_writer.writerow([cls, total, correct, incorrect, f"{accuracy:.2f}"])
print("="*30)
csv_file.close()
cv2.destroyAllWindows()
print(f"\n処理が終了しました。検証データと集計レポートは 'accuracy_report.csv' に保存されました。")