Mobilenetを使って太陽光パネル上の異物を検知するアプリを作ってみました

# 目次
・はじめに
・太陽光パネルの異物を検知する目的
・作業環境
・学習に用いたデータセット
・転移学習に使用するモデルの選定
・学習用データ作成
・MobileNetの転移学習
・判断根拠の可視化
・最後に



## はじめに
自然エネルギー関係の仕事に転職を考えている社会人です。
機械学習を活用して、太陽光パネルの異物を検知するアプリを作成したので、記事にしました。

作成したアプリはこちらになります。
https://foreignmatterdetection.herokuapp.com
![スクリーンショット 2022-06-27 221311.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712746/666c87b1-4712-d917-433f-d59e3298442d.png)

## 太陽光パネルの異物を検知する目的
太陽光パネルが影や異物に覆われてしまうと、覆われた面積以上の発電量低下が生じるそうです。また、発電パネルの一部に負荷が集中して故障の原因にもなるようです。
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Ryk3CD6EoM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
【太陽電池】太陽電池パネルにおける部分影の悪影響　Negative Impact of Partial Shading on Photovoltaic Panel　パワーエレクトロニクス研究室―Power Electronics Lab.


そのため、発電量の低下や発電パネルの故障を防ぐために、パネルの画像から異物を検出して、メンテナンスが必要であることを通知するアプリを作りたいと思いました。

## 作業環境
・Windows 11 PC
・Google Colaboratory

## 学習に用いたデータセット
ワシントン大学のサチンらによって作成されたソーラーパネルの45,754枚の画像を使用しました。
ソーラーパネルに異物がない写真と異物が載せられている写真をそれぞれ15000枚ずつに分けて学習を行いました。
https://www.kaggle.com/datasets/paulrohan2020/solarpanelsoilingimagedataset
S. Mehta, A. P. Azad, S. A. Chemmengath, V. Raykar, and S. Kalyanaraman, DeepSolarEye: Power Loss Prediction and Weakly Supervised Soiling Localization via Fully Convolutional Networks for Solar Panels," 2018 IEEE Winter Conference on Applications of Computer Vision (WACV), Lake Tahoe, NV, 2018, pp. 333-342.


## 転移学習に使用するモデルの選定
MobileNetはVGG16 に比較して、下記のメリットがあるため、今回はMobileNetを使用しました。
・同等の精度を持つ
・サイズは32分の１
・処理速度は23分の１
Andrew G. Howard et al. "MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications" https://arxiv.org/pdf/1704.04861.pdf


## 学習用データ作成
Googleドライブ上のファイルを使えるように下記のコードを実行します。
```python
from google.colab import drive
drive.mount('/content/drive')
```

続いて、ダウンロードしたソーラーパネル画像のデータセットを学習用データにします。
```python
# -*- coding: utf-8 -*-
#ラベリングによる学習/検証データの準備
from PIL import Image
import os, glob
import numpy as np
import random, math
from google.colab import files

#画像が保存されているルートディレクトリのパス
root_dir = "/content/drive/My Drive/make_dataset/"
# パネルの状態
categories = ["通常","異物あり"]

# 画像データ用配列
X = []
# ラベルデータ用配列
Y = []

#画像データごとにadd_sample()を呼び出し、X,Yの配列を返す関数
def make_sample(files):
    global X, Y
    X = []
    Y = []
    for cat, fname in files:
        add_sample(cat, fname)
    return np.array(X), np.array(Y)

#渡された画像データを読み込んでXに格納し、また、
#画像データに対応するcategoriesのidxをY格納する関数
def add_sample(cat, fname):
    img = Image.open(fname)
    img = img.convert("RGB")
    img = img.resize((150, 150))
    data = np.asarray(img)
    X.append(data)
    Y.append(cat)

#全データ格納用配列
allfiles = []

#カテゴリ配列の各値と、それに対応するidxを認識し、全データをallfilesにまとめる
for idx, cat in enumerate(categories):
    image_dir = root_dir + "/" + cat
    file = glob.glob(image_dir + "/*.jpg")
    for f in file:
        allfiles.append((idx, f))

#シャッフル後、学習データと検証データに分ける
random.shuffle(allfiles) 
th = math.floor(len(allfiles) * 0.6)
train = allfiles[0:th]
test  = allfiles[th:]
X_train, y_train = make_sample(train)
X_test, y_test = make_sample(test)
xy = (X_train, X_test, y_train, y_test)
#データを保存する
np.save("/content/drive/My Drive/dataset_size150.npy", xy)
```



## MobileNetの転移学習
先ほど作成したデータセットを用いて転移学習し、モデルの重みを出力します。
```python
# -*- coding: utf-8 -*-
import glob
import shutil
import cv2
import os
from sklearn.cluster import KMeans
import numpy as np
import tensorflow
from tensorflow.keras import optimizers
from tensorflow.keras.applications.mobilenet import MobileNet
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras.utils import to_categorical
import matplotlib.pyplot as plt
import numpy as np
#モデルの保存
import os
from google.colab import files

#モデルの構築
#input_tensorの定義をして、MobileNetのImageNetによる学習済みモデルを作成
input_tensor = Input(shape=(200, 200, 3))
MobileNet =  MobileNet(include_top=False, weights='imagenet', input_tensor=input_tensor)

# 特徴量抽出部分のモデルを作成
top_model = Sequential()
top_model.add(Flatten(input_shape=MobileNet.output_shape[1:]))
top_model.add(Dense(64, activation='softmax'))
top_model.add(Dropout(0.5))
top_model.add(Dense(2, activation='softmax'))

# MobileNetV2とtop_modelを連結
model = Model(inputs=MobileNet.input, outputs=top_model(MobileNet.output))

# 最後の層以前の重みを固定
for layer in model.layers[:-1]:
    #layer.trainable = True
    layer.trainable = False

#モデル構造を確認
model.summary()

#モデルのコンパイル
model.compile(optimizer=optimizers.RMSprop(lr=1e-4),
              loss="binary_crossentropy",
              metrics=["accuracy"])

#データの準備
categories = ["通常","異物あり"]
nb_classes = len(categories)

X_train, X_test, y_train, y_test = np.load("/content/drive/My Drive/dataset_size200.npy",allow_pickle=True)

#データの正規化
#X_train = X_train.astype("float") / 255
#X_test  = X_test.astype("float")  / 255

#kerasで扱えるようにcategoriesをベクトルに変換
y_train = to_categorical(y_train, nb_classes)
y_test  = to_categorical(y_test, nb_classes)


#モデルの学習
history1 = model.fit(X_train,
                  y_train,
                  epochs=5,
                  validation_data=(X_test,y_test))

#resultsディレクトリを作成
result_dir = 'results'
if not os.path.exists(result_dir):
    os.mkdir(result_dir)
# 重みを保存
model.save(os.path.join(result_dir, 'MobileNet_size200.h5'))
files.download( '/content/results/MobileNet_size200.h5' ) 

#学習結果を表示
import matplotlib.pyplot as plt
acc = history1.history['accuracy']
val_acc = history1.history['val_accuracy']
loss = history1.history['loss']
val_loss = history1.history['val_loss']
epochs = range(len(acc))

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.savefig('精度を示すグラフ.png')

plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.savefig('損失値を示すグラフ.png')
```
検証用データでの精度は99%以上でした。
![精度を示すグラフ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712746/7e6f549d-7b48-9872-7d9d-26a8ec0eca99.png)
Validation lossは4 epochで最小になっている結果でした。
![損失値を示すグラフ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712746/cd94f5b1-46a4-033c-7f91-978fcc79fa6c.png)




## 判断根拠の可視化
太陽光パネルに異物がある画像をモデルに入力した際に、モデルがどこに注目しているのかをGrad-CAM (Selvaraju et al.) を使って確認してみました。
IQ108のうに (https://zenn.dev/iq108uni/articles/7269a1b72f42be) さんの記事を参考にさせていただきました。

```python
# -*- coding: utf-8 -*-
import shutil
import numpy as np
from tensorflow.keras import optimizers
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras.utils import to_categorical
import matplotlib.pyplot as plt


from PIL import Image
import os, glob
import random, math
from tensorflow.keras import backend as K
import tensorflow as tf
import keras
from tensorflow.keras import models
from tensorflow.keras.models import model_from_json
from tensorflow.keras.preprocessing import image
from tensorflow.keras.models import Sequential, load_model

#学習済みモデルをロード
model = load_model('/content/drive/My Drive/MobileNet_size200.h5')

model.compile(loss="binary_crossentropy",
              optimizer=optimizers.RMSprop(lr=1e-4),
              metrics=["accuracy"])

#画像を読み込む
img_path ="/content/drive/My Drive/solar_Tue_Jun_20_10__50__48_2017_L_0.133647128118_I_0.772329411765.jpg"
img = image.load_img(img_path,target_size=(200, 200, 3))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)

#予測
features = model.predict(x)
print(features)
result = model.predict(x)[0]
predicted = result.argmax()

classes = ["異物なし","異物あり"]
print(classes[predicted])

from copy import deepcopy
from skimage.transform import resize
def make_gradcam_heatmap(img,model,target_layer_name,pred_index=None):
    grad_model = tf.keras.Model(
        [model.inputs],[model.get_layer(target_layer_name).output,model.output]
    )
    
    with tf.GradientTape() as tape:
        target_layer_output, preds = grad_model(img)
        if pred_index is None:
            pred_index = tf.argmax(preds[0])
        class_channel = preds[:,pred_index]
    
    grads = tape.gradient(class_channel,target_layer_output)
    pooled_grads = tf.reduce_mean(grads,axis=(0,1,2)) # αの計算
    
    heatmap = target_layer_output[0] @ pooled_grads[...,tf.newaxis] # α * A^k
    heatmap = tf.squeeze(heatmap)
    
    # ReLU and normalize
    heatmap = tf.maximum(heatmap, 0) / tf.math.reduce_max(heatmap)
    
    return heatmap.numpy()


img_rgb = image.load_img(img_path,color_mode="rgb",target_size=(224,224))
img = image.img_to_array(img_rgb)
test_img = deepcopy(img)


target_layer_name = "conv_pw_13_relu"
heatmap = make_gradcam_heatmap(x,model,target_layer_name)
# 画像と同サイズにupsampling
# order=0 でただの拡大 order=1~5 で滑らかに拡大
gradcam = resize(heatmap,(200,200),order=1,mode="reflect",anti_aliasing=False)

# Grad-CAMの結果表示
fig = plt.figure(figsize=(200,200))
fig.add_subplot(1,2,2)
plt.imshow(test_img / 255.)
plt.imshow(gradcam ,cmap="jet",alpha=0.5)
fig.add_subplot(1,2,1)
plt.imshow(test_img / 255.)
plt.show()
```
出力された画像を見ると、異物付近に注目していることが確認できました。![detect_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2712746/c6ec2df8-0313-7d50-326c-b0e1be2c9ac5.png)


## 最後に
今回、Mobilenetを転移学習して、太陽光パネルの異物をある程度検出できるモデルを作ることができました。
課題としては、学習に使用した画像が限定的なため、汎用性が低いことです。
インターネットで他の太陽光パネルの画像を探して、モデルに入れると異物がないパネルの画像を異物ありと誤判定してしまいます。
学習画像の重要性を実感することができました。

この記事が誰かのお役に立てたら幸いです。
最後まで見ていただき、ありがとうございました。