初心者が道路標識を認識するAIを作ってみた話

## はじめに

はじめまして。AI初学者のすし太郎です。
私は工場内にある機械や計器を保全する仕事をしています。

現場を回り、点検結果を手書きで記録し、エクセルに転記という時代を経て、
最近ではスマホで写真を撮って事務所でそれを見ながらExcelに入力をするという流れになってきました。
拙いながらもVBAで入力を簡素化したりしているうちに、
「そもそもこの写真を自動で読み取って表を作ってくれたら、時間をもっと有効に使えるんじゃないか？」と思い至り、python、AIを学び始めました。

今回の制作では、すでにサンプルデータが用意されている「データセット」というものを使い、AI画像認識モデルを作成し、ウェブアプリケーションとして動作させるところまでを実証してみたいと思います。
使用するデータは、形状や、表記、色などで認識をするという点で将来的な目的と共通している部分の多い、「交通標識」をサンプルとして取り上げ「画像をもとに自動で分類・判別する仕組み」をつくってみたいと思います。


## 目次

1.実行環境 
2.データセットについて
3.モデルの学習
4.ウェブへの実装
5.今後の活用について



## 作成したプログラム

標識認識AI
https://trafficsign-app.onrender.com/

※Renderの無料環境を使用しているため、立ち上がるのに時間がかかります。

## 1.実行環境

- python3.11
- Google Colaboratory
- Visual Studio Code
- cursor


## 2.データセットについて

交通標識のデータセットはKaggleのGTSRB (German Traffic Sign Recognition Benchmark) を使用しました。
ドイツの交通標識を集めたデータセットとなっています。


[GTSRB (German Traffic Sign Recognition Benchmark)](https://www.kaggle.com/datasets/meowmeowmeowmeowmeow/gtsrb-german-traffic-sign/data)　

このデータセットには 43種類 の交通標識クラスがあります。
ラベルの一覧は以下の通りです。

## GTSRB のラベル一覧

0: 「速度制限 (20km/h)」
1: 「速度制限 (30km/h)」
2: 「速度制限 (50km/h)」
3: 「速度制限 (60km/h)」
4: 「速度制限 (70km/h)」
5: 「速度制限 (80km/h)」
6: 「速度制限解除 (80km/h)」
7: 「速度制限 (100km/h)」
8: 「速度制限 (120km/h)」
9: 「追い越し禁止」
10: 「3.5トン超の車両は追い越し禁止」
11: 「交差点での優先通行権」
12: 「優先道路」
13: 「徐行」
14: 「停止」
15: 「車両通行禁止」
16: 「3.5トン超の車両通行禁止」
17: 「進入禁止」
18: 「一般的な警告」
19: 「左カーブ注意」
20: 「右カーブ注意」
21: 「連続カーブ」
22: 「凸凹道路」
23: 「滑りやすい道路」
24: 「右側車線減少」
25: 「道路工事中」
26: 「信号機あり」
27: 「歩行者横断」
28: 「児童横断」
29: 「自転車横断」
30: 「凍結・積雪注意」
31: 「野生動物横断注意」
32: 「速度制限・追い越し禁止解除」
33: 「この先右折」
34: 「この先左折」
35: 「この先直進のみ」
36: 「この先直進または右折」
37: 「この先直進または左折」
38: 「右側通行」
39: 「左側通行」
40: 「ロータリー進入義務」
41: 「追い越し禁止解除」
42: 「3.5トン超の車両追い越し禁止解除」

このデータセットは、道路標識の自動認識システムの開発や、画像分類の研究に利用されています。
モデルを訓練するときには、これらのラベルに基づいて正解を判断することになります。

### データセットの準備
データセットを準備する手順を示します。
Google Colaboratoryを使用することでGPUを使って学習を行うことができます。
GTSRB データセットを Kaggle からダウンロードし、Google Colab で使用するには以下の方法をとります。
Google Colaboratory内のセルに入力します。

### Kaggle からデータを取得
```python
# Kaggle APIの設定
os.makedirs("/root/.kaggle", exist_ok=True)
files.upload()  # ここで kaggle.json をアップロード

# kaggle.json を正しい場所に移動
!mv kaggle.json /root/.kaggle/
!chmod 600 /root/.kaggle/kaggle.json

# Kaggleからデータをダウンロード
!kaggle datasets download -d meowmeowmeowmeowmeow/gtsrb-german-traffic-sign
!unzip gtsrb-german-traffic-sign.zip -d gtsrb
```
### Google Colaboratoryに解凍
```python
# 解凍するディレクトリ(colab内)
extract_path = "/content/GTSRB"

# zip を解凍
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
    zip_ref.extractall(extract_path)
```

データの中身を確認するとこのような構成でした。
GTSRB/Train 内の合計 .png ファイル数: 39209

![スクリーンショット 2025-03-16 184522.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4035586/c2a27c1b-3ea8-4c1c-af53-bd55341590c2.png)

多いものだとクラス1の2220枚、少ないものだとクラス19の210枚とデータの数にかなり差があることがわかります。
そこで以下の手順でクラスの偏りをデータ拡張やダウンサンプリングで統一します。

拡張する場合は既存の画像を加工して水増しします。

### データの拡張
```python
datagen = ImageDataGenerator(
    rotation_range=10,
    width_shift_range=0.1,
    height_shift_range=0.1,
    shear_range=0.1,
    zoom_range=0.1,
    horizontal_flip=False,
    fill_mode='nearest'
)
```
1クラス1500枚を目安として揃えます。
```python
        #クラスが1500枚以上の場合 → ランダムに1500枚を選択
        if len(original_images) > TARGET_SAMPLES:
            selected_images = random.sample(original_images, TARGET_SAMPLES)

        # クラスが1500枚未満の場合 → データ拡張で補う
        else:
            extra_needed = TARGET_SAMPLES - len(original_images)
            selected_images = original_images.copy()
            
            while extra_needed > 0:
                img = random.choice(original_images)  # 元の画像からランダムに選択
                img = np.expand_dims(img, axis=0)  # バッチ次元を追加
                aug_iter = datagen.flow(img, batch_size=1)

                # 追加サンプルを作成
                for _ in range(min(extra_needed, 5)):  # 各画像から最大5枚拡張
                    aug_img = next(aug_iter)[0].astype(np.uint8)
                    selected_images.append(aug_img)
                    extra_needed -= 1
                    if extra_needed == 0:
                        break
```

なんとなく揃いました。

![スクリーンショット 2025-03-16 185022.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4035586/3b0590f2-67b1-4009-a5f2-6b64220d32e2.png)

これで学習の準備が整いました。

## 3.モデルの学習
データが揃ったので、いよいよ学習を始めます。

### モデルの構築
```python
model = tf.keras.Sequential([
    tf.keras.layers.Conv2D(32, (3, 3), activation='relu', input_shape=(32, 32, 3)),
    tf.keras.layers.MaxPooling2D((2, 2)),
    tf.keras.layers.Conv2D(64, (3, 3), activation='relu'),
    tf.keras.layers.MaxPooling2D((2, 2)),
    tf.keras.layers.Conv2D(128, (3, 3), activation='relu'),
    tf.keras.layers.MaxPooling2D((2, 2)),
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(128, activation='relu'),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(NUM_CLASSES, activation='softmax')
])
```

### 層の構成


- **畳み込み層 (Conv2D):**

最初の層は32個のフィルターを持ち、3x3のカーネルサイズを使用して、入力画像（32x32ピクセル、3チャンネル）から特徴を抽出します。
次に64個、128個のフィルターを持つ畳み込み層が続き、各層で特徴の抽出を深めます。

- **プーリング層 (MaxPooling2D):**

各畳み込み層の後に2x2のマックスプーリング層が配置され、特徴マップのサイズを半分に縮小し、計算量を削減しつつ重要な特徴を保持します。

- **フラット層 (Flatten):**

最後の畳み込み層の出力を1次元のベクトルに変換します。これにより、全結合層に入力できる形にします。

- **全結合層 (Dense):**

128ユニットの全結合層があり、ReLU活性化関数を使用して非線形性を導入します。

**ドロップアウト層 (Dropout):**

過学習を防ぐために、50%のドロップアウト率でユニットをランダムに無効にします。

- **出力層 (Dense):**

最後の層は、クラス数（NUM_CLASSES）に応じたユニットを持ち、softmax活性化関数を使用して各クラスの確率を出力します。

### 学習の進捗確認
学習後に accuracy (精度) と loss (損失関数) をプロット し、学習の進捗を確認します。
```python
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.plot(history.history['accuracy'], label='Train Accuracy')
plt.plot(history.history['val_accuracy'], label='Validation Accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()
plt.title('Accuracy Curve')

plt.subplot(1, 2, 2)
plt.plot(history.history['loss'], label='Train Loss')
plt.plot(history.history['val_loss'], label='Validation Loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.title('Loss Curve')

plt.show()
```
### 学習曲線
![スクリーンショット 2025-03-16 065435.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4035586/17115655-9f1d-4b6d-ae83-2321018d206d.png)
### モデルの予想と正解
![スクリーンショット 2025-03-16 070731.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4035586/339e5c85-234e-4600-b3d7-f5ea525e0346.png)
![スクリーンショット 2025-03-16 071742.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4035586/e7a51e48-9bbd-4aa6-98a1-d6f1cda9a54e.png)

かなりの好成績です。
これはこれで過学習(データセットに慣れすぎて他のデータに対応できない)の可能性があり、注意です。
実装後に検証していきます。
※OpenCVで画像を読み込むと、BGR形式（Blue-Green-Red）になります。
そのため赤が青に変換されています。

## 4.ウェブへの実装

Flaskベースでアップロードしました。
アップロード画像は input_images/ に保存されます。
推論結果を HTML 上に表示します。
推論画像は debug_images/ に保存してトラブルシュートしやすく設計しました。
また、UIはCSSで調整し、画像のプレビューや送信ボタンのアニメーション（ポップアップ効果）も追加しました。

### 送信ボタンをポップアップさせる
```css
.btn:hover {
    transform: scale(1.2); /* 1.2倍に拡大 */
    box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.3);
}
```
普段何気なく使っているアプリの挙動を真似して作ってみるとこんなに工夫を凝らしているのだ、と見る目が変わってきます。
UIに関してはこだわりだすと時間がいくらあっても足りないのでほどほどにしておきます。

## 5.今後の活用について
「画像をもとに自動で分類・判別する仕組み」を作ることを目的に取り組んできました。
実際に検証してみると、水増ししたデータのクラスの精度があまり良くないようです。
この点はデータの準備からもう一工夫必要と感じました。
今後はこの技術をベースに、以下のような実用的な機能を拡張していく予定です。

- モバイル対応のUI強化

- 推論スピードの最適化（モデルの軽量化）

- 誤認識の分析に基づいた継続的なモデル改善

- 撮影画像からの情報抽出 → 自動Excel生成などの業務応用

## おわりに
今回の取り組みでは、交通標識を自動で識別するAIを作って、実際にWebアプリとして動かすところまでチャレンジしました。
現場では、手書きの記録や写真を見ながらのExcel入力など、意外と時間のかかる作業が多くあります。
そんな中で「これ、AIがやってくれたら便利なのでは？」と思ったのがきっかけでした。

今回はその第一歩として、画像を読み取って自動で標識を識別する仕組みを作りましたが、今後は自作のデータセットを準備して、実際の現場で応用できたらと思っています。
これからも、「人がやらなくてもよい作業」をAIにやってもらって、人は判断や対応に集中できるような仕組みを目指して、さらなる改良と応用に取り組んでいきます。