はじめて作る予測モデルを組み込んだWebアプリ

# はじめに
はじめまして。金丸と申します。
プログラミングスクールで学んだことを活かして「ブタクサチェッカー」なるアプリを作ってみました。
「ブタクサ」と「セイタカアワダチソウ」という似ている植物を、機械学習した予測モデルで2値分類するWebアプリです。

## 開発言語
* HTML5
* CSS3
* Python3.11.8
### フレームワーク
* Django4.2.1
* Bootstrap5.3
### ライブラリ
* jQuery3.5.1
* tensorflow2.12.0
* Pillow9.5.0
* numpy1.23.5
* selenium4.31.0
* webdriver-manager4.0.2
### ツール
* GoogleColaboratory
* ColorHunt
* diagrams.net(旧: draw.io)
* ChatGPT

## データ集め
最初の作業として、予測モデルを作成するために、Webスクレイピングを行いました。こちらの記事（[【Pythonスクレイピング】Google画像検索から画像を自動保存してみた【コピペOK】](https://pepenoheya.blog/python-scraping-google-img-search/)）を参考にChatGPTやインストラクター様から手助けをもらい、「ブタクサ」と「セイタカアワダチソウ」の画像データを合わせて約800枚集めることができました。
参考までにコードを載せておきます。このコードは画像検索したいキーワードを入力して、そのキーワードをgoogle画像検索して集めるコードです。
```Python
import os
import requests
from selenium.webdriver import Chrome, ChromeOptions
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from time import sleep

# Selenium設定（ヘッドレスモード）
options = ChromeOptions()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--window-size=1920,1080")
service = Service()
service.creation_flags = 0x08000000
browser = Chrome(options=options, service=service)

# Google画像検索ページにアクセス
url = "https://www.google.co.jp/imghp?hl=ja"

try:
    browser.get(url)
    # 検索ボックスが現れるまで待機
    kw_search = WebDriverWait(browser, 10).until(
        EC.presence_of_element_located((By.NAME, "q"))
    )

except Exception as e:
    print(f"検索ボックスが見つかりませんでした: {e}")
    browser.quit()
    exit()

# 検索キーワード入力
keyword = input("画像検索したいキーワードを入力してください！：")

kw_search.send_keys(keyword)
kw_search.send_keys(Keys.ENTER)

# 画像URLを取得するためのリストと取得枚数の目標
img_urls = set()  # 重複防止のためset利用
target_count = 400
scroll_pause = 2
max_attempts = 50  # ループ回数の上限

attempt = 0
thumbnail_num = 0
while thumbnail_num < target_count and attempt < max_attempts:

    attempt += 1
    # スクロール
    browser.execute_script("window.scrollTo(0, document.body.scrollHeight);")
    sleep(scroll_pause)
    
    # ページ内の「もっと見る」ボタンがあればクリックする
    try:
        # ボタンの文言やxpathは変更される可能性があるため、表示されていればクリック
        show_more = browser.find_element(By.CSS_SELECTOR, ".mye4qd")
        if show_more.is_displayed():
            show_more.click()
            sleep(scroll_pause)
    except Exception:
        # ボタンがない場合は無視
        pass

    # 現在表示されている画像要素を取得し、src属性にhttpが含まれているものをURLに追加する
    thumbnail_elements = browser.find_elements(By.XPATH, "//a[div[div[div[g-img[img]]]]]")
    thumbnail_num = len(thumbnail_elements)
    print("取得可能画像数：　", thumbnail_num)

# 画像がクリックできるようにTOPに戻る
browser.execute_script("window.scrollTo(0, 0);")
sleep(scroll_pause)

# 画像をクリックして、大きい画像のリンクを取得、蓄積
for img in thumbnail_elements:

    img.click()

    WebDriverWait(browser, 10).until(
        EC.presence_of_element_located((By.CLASS_NAME, "sFlh5c"))
    )
    img_element = browser.find_element(By.CLASS_NAME, "sFlh5c")
    src = img_element.get_attribute("src")
    if src and src.startswith("http"):
        img_urls.add(src)
    
print(f"取得済み画像URL数：{len(img_urls)}")

# 取得したURLをリストに変換し、目標枚数に切り詰める
img_urls = list(img_urls)[:target_count]

# 保存先ディレクトリ
save_dir = r"./img/" + keyword
if not os.path.exists(save_dir):
    os.makedirs(save_dir)

# 画像保存
for idx, url_img in enumerate(img_urls):
    try:
        r = requests.get(url_img, timeout=10)
        if r.status_code == 200:
            file_path = os.path.join(save_dir, "{0}_{1:0=3}.jpg".format(keyword, idx+1))
            with open(file_path, "wb") as fp:
                fp.write(r.content)
            print(f"{file_path} を保存しました。")
        else:
            print(f"ステータスコード {r.status_code} で失敗: {url_img}")
        sleep(0.1)
    except Exception as e:
        print(f"エラーが発生しました: {e}")

browser.quit()
```
### 簡単な解説（使用方法）
**検索方法**
```python
# 検索キーワード入力
keyword = input("画像検索したいキーワードを入力してください！：")

kw_search.send_keys(keyword)
kw_search.send_keys(Keys.ENTER)

# 実行時（例 ブタクサ）
# PS C:\myapp\myenv> python .\scraping\scraping.py
# 画像検索したいキーワードを入力してください！：ブタクサ
```
この例では、コンソール画面に「ブタクサ」と入力しています。

**取得したい画像枚数**
```python
target_count = 400
```
「target_count」に必要な画像データの枚数を指定します。
今回は、400枚欲しかったので、target_count = 400にしました。

```python
# 保存先ディレクトリ
save_dir = r"./img/" + keyword
if not os.path.exists(save_dir):
    os.makedirs(save_dir)

# 実行時（例）
# myapp/img/ブタクサ
```
現在いるディレクトリに「img/keyword」ディレクトリがなければ作成する処理です。

## データの前処理
データを集めることができたので、集めたデータの前処理を行いました。以下の点に気を付けて、データの選別を行いました。
* [人や文字が中心に映っている写真](#人や文字が中心に映っている写真)
* [イラスト](#イラスト)
* [対象とするデータかどうか判別が難しい写真](#対象とするデータかどうか判別が難しい写真)
データの前処理に苦労したので、一つずつ解説していきます。
### 人や文字が中心に映っている写真
![butakusa-moji-hito.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4059099/4e2e1c71-7b50-470e-873e-e9cbe47aa230.png)

この例では、画面左側に確かにブタクサの写真が入っています。しかし、画面右側には関係のない「文字」や「人」が混ざり込んでしまい、その部分も学習してしまうためデータには適していません。

### イラスト
![butakusa-イラストや.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4059099/7a02c51d-5b62-4f2f-8bbe-628f1b7ec081.png)
イラストは
* 「抽象化」や「デフォルメ」が強い
* 「現実の法則」や「一貫性」が保証されない
* 絵柄や表現の多様性が高すぎて、同じクラスでも特徴がバラバラになりやすい
といった理由からデータセットしては不適とされることが多いです。

### 対象とするデータかどうか判別が難しい写真
![butakusa-muzukasii.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4059099/fd30f744-65fc-46f0-82cb-4f5451a47575.jpeg)
ブタクサには葉がギザギザという特徴があるのですが、この写真は上記2つの画像と比べると、葉がやや丸く雄花や雌花もないため、このようなデータは取り除いた方が無難です。


## 予測モデルの作成
テストデータと学習用データに振り分け出来たら、GoogleColabを利用して予測モデル作成しました。
学習済みのVGG16を転移学習させて、出力層を2つに絞りました。

1. __VGG16の全結合層以外の取得__
    ```Python
    from tensorflow.keras.applications.vgg16 import VGG16
    vgg16_without_fc = VGG16(weights='imagenet', include_top=False, input_shape=(256, 256, 3))
    ```
1. __独自の全結合層の追加__
    ```Python
    # VGG16のSequentalモデルへの変換
    from tensorflow.keras.models import Sequential
    model = Sequential()
    for layer in vgg16_without_fc.layers:
        model.add(layer)
    
    # VGG16のパラメータ凍結
    for layer in model.layers:
        layer.trainable = False
    ```

1. __独自の全結合層部分のみの学習__
    ```Python
    # 独自の全結合層の追加
    from tensorflow.keras.layers import Flatten, Dense
    model.add(Flatten())
    model.add(Dense(256, activation='relu'))
    model.add(Dense(2, activation='softmax'))

    # 誤差関数、最適化方法、評価指標の設定
    model.compile(loss='binary_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])
    ```
* 隠れ層の活性化関数にrelu関数、出力層の活性化関数にソフトマックス関数を使用
* 誤差関数として二値交差エントロピー誤差（Binary Cross-Entropy Error）を使用
* 最適化手法としてAdamを、評価指標として正解率（accuracy）を指定
* エポック数を6~12,バッチサイズを16,32,64と変更しながら学習

適切なところで学習を完了し、予測モデルを保存しました。

__イメージ図__
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4059099/197561ee-366b-471c-a214-6dbf64cbea2e.png)

## Webアプリの実装
Djangoを使い、以下の手順でWebアプリを実装していきました。
1. [ワイヤーフレーム作成](#ワイヤーフレーム作成)
1. [フォームの作成](#フォームの作成)
1. [ビューの作成](#フォームの作成)
1. [テンプレートの作成](#テンプレートの作成)

### ワイヤーフレーム作成
ここまでの工程で予測モデル作成が完了しましたので、次はWebアプリのワイヤーフレーム作成に取り掛かりました。
シンプルなWebアプリであるため、デザインもシンプルなものにしました。
文字や判別機を中央に配置することを意識して、ColorHuntとdiagrams.netを使いユーザーが見やすいデザインを考えました。

### フォームの作成
```python
from django import forms

class ImageUploadForm(forms.Form):
    image = forms.ImageField(label='',
                            widget=forms.ClearableFileInput(attrs={'class': 'custom-file-input'})
    )
```
画像をアップロードする機能を作るため、Formクラスを継承しクラス変数「image」にフィールドを設定しています。
見た目を整えるためにフィールドオプションに「label」と「widget」を設定しています。

### ビューの作成
次に、以下の順でビューの作成をしました。
1. 必要なライブラリのインポート
1. GETリクエストによるアクセス時の処理の実装
1. POSTリクエストによるアクセス時の処理の実装

```python
from django.shortcuts import render
from django.conf import settings
from io import BytesIO
from .forms import ImageUploadForm
from tensorflow.keras.models import load_model
from tensorflow.keras.preprocessing.image import load_img
from tensorflow.keras.preprocessing.image import img_to_array
import os

def predict(request):
    if request.method == 'GET':
        form = ImageUploadForm()
        return render(request, 'top.html', {'form': form})
    if request.method == 'POST':
        form = ImageUploadForm(request.POST, request.FILES)
        if form.is_valid():
            img_file = form.cleaned_data['image']
            img_file = BytesIO(img_file.read())
            img = load_img(img_file, target_size=(256, 256))
            img_array = img_to_array(img)
            img_array = img_array.reshape((1, 256, 256, 3))
            img_array = img_array/255
            model_path = os.path.join(settings.BASE_DIR, 'prediction', 'models', 'model.h5') 
            model = load_model(model_path)
            result = model.predict(img_array)
            probabilities = max(result[0].tolist())
            probabilities = round(probabilities * 100)
            if result[0][0] > result[0][1]:
                prediction = 'ブタクサ'
            else:
                prediction = 'セイタカアワダチソウ'
            img_data = request.POST.get('img_data')
            return render(request, 'top.html', {'form': form, 'prediction': prediction, 'img_data': img_data, 'probabilities': probabilities})
        else:
            form = ImageUploadForm()
            return render(request, 'top.html', {'form': form})
```

#### 必要なライブラリのインポート
```python
from django.shortcuts import render
```
Djangoが提供する「render」関数をインポートする記述です。「render」関数は、特定のテンプレートとデータを元にHTMLを作成します。
```python
from .forms import ImageUploadForm
```
先程作成した「ImageUploadForm」クラスをインポートしています。これにより、「views.py」で「ImageUploadForm」クラスを利用できます。
```python
from io import BytesIO
```
主にデータの取り扱いを担う「io」モジュールの「BytesIO」をインポートしています。アップロードされた画像ファイルを、予測モデルに適した形式へ変換するために使用します。

#### GETリクエストによるアクセス時の処理の実装
```python
def predict(request):
    if request.method == 'GET':
        form = ImageUploadForm()
        return render(request, 'top.html', {'form': form})
```
render関数の第3引数に連想配列を指定しています。「top.html」テンプレート内で、「form」変数を活用して画像アップロードフォームを表示できます。

#### POSTリクエストによるアクセス時の処理の実装
```python
if request.method == 'POST':
        form = ImageUploadForm(request.POST, request.FILES)
        if form.is_valid():
            img_file = form.cleaned_data['image']
            img_file = BytesIO(img_file.read())
            img = load_img(img_file, target_size=(256, 256))
            img_array = img_to_array(img)
            img_array = img_array.reshape((1, 256, 256, 3))
            img_array = img_array/255
            model_path = os.path.join(settings.BASE_DIR, 'prediction', 'models', 'model.h5') 
            model = load_model(model_path)
            result = model.predict(img_array)
            probabilities = max(result[0].tolist())
            probabilities = round(probabilities * 100)
```
```python
form = ImageUploadForm(request.POST, request.FILES)
```
POSTリクエストで送信されたデータを引数として「ImageUploadForm」クラスのインスタンスを作成し、「form」変数に代入しています。「request.POST」はファイル以外の送信されたデータ、「request.FILES」は送信されたファイルを意味します。

```python
img_file = form.cleaned_data['image']
img_file = BytesIO(img_file.read())
img = load_img(img_file, target_size=(256, 256))
img_array = img_to_array(img)
img_array = img_array.reshape((1, 256, 256, 3))
img_array = img_array/255
```
送信された画像データを、4次元の配列に変換し255で割ることで0から1の範囲で正規化しています。

```python
model_path = os.path.join(settings.BASE_DIR, 'prediction', 'models', 'model.h5') 
model = load_model(model_path)
result = model.predict(img_array)
probabilities = max(result[0].tolist())
probabilities = round(probabilities * 100)
```
予測モデルのファイルパスを生成して、判定した予測結果を百分率に変換しています。

#### テンプレートの作成
```Django
<form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">判定</button>
  </form>
```
ユーザーが入力したデータを送信するためのものです。「enctype="multipart/form-data"」は、フォームにファイルアップロードが含まれる場合に記述します。
また、CSRF攻撃を防ぐために{% csrf_token %}を記述しています。formを実装する場合、formタグの内部に配置する必要があります。

# 終わりに
最後までお読みいただき、誠にありがとうございます。
データの集めに使ったコードや、データの前処理の考え方などが皆様の参考になれば幸いです。
この経験を活かして、次はデータベースや会員登録機能などを実装したWebアプリを開発できるように精進して参ります。

# 参考にした記事
[【Pythonスクレイピング】Google画像検索から画像を自動保存してみた【コピペOK】](https://pepenoheya.blog/python-scraping-google-img-search/)