画像認識モデル(YOLO)を使った物体検出アプリ作成に挑戦！！

## まえおき
私は自動車業界にて自動運転システム開発に従事しています。
緊急ブレーキ(AEB)や先行車追従(ACC)等のシステム側に携わっており、周囲の状況を把握する物体認識技術はチンプンカンプン。近年、DeepLearningや画像認識技術の発展が著しく、勉強しないと時代の波に乗り遅れる・・・
という状況から、Aidemy(プログラミングスクール)AIアプリ開発講座にて勉強しました。
本ブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています。

## 本記事の構成
①成果物紹介
②作成アプリの検討
③画像認識モデル実装の前準備
④アプリ実装
 -モデル学習
 -アプリ公開
 ⑤感想

### ①成果物紹介
私が作成したアプリケーションは下記
https://objectdetection-1112.onrender.com
### ②作成アプリの検討
Aidemy講座では、python勉強～CNNによる文字認識アプリ実装までを学びました。
講座で学んだ内容に近い成果物作成をしようかと思いましたが、
Aidemy講師陣のサポートも受けれることから、せっかくなので今の仕事に近く(興味がある)内容に挑戦することにしました。
その結果選んだテーマは、最新(有名)の画像認識モデルを用いた車載カメラからの物体検出としました。

### ③画像認識モデル実装の前準備
(画像認識モデル選定)
「物体検出　手法」と調べると、いくつかヒットしました。
R-CNN、YOLO、SSD・・・、会社で聞いたことがあったYOLOというモデルを直感で選択しました。
YOLOは従来手法より高速で高精度なアルゴリズムなようです。
(学習データセット)
車載カメラの映像を使って学習をしてみたかったので、KITTIのオープンデータセットを使うことにしました

### ④アプリ実装
#### モデル学習
##### 環境
googlecolab ・・・ モデル学習の為に利用
VScode/python3.9.2 ・・・ アプリ公開用のコード作成
##### 学習データ
KITTIデータセット
https://www.cvlibs.net/datasets/kitti/
##### 記述コード
ここからは記述したコードで説明する
~~~
#　環境依存対応
import locale
locale.getpreferredencoding = lambda: "UTF-8"
~~~
おまじない
googlecolab実行時に謎のエラー：NotImplementedError: A UTF-8 locale is required. Got ANSI_X3.4-1968
文字コード起因のエラーのようでググって対策
~~~
# googleドライブをマウント(アクセスできるようにする)
from google.colab import drive
drive.mount('/content/drive')
~~~
グーグルドライブをマウント。
学習データ等をグーグルドライブに配置するので参照できるように準備
~~~
# YOLOパッケージのインストール
! pip install ultralytics
from ultralytics import YOLO
# 必要なパッケージをインポート
import os
import numpy as np
import pandas as pd
import shutil
import yaml
import cv2
import torch
import matplotlib.pyplot as plt
from PIL import Image,ImageOps
%matplotlib inline
~~~
必要パッケージのインポート
~~~
#軽量化の為にリサイズ。サイズは事前学習済みのモデルと同様に640pxにする
# リサイズ前のデータパスとリサイズ後にデータを配置するパスを指定
images_all_dir="/content/drive/MyDrive/myYOLO/images_all"
images_resize_dir="/content/drive/MyDrive/myYOLO/images_resize_all"
# リサイズするサイズを指定
RESIZE=640

# リサイズ後の画像データフォルダの作成
!mkdir $images_all_dir

for file_name in os.listdir(images_all_dir):
    if file_name.endswith('.png'):
        # 画像の読み込み
        file_path = os.path.join(images_all_dir, file_name)
        img = cv2.imread(file_path)
        # 画像の大きさを取得 (縦・横・チャンネル)
        height, width, channels = img.shape
        # 640pxに対して、縦横比の計算
        ratio = RESIZE / max(height, width)
        # 新しい大きさを計算
        new_height = int(height * ratio)
        new_width = int(width * ratio)
        # リサイズ
        resized_img = cv2.resize(img, (new_width, new_height), interpolation = cv2.INTER_AREA)
        # 保存
        output_path = os.path.join(images_resize_dir, file_name)
        cv2.imwrite(output_path, resized_img)
~~~
画像リサイズ
KITTIの画像サイズ[pix]が[縦:横]=[375:1242]となっており、
YOLOの事前学習モデルの入力サイズより大きかったので、軽量化も兼ねて、
事前学習モデルと同様に640pixにリサイズし、別のフォルダに格納
~~~
# YOLO入力画像サイズが32の倍数である必要がある為、32の倍数になるようにパディング
# 代表画像ファイルを読み込む
img_path="/content/drive/MyDrive/myYOLO1/images_resize_all/000000.png"
img = Image.open(img_path)

# 画像の高さと幅を取得する
width, height = img.size

# 画像の高さと幅を32で割り、余りを求める
height_mod = height % 32
width_mod = width % 32

# 余りが0でない場合は、パディングする
if height_mod != 0 or width_mod != 0:
  # パディングするピクセル数を計算する
  height_pad = 32 - height_mod if height_mod != 0 else 0
  width_pad = 32 - width_mod if width_mod != 0 else 0

# パディングした画像保存先を指定
images_all_dir="/content/drive/MyDrive/myYOLO1/images_resize_all"
images_pad_dir="/content/drive/MyDrive/myYOLO1/images_resize_all_pad"
!mkdir $images_pad_dir
# パディングする色(黒)を指定
color = (0, 0, 0)

for file_name in os.listdir(images_all_dir):
  if file_name.endswith('.png'):
    # 画像ファイルを読み込む
    file_path = os.path.join(images_all_dir, file_name)
    img = Image.open(file_path)
    # 画像の右端と下端にパディングを追加する
    img = ImageOps.expand(img, border=(0, 0, width_pad, height_pad), fill=color)
    # パディングした画像を保存する
    save_img_path = os.path.join(images_pad_dir, file_name)
    img.save(save_img_path)
~~~
YOLO入力画像サイズの制約への対応
~~~
# ラベルファイル情報をリサイズ後の画像に適した情報に変換し、YOLO学習ファイル構成に置き換える
img_defort_path="/content/drive/MyDrive/myYOLO/images_all/000000.png"
# 画像ファイルを読み込む
img = Image.open(img_defort_path)
# 画像の高さと幅を取得する
defort_width , defort_height = img.size

img_path="/content/drive/MyDrive/myYOLO1/images_resize_all_pad/000000.png"
# 画像ファイルを読み込む
img = Image.open(img_path)
# 画像の高さと幅を取得する
width , height = img.size
# ラベル情報を書き換える比率を算出
ratio=width/defort_width

# (YOLO学習ファイル構成に変換前の)学習用のラベル情報と画像の格納先
images_all_dir="/content/drive/MyDrive/myYOLO1/images_resize_all_pad"
labels_all_dir="/content/drive/MyDrive/myYOLO1/labels_all"

#訓練用と評価用に分解する為のディレクトリ生成
base_dir="/content/drive/MyDrive/myYOLO1"
!mkdir $base_dir
#画像用
images_dir="/content/drive/MyDrive/myYOLO1/images"
!mkdir $images_dir
images_dir_train=os.path.join(images_dir, 'train')
!mkdir $images_dir_train
images_dir_val=os.path.join(images_dir, 'val')
!mkdir $images_dir_val
images_dir_test=os.path.join(images_dir, 'test')
!mkdir $images_dir_test
#ラベル用
labels_dir="/content/drive/MyDrive/myYOLO1/labels"
!mkdir $labels_dir
labels_dir_train=os.path.join(labels_dir, 'train')
!mkdir $labels_dir_train
labels_dir_val=os.path.join(labels_dir, 'val')
!mkdir $labels_dir_val
labels_dir_test=os.path.join(labels_dir, 'test')
!mkdir $labels_dir_test

#画像データリスト取得
images_list=list(sorted(os.listdir(images_all_dir)))
idxs=list(range(len(images_list)))
np.random.shuffle(idxs)

#データを訓練(7)用/検証(2)用/評価(1)用に分割
train_idx=idxs[:int(0.7*len(idxs))]
val_idx=idxs[int(0.7*len(idxs)):int(0.9*len(idxs))]
test_idx=idxs[int(0.9*len(idxs)):]

# 画像データ分のループを回し、訓練用/検証用/評価用に分けてラベルデータを格納していく
for idx,img_name in enumerate(images_list):
    print(idx)
    # idxにより格納先のフォルダ名を振り分け
    if idx in val_idx:
      subset="val"
    elif idx in test_idx:
      subset="test"
    else:
      subset="train"

    #ラベルファイル名/パスを取得
    label_name = img_name[:-4]+".txt"
    path=os.path.join(labels_all_dir,label_name)
    if os.path.isfile(path):
      #ラベルの読み込み
      f = open(path, 'r', encoding='UTF-8')
      df = pd.read_csv(path, sep=" ", header=None)
      df.columns = ['type', 'truncated', 'occluded', 'angle_rad', 'bbox_lftx', 'bbox_lfty', 'bbox_rgtx', 'bbox_rgty', 'hgt', 'wdt', 'lgt', 'X', 'Y', 'Z', 'YAW']

      #YOLO対応フォーマットに変換、画像をリサイズしたので検出矩形座標も変換
      df['x_center'] = (ratio*0.5*(df['bbox_rgtx']+df['bbox_lftx']))/width
      df['y_center'] = ((0.5*(df['bbox_rgty']+df['bbox_lfty']))*ratio)/height
      df['width'] = (ratio*(df['bbox_rgtx']-df['bbox_lftx']))/width
      df['height'] = (ratio*(df['bbox_rgty']-df['bbox_lfty']))/height
      df_yolo = pd.DataFrame([df['type'], df['x_center'], df['y_center'], df['width'], df['height']]).T
      df_yolo.columns = ['class', 'x_center', 'y_center', 'width', 'height']

      # 画角外にはみ出すラベルを除去する
      df_yolo=df_yolo[df_yolo['x_center']<1]
      df_yolo=df_yolo[df_yolo['y_center']<1]
      df_yolo=df_yolo[df_yolo['width']<1]
      df_yolo=df_yolo[df_yolo['height']<1]

      #classを数値に変換
      df_yolo["class"] = df_yolo["class"].replace({"Car":"2", "Van":"2", "Truck":"2", "Pedestrian":"0", "Person_sitting":"0", "Cyclist":"3", "Misc":"3", "DontCare":"3", "Tram":"3"})
      #検出対象ではないclassを削除
      df_yolo = df_yolo.drop(df_yolo[df_yolo["class"] == "3"].index)

      #YOLO対応フォーマットに変換したラベルを出力
      label_file_path=os.path.join(labels_dir,subset,img_name[:-4]+".txt")
      df_yolo.to_csv(label_file_path, index=False, header=False, sep="\t")

    old_image_path=os.path.join(images_all_dir,img_name)
    new_image_path=os.path.join(images_dir,subset,img_name)
    shutil.copy(old_image_path,new_image_path)
~~~
学習用と評価用にデータを分ける
YOLO学習に対応するフォルダ階層に、画像データとラベルデータを分けた。
KITTIのラベル情報は検出矩形の左上と右下の座標を示す形式になっているので、YOLOフォーマットへ変換した。
画像をリサイズしているのでその分の補正も実施した。
また、今回は[車両][歩行者]に識別対象を絞り、不要な検出種別に関する情報を削除した。
~~~
#YOLO学習用の設定ファイル(yaml)を作成(車両と歩行者を識別する)
yolo_format=dict(train="images/train",
                 val="images/val",
                 nc=3,
                 names=["Pedestrian", "None", "Car"])

with open('/content/drive/MyDrive/myYOLO1/yolo.yaml', 'w') as outfile:
    yaml.dump(yolo_format, outfile, default_flow_style=False)

# 学習開始
model=YOLO('yolov8n.pt')
history=model.train(data="/content/drive/MyDrive/myYOLO1/yolo.yaml", project="/content/drive/MyDrive/myYOLO1/save",epochs=50,patience=25,batch=16,lr0=0.0005,imgsz=640)
~~~
YOLOパラメータ学習
学習用情報を記載したyamlファイルを作成し、事前学習済みのパラメータ(yolov8m)から学習を実行
~~~
# 評価
path_best_weights="/content/drive/MyDrive/myYOLO1/save/train/weights/best.pt"
model = YOLO(path_best_weights)
metrics = model.val()

print(f"Mean Average Precision @.5:.95 : {metrics.box.map}")
print(f"Mean Average Precision @ .50   : {metrics.box.map50}")
print(f"Mean Average Precision @ .70   : {metrics.box.map75}")
~~~
学習したモデルの評価/学習の過程を確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586353/e3355ea8-9fd0-0c62-2d33-a8ee4f2b35a1.png)
~~~
# テストデータで検知結果を確認
test_imgs_dir='/content/drive/MyDrive/myYOLO1/images/test'
with torch.no_grad():
    results=model.predict(source=test_imgs_dir,conf=0.50,iou=0.75)
# 推論結果の格納先
!mkdir "/content/drive/MyDrive/myYOLO1/predictions"
prediction_dir="/content/drive/MyDrive/myYOLO1/predictions"

fig,axes=plt.subplots(5,3,figsize=(12,12))
plt.subplots_adjust(wspace=0.1,hspace=0.1)
ax=axes.flatten()
imgs_name=np.random.choice(test_img_list,15)

for i,img_name in enumerate(imgs_name):
    img_file_path=os.path.join(test_imgs_dir,img_name+".png")
    img=cv2.imread(img_file_path)
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)

    label_file_path=os.path.join(prediction_dir,img_name+".txt")
    label=pd.read_csv(label_file_path,sep=" ",header=None).values
    scores=label[:,0]
    boxes=label[:,1:]
    show_bbox(img,boxes,scores,axis=ax[i])
plt.savefig("1.png")

# 推論結果を表示する関数
def show_bbox(img,boxes,scores,axis,color=(0,255,0)):
    boxes=boxes.astype(int)
    scores=scores
    img=img.copy()
    for i,box in enumerate(boxes):
        score=f"{scores[i]:.4f}"
        cv2.rectangle(img,(box[0],box[1]),(box[2],box[3]),color,2)
        y=box[1]-10 if box[1]-10>10 else box[1]+10
        cv2.putText(img,score,(box[0],y),cv2.FONT_HERSHEY_SIMPLEX,0.5,color,2)

    axis.imshow(img)
    axis.axis("off")
~~~
テストデータで実行結果を確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3586353/204ada0c-27da-e8f2-87da-b7f25930cbd1.png)
問題ないレベル感と判断
※リサイズに伴うラベル情報の補正間違いにより、全然変なとこに検出し「？」なんだこれを何度も繰り返した・・

#### アプリ作成
~~~
import os
from flask import Flask, request, redirect, render_template, flash, send_file, make_response, Response
from werkzeug.utils import secure_filename
import torch
from ultralytics import YOLO
from PIL import Image
import numpy as np
import cv2
import io

UPLOAD_FOLDER = "uploads"
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])

app = Flask(__name__)
# app.config['JSON_AS_ASCII'] = False

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

#学習済みモデルをロード(モデル軽量化対策)
model = YOLO('./best.pt')
state_dict = model.state_dict()
saved_state_dict = torch.load("./model_para.pt")
for name, value in saved_state_dict.items():
    state_dict[name] = value
model.load_state_dict(state_dict, strict=False)

@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        if 'file' not in request.files:
            flash('ファイルがありません')
            return redirect(request.url)
        file = request.files['file']
        if file.filename == '':
            flash('ファイルがありません')
            return redirect(request.url)
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)

            # 受け取った画像をモデルに渡して推論する
            with torch.no_grad():
                results = model.predict(filepath, save=False)
                
            img=cv2.imread(filepath)
            img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)

            if len(results[0].boxes.xyxy):
                boxes=results[0].boxes.xyxy.cpu().numpy()
                scores=results[0].boxes.conf.cpu().numpy()
                show_bbox_tmp(img,boxes,scores)
  
            # レスポンスに画像を添付する
            # 画像をメモリ上に保存する
            image = io.BytesIO()
            Image.fromarray(img).save(image, 'PNG')
            image.seek(0)
            # レスポンスオブジェクトを作成する
            response = make_response(send_file(image, mimetype='image/png'))
            return response
    return render_template("index.html",answer="")

def show_bbox_tmp(img,boxes,scores,color=(0,255,0)):
    boxes=boxes.astype(int)
    scores=scores
    for i,box in enumerate(boxes):
        score=f"{scores[i]:.4f}"
        cv2.rectangle(img,(box[0],box[1]),(box[2],box[3]),color,2)
        y=box[1]-10 if box[1]-10>10 else box[1]+10
        cv2.putText(img,score,(box[0],y),cv2.FONT_HERSHEY_SIMPLEX,0.5,color,2)

if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)
~~~
YOLOで学習したモデルサイズが大きく、アプリ公開サイトのRenderの無料容量オーバー問題が発生
学習回数の少ない容量の小さいモデルと学習後のモデルパラメータのみを保存したファイルを用意。
モデルパラメータを上書きすることで、容量問題をクリア
Git登録→Renderデプロイを経て、画像から車両と歩行者を検出し表示する自作アプリの完成

### ⑤感想
さらっとやったことを記載しましたが、いくつかのポイントで頭が禿げそうになるくらい悩みました。。
googlecolabで学習していましたが学習時間が長く自動でランタイム接続が切れて学習結果を得られないとか、推論結果の精度が出ない問題、renderのファイル容量オーバー。
Aidemy講師の方に迅速かつ的確なアドバイスをいただきながら、少しずつ課題要因を特定し問題解決していくことを繰り返すことで、能力進展につながったと思います。
ここで学んだことを活かして、pythonやAIを使い、いろんなことに挑戦したいと思いました。