LoginSignup
1
1

More than 1 year has passed since last update.

VGG16をファインチューニングして縄文顔・弥生顔を分類するアプリを作ってみた

Last updated at Posted at 2023-04-25

はじめに

この記事は、「Aidemy AIアプリ開発講座」の最終課題として「VGG16のファインチューニングによる縄文顔・弥生顔の分類」に取り組んだ結果をまとめたものです。

筆者は齢40代半ば、ちょっとばかし独学で簡単なマクロをいじったことはあるものの、本格的なプログラミングとは無縁の事務職をやっております。近年巷で騒がれている「DX」やら「AI」やらに触れてみたいと思い、このたび一念発起してAidemyさんの講座を受講するに至った次第です。学習成果を今後の仕事に活かせるかどうかはさておき、プログラミングは頭を使うのでボケ防止になりそうですよね!?生涯学習の一環として学んで参りたいと思います!!

以下、プログラミング初心者の試行錯誤の結果なので、いろいろ突っ込みどころが多いかもしれませんが、どうぞ悪しからず(ペコリ)。

目次

  1. 成果物
  2. 実行環境
  3. プログラミング作業概要
  4. 各工程の説明
  5. 完成したアプリ
  6. 考察
  7. 参考文献

1.成果物

VGG16のファインチューニング/転移学習により「縄文顔(jomonクラス)」と「弥生顔(yayoiクラス)」を学習させ、顔画像を縄文顔・弥生顔に分類するアプリをPythonで作成しました。また、作成したアプリをFlaskでウェブアプリ化し、Render上で公開しました。

↓アプリはこちら(クリックするとアプリが開きます ※起動に時間がかかることがありますがご容赦ください・・・)↓
flaskapp.png

2.実行環境

2.1.ハードウェア

  • PC
    DELL inspiron 14 5425
    OS: Windows 11
    CPU: AMD Ryzen 7 5825U
    メモリ: 16GB

2.2.ソフトウェア

  • コーディング用エディタ
    Visual Studio Code

  • ブラウザ
    Google Chrome

  • プログラミング用ソフトウェア
    Python v3.9.13 および 関連ライブラリ

2.3.作業用フォルダ

2.3.1.フォルダ構成(全体像)

作業用フォルダimage_recognitionを作成し、ソースファイルや機械学習用の画像等のアプリ関連データ一式を格納しました。

	image_recognition	 
			|
			 --	download_imgs *画像データを格納するためのフォルダ
			|
			 -- test.py *実行用ファイル(my_modules内のモジュールを適宜呼び出して利用する)
			|
			|
			 --haarcascade_frontalface_default.xml * my_modules/clip_face.py で必要
			| 
			|		
			 --	my_modules *画像収集、データセット作成、機械学習で利用する自作モジュール

2.3.2.my_modules関連

工程別の処理内容をモジュール化し、フォルダmy_modulesにまとめておき、必要に応じて呼び出して使用するようにしました(手戻りが発生することも多々あったので(汗)、必要なモジュールを適宜利用できるようにしておくのが便利だと感じました・・・)。

import文で自作モジュールを呼び出せるようにするため、my_modulesの中に__init.py__という名前の空のファイルを作成しておきます。

自作モジュール webscraper.py, clip_face.py, imgdata_split.py, vgg16model_learn.py, plot_learning_history.py, plot_learning_history.py, judge_jomon_yayoi.py の中身については後ほど説明します。

	image_recognition
			|
			|
			 -- test.py *実行用ファイル(my_modules内のモジュールを適宜呼び出して利用する)
			|
			|
			 --haarcascade_frontalface_default.xml * my_modules/clip_face.py の処理で必要
			|
			|
			 --	my_modules *画像収集、データセット作成、機械学習で利用する自作モジュール
					|
					 --	__init.py__ *自作モジュールをimportするために必要(中身は空)
					|
					 --	webscraper.py *スクレイピングによる画像収集用
					|
					 --	clip_face.py *スクレイピングで収集した画像から顔画像を切り抜いて保存
					|
					 --	imgdata_split.py *データセット作成(顔画像をtrain, validation, test用に分割)
					|
					 --	vgg16model_learn.py *VGG16のファインチューニング実行
					|
					 --	plot_learning_history.py *epochごとのaccuracy, loss(historyデータ)をグラフ化
					|
					 --	judge_jomon_yayoi.py *テスト画像のクラス判定結果の可視化用

3.アプリ作成の流れ

以下のような流れでアプリを作成しました(後述の 4.各工程の説明 で詳しく説明します)。

ステップ1. ライブラリ等の準備 ... 4.1.
ステップ2. 縄文顔、弥生顔の画像収集 ... 4.2.
ステップ3. 画像の確認 ... 4.3.
ステップ4. 顔部分の切り出し ... 4.4.
ステップ5. データセットの作成 ... 4.5.
ステップ6. 機械学習の実行 ... 4.6.
ステップ7. モデルの評価 ... 4.7.
ステップ8. ウェブアプリの作成 ... 4.8.
ステップ9. ウェブアプリの公開 ... 4.9.

4.各工程の説明

以下では、アプリ作成時の各工程の詳細と、実際に作成したコードについて説明します。

4.1.ライブラリ等の準備

ライブラリのインストール

アプリ作成で必要となるPythonライブラリを予めインストールしておきます。

  • numpy
  • pandas
  • matplotlib
  • glob
  • random
  • shutil
  • math
  • os
  • time
  • datetime
  • requests
  • selenium
  • opencv-python
  • webdriver-manager
  • keras
  • tensorflow
  • ImageDataGenerator
  • flask
  • werkzeug

顔認識に用いるデータファイルのダウンロード

顔認識に用いるhaarcascade_frontalface_default.xml をダウンロードして、image_recognitionフォルダの直下に配置します。
haarcascade_frontalface_default.xml のダウンロードはこちら

4.2.縄文顔、弥生顔の画像収集

Google 画像検索で「縄文顔(jomonクラスに相当)」「弥生顔(yayoiクラスに相当)」の画像を収集します。

検索ワードが「縄文顔」「弥生顔」のみでは画像数に限りがあったため、以下のような検索ワードを用いて画像を収集することとしました。

  • jomonクラス = 縄文顔、ソース顔、彫りの深い顔
  • yayoiクラス = 弥生顔、醤油顔、塩顔、彫りの浅い顔

ここでは、「スクレイピング」という手法により、ブラウザ起動・画像検索・ダウンロードまでを自動で行います(my_modules/webscraper.py)。ダウンロードした画像は、検索ワード別にフォルダ分けして保存します。

スクレイピングにはseleniumというライブラリを用います。Google Chrome(以下Chrome)の操作に必要となるwebdriver最新版をダウンロードするため、webdriver_managerを使用しています。

my_modules/webscraper.py について

webscraper.py
###########################################################################
# サブファンクション1
###########################################################################
# Google画像検索で画像URL一覧を取得しCSVファイルに保存する
# 
# input: 
#  search_word : 検索ワード ex. ソース顔
#  save_name : 検索結果の画像URL一覧の出力先となるCSVファイル名のプレフィクス ex."sauce"
#  n_add_scroll: int (>=0) / 画面スクロール追加(値を増やすほど多くの画像URLの読み込みが可能)
# output:
#  画像URL一覧: ex. sauce.csv
#  画像URL一覧のCSVファイルのパス ex. "./sauce.csv"
# 
def get_img_urls_by_google(search_word, save_name, n_add_scroll):
    import pandas as pd
    import os
    from time import sleep
    from selenium import webdriver
    from selenium.webdriver.common.by import By
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.chrome.service import Service
    from webdriver_manager.chrome import ChromeDriverManager
    
    options = Options()
    options.add_argument("--incognito")  # シークレットウィンドウでChromeを起動
    # options.add_argument('--headless') # headlessモードで実行する際に使用

    # drivermanagerの最新バージョンをインストール
    service = Service(ChromeDriverManager().install())
    driver = webdriver.Chrome(service=service, options=options)
    driver.implicitly_wait(10)

    # google画像検索画面を開く
    driver.get("https://www.google.co.jp/imghp?hl=ja&tab=ri&ogbl")
    sleep(3)

    # サーチボックスにsearch_wordを入力して検索
    search_box = driver.find_element(By.CLASS_NAME, "gLFyf")
    search_box.send_keys(search_word)
    search_box.submit()
    sleep(2)

    # 検索画面をheight<XXXの値に達するまでスクロール(検索画像を画面に読み込むための処理)
    height = 1000
    max_height = 1000 + n_add_scroll*100
    while height <= max_height:
        driver.execute_script("window.scrollTo(0,{});".format(height))
        height += 100
        sleep(2)

    # 検索画面の画像要素を選択する
    img_elements = driver.find_elements(By.CLASS_NAME, "rg_i")

    # 画像要素の属性値からURLを取得しリストに格納する
    url_list = []
    i = 1
    for img_element in img_elements:
        img_url = img_element.get_attribute("src")
        # urlの値が空でない&httpsを含む場合、urlをリストに追加
        if img_url and "https:" in img_url:
            data = {"search_word": search_word, "filename":"{}_{}.jpg".format(save_name, i), "img_url": img_url}
            url_list.append(data)
            i += 1
        sleep(2)

    # 画像のURLリストをデータフレームに読み込む
    df = pd.DataFrame(url_list)
    
    # 画像のURLリストをCSVファイルに出力する
    urllistfile_path = "./" + save_name + ".csv"
    df.to_csv(urllistfile_path)
    
    # 画像URLリストのCSVファイルのパスを返す
    return (urllistfile_path)

    # ブラウザーを閉じる
    driver.quit()



###########################################################################
# サブファンクション2
###########################################################################
# CSVに記載された画像URL一覧を読み込み、URLから画像をダウンロードして保存する
# 
# input: 
#  save_name: 検索ワード別の画像URL一覧の出力フォルダ名 兼 出力ファイル名のプレフィクス ex. "sauce"
#  urllistfile_path: 画像URL一覧(CSVファイル)のパス
# output:
#  画像保存用フォルダ: ex. ./sauce
#  画像ファイル: ex. ./sauce/sauce1.jpg, ./sauce/sauce2.jpg, ... ./sauce/sauceN.jpg
# 
def download_img(save_name, urllistfile_path):
    import os
    from time import sleep
    import requests
    import pandas as pd

    # 画像のURLリストのCSVの読み込み
    df = pd.read_csv(urllistfile_path)

    # 画像保存用のフォルダを作成(既に同名フォルダが存在する場合は作成しない)
    savedir_path = "./" + save_name
    if not os.path.exists(savedir_path):
        os.makedirs(savedir_path)
    
    # 画像を保存
    for savefile_name, img_url in zip( df["filename"], df["img_url"] ):
        savefile_path = savedir_path + "/" + savefile_name
        img_data = requests.get(img_url)
        with open(savefile_path, "wb") as f:
            f.write(img_data.content)
        sleep(2)


###########################################################################
# メインファンクション
###########################################################################
# スクレイピングの実行用関数(画像URL一覧の取得と画面のダウンロード)
# 
# input: 
#  search_word : 検索ワード ex. ソース顔
#  save_name : 検索結果の画像URL一覧の出力先となるフォルダ名 兼 CSVファイル名のプレフィクス ex."sauce"
#  n_add_scroll: int (>=0) / 画面スクロール追加(値を増やすほど多くの画像URLの読み込みが可能)
# output:
#  画像URL一覧: ex. sauce.csv
#  画像URL一覧のCSVファイルのパス ex. "./sauce.csv"
#  画像保存用フォルダ: ex. ./sauce
#  画像ファイル: ex. ./sauce/sauce1.jpg, ./sauce/sauce2.jpg, ... ./sauce/sauceN.jpg
#
#
def webscraper_google(search_word, save_name, n_add_scroll):
    download_img(save_name, get_img_urls_by_google(search_word, save_name, n_add_scroll))

test.pyから呼び出して実行する方法(例)

test.py
import my_modules.webscraper as ws
search_word = "縄文顔"
save_name = "jomon" #検索ワード別の画像URL一覧の出力フォルダ名 兼 出力ファイル名のプレフィクス
n_add_ascroll = 300 #ここを増やすとダウンロード画像数が増える 
ws.webscraper_google(search_word, save_name, n_add_scroll)

4.3.画像の確認

検索ワード別のフォルダ内にダウンロードされた画像を実際に開いて確認します。

画像の中には意図せぬ画像(例えば、「縄文顔」で検索したのに縄文土器の画像がダウンロードされている、等)が混じっていることが多々あるので、不要なものは削除します。

確認完了後の画像を格納するため、download_imgsの直下に新規フォルダ after_inspection を作成し、検索ワード別のフォルダごとこちらに移動しておきます。

4.4.顔部分の切り出し

機械学習の際、画像上の顔判別と無関係な部分は学習時のノイズになり得るため、画像データから顔部分のみを切り出して利用します( clip_face.py)。

clip_face.pyでは、顔画像を抽出するため、cv2というライブラリを利用しています。

my_modules/clip_face.py について

clip_face.py

###########################################################################
# サブファンクション(メインファンクションで利用)
###########################################################################
# 指定フォルダに格納されたjpg画像(複数枚可)から顔部分を抽出し、別途指定された保存用フォルダ内に出力する
# 
# input:
#  img_dir_path: 顔抽出元となるjpg画像(複数枚可)が保存されているフォルダのパス
#  out_dir_path: 抽出された顔画像の出力先となるフォルダのパス
#  haar_file_path: haarcascade_frontalface_default.xmlのファイルパス
# 
# output:
#  顔画像の出力先となるフォルダ
#  顔画像
# ##########################################################################
def clip_face_from_img_dir(img_dir_path, output_dir_path, haar_file_path="./haarcascade_frontalface_default.xml"):
    # OpenCVのインポート
    import cv2
    import glob
    import os
    import shutil

    # カスケード型分類器に使用する分類器のデータ(xmlファイル)を読み込み
    cascade = cv2.CascadeClassifier(haar_file_path)

    # 画像ファイルのパスをリストで取得
    img_path_list = glob.glob(img_dir_path + "/*.jpg")
    
    # 検知された顔画像の保存用フォルダの作成(保存用フォルダが未作成の場合)
    if not os.path.exists(output_dir_path):
        os.makedirs(output_dir_path)
 
    for img_path in img_path_list:
        
        # 画像ファイル名の拡張子を除くベース部分を取り出す
        imgfilename_base = os.path.splitext(os.path.basename(img_path))[0]
        
        img = cv2.imread(img_path)
        
        # グレースケールに変換する
        img_gray = cv2.imread(img_path, 0)

        # カスケード型分類器を使用して画像ファイルから顔部分を検出する
        face_rects = cascade.detectMultiScale(img_gray, minSize=(50, 50))

        # 1つ以上の顔が検知された場合
        if len(face_rects) > 0:
            face = face_rects.tolist()
            
            # 検知された顔画像を保存用フォルダに出力する
            i = 1
            for (x, y, w, h) in face_rects:
                # print("x, y, w, h:", x, y, w, h)
                
                # # 元画像の顔を白枠で囲む
                # cv2.rectangle(img, (x,y), (x+w,y+h), color=(255,255,255), thickness=2)
                
                # 顔部分を切り取る
                face = img[y:y+h, x:x+w]                
                cv2.imwrite(output_dir_path + "/" + imgfilename_base + "_f" + str(i) + ".jpg", face)
                
                i += 1
            
            # print("<result> " + imgfilename_base + ".jpg" + " -- " + str(i) + " face file saved in " + output_dir_path)      
            # # 元画像のすべての顔に白枠をつけたものを出力
            # cv2.imwrite(output_dir_path + "/" + imgfilename_base + "_all" + ".jpg", img)
        else:
            no_face_output_path = os.path.join("./no_face_imgs", os.path.basename(output_dir_path))
            if not os.path.exists(no_face_output_path):
                os.makedirs(no_face_output_path)
            shutil.copy(img_path, no_face_output_path)
            # print("<result> " + imgfilename_base + ".jpg" + " -- No face was detected.")



###########################################################################
# メインファンクション
###########################################################################
# 指定フォルダ(classified_dirs_root)内でフォルダ分けされたjpg(複数枚可)
# から顔部分を抽出し、別途指定された保存用フォルダ(output_dirs_root)内
# にフォルダ分けして保存する
# 
# input:
#  classified_dirs_root: フォルダのパス。直下に複数のサブフォルダを有し、
#                        各サブフォルダには顔抽出元となるjpg画像が複数格納されている
#           classified_dirs_root
#                   |
#                    --subdir1 * 各サブフォルダ内にはjpg画像が複数格納されている
#                   |
#                    --subdir2
#                        ...
#                   |
#                    --subdirN
# 
#  output_dirs_root: 抽出された顔画像が格納されたサブフォルダを格納するフォルダのパス。
# 
# output:
#  output_dirs_root内にサブフォルダ分けされた顔画像。各サブフォルダはclassified_dirs_root内のサブフォルダと対応関係にある。
# ##########################################################################

def clip_face_from_classified_dirs(classified_dirs_root, output_dirs_root):
    import os
    
    img_dir_path_list = []
    output_dir_path_list = []
    obj_list = os.listdir(classified_dirs_root)
    
    for obj in obj_list:
        imgdir_path = os.path.join(classified_dirs_root, obj)
        if os.path.isdir(imgdir_path):
            img_dir_path_list.append(imgdir_path)
            outputimgdir_path = os.path.join(output_dirs_root, obj)
            os.makedirs(outputimgdir_path)
            output_dir_path_list.append(outputimgdir_path)

    for img_dir_path, output_dir_path in zip(img_dir_path_list, output_dir_path_list):
        clip_face_from_img_dir(img_dir_path, output_dir_path)        

test.pyから呼び出して使う方法(例)
./download_imgs/after_inspection/検索ワード別のサブフォルダ内の画像データから顔部分を切り出し、出力用フォルダ./download_imgs/clipped_faces/検索ワード別のサブフォルダ内に保存します。

test.py
import my_modules.clip_face as cf

# classified_dirs_root は、直下の複数のサブフォルダに画像が格納されている前提
classified_dirs_root = "./download_imgs/after_inspection"

# 切り取った顔画像は、output_dirs_root の直下にサブフォルダ分けした形で出力される
# (classified_dirs_rootのサブフォルダとoutput_dirs_rootのサブフォルダとは対応関係にある)
output_dirs_root = "./download_imgs/clipped_faces"

cf.clip_face_from_classified_dirs(classified_dirs_root, output_dirs_root)

4.5.データセットの作成

顔画像の出力先フォルダ(./download_imgs/clipped_faces/検索ワード別のサブフォルダ)を開き、切り取られた顔画像を目視で確認します。時々、顔ではない部分が切り取られていることがあるので、そのようなデータは削除します。

次に、download_imgsの直下にclipped_faces_in_class_dirsを設け、その直下にクラス別のフォルダ(jomonフォルダ, yayoiフォルダを)を作成します。

続いて、顔画像の出力先フォルダ(./download_imgs/clipped_faces/検索ワード別のサブフォルダ)をjomonフォルダまたはyayoiフォルダのいずれか該当する方の直下に移動します(下図参照)。

※顔画像は サブフォルダ(検索ワードj-1), サブフォルダ(検索ワードj-2), ・・・, サブフォルダ(検索ワードy-N) 内に格納されている

image_recognition
    |
     -- download_imgs
    |           |
    |            -- clipped_faces_in_class_dirs
    |                       |
    |                        --  jomon
    |                       |       |
    |                       |        -- サブフォルダ(検索ワードj-1)
    |                       |       |
    |                       |        -- サブフォルダ(検索ワードj-2)
    |                       |      ...
    |                       |       |
    |                       |        -- サブフォルダ(検索ワードj-N)
    |                       |    
    |                       |
    |                        --  yayoi
    |                               |
    |                                -- サブフォルダ(検索ワードy-1)
    |                               |
    |                                -- サブフォルダ(検索ワードy-2)
    |                              ...
    |                               |
    |                                -- サブフォルダ(検索ワードy-N)
    |

データセット作成にあたり、clipped_faces_in_class_dirs内の顔画像を学習用(train)・検証用(validation)・テスト用(test)に振り分けます(imgdata_split.py)。

今回は、train+validationデータ数 と testデータ数 の比率を 8:2 とし、trainデータ数 と validationデータ数 の比率を 8:2 としています。

上記比率での振り分け処理を検索ワード別のサブフォルダごとに行うことで、train, validation, test のそれぞれにおける検索ワード別の画像数の比率に偏りが発生しないようにしています。

my_modules/imgdata_split.py について

imgdata_split.py
###########################################################################
# メインファンクション
###########################################################################
# クラス別のフォルダ内の画像データのコピーをtrain, validation, test用に振り分ける。
#  画像振り分け後、output_class_dirs_rootのtrain, validation, test別にフォルダ分けして
#  保存する。
# 
#  (前提)class_dirs_root内にクラス別(jomon, yayoi)のフォルダが存在し、
#         クラス別フォルダ内のサブフォルダ(複数可)内に画像データ(jpg)が
#         格納されていること
# 
def imgdata_split_from_class_dirs(class_dirs_root, output_class_dirs_root, train_val_test_ratiolist):
    
    import os
    import glob
    import random
    import shutil
    import math


    # クラス名リストの取得:
    #  class_dirs_root内のフォルダ(複数可)名はクラス名に一致。
    #  クラス名別のフォルダの中のサブフォルダ(複数可)内に画像データが格納されている。
    class_dir_name_list = []
    for obj_name in os.listdir(class_dirs_root):
        if os.path.isdir(os.path.join(class_dirs_root, obj_name)):
            class_dir_name_list.append(obj_name)
    
    # 出力用フォルダの作成:
    #  output_class_dirs_rootの直下に train, validation, testフォルダを作成する。
    for dir_name in ["train", "validation", "test"]:
        dir_path = output_class_dirs_root + "/" + dir_name
        if not os.path.exists(dir_path):
            os.makedirs(dir_path)
        for class_dir_name in class_dir_name_list:
            dir_path = output_class_dirs_root + "/" + dir_name + "/" + class_dir_name
            if not os.path.exists(dir_path):
                os.makedirs(dir_path)
    
    # 画像データの振り分け:
    #  class_dirs_rootのクラス名別フォルダのサブフォルダ内の画像データを
    #  train, validation, test用に振り分ける(データの比率はtrain_val_test_ratiolistに従う)
    for class_dir_name in class_dir_name_list:
        for obj_name in os.listdir(class_dirs_root + "/" + class_dir_name):
            obj_path = class_dirs_root + "/" + class_dir_name + "/" + obj_name
            if os.path.isdir(obj_path):
                subdir_path = obj_path
                # サブフォルダ内のjpgファイルのパスのリストを取得
                img_file_path_list = glob.glob(subdir_path + "/*.jpg")
                # train_val_test_ratiolistをもとに、validation・test用に振り分ける画像の個数を決定
                n_validation = math.floor( len(img_file_path_list) * train_val_test_ratiolist[1] / sum(train_val_test_ratiolist) ) 
                n_test = math.floor( len(img_file_path_list) * train_val_test_ratiolist[2] / sum(train_val_test_ratiolist) )
                
                print("train:val:test", len(img_file_path_list) - n_validation - n_test, n_validation, n_test)
                
                output_train_dir_path = output_class_dirs_root + "/train/" + class_dir_name
                output_validation_dir_path = output_class_dirs_root + "/validation/" + class_dir_name
                output_test_dir_path = output_class_dirs_root + "/test/" + class_dir_name
                
                random.shuffle(img_file_path_list)
                i = 0
                while i < n_validation:
                    shutil.copy(img_file_path_list[i], output_validation_dir_path)
                    i += 1
                while i < (n_validation + n_test):
                    shutil.copy(img_file_path_list[i], output_test_dir_path)
                    i += 1
                while i < len(img_file_path_list):
                    shutil.copy(img_file_path_list[i], output_train_dir_path)
                    i += 1

test.pyから呼び出して使う方法(例)

test.py
import my_modules.imgdata_split as ims

class_dirs_root = "./download_imgs/clipped_faces_in_class_dirs"
output_class_dirs_root = "./download_imgs/for_datasets"
train_val_test_ratiolist = [6.4, 1.6, 2]
ims.imgdata_split_from_class_dirs(class_dirs_root, output_class_dirs_root, train_val_test_ratiolist)

test.pyを実行すると、./download_imgs/for_datasets 内の train, validation, test の各フォルダの中に、jomon,yayoi のクラス別にフォルダ分けされた形で画像が出力されます(下図参照)。以上でデータセットの完成です。

作成されたデータセット
	image_recognition	 
			|
			 --	download_imgs 
			|			|
			|			--	for_datasets 
			|						|--	train
			|						|	 |
			|						|	  --	jomon *学習用画像を格納(クラス:jomon)
			|						|	 |
			|						|	  --	yayoi	*学習用画像を格納(クラス:yayoi)
			|						|
			|						 --	validation
			|						|	 |
			|						|	  --	jomon *検証用画像を格納(クラス:jomon)
			|						|	 |
			|						|	  --	yayoi	*検証用画像を格納(クラス:yayoi)
			|						|
			|						 --	test *学習済モデルの評価用のテストデータ格納用フォルダ
			|							 |
			|							  --	jomon *学習済モデルの精度評価用画像を格納(クラス:jomon)
			|							 |
			|							  --	yayoi	*学習済モデルの精度評価用画像を格納(クラス:yayoi)
			|

ちなみに、今回作成されたデータセットにおける train, validation, test のデータ件数(顔画像数)は下表の通りとなりました。

train validation test
jomon 116 26 35
yayoi 154 34 44
total 270 60 79

4.6.機械学習の実行

4.5.で作成したデータセットを用いて、縄文顔・弥生顔を判別するモデルを作成します(my_modules/vgg16model_learn.py)。

データセットのデータ数が少ないので(train用データ 計270件)、モデルをゼロから学習させて精度を上げるのは困難です。限られたデータ件数のもとで効率良く学習させられるよう、訓練済みモデルのファインチューニング/転移学習によりモデルを作成することとしました。ベースとなる訓練済モデルとして、画像分類のCNNモデルとして代表的なVGG16を選択しました。機械学習には keras というライブラリを使用します。

モデル作成用プログラムmy_modules/vgg16model_learn.pyの処理の流れは以下の通りです(詳細は4.6.1以降で説明します)。

■プログラム処理の流れ

  1. 各種パラメタの設定 ・・・ 4.6.1
  2. 画像データの水増し・正規化 ・・・ 4.6.2
  3. モデルの作成および機械学習の実施 ・・・ 4.6.3
  4. 結果の出力 ・・・ 4.6.4

my_modules/vgg16model_learn.py について

vgg16model_learn.py
###########################################################################
# メインプログラム
###########################################################################
# vgg16model作成・機械学習の実行用プログラム
# 
# input:
#  ・データセット画像一式をtrain・validation・test別に、かつクラス名別にフォルダ分けしたもの
# 
#  ・パラメタ
#     パス関連
#      app_root アプリ実行時のカレントフォルダ (例)"."
#      train_path (例)"./download_imgs/for_dataset/train"
#      validation_path (例)"./download_imgs/for_dataset/validation"
#      test_path (例)"./download_imgs/for_dataset/test"
# 
#      クラス情報
#       * class_list の要素は、train_path内およびvalidation_path内の サブフォルダ名(=クラス名に該当) と一致させる。
#       class_list (例) ["jomon", "yayoi"]
#       class_mode (例) "categorical" # 多項分類を扱う場合
#       loss (例) "categorical_crossentropy" # 多項分類を扱う場合
# 
#      画像サイズ・カラーモード
#       img_size (例) 128 # 顔画像(当プログラムでリシェイプして正方形に整えた後)の一辺のサイズ(px)
#       color_mode (例) "rgb"
# 
#      ミニバッチのサイズ
#       batch_size (例) 8
# 
#      ドロップアウトレート
#       dropout_rate (例) 0.50
# 
# output:
#   >> model_output_dir_path = app_root + "/model_output_{}".format(process_start_time)
#  ・学習済モデル
#  ・学習履歴(CSVファイル)
# 
# ##########################################################################
def learn_and_evaluate(
        app_root, 
        train_path,
        validation_path, 
        test_path, 
        class_list, 
        class_mode, 
        loss, 
        img_size, 
        color_mode, 
        batch_size, 
        dropout_rate, 
        ):

    ### 【STEP1】各種パラメタの設定 ##################################
    import datetime

    # モデルデータの出力用に model_output_YYYYmmddHHMMSS という名前のフォルダを作成(ただしYYYYmmddHHMMSSはプログラム処理の開始時刻)
    process_start_time = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))).strftime('%Y%m%d%H%M%S')
    model_output_dir_path = app_root + "/model_output_{}".format(process_start_time)



    print()
    print("------------------ データ拡張 -----------------------")
    ### 【STEP2】データ拡張 ##################################
    from keras.preprocessing.image import ImageDataGenerator
    train_datagen = ImageDataGenerator(
        rotation_range=45,
        width_shift_range=0.2,
        height_shift_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True,
        channel_shift_range = 0.2,
        fill_mode = "constant", # 入力画像が枠内に合わない場合の補間方法(ここでは黒単色で補完fill_mode="constant", cval=0))
        cval = 0,
        rescale=1./255, # 画像の正規化
        )

    train_generator = train_datagen.flow_from_directory(
        directory=train_path,
        classes=class_list,
        target_size=(img_size, img_size),
        color_mode = color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=True,
        seed=42,
        )


    validation_datagen = ImageDataGenerator(
        rescale=1./255, # 画像の正規化
        )

    validation_generator = validation_datagen.flow_from_directory(
        directory=validation_path,
        classes=class_list,
        target_size=(img_size, img_size),
        color_mode=color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=True,
        seed=42,
        )

    test_datagen = ImageDataGenerator(
        rescale=1./255, # 画像の正規化
        )

    test_generator = test_datagen.flow_from_directory(
        directory=test_path,
        classes=class_list,
        target_size=(img_size, img_size),
        color_mode=color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=False,
        seed=42,
        )

    print()
    print("------------------ 機械学習実行 -----------------------")
    ### 【STEP3】機械学習実行 ##################################

    from keras.applications.vgg16 import VGG16
    from keras.models import Sequential, Model
    from keras.layers import Input, Flatten, Dense, Dropout
    from keras.applications.vgg16 import VGG16
    from keras import optimizers
    from keras.callbacks import EarlyStopping, ModelCheckpoint

    input_tensor = Input(shape=(img_size, img_size, 3))

    # 最終層は差し替えるためinclude_top=Falseと指定
    vgg16 = VGG16(include_top=False, weights="imagenet", input_tensor=input_tensor)

    # 最終層を作成する
    top_model = Sequential()
    top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
    top_model.add(Dense(256, activation="relu"))
    top_model.add(Dropout(dropout_rate))
    top_model.add(Dense(len(class_list), activation="softmax"))
    model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

    # モデルの構造を表示する
    print(model.summary())

    # 初めの19レイヤの重みは更新しない
    for layer in model.layers[:19]:
        layer.trainable = False

    model.compile(
        loss=loss,
        optimizer=optimizers.SGD(learning_rate=1e-4, momentum=0.9),
        metrics=["accuracy"],
        )

    # epochsの値によらず、patienceで指定される回数のepoch数経過後に改善がない場合に訓練を停止させるためのコールバックの設定
    es = EarlyStopping(monitor='val_loss', patience=10, verbose=1)

    # 学習結果が最も良いmodelデータを保存するためのcheckpoint設定
    checkpoint_save_path = model_output_dir_path + "/model.h5"
    cp = ModelCheckpoint(checkpoint_save_path, save_best_only=True, save_weights_only=False)

    # モデルの機械学習を実行
    history = model.fit(
        train_generator,
        steps_per_epoch = train_generator.n // batch_size,
        epochs = 1000, # epochs上限ではなくcallbackで学習を終了させたいので、epochs上限は充分に大きな値に設定しておく
        verbose = 1,
        validation_data = validation_generator,
        validation_steps = validation_generator.n // batch_size,
        callbacks = [es, cp],
        )

    print()
    print("------------------ 学習結果の出力 -----------------------")
    ### 【STEP4】学習結果の出力 ##################################
    import pandas as pd

    # 学習過程のログをDataFrameに読み込み後、CSVファイルとして保存
    history_df = pd.DataFrame(history.history)
    history_csv_file_path = model_output_dir_path + "/history.csv"
    history_df.to_csv(history_csv_file_path)

    print("<result> model data saved in:", model_output_dir_path)

    # testデータ用のジェネレータを用いてモデルを評価し、accuracy, lossを画面に出力
    print("<result> model evaluation:")
    print(model.evaluate(test_generator))

test.pyから呼び出して使う方法(例)
引数を辞書型の変数input_dicに代入しておき、**input_dicとすることで、個々の引数として展開されて関数に引き渡されます。

test.py
import my_modules.vgg16model_learn as ml

input_dic = {
    "app_root": ".", 
    "train_path": "." + "/download_imgs/for_dataset/train", 
    "validation_path": "." + "/download_imgs/for_dataset/validation",
    "test_path": "." + "/download_imgs/for_dataset/test",
    "class_list": ["jomon", "yayoi"],
    "class_mode": "categorical", 
    "loss": "categorical_crossentropy", 
    "img_size": 128,
    "color_mode": "rgb", 
    "batch_size": 32,
    "dropout_rate":0.0, 
}

ml.learn_and_evaluate(**input_dic)

以下、vgg16model_learn.py における処理内容について順次説明します。

4.6.1.各種パラメタの設定

メインファンクション learn_and_evaluate() の引数として以下のパラメタを受け取ります。

■引数
app_root : プログラム実行時のカレントフォルダのパス
train_path : trainデータ用のフォルダパス
validation_path : validationデータ用のフォルダパス
test_path : testデータ用のフォルダパス
class_list : 今回の場合は["jomon", "yayoi"]
class_mode : 多項分類なので"categorical"とします
loss : 多項分類なので"categorical_crossentropy"とします
img_size : 入力画像(正方形)の一辺の長さ(px)
color_mode : カラー画像なので"rbg"とします
batch_size : ミニバッチのサイズ
dropout_rate : ドロップアウトレート

4.6.2.画像データの水増し・正規化

ImageDataGeneratorを用いて、画像データの水増し・リサイズ・正規化を行いながら学習を実施します。

trainデータ, validationデータ, testデータ のそれぞれに対して固有の設定を行えるよう、 それぞれ個別にImageDataGeneratorのインスタンスを作成しています。

train用の設定

(1)画像データの水増し

データ件数が少ないため、学習と並行してデータの水増しを行います。水増し方法は、様々な状況で撮影された顔画像を想定してある程度のバリエーションを持たせつつも、クラス別の特徴を失わないように注意しました。
・首の角度がすこし傾げているパターンを想定し、rotation_rangeの幅を持たせる
・画像の中心から顔の中心がずれて写っている場合を想定し、width_shift_rangeheight_shift_rangeの幅を持たせる
・カメラに寄り気味で撮影された場合を想定し、zoom_rangeの幅を持たせる
・照明の色合いで肌写りが変わるため、channel_shift_rangeの幅を持たせる
・左右反転した顔も顔データとして有効とみなし、horizontal_flip=Trueとする

train用, validation用, test用の共通設定

(1)画像の正規化

ピクセル値の正規化のため、rescale=1./255としています。

(2)画像のリサイズ

画像をモデルの入力サイズに合わせるため、flow_from_directory()メソッドでtarget_size=(img_size, img_size) ただしimg_sizeは学習時に設定した画像サイズ(px)としています。

vggmodel_learn.py(抜粋)
    train_datagen = ImageDataGenerator(
        rotation_range=45, # 【水増し】回転角度のレンジ
        width_shift_range=0.2, # 【水増し】左右へのシフトの割合
        height_shift_range=0.2, # 【水増し】高さ方向へのシフトの割合
        zoom_range=0.2, # 【水増し】拡大率
        horizontal_flip=True, # 【水増し】左右反転
        channel_shift_range=0.2, # 【水増し】色合いの変更
        fill_mode = "constant", # 水増し後の画像がtarget_sizeの枠出る場合は単色補完(ここでは黒単色で補完 cval=0))
        cval = 0,
        rescale=1./255, # 【正規化】ピクセル値を0から1までの値に変換
        )

    train_generator = train_datagen.flow_from_directory(
        directory=train_path,
        classes=class_list,
        target_size=(img_size, img_size), # 【リサイズ】img_sizeは学習時に設定した画像サイズ(px)
        color_mode = color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=True,
        seed=42,
        )

    validation_datagen = ImageDataGenerator(
        rescale=1./255, # 【正規化】ピクセル値を0から1までの値に変換
        )

    validation_generator = validation_datagen.flow_from_directory(
        directory=validation_path,
        classes=class_list,
        target_size=(img_size, img_size), # 【リサイズ】img_sizeは学習時に設定した画像サイズ(px)
        color_mode=color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=True,
        seed=42,
        )

    test_datagen = ImageDataGenerator(
        rescale=1./255, # 【正規化】ピクセル値を0から1までの値に変換
        )

    test_generator = test_datagen.flow_from_directory(
        directory=test_path,
        classes=class_list,
        target_size=(img_size, img_size), # 【リサイズ】img_sizeは学習時に設定した画像サイズ(px)
        color_mode=color_mode,
        batch_size=batch_size,
        class_mode=class_mode,
        shuffle=False,
        seed=42,
        )

4.6.3.モデルの作成および機械学習の実施

今回は、既存モデルVGG16のファインチューニングによりアプリ用のモデルを作成しました。

VGG16の最終層を差し替えるため、include_top=Falseと指定しています。

my_modules/vgg16model_learn.py抜粋
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

最終層のみ学習させるため、VGG16の19層目までの重みを固定しています。

my_modules/vgg16model_learn.py抜粋
for layer in model.layers[:19]:
    layer.trainable = False

EarlyStoppingを用いて、patienceで指定される回数のepoch数経過後に改善がない場合に訓練を停止させる設定としています。

my_modules/vgg16model_learn.py抜粋
    # epochsの値によらず、patienceで指定される回数のepoch数経過後に改善がない場合に訓練を停止させるためのコールバックの設定
    es = EarlyStopping(monitor='val_loss', patience=10, verbose=1)

4.6.4.結果の出力

学習済モデルのデータをmodel.h5として保存します。save_best_only=Trueとすることにより、学習過程において最も成績の良い結果のみ保存する設定としています。

my_modules/vgg16model_learn.py抜粋
    cp = ModelCheckpoint(checkpoint_save_path, save_best_only=True, save_weights_only=False)

trainデータおよびvalidationデータに対するepoch数ごとのaccuracy, lossの値をhistory.csvに出力します(後ほどグラフにします)。

vgg16model_learn.py
    # 学習過程のログをDataFrameに読み込み後、CSVファイルとして保存
    history_df = pd.DataFrame(history.history)
    history_csv_file_path = model_output_dir_path + "/history.csv"
    history_df.to_csv(history_csv_file_path)

testデータに対するaccuracy, lossの値を画面に出力し、my_modules/vgg16model_learn.pyの処理が終了します。

vgg16model_learn.py抜粋
    # testデータ用のジェネレータを用いてモデルを評価し、accuracy, lossを画面に出力
    print("<result> model evaluation:")
    print(model.evaluate(test_generator))

4.7.モデルの評価

4.6.4.結果の出力の結果をもとに、モデルの性能を評価し、最良なものをウェブアプリ用に採用することとします。

今回は、img_size, batch_size, dropout_rate をパラメタとし、モデルの性能を比較してみました。

・img_size (モデルのinput_shape (img_size, img_size, 3)を定める): 64(px), 128(px)で比較
・batch_size :1, 2, 4, 8, 16, 32 で比較
・dropout_rate :0.00, 0.25, 0.50, 0.75 で比較

4.7.1.学習過程のグラフ化

まず、plot_learning_history.pyを用いてhistory.csvのデータをプロットし、各パラメタのもとでの学習過程(train data, validation data に対する epochごとの accuracy, loss の変化)をグラフに表示します。

my_modules/plot_learning_history.py について

plot_learning_history.py
# epochsごとの学習進捗をグラフ化

def plot_history_csv(history_csv_file_path, save_graf_fig_path):

    import pandas as pd
    import matplotlib.pyplot as plt
    
    history_df = pd.read_csv(history_csv_file_path)
    acc = history_df["accuracy"]
    loss = history_df["loss"]
    val_acc = history_df["val_accuracy"]
    val_loss =history_df["val_loss"]

    epochs = range(1, len(acc) + 1)

    fig, axes = plt.subplots(2,1, sharex="all", tight_layout=True)

    axes[0].plot(epochs, acc, "r", label="train accuracy")
    axes[0].plot(epochs, val_acc, "b", label="validation accuracy")
    axes[0].set_title("train and validation accuracy values as a function of epochs")
    axes[0].legend()

    axes[1].plot(epochs, loss, "r", label="train loss")
    axes[1].plot(epochs, val_loss, "b", label="validation loss")
    axes[1].set_title("train and validation loss values as a function of epochs")
    axes[1].legend()

    # プロットされたグラフを画像データそてい保存(plot.png)
    plt.savefig(save_graf_fig_path + "/plot.png")
    
    plt.show()

test.pyから呼び出して使う方法(例)

test.py
import my_modules.plot_learning_history as plh

app_root = "."

# モデルデータ(model.h5)が格納されているフォルダのパスを指定"
# 例:app_root + "/model_output_YYYYMMDDhhmmss"
model_dir_path = "モデルデータが格納されているフォルダのパス"

save_graf_fig_path = model_dir_path
history_csv_file_path = model_dir_path + "/history.csv"

plh.plot_history_csv(history_csv_file_path, save_graf_fig_path)

plot_learning_history.pyで作成したグラフを下図に示します。

各グラフ(plot_dXXX_bXXX_imgXXX.png[※注])の上段がaccuracy, 下段がlossを示します。accuracy, lossの値が大きく揺らいでいるものは収束性が悪いとみなし(感覚的な判断ですが(汗))、ウェブアプリ用候補から除外します。

※d000, d025, d050, d075 はドロップアウトレートがそれぞれ0.00, 0.25, 0.50, 0.75を示します。また、img_064, img_128 は モデルのinput_shape (img_size, img_size, 3)における img_size がそれぞれ64px, 128pxであることを示します。

img_size_064.png

img_size_128.png

4.7.2.パラメタごとのaccuracy, lossの変化

下表は、各パラメタのもとで作成されたモデルの acc(accはaccuracyの略、以下同様), lossの値を示します。

表において、

  • train_acc、val_acc(valはvalidationの略、以下同様)、test_acc の各列で値が大きいものの上位10%(=結果が良好なもの)を赤色でハイライトしています。
  • train_loss, val_loss, test_loss の各列で値が小さいものの上位10%(=結果が良好なもの)を赤色でハイライトしています。
  • 「×:収束せず」とあるのは、4.7.1で収束性が悪いと判断したもの(モデル候補から除外すべきもの)を示します。

accuracy, lossが「良好」であり、かつ収束性が悪くないことが確認されたものをウェブアプリ用に採用します。今回は、赤枠内の条件「img_size=128(px), dropout_rate=0.25, batch_size=4」で作成されたモデルをウェブアプリ用に採用することとしました。

resultsheet.png

4.7.3. テスト画像の分類結果の確認

ウェブアプリ用に採用したモデルのもとで、個々のテスト画像に対して具体的にどのような判定結果が出されたのかを確認してみます(judge_jomon_yayoi.py)。

mu_modules/judge_jomon_yayoi.py について

judge_jomon_yayoi.py
##########################################################################
# メインファンクション
###########################################################################
# 学習済モデルで縄文顔/弥生顔を判別する
# 
# input:
#  model_weights_file_path:(必須) 学習済モデルの重みデータのファイルパス
#  test_dir_path:(必須) テスト用の顔画像(複数枚可)が格納されているフォルダのパス
#  img_size: (必須) モデルの学習時のinput_shape (img_size, img_size, channel) のimg_sizeと一致させること。
#  class_list: クラス名のリスト。モデルの学習時に使用したclass_listと一致させること。
#              ※class_listを設定しない場合は、test_dir_path + "内にクラスごとに
#             クラス別にフォルダを設けて画像を格納したうえで実行すること。
# 
# output:
#  顔画像の判定結果
# 
def judge_by_loading_model(model_dir_path, test_dir_path, img_size, class_list=[]):

    import numpy as np
    import glob
    import matplotlib.pyplot as plt
    import os
    
    # 画像データの読み込み・np配列への変換時に用いるモジュール
    from tensorflow.keras.utils import load_img, img_to_array

    # 学習済モデルをロードするために用いるモジュール
    import keras.models


    # 判定対象画像のリサイズに関する設定
    img_shape = (img_size, img_size, 3) 


    # test_dir_path直下のサブフォルダ(サブフォルダ名はクラス名に相当)をリストアップ
    class_dir_path_list = []
    subdir_name_list = []
    obj_path_list = glob.glob(test_dir_path + "/*")
    for obj_path in obj_path_list:
        if os.path.isdir(obj_path):
            class_dir_path_list.append(obj_path)
            subdir_name_list.append(os.path.basename(obj_path))
    # もしサブフォルダがない場合
    if len(class_dir_path_list) == 0:
        # 便宜上、class_dir_path_listは[test_dir_path](要素1つ)とする
        class_dir_path_list = [test_dir_path]
    # 関数の引数としてclass_listが設定されず、デフォルト[]のままの場合
    if len(class_list) == 0:
        # サブフォルダ名のリストをclass_listとする
        if not len(subdir_name_list) == 0:
            class_list = subdir_name_list
        else:
            # test_dir_path直下のサブフォルダがなく、かつ、関数の引数としてclass_listが設定されていない場合は、ここで処理中断
            print(test_dir_path + "内にクラスごとにフォルダを設けて画像を格納するか、もしくは、class_listにクラス名のリストを格納してからプログラムを実行してください。")
            exit()


    # 学習済モデルをロードする
    model = keras.models.load_model(model_dir_path, compile=False)
    

    for class_dir_path in class_dir_path_list:
        # 判定対象画像ファイルのパス一覧をリストで取得
        test_img_path_list = glob.glob(class_dir_path + "/*.jpg")

        # 画像の判定
        for img_path in test_img_path_list:
            # 画像データの読み込み
            img = load_img(img_path, grayscale=False, target_size=img_shape)

            # 画像データをnp配列に変換後、ピクセル値を正規化、shapeを(1, width, height, channel)に変換
            X_test = ( img_to_array(img) / 255 )[np.newaxis, :, :, :]
            
            # 学習済モデルで画像データに対する推定値y_pred_testを算出
            y_pred_test = model.predict(X_test)

            # np.argmaxでy_predをクラスラベル(0 or 1)に変換
            pred_index = np.argmax(y_pred_test, axis=1)

            # 結果の可視化
            plt.title( "predected val:" + class_list[int(pred_index)] + ", folder:" + os.path.basename(class_dir_path) + ", file:" + os.path.basename(img_path))
            plt.subplot().imshow(img)
            plt.show()

test.pyから呼び出して使う方法(例)

test.py
import my_modules.judge_jomon_yayoi as jy

app_root = "."

# testフォルダの中にjomon, yayoiフォルダがあり、それぞれにテスト用画像(.jpg)が格納されている
test_dir_path = app_root + "/download_imgs/for_dataset/test"

# モデルデータ(model.h5)が格納されているフォルダのパスを指定"
# 例:app_root + "/model_output_YYYYMMDDhhmmss"
model_dir_path = "モデルデータが格納されているフォルダのパス"

model_file_path = model_dir_path + "/model.h5"

img_size = 128
class_list = ["jomon", "yayoi"]

jy.judge_by_loading_model(model_file_path, test_dir_path, img_size, class_list)

実行すると、test用画像に対する判定結果が表示されます(閉じる「X」ボタンで次の画像が表示されます)。
result_of_judgement.png

この図では、判定結果が実際のクラス名と一致しているので、「正解」です。確認したところ、テスト画像79個のうち正解したものは59個、正解率は0.7468となりました(当然ですが、4.7.2.の表の赤枠内のtest_accに一致します)。

4.8.ウェブアプリの作成

学習済モデルを利用してウェブアプリを作成しました。仕様は以下の通りです。

ウェブアプリの仕様

  • ウェブフォーム経由でユーザが選択した人の顔が写っている画像(複数人の顔が写っている写真でもOK)を受領
  • 学習済モデルを用いて縄文顔・弥生顔に分類
  • 顔写真とともに分類結果を表示

ウェブアプリの作成手順は下記の通りです。

■手順
1.ウェブアプリ用のフォルダの作成 ... 4.8.1.
2.学習済モデルのロード ... 4.8.2.
3.requirements.txtの作成 ... 4.8.3.
4.ウェブアプリ用プログラムの作成 ... 4.8.4.
5.ホームページの作成 ... 4.8.5.
6.ローカル環境上での動作確認 ... 4.8.6.

以下、順を追って説明します

4.8.1.ウェブアプリ用のフォルダの作成

flask_appというフォルダを新規作成し、下図に倣ってサブフォルダを作成します。

.gitkeep は空のダミーファイルです。テキストエディタ等で空のファイルを作成し「.gitkeep」というファイル名で保存します。

フォルダ構成
hito_joumon.png

       flask_app *ウェブアプリのルートフォルダ
            |
            |
             --	static
            |		|
            |		 --	uploads *アップロードした画像をここに一時保管する
            |       |       |
            |       |        -- .gitkeep *中身は空。中身は空。空のフォルダはGitHubへアップロードされないため、uploadsフォルダ内にダミーの空ファイルを置いておく。
            |		|
            |		 -- hito_joumon.png *ページ装飾用の縄文人画像(無くても動きます)
            |		|
            |		 -- hito_yayoi.png *ページ装飾用の弥生人画像(無くても動きます)
            |		|
            |		 --	stylesheet.css
            |
             --	templates
            |		|
            |		--	index.html
            |
             --	judge_jomon_yayoi_flask.py *アプリ本体
            |
            |
             --	model.h5 *学習済モデル
            |
            |
             --	haarcascade_frontalface_default.xml *顔画像切り出しに使用
            |
            |
             --	requirements.txt *アプリで使用するpythonライブラリ一覧

4.8.2.学習済モデルのロード

フォルダflask_appの直下に学習済モデルmodel.h5をコピペしておきます。

4.8.3.requirements.txtの作成

テキストエディタを開き、アプリで使用するpythonライブラリの一覧をrequirements.txtというファイル名で作成し、flask_appの直下に保存しておきます。

requirements.txt
flask==2.0.1
h5py==2.10.0
Jinja2==3.0.1
numpy==1.18.0
opencv-python==4.7.0.72
pillow==7.2.0
protobuf==3.12.4
requests==2.25.1
tensorflow-cpu==2.3.0
Werkzeug==2.0.0

4.8.4.ウェブアプリ用プログラムの作成

画像から顔部分(複数人の顔が映っていてもOK)を抽出し、学習済モデルで縄文顔・弥生顔の判定を行い、判定結果をリストでhtmlに引き渡すプログラムを作成します。

事前準備として、顔抽出で使用するhaarcascade_frontalface_default.xmlを、flask_appの直下にコピペしておきます。

続いて、ウェブアプリの本体であるjudge_jomon_yayoi_flask.pyを作成します。

judge_jomon_yayoi_flask.py について

judge_jomon_yayoi_flask.py
import os
import glob
import numpy as np
from flask import Flask, request, render_template, flash
from werkzeug.utils import secure_filename
from tensorflow.keras.preprocessing import image
from tensorflow.keras.models import load_model


app = Flask(__name__, static_folder='static')

UPLOAD_FOLDER = "./static/uploads"
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

ALLOWED_EXTENSIONS = set(["jpg", "jpeg"])
HAAR_FILE_PATH = "./haarcascade_frontalface_default.xml"

# vgg16modelのinput_shape=(1,128,128,3)に合わせて設定
IMG_SIZE = 128
IMG_SHAPE = (IMG_SIZE, IMG_SIZE, 3)

# class_listの日本語表記(class_listはvgg16model作成時のもの:class_list = ["jomon", "yahoi"])
class_list_ja = ["縄文顔", "弥生顔"]

# ブラウザキャッシュの影響を受けないようにするための関数(元のurl_forと同じ引数)
def my_url_for(endpoint, **values):
    import time
    from flask import url_for
    url = url_for(endpoint, **values) 
    return url + '?ts={}'.format(int(time.time()))
# url_forを上書き
app.add_template_global(name='url_for', f=my_url_for)


# アップロードされた古い不要ファイルを削除するための関数
def remove_old_files_from_dir(dir_path):
    old_file_path_list = glob.glob(dir_path + "/*")
    for old_file_path in old_file_path_list:
        os.remove(old_file_path)


# アップロードされたファイルの拡張子が適切(jpgもしくはjpeg)かどうかを確認する関数(適切な場合はTrueを返す)
def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


# アップロード画像から顔部分を抽出してTEMP_IMG_FOLDERに保存(.jpg)するための関数
def clip_face_for_flaskapp(img_path, output_dir_path, haar_file_path):

    import cv2

    # 画像ファイルから顔部分のみ抽出するためのカスケード型分類器のインスタンス化
    cascade = cv2.CascadeClassifier(haar_file_path)

    # 画像データを読み込み
    img = cv2.imread(img_path)

    # 画像データをグレースケールに変換
    img_gray = cv2.imread(img_path, 0)

    # カスケード型分類器を使用して画像ファイルから顔部分を検出
    face_rects = cascade.detectMultiScale(img_gray, minSize=(50, 50))

    # 1つ以上の顔が検知された場合
    if len(face_rects) > 0:
        face_rects = face_rects.tolist()

        # 検知された顔画像をoutput_dir_pathで指定されるフォルダに出力する
        i = 1
        for (x, y, w, h) in face_rects:            
            # 顔部分を切り取る
            face = img[y:y+h, x:x+w]
            cv2.imwrite(output_dir_path + "/face-" + str(i) + ".jpg", face)
            i += 1
    else:
        flash("顔検出に失敗しました")
        


# @app.route("/", methods=["GET", "POST"])
@app.route("/")
def index():
    # 空の判定結果と空の顔画像ファイル名をindex.htmlへ引き渡す
    return render_template( "index.html", model_results=zip([],[]) )


@app.route("/send", methods=["GET", "POST"])
def judge():
    # UPLOAD_FOLDERに格納されている古いファイルがあれば削除する
    remove_old_files_from_dir(UPLOAD_FOLDER)

    # リクエストがPOSTの場合
    if request.method == "POST":
        
        # モデルによる判定結果を格納するためのリストの初期化
        answers = []
        face_file_path_list = []

        # 学習済モデルのロード
        model = load_model("./model.h5", compile=False)

        # POSTで送信されたファイルの適正さをチェック
        if "file" not in request.files:
            flash("ファイルがアップロードされていません")
            return render_template( "index.html", model_results=zip([],[]) )
            # return redirect(request.url)
        file = request.files["file"]
        if file.filename == "":
            flash("ファイルがアップロードされていません")
            return render_template( "index.html", model_results=zip([],[]) )
            # return redirect(request.url)
        
        # fileが空でない、かつ、ファイル名の拡張子がjpgまたはjpegである場合に限り、モデルによる画像判定処理(以下)を実行
        if file and allowed_file(file.filename):
            # アップロードファイルが悪意のあるファイル名をもつ可能性があるため、secure_filenam()で安全なものに変換
            filename = secure_filename(file.filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)

            clip_face_for_flaskapp(filepath, UPLOAD_FOLDER, HAAR_FILE_PATH)
            os.remove(filepath)

            # 処理対象とする顔画像のパス一覧を取得
            face_file_path_list = glob.glob(UPLOAD_FOLDER + "/*.jpg")
            
            # staticフォルダに対する顔画像の相対パスを格納するためのリストを初期化(htmlでの画像表示用)
            static_filepath_list = []

            # モデルによる顔画像判定と、判定結果と顔画像ファイル名のリストの出力            
            for face_file_path in face_file_path_list:

                # 顔画像のファイルパスをstaticフォルダに対する相対パスに変換(htmlでの画像表示用)
                static_filepath = "uploads" + "/" + os.path.basename(face_file_path)
                static_filepath_list.append(static_filepath)

                # 顔画像をIMG_SHAPEに指定されるサイズに変換して読み込み
                img = image.load_img(face_file_path, grayscale=False, target_size=IMG_SHAPE)

                # 顔画像をnp配列に変換後、ピクセル値を正規化、shapeを(1, width, height, channel)に変換
                data = ( image.img_to_array(img) / 255 )[np.newaxis, :, :, :]

                # モデルによる推定
                pred = np.argmax( model.predict(data) )

                # 判定結果をanswersに格納
                pred_answer = class_list_ja[int(pred)] + " です"
                answers.append(pred_answer)

            # 判定結果と顔画像ファイル名のリストをhtmlへ引き渡す
            return render_template( "index.html", model_results=zip(answers, static_filepath_list) )
        
        else:
            flash("顔画像のjpgファイルをアップロードしてください")
            return render_template( "index.html", model_results=zip([],[]) )

    # リクエストがGETの場合、空の判定結果と空の顔画像ファイル名をindex.htmlへ引き渡す
    return render_template( "index.html", model_results=zip([],[]) )


# Renderへデプロイする際はこちらを使用(デプロイ前の確認用は無効化しておくこと)
if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)

# # デプロイ前の確認用
# if __name__ == "__main__":
#     app.run()

4.8.5.ホームページの作成

ウェブアプリをホームページ上で動作させるので、ホームページ用のHTMLファイルindex.html および CSSファイルstylesheet.css を作成します。

index.htmlはホームページの構成を決めるファイルです。Flaskを利用して、アップロード画像のプログラム処理結果をリストで受け取り、画面に表示する設定としてます。index.htmltemplatesフォルダの直下に配置します。

index.html について

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>縄文・弥生顔判定</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='stylesheet.css') }}">
</head>
<body>
    <header>
        <h1>縄文顔・弥生顔判定</h1>
    </header>

    <main>
        <div class="illust_msg_box">
            <div class="illust_box"><img src="{{ url_for('static', filename='hito_joumon.png') }}" alt="jomon_person"/></div>
            <div class="maintxt">
                縄文顔か弥生顔かを判定します。<br>
                人の顔が写っている画像ファイル(.jpg .jpeg)を選択し、<br>
                「判定」ボタンを押してください。
            </div>
            <div class="illust_box"><img src="{{ url_for('static', filename='hito_yayoi.png') }}" als="yayoi_person"/></div>
        </div>


        <div class="img_submit_form">
            <form method="post" action="/send" enctype="multipart/form-data">
                <input class="file_choose" type="file" name="file">
                <input class="btn" value="判定" type="submit">
            </form>
        </div>

        <p>{{ file_name_list }}</p>
        {% for answer, file_name in model_results %}
            <div class="resultbox">
                {% if answer %}
                <p>{{ answer }}</p>
                {% endif %}
                {% if file_name %}
                <img src="{{ url_for('static', filename=file_name) }}" />
                {% endif %}
            </div>
        {% endfor %}

    </main>

    <footer>
        <small>&copy; All rights reserved.</small>   
    </footer>
</body>
</html>

stylesheet.css ではページの装飾を設定しています。stylesheet.css はstaticフォルダの直下に配置します。

stylesheet.css について

stylesheet.css
body {
    width: 1200px;
    height: auto;
    margin: 0 auto;
}

header {
    background-color: olivedrab;
    color: white;
    width: 1200px;
    height: 50px;
}

h1 {
    margin: 0 auto;
    text-align: center;
}


main {
    width: 800px;
    margin: 10px auto;
    min-height: 500px;
    color: #444444;
}

.illust_msg_box {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.illust_box {
    
}

.maintxt {
    flex: 1;
    color: #444444;
    text-align: center;
}

.img_submit_form {
    background-color:olive;
    color: white;
    text-align: center;
    margin: 5px auto;
    padding: 10px;
}

.resultbox {
    background-color: olivedrab;
    color: white;
    margin: 5px auto;
    text-align: center;
}

.faceimg {
    display:block;
    margin: 0 auto;
}

footer {
    width: 1200px;
    height: 30px;
    margin: 10px auto;
    background-color: olivedrab;
    color:white;
    text-align: center;
}


4.8.6.ローカル環境上での動作確認

ウェブアプリをRenderへデプロイする前に、ローカル環境上で動作確認します。

以下のように、flask_app/judge_jomon_yayoi_flask.pyの終盤にある「本番用」の部分をコメントアウトして無効化し、「ローカル環境上での動作確認用」の部分をアクティブにしておきます。

judge_jomon_yayoi_flask.py(抜粋)
# # 本番用(Renderへデプロイする際はこちらを使用。ローカル環境上での動作確認用は無効化しておくこと)
# if __name__ == "__main__":
#     port = int(os.environ.get('PORT', 8080))
#     app.run(host ='0.0.0.0',port = port)

# ローカル環境上での動作確認用
if __name__ == "__main__":
    app.run()

コマンドターミナルでpython judge_jomon_yayoi_flask.pyを実行すると、以下のようなメッセージが表示されます。

PS C:\hoge\image_recognition\flask_app> python judge_jomon_yayoi_flask.py
 * Serving Flask app "judge_jomon_yayoi_flask" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

コマンドターミナルに表示されたURL(http://xxx.xxx.xxx.xxx:XXX /)にブラウザからアクセスすると、ウェブアプリが表示されます。
flaskapp_test_1.png

顔が写っている画像(複数人の顔が写っている写真でも可)を選択して「判定」をクリックすると、判定結果が表示されます。
flaskapp_test_2.png

4.9.ウェブアプリの公開

アプリが正常に動作することが確認できたら、アプリをRenderへデプロイします。

まず、以下のように、flask_app/judge_jomon_yayoi_flask.pyの終盤にある「本番用」の部分をアクティブにし、「ローカル環境上での動作確認用」の部分をコメントアウトして無効化します。

judge_jomon_yayoi_flask.py(抜粋)
# 本番用(Renderへデプロイする際はこちらを使用。ローカル環境上での動作確認用は無効化しておくこと)
if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)

# # ローカル環境上での動作確認用
# if __name__ == "__main__":
#     app.run()

GitHub上にウェブアプリ用のレポジトリを作成し、flask_appの中身をプッシュ(アップロード)しておきます。Renderでアプリを新規作成し、GitHubのレポジトリを連携すれば、ウェブアプリを公開できます。

GitHubおよびRenderの利用について
ご利用あたってはアカウント登録が必要です。具体的な利用方法については各種文献・HP等に掲載されているので、ここでは省略します。

5.完成したアプリ

下の画像をクリックするとアプリへ移動します。
動作が重いので、なるべく軽めのJPG画像(~数百kB)でお試しください(汗)
flaskapp.png

6.考察

縄文顔=彫りの深い顔、弥生顔=彫りの浅い顔 という直感的な分類がそこそこ実現できたという印象です(accuracy 0.75程度)。

弱点として、
(1) 厚化粧を施された顔の判別が難しい
(2) 高齢者の顔はおおむね「縄文顔」に分類されてしまう
が挙げられます。

(1) については、厚化粧により外見が大きく変えられてしまうため、画像判断という性質上、克服が難しいと感じます。

(2) の原因は、加齢による皺の深さを彫りの深さと認識してしまっているためと推定しますが、もしそうであれば、高齢者の縄文顔・弥生顔の学習データを増やすことで改善が可能かもしれませんので、今後の課題としたいと思います。

7.参考文献

7.1.Seleniumによるスクレイピング

  1. [Qiita] selenium向けChromeDriverをpipでインストールする方法(パス通し不要、バージョン指定可能)

  2. [Qiita] Python+SeleniumWebDriverではwebdriver_managerを使うといちいちdriverのexeを置き換えなくて済む

7.2.画像からの顔抽出

  1. [IT PORT] (第3回)Python + OpenCV で遊んでみる(顔検出編)

7.3.データ増強

  1. [Qiita] ImageDataGeneratorを使ってみた
  2. [codexa] データ拡張(Data Augmentation)徹底入門!Pythonとkerasでデータ拡張を実装しよう

7.4.VGG16model作成

  1. [Keras Documentation] ImageDataGenerator

7.5.CNNによる機械学習

  1. [株式会社東京システム技研] 第4回 深層学習による画像分類

7.6.checkpoint設定

[Note] TensorFlow解説、モデルの保存と復元

7.7.学習過程の保存

  1. [cocoinit23] Kerasのmodel.fitが返すhistoryをpandasで保存して図のplotまで

7.8.flask関連

  1. [Tech Life] Flaskのテンプレートでurl_forを用いてリンク作成、画像表示を行う。

  2. [Stack Overflow] Access jinja2 globals variables inside template

  3. [EnsekiTT Blog] FlaskとOpenCVで投稿された画像をOpenCVで加工して返す話

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1