FlaskとOpenCVで液滴の粒子径と指数を表示してみた

# はじめに
農業用ドローンの研究をしている ”でって” です。よろしくお願いします。

初回投稿である今回は、[Aidemy PREMIUM AIアプリ開発講座](https://aidemy.net/)の成果物を紹介させていただきます。

微力ながら日々農業と向き合う中で、プログラミングができれば、これまでとは別の形で農業界へ恩返しできるのではと思い、Aidemyさんの講座を利用することにしました。
<br>

# 本記事の概要
- 農業用ドローンの散布性能評価
- 作成したプログラム　※コードの細かな解説や環境構築などの手順は除く
- 課題と展望

<br>

# 農業用ドローンの散布性能評価
ドローンの散布性能を評価する方法として、感水紙等の調査紙に付着した液滴を目視で判定する手法[「液剤少量散布（液剤散布）落下調査指標：p.58-59, 附-7/一般社団法人 農林水産航空協会」](https://www.j3a.or.jp/business/multirotor/4manual/manual_r05.pdf)があります。

目視に頼る手法のため、人が変われば判定結果は揺らぎますし、紙の枚数が増えれば作業負荷にもなります。
そこで、スマートフォンのカメラで撮影した画像から安定して判定できれば良いのではと思い、アプリ化に挑戦することにしました。

しかしながら、屋外の試験でスマートフォン撮影となると画角と光の問題で安定した画像は望めないため、まずはスキャナーを活用して調査紙の画像を作成し、安定した判定ができるWebアプリを目指します。

余談ですが、感水紙は文字通り水分に反応する紙のため、素手では触れませんし、試験日だけでなく保管中も注意が必要です。
そのため、ミラーコート紙（色付きの水で散布）を使用することもあります。

※今回の画像処理もミラーコート紙（1つの面が50mm x 50mm の十字シート, 1面ずつ事前に画像処理で抽出したもの）を使用しています。

【感水紙】
![kansui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/145d3721-183c-2ec0-54b3-e0fed9aa7d1a.png)


【十字シート（ミラーコート紙）】
![178-13.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/bb9822b0-e40a-2ed1-16dd-e63c43d5f906.jpeg)



![reference.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/80708a00-bf72-ca58-f41a-0dc0bc301cde.png)

![table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/2d6d3afd-01e8-8abb-27b6-4affa06782ef.png)


# 作成したプログラム
**使用環境**
- VS Code 1.77.0（デバッグ含む）※Python 3.9.13/anaconda, Flask 2.0.1
- Visual Studio 2022(HTMLのみ)
- GitHub（プログラム管理）　
- Render（Webアプリの作成）※pip 20.1.1

<br>

**アプリの構造**
root/ 
 ├ main.py　　　　　　　
 ├ uploads/
 ├ templates/
 │         ├ index.html
 │         └ images/
 │         └ reference.png
 ├ .vscode/
 │       └ launch.json
 ├ ＿pyache＿/
 │          └ main.cpython-39.pyc
 ├ requirements.txt
 └ README

<br>

**作成手順**
- 画像処理のみのプログラム作成　※この記事では扱いません。
- HTMLの作成
- Flaskを用いたWebアプリの作成
- GitHubへのアップロード　※この記事では扱いません。
- Renderでのデプロイ　※この記事では扱いません。

<br>

**プログラムの流れ**
簡単なフローを以下に示します。各プログラムについては個別の記載がありますので、そちらを参考にしてください。

![flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/72dc2d8f-631b-3cc6-b0ac-dc00a6499ec6.png)

<br>

**HTMLの作成**
![start.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/44b12870-4dee-e163-d158-5b3e2b3dc9c4.png)


~~~html:index.html
<!DOCTYPE html>
<html lang="ja">
 
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Determination of droplet</title>
    <style>
        body {
            background-image: radial-gradient(290px 300px, rgba(123,222,217, 0.5) 20%, transparent 20%), radial-gradient(800px 780px, rgba(123,222,217, 0.5) 20%, transparent 20%), radial-gradient(1000px 990px, rgba(123,222,217, 0.5) 20%, transparent 20%), radial-gradient(400px 380px, rgba(123,222,217, 0.5) 20%, transparent 20%), radial-gradient(750px 750px, rgba(123,222,217, 0.5) 20%, transparent 20%), radial-gradient(100px 100px, rgba(123,222,217, 0.5) 20%, transparent 20%);
            background-size: 1230px 1280px, 810px 910px, 1470px 990px, 1200px 1700px, 1520px 1200px, 1100px 1300px;
            background-position: -300px -550px, -200px 100px, 50px 510px, -200px -550px, -180px -250px, 130px -150px;
            font-family: "Meiryo UI";
        }
        .answer{margin-bottom:30px}
        </style>
</head>

<body>
    <div class="main" style="text-align:center">
        <p>
            <strong><font size="6">薬剤落下指標</font></strong>
        </p>
        <form method="POST" enctype="multipart/form-data">
            <label>
                <input class="file_choose" type="file" name="file" accept="image/*" multiple>
            </label></br></br>
            <input class="btn" type="submit" value="実行">
        </form>
        </br>
        <div class="answer"><strong><font size="5">{{answer}}</font></strong></div>
        <img src="images/reference.png" style="width:640px">
    </div>

</body>
 
</html>
~~~

複数画像のファイル選択を設けてあり、実行ボタンによって一連の画像処理が開始、判定結果が表示されます。

ちなみに、背景のデザインは、[【初心者でもわかる】CSSで水玉の背景を作る（整列・ランダム風）/7note （セブンノート）様](https://qiita.com/7note/items/0863d5cc90424fd06f51)を活用させていただきました。

なお、今回は複雑な構造ではないためHTMLのみでデザインを管理していますが、CSSで装飾するやり方が主流のようです。
また、br を複数用いて改行するやり方も推奨ではないようです。

<br>

**Flaskを用いたWebアプリの作成**

~~~python:main.py
from flask import Flask, render_template, redirect, request, flash
import numpy as np
import cv2
import os
import glob
import copy
from werkzeug.utils import secure_filename

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

WIDTH = 1024
HEIGHT = 1024 
     
W_CM = 4.3
H_CM = 4.3
    
app = Flask(__name__, static_folder='./templates/images')

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

@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']
        upload_files = request.files.getlist('file')    
                
        if upload_files and allowed_file(file.filename):
            #フォルダ内の画像を削除
            file_list_past = glob.glob(UPLOAD_FOLDER + "*.jpg")
            for p in file_list_past:
                os.remove(p) 

            for file in upload_files:
                filename = secure_filename(file.filename)
                file.save(os.path.join(UPLOAD_FOLDER, filename))                    
            
            file_list = glob.glob(UPLOAD_FOLDER + "*.jpg")

            area_arr = np.zeros(len(file_list), dtype=np.float64)
            area_ratio_arr = np.zeros(len(file_list), dtype=np.float64)
            num_obj_arr = np.zeros(len(file_list), dtype=np.int64)
            diameter_arr = np.zeros(len(file_list), dtype=np.int64)
            num_obj_dens_arr = np.zeros(len(file_list), dtype=np.int64)
            
            list_data_name = []
            list_trial = []
            list_box = []
            list_face = []
                        
            for f_i, f in enumerate(file_list):
                data_name = f[-12:-4]
                list_data_name.append(data_name)
                list_trial.append(data_name[:3]) #trial
                list_box.append(data_name[4:6]) #box
                list_face.append(data_name[-1]) #face
                img = cv2.imread(f)
                img_RGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
                
                img_HSV = cv2.cvtColor(img_RGB, cv2.COLOR_BGR2HSV)
                # https://qiita.com/miyamotok0105/items/ce6f44064f128a580640
                img_HSV_H, img_HSV_S, img_HSV_V = cv2.split(img_HSV)
                 
                img_th = copy.copy(img_HSV_S)
                img = copy.copy(img_RGB)
                ret,thresh = cv2.threshold(img_th,12,255,cv2.THRESH_BINARY)
                
                # Extract contours
                contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
                #contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
                
                num_objects = 0
                total_area = 0.0
                droplets_size= []
            
                if len(contours) > 0:
                    th = 5 # threshldold for removing small dots
                
                    for i in range(0, len(contours)):
                        # remove small objects
                        area = cv2.contourArea(contours[i])
                        if area > th:
                            # continue #前のif文に戻る
                            total_area += area
                            droplets_size.append(area)
                            
                            #面積(pixel)粒径(mm)に変換して0以上の値を抽出する関数
                            #引数に液滴データ
                            #A=πr2 
                            #r=√A/π
                    
                            diameter_arr = np.sqrt(area*0.001722/np.pi)*2
                            diameter_arr = diameter_arr[diameter_arr > 0]
                                                       
                        num_objects += 1

                    area_ratio = total_area/(WIDTH*HEIGHT)
                    num_obj_dens =  num_objects/(W_CM*H_CM)
                    area_arr[f_i] = total_area
                    num_obj_arr[f_i] = num_objects
                    area_ratio_arr[f_i] = area_ratio
                    num_obj_dens_arr[f_i] = num_obj_dens  
       
            ave_diameter = np.mean(diameter_arr)
        
            calc_diameter = np.floor(ave_diameter *100)/100
        
            max_dens = np.fmax.reduce(num_obj_dens_arr)

            calc_dens =  np.floor(max_dens * 100)/100       
     
            #階級分け
            #粒径0.35mm以下:2-3粒/cm2⇒1, 4-7粒/cm2⇒2, 8-15粒/cm2⇒3, 16-31粒/cm2⇒4, 32-63粒/cm2⇒5, 64-127粒/cm2⇒6, 128-255粒/cm2⇒7, 256粒/cm2以上⇒8
            #粒径0.36-0.75mm:0.8-1.5粒/cm2⇒1, 1.6-3.1粒/cm2⇒2, 3.2-6.3粒/cm2⇒3, 6.4-12.7粒/cm2⇒4, 12.8-25.5粒/cm2⇒5, 25.6-51.1粒/cm2⇒6, 51.2-102.3粒/cm2⇒7, 102.4粒/cm2以上⇒8
            #粒径0.76-1.25mm:0.2-0.3粒/cm2⇒1, 0.4-0.7粒/cm2⇒2, 0.8-1.5粒/cm2⇒3, 1.6-3.1粒/cm2⇒4, 3.2-6.3粒/cm2⇒5, 6.4-12.7粒/cm2⇒6, 12.8-25.5粒/cm2⇒7, 25.6粒/cm2以上⇒8
            #粒径1.26mm以上:0.05-0.09粒/cm2⇒1, 0.1-0.19粒/cm2⇒2, 0.2-0.39粒/cm2⇒3, 0.4-0.79粒/cm2⇒4, 0.8-1.59粒/cm2⇒5, 1.6-3.19粒/cm2⇒6, 3.2-6.39粒/cm2⇒7, 6.4粒/cm2以上⇒8
        
            if calc_diameter <= 0.35:
                diameter_rank = "A"
                if 2.00 <= calc_dens <=3.99:
                    density_rank = 1
                elif 4.00 <= calc_dens <=7.99:
                    density_rank = 2
                elif 8.00 <= calc_dens <=15.99:
                    density_rank = 3
                elif 16.00 <= calc_dens <=31.99:
                    density_rank = 4
                elif 32.00 <= calc_dens <=63.99:
                    density_rank = 5
                elif 64.00 <= calc_dens <=127.99:
                    density_rank = 6
                elif 128.00 <= calc_dens <=255.99:
                    density_rank = 7
                elif 256.00 <= calc_dens:
                    density_rank = 8
                else:
                    density_rank = 0
        
            elif 0.36 <= calc_diameter <= 0.75:
                diameter_rank = "B"
                if 0.80 <= calc_dens <=1.59:
                    density_rank = 1
                elif 1.60 <= calc_dens <=3.19:
                    density_rank = 2
                elif 3.20 <= calc_dens <=6.39:
                    density_rank = 3
                elif 6.40 <= calc_dens <=12.79:
                    density_rank = 4
                elif 12.80 <= calc_dens <=25.59:
                    density_rank = 5
                elif 25.60 <= calc_dens <=51.19:
                    density_rank = 6
                elif 51.20 <= calc_dens <=102.39:
                    density_rank = 7
                elif 102.40 <= calc_dens:
                    density_rank = 8
                else:
                    density_rank = 0
        
            elif 0.76 <= calc_diameter <= 1.25:
                diameter_rank = "C"
                if 0.20 <= calc_dens <=0.39:
                    density_rank = 1
                elif 0.40 <= calc_dens <=0.79:
                    density_rank = 2
                elif 0.80 <= calc_dens <=1.59:
                    density_rank = 3
                elif 1.60 <= calc_dens <=3.19:
                    density_rank = 4
                elif 3.20 <= calc_dens <=6.39:
                    density_rank = 5
                elif 6.40 <= calc_dens <=12.79:
                    density_rank = 6
                elif 12.80 <= calc_dens <=25.59:
                    density_rank = 7
                elif 25.60 <= calc_dens:
                    density_rank = 8
                else:
                    density_rank = 0
        
            else:
                diameter_rank = "D"
                if 0.05 <= calc_dens <=0.09:
                    density_rank = 1
                elif 0.10 <= calc_dens <=0.19:
                    density_rank = 2
                elif 0.20 <= calc_dens <=0.39:
                    density_rank = 3
                elif 0.40 <= calc_dens <=0.79:
                    density_rank = 4
                elif 0.80 <= calc_dens <=1.59:
                    density_rank = 5
                elif 1.60 <= calc_dens <=3.19:
                    density_rank = 6
                elif 3.20 <= calc_dens <=6.39:
                    density_rank = 7
                elif 6.40 <= calc_dens:
                    density_rank = 8
                else:
                    density_rank = 0
            
            result = f"{str(diameter_rank)}, {str(density_rank)}"
            pred_answer = "粒径区分と最大指数は " + result + " です"

            return render_template("index.html",answer=pred_answer)

    return render_template("index.html",answer="")

if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host = '0.0.0.0', port = port)
~~~

色相変換と二値化を施した画像から、該当する粒子を探していきます。
※enumerate()関数を使用すると、forループの中でリストやタプルなどのオブジェクトの要素と同時にインデックス番号（カウント、順番）を取得できます。

【アップロード前】
![178-13_C.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/827c9e34-7d5d-34d9-3bfe-9cb0912f06e1.jpeg)

【img_RGB（色の順番をBGRからRGBへ入れ替える）】
![BGR2RGB.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/17d8f4cc-f3ea-8e07-e2b2-838e94a247c0.png)

【img_HSV（RGBからHSVへ変換）】
![BGR2HSV.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/cd70b1fd-4e9e-37f4-c7d2-b51fbd7e83e8.png)
【img_HSV_H】
![H.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/d0457708-10e0-afc3-8bcf-ac2f90f808f2.png)
【img_HSV_S（二値化に使用）】
![S.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/50860727-8290-5052-4ffa-c380bc2faa91.png)
【img_HSV_V】
![V.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/abb99a66-6fe0-e85e-703e-3baadc32428b.png)
【thresh（二値化後）】
![binary.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/441647a0-d6c7-4b21-0759-43786d930d72.png)
粒子が発見されるたびに粒子の個数（num_objects）と面積（total_area）が上書きされますが、面積から変換した粒子径（diameter_arr）も記録されています。
※cv2.findContours()を用いて輪郭を検出しています。

これらを各画像に実施し、フィードバックに必要な指標を計算しています。

【平均粒子径の計算】
area → diameter_arr → ave_diameter → calc_diameter

【最大粒子密度】
num_objects → num_obj_dens → num_obj_dens_arr → max_dens → calc_dens

なお、今回扱った粒径区分と指数ですが、段階の間の解釈が明記されていません。
そこで、粒径は各段階の間の中央を境界とし、指数は次の段階未満（小数第二位までの計算）を区分として適用しました。

<br>

# 課題と展望
今回の画像処理は、取り扱う紙のサイズが一定であることを前提にしたものです。
そのため、今後はプルダウンや画像ファイルの読み取り処理で、現物のサイズ（W_CM, H_CM）や画像のピクセル数（WIDTH, HEIGHT）を選択できるようにしていく必要があると思います。

また、指数の表現方法も工夫が必要です。
今回は一番簡単な最大指数の表示としましたが、各画像ごとの表示や、平均値などの統計処理も検討する必要があります。
（現在は、一度に50枚程度の画像処理が可能なようです。）
![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3325448/affdb848-4aee-de71-378c-70112c0d59a9.png)

最後に、スマートフォンでの撮影画像を想定した対応ができていないため、開発環境だけでなく、アプリの方向性も検討していきたいです。

※余談ですが、AIを使用した発展としては、粒子径と密度指数を掛け合わせた薬効指数計算モデルの構築があるかもしれません。
（画像識別のAIモデルを活用するとアプリ動作が重くなる、または画像軽量化による粒子誤認識の発生が考えられるので、紙に付着した液滴のパラメータと薬効の関係性を関数化するという考え方です。）

今回のテーマに限らず、当面は農業用ドローンに関わる技術の改善に向けて、プログラミングを活用していきたいと思います。ありがとうございました。