1.目的
某コミュニティの冬休みの自由研究の一環で、12月に登場したAzure OpenAI GPT-4 Turob with visionへセンサー情報と画像を送信して状況を説明させるWebサイトを作成しつつ以下を目的とする。
・Azure OpenAI GPT-4 Turob with visionのAPI周りを中心に学ぶ。
・センサと画像情報を活用し、GPT4にて何ができるかを考える。
なお今回は1日程度で作成が可能な構成とした。
2.構成概要
今回は以下図の構成を作成する。
緑の枠で囲まれた部分が今回の作業範囲となる。
-
Azure上の構成
①Azure Open AI GPT-4Turbo with vision
画像とセンサー情報を含むプロンプトから水槽の状況を説明する。
APIでは画像は送信できず(想定)、APIに記載したURLから画像を取得するため、インターネットから⑥のWEBサイトへ要求できる経路を作成する。
(2024/1/9訂正)画像を送信できました。 -
ネットワーク機器
②YAMAHA RTX830
①から③へのHTTP要求および、③から⑥へのHTTP要求を通すための通信許可を投入する。詳細についてはセキュリティの都合により割愛する。 -
プロキシサーバ上の構成
②プロキシ
GPT-4Turbo with visionの画像要求を受けて内部へ転送する。
セキュリティを考慮し、画像のURLだけが転送許可されている。
詳細はセキュリティの都合により今回は割愛する。 -
水槽ストリーミングサーバ上の構成
④API用HTTPサイト
このサーバは元々Webサイトのみの提供であったが、新たに蓄積したデータを他の機器から取得するためのAPIサイトをPython Flaskで作成する。 -
水槽分析サーバ上の構成
⑤水槽状況説明バッチ
1日1回8:00に水槽の画像とセンサのデータを含むプロンプトで、GPT-4Turbo with visonへ状況説明の問い合わせをする。
本来⑥で実施すべき処理であるが、悪意ある第3者にAzureへの問い合わせを連打され、支払いがつらくなる脆弱性が懸念されるため今回はバッチ処理とした。
⑥ブラウザ用HTTPサイト
④から取得したセンサ情報をグラフ表示する。またWebカメラの情報をリアルタイムで表示する。
今回は魚の情報を表示するため、物体検知モデルYOLOv8を再学習させ、バウンディングボックスも付与する。
なお、画像内にボックスや文字で補足があるとGPT-4Turbo with visonの認識精度も向上も期待できる。(※)
※参考:The Dawn of LMMs: Preliminary Explorations with GPT-4V(ision)
https://arxiv.org/abs/2309.17421
3.Azure OpenAI GPT-4Turob with visonのリソースデプロイ
2023/12/31現在 AzureOpenAI GPT-4Turbo with visonは地域限定のプレビュー版である。
そこで今回は以下公式サイトを参考にSwitzerland Northにリソースを作成する。(今回West USはなぜかリソースがデプロイできなかった)
※Azure OpenAI Service モデル
https://learn.microsoft.com/ja-jp/azure/ai-services/openai/concepts/models#gpt-4-and-gpt-4-turbo-preview-model-availability
②リソース作成に必要な情報を入力
今回は以下の通り入力した。
③モデルをデプロイする
Azure OpenAIはリソースを作成後、Azure OpenAI Studioからモデルを選択してデプロイする。
今回は図の通りモデルを作成した。
④Azure Computer Visionのデプロイ
GPT-4 Turbo with VisionはComputer Visionと併用することで画像や文字の認識精度が向上できるため、併せてCOmputer Visionのリソースも作成する。
今回はAzure OpenAI StudioのサンドボックスよりAzure Computer Visionのリソースをデプロイする。
※参考:GPT-4 Turbo with Vision is now available on Azure OpenAI Service!
https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/gpt-4-turbo-with-vision-is-now-available-on-azure-openai-service/ba-p/4008456
リソースは以下の設定で作成した。
なお価格レベル「Free F0」はAzure OpenAIに対応していなかったため、後ほど「S1 Standard」へ変更した。
4.水槽ストリーミングサーバへのAPIサイトの構築
このサーバは本来画像のような水槽の状況をストリーミングするサーバであるが、MySQLに温度とPHの情報を3日分蓄積していたため、蓄積したデータを取り出すAPIを作成する。
①データの状態について
簡単ではあるが以下画像のように日付、時間、PH、温度を記録している。
②APIサイトの作成
今回は以下のリファレンスと記事を参考に参照したデータをリスト化し、JSONで返却するAPIサイトを作成した。
本来1日分のデータを返却してもよいが、GPT-4Trubo with visionへ送信するトークン数が爆増するため、今回は60分とする。
※参考:MySQLリファレンス
https://dev.mysql.com/doc/connector-python/en/connector-python-api-mysqlcursor.html
※参考:PythonでMySQLのデータを取得する(mysql-connector-python)
https://qiita.com/IKEH/items/0211bf81b16c15bed1e2
# coding: UTF-8
from flask import Flask, jsonify
import mysql.connector
def fetchMySql(getAttribute):
#クエリの定義
query = "SELECT " + getAttribute + " FROM status ORDER BY date DESC,time DESC LIMIT 60;"
# DBへ接続
conn = mysql.connector.connect(
user='xxxxx',
password='xxxxx',
host='localhost',
database='xxxx'
)
#カーソルの定義
cur = conn.cursor(dictionary=True)
#クエリの実行
cur.execute(query)
#実行結果を格納する返却用のリスト
idList = []
#クエリの結果からgetAttributeで指定した属性のリストを取得
for temperatureElem in cur.fetchall():
id = temperatureElem[getAttribute]
idList.append(id)
#返却用のリストを返却する
return idList
app = Flask(__name__)
#過去60分の水温をJSONで返却する
@app.route('/getTemperature60m')
def getTemperature60m():
#SQLの定義
getAttribute = "temperature"
#MySQLに問い合わせし、結果のリストを受領
idList = fetchMySql(getAttribute)
#返却データの作成
data = {
"temperature": idList,
"status": "Success"
}
return jsonify(data)
#過去60分のPHをJSONで返却する
@app.route('/getPh60m')
def getPh60m():
#SQLの定義
getAttribute = "ph"
#MySQLに問い合わせし、結果のリストを受領
idList = fetchMySql(getAttribute)
#返却データの作成
data = {
"ph": idList,
"status": "Success"
}
return jsonify(data)
#メイン処理
if __name__ == '__main__':
app.run(host = '0.0.0.0', port = 8081, threaded = True)
②自動起動処理の追加
このような機器は予期せず停止や起動をするリスクが高いため、サービス化して、OS起動時に自動的に立ち上がるようにしておく。
今回は以下のようなサービス定義を「/etc/systemd/system/getdbif.service」に作成した。
[Unit]
Description=getdbif
After=network.target
[Service]
WorkingDirectory=/home/pi/aquacontent/
ExecStart=/usr/bin/python /home/pi/aquacontent/getDBIF.py
StandardOutput=inherit
StandardError=inherit
Restart=always
User=xxxx
[Install]
WantedBy=multi-user.target
定義作成後以下のコマンドでサービスを起動する。
#sudo systemctl enable getdbif
#sudo systemctl start getdbif
③動作確認
最終的にブラウザからアクセスすると以下のようにJSONが表示された。
5.GPT-4Trubo with visionによる水槽状況説明バッチの作成
項番3で作成した、Azure OpenAIのリソースを利用するためのAPIを利用し、水槽の画像と項番4のセンサーの情報を利用して水槽の状況を説明するバッチプログラムを作成する。
①環境変数の登録
Azureの情報をプログラムにベタ書きすることを避けるため、環境変数にAzureのキーやエンドポイントを登録する。
今回は以下の環境変数を登録のうえ再起動している。
- Azure OpenAI
キー:AZURE_OPENAI_API_KEY_DRBK
エンドポイント:AZURE_OPENAI_ENDPOINT_DRBK - Azure Computer Vision
キー:CUSTOM_VISION_API_KEY_DRBK
エンドポイント:CUSTOM_VISION_ENDPOINT_DRBK
②APIの作成
Microsoft learnにAPIの記述方法があるためこちらを参考にする。
参考:GPT-4 Turbo with Vision を使用する
https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/gpt-with-vision
またこちらの記事も参考に作成した。
参考:GPT-4V(GPT-4 Turbo with Vision)をPythonから使う
https://qiita.com/canadie/items/7be54a101fd5e6092351
まずは以下の通り水槽ストリーミングサーバより、APIで取得したjsonからセンサの情報を取得する
#水槽ストリーミングサーバのgetTemperature60mのIFを実行し温度をjsonで取得する
url = "http://<ip address>:8081/getTemperature60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
temperatureList = jsonData['temperature']
#水槽ストリーミングサーバのgetPh60mのIFを実行しphをjsonで取得する
url = "http://<ip address>:8081/getPh60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
phList = jsonData['ph']
プロンプトは以下の通り正常な状態と取得したセンサの情報を与えた。
prompt = """
あなたはとても素晴らしいプロの熱帯魚飼育員です。
この水槽にはCorydorasが3匹、Neontetraが4匹います。
水槽は26度であることが好ましく、PHは5.6であることが好ましいです。
直近60分の1分ごとの温度のリストは次の通りで、先頭のほうが直近の状態です。
""" + str(temperatureList) +"""
また直近60分の1分ごとのPHのリストは次の通りで、先頭のほうが直近の状態です。
""" + str(phList) + """
それではここまでの説明を踏まえて水槽の状況を日本語で説明してください。
"""
Azure OpenAIへの要求は以下の通りとする。
enhancementの各項目を「True」にすると、項番3で定義した、Conputer Visionの拡張機能も併用できる。
軽く調べた限りの想定ではあるがこのAPIでは画像を送信できない可能性があるため、画像は後述のWebサイトに配置したものをURLで指定して参照させる。(正攻法はAzureのサービスに画像をアップロードする方式と想定できるが今回は断念)
(2024/1/9訂正)画像を送信できました。
payload = {
"enhancements": {
"ocr": {
"enabled": True
},
"grounding": {
"enabled": True
}
},
"dataSources": [
{
"type": "AzureComputerVision",
"parameters": {
"endpoint": cvApiKey,
"key": cvApiEndpoint
}
}
],
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
},
{
"type": "image_url",
"image_url": {
"url": imagePath
}
}
]
}
],
"max_tokens": 1024,
"stream": False,
}
上記を送信するとレスポンスがjsonで返却されるため、解答を以下の通り抽出する。
print(response.json())
#レスポンスから回答を取得
answer = response.json()['choices'][0]['message']['content']
return answer
最終的に以下のようなプログラムが完成する
import requests
import json
import os
import datetime
#水槽の画像と温度PHからAzureOpenAIに水槽の状況を問い合わせる関数
def azureOpenAiApiRequest():
#環境変数からAPIキーの取得
aoaiApiKey = os.environ.get('AZURE_OPENAI_API_KEY_DRBK')
aoaiEndpoint = os.environ.get('AZURE_OPENAI_ENDPOINT_DRBK')
aoaiDeployName = "BKRDgpt4vision"
cvApiKey = os.environ.get('CUSTOM_VISION_API_KEY_DRBK')
cvApiEndpoint = os.environ.get('CUSTOM_VISION_ENDPOINT_DRBK')
#水槽ストリーミングサーバのgetTemperature60mのIFを実行し温度をjsonで取得する
url = "http://<ip address>:8081/getTemperature60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
temperatureList = jsonData['temperature']
#水槽ストリーミングサーバのgetPh60mのIFを実行しphをjsonで取得する
url = "http://<ip address>:8081/getPh60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
phList = jsonData['ph']
imagePath = r"<URL>"
prompt = """
あなたはとても素晴らしいプロの熱帯魚飼育員です。
この水槽にはCorydorasが3匹、Neontetraが4匹います。
水槽は26度であることが好ましく、PHは5.6であることが好ましいです。
直近60分の1分ごとの温度のリストは次の通りで、先頭のほうが直近の状態です。
""" + str(temperatureList) +"""
また直近60分の1分ごとのPHのリストは次の通りで、先頭のほうが直近の状態です。
""" + str(phList) + """
それではここまでの説明を踏まえて水槽の状況を日本語で説明してください。
"""
print(prompt)
#リクエストのペイロードを作成
payload = {
"enhancements": {
"ocr": {
"enabled": True
},
"grounding": {
"enabled": True
}
},
"dataSources": [
{
"type": "AzureComputerVision",
"parameters": {
"endpoint": cvApiKey,
"key": cvApiEndpoint
}
}
],
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt
},
{
"type": "image_url",
"image_url": {
"url": imagePath
}
}
]
}
],
"max_tokens": 1024,
"stream": False,
}
response = requests.post(
f"{aoaiEndpoint}/openai/deployments/{aoaiDeployName}/extensions/chat/completions?api-version=2023-12-01-preview",
headers={
"Content-Type": "application/json",
"api-key": aoaiApiKey
},
data=json.dumps(payload)
)
print(response.json())
#レスポンスから回答を取得
answer = response.json()['choices'][0]['message']['content']
return answer
#文字をテキストに出力する関数
def printtext(text):
file = open(r"<file path>", 'w', encoding='utf-8')
file.write(str(datetime.datetime.now()) + "の水槽の状況(by GPT-4Turbo with vision): \n" + text)
file.write('\n')
file.close()
#状況問い合わせ
gptanswer = azureOpenAiApiRequest()
#結果をテキスト出力
printtext(gptanswer)
③動作確認
以下のテスト画像で問い合わせた結果次の解答が返ってきた。
まだ改善は必要そうである。
●画像
●回答
この水槽には、砂の上にいる黒くて丸い斑点のある3匹のCorydoras(コリドラス)がおり、ガラス面にはNeontetra(ネオンテトラ)と書かれている部分が4か所ありますが、魚自体は写っていません。水槽の環境に関しては、理想的な温度が26度、PHが5.6であることが述べられていますが、提供された60分間の温度のデータはすべて26度未満であり、一部は急激に低下して21.25度まで落ち込んでいます。PHのデータも5.6をわずかに上回るか下回る程度ですが、最後の方ではPHが急激に低下し、5.01まで下がっています。飼育環境としては改善の余地があると言えるでしょう。
④タスクスケジューラーの設定
今回は8:00に自動起動する。本バッチの実行環境はWindows 10であるため、まずはタスクスケジューラに設定する。
6.ブラウザ用HTTPサイトの作成
①YOLOv8の追加学習
熱帯魚を検出するために物体検知モデルであるYOLOv8を追加学習する。
今回は以下を参照して、ダウンロードした重みを再学習させ、新しい重みを出力した。
今回は熱帯魚が7匹程度写った画像を100枚程度、ラベルは「Corydoras」と「Neontetra」でアノテーションしている。
参考:yolov8にて物体検出のオリジナルモデル学習及び使用方法
https://qiita.com/ryuka0610/items/a47fd253708a98d3726c
なお最終的に、学習済みの重みは以下のディレクトリに出力される
runs/runs/detect/train/weights/
last.ptが最終の重み、best.ptがベストスコアを出力した重みとなる。
なおテストデータに対してpredictメソッドのみを実行すると以下の通り無事検出された。
②ストリーミング・熱帯魚物体検知アプリケーションの構築
ストリーミング+ 熱帯魚物体検知部分を構築する。
以下の記事を参考に一部変更した。
参考:FlaskとOpenCVでカメラ画像をストリーミングして複数ブラウザでアクセスする
https://qiita.com/RIckyBan/items/a7dea207d266ef835c48
今回ごり押しではあるが、グローバル変数にフレーム画像の情報を1/2秒ごとに格納し、Flask側から取得できるようにした。
YOLOv8による物体検知予測は、公式のリファレンスを参考に以下の通り実装する。
※参考
https://docs.ultralytics.com/modes/predict/#inference-arguments
resultsList: list = model.predict(image)
resultImage = resultsList[0].plot()
また以下の行は毎日7:50にGPT-4Turbo with visionから参照するjpg画像を出力しているが詳細は割愛する。
#7:50だったらjpgをファイル出力(GPT-4V予測用)
jpgOutputFlg = printJpg750(resultImage, jpgOutputFlg)
最終的にWebカメラ + 熱帯魚物体検知関連のコードは以下の通りとなる
frameImage = None
#Webカメラから画像を取得して変数に格納する
def capture():
#結果を格納するグローバル変数
global frameImage
#使用するモデルの指定
model = YOLO(r'<weight file path>')
#キャプチャするカメラの指定
camera = cv2.VideoCapture(0)
if not camera.isOpened(): # ビデオキャプチャー可能か判断
exit()
#フラグが0の時jpgファイルを出力する
jpgOutputFlg = 0
while True:
#フレームの読み込み
ret, image = camera.read()
if not ret: # キャプチャ失敗時に終了
break
#サイズの変更(640x484)
image = cv2.resize(image, dsize = (500, 374))
resultsList: list = model.predict(image)
resultImage = resultsList[0].plot()
# キャプチャ画像をエンコード
result, encImg = cv2.imencode('.jpg', resultImage)
frameImage = encImg
#7:50だったらjpgをファイル出力(GPT-4V予測用)
jpgOutputFlg = printJpg750(resultImage, jpgOutputFlg)
#1/2秒スリープ
time.sleep(1/2)
app = Flask(__name__, static_folder=r'<static folder path>')
#index関数
@app.route("/")
def index():
return render_template("index.html")
#フレームの情報をshwFrameに渡す関数
def getFrame():
global frameImage
while True:
if frameImage is not None:
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frameImage.tobytes() + b"\r\n")
time.sleep(1/2)
#画像を表示する関数
@app.route("/showFrame")
def showFrame():
return Response(getFrame(), mimetype = "multipart/x-mixed-replace; boundary=frame")
③グラフの表示
今回は以下を参考にPlotlyで作成した。
参考:【Plotly】 Python でグラフ作成し、html に埋め込む方法
https://pi-trade.tech/programing-learning/plotly-python-introduction-embed-html/
水槽ストリーミングサーバのAPIから取得したデータをPandas Dataframeに変換してPlotlyで表示する。
#PHグラフを表示する関数
@app.route("/showGraphPh")
def showGraphPh():
#センシングエッジのgetPh60mのIFを実行しphをjsonで取得する
url = "http://<ip address>:8081/getPh60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
phList = jsonData['ph']
dfPh = pd.DataFrame(phList)
dfPh.columns = ["ph"]
#線グラフのオブジェクト作成
traceData = go.Scatter(x = dfPh.index, y = dfPh["ph"])
#グラフオブジェクト作成
figPh = go.Figure(data = traceData)
#グラフタイトル追加
figPh.update_layout(title = "PHグラフ(X軸:分前 Y軸:PH)")
#html文字列作成
htmlStr = figPh.to_html(full_html = False, include_plotlyjs = True, default_width = 500 ,default_height = 400)
return htmlStr
④アプリケーション全体と動作確認
最終的に以下のようなコードが完成した。
# モジュールのインポート
#YOLOv8と画像制御に関するモジュール
from ultralytics import YOLO
import os
import cv2
#Flaskの制御に関するモジュール
from flask import Flask, render_template, Response
import time
import threading
#グラフの表示に関するモジュール
import requests
import pandas as pd
import pandas_datareader.data as web
import plotly.graph_objects as go
import datetime
import numpy as np
frameImage = None
#6:50に画像を出力する関数(GPT-4V予測用画像)
def printJpg750(jpgImg, jpgOutputFlg):
# 1:00
base100 = datetime.time(1, 00, 0)
# 1:10
base110 = datetime.time(1, 10, 0)
# 6:50
base650 = datetime.time(7, 50, 0)
#現在時刻を取得
dt_now = datetime.datetime.now()
now = dt_now.time()
#7:50にファイルを出力する
if jpgOutputFlg == 0 and now > base650:
cv2.imwrite(r"<image file path to static folder>", jpgImg)
jpgOutputFlg = 1
elif jpgOutputFlg == 1 and now > base100 and now < base110:
jpgOutputFlg = 0
return jpgOutputFlg
#Webカメラから画像を取得して変数に格納する
def capture():
#結果を格納するグローバル変数
global frameImage
#使用するモデルの指定
model = YOLO(r'C:\program\fishYoloAndGpt\weight\last.pt')
#キャプチャするカメラの指定
camera = cv2.VideoCapture(0)
if not camera.isOpened(): # ビデオキャプチャー可能か判断
exit()
#フラグが0の時jpgファイルを出力する
jpgOutputFlg = 0
while True:
#フレームの読み込み
ret, image = camera.read()
if not ret: # キャプチャ失敗時に終了
break
#サイズの変更(640x484)
image = cv2.resize(image, dsize = (500, 374))
resultsList: list = model.predict(image)
resultImage = resultsList[0].plot()
# キャプチャ画像をエンコード
result, encImg = cv2.imencode('.jpg', resultImage)
frameImage = encImg
#6:50だったらjpgをファイル出力(GPT-4V予測用)
jpgOutputFlg = printJpg750(resultImage, jpgOutputFlg)
#1/2秒スリープ
time.sleep(1/2)
app = Flask(__name__, static_folder=r'<static folder path>')
#index関数
@app.route("/")
def index():
return render_template("index.html")
#フレームの情報をshwFrameに渡す関数
def getFrame():
global frameImage
while True:
if frameImage is not None:
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frameImage.tobytes() + b"\r\n")
time.sleep(1/2)
#画像を表示する関数
@app.route("/showFrame")
def showFrame():
return Response(getFrame(), mimetype = "multipart/x-mixed-replace; boundary=frame")
#温度グラフを表示する関数
@app.route("/showGraphTemperature")
def showGraphTemperature():
#センシングエッジのgetPh60mのIFを実行しphをjsonで取得する
url = "http://<ip address>:8081/getTemperature60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
phList = jsonData['temperature']
dfPh = pd.DataFrame(phList)
dfPh.columns = ["temperature"]
#線グラフのオブジェクト作成
traceData = go.Scatter(x = dfPh.index, y = dfPh["temperature"])
#グラフオブジェクト作成
figPh = go.Figure(data = traceData)
#グラフタイトル追加
figPh.update_layout(title = "温度グラフ(X軸:分前 Y軸:温度)")
#html文字列作成
htmlStr = figPh.to_html(full_html = False, include_plotlyjs = True, default_width = 500 ,default_height = 400)
return htmlStr
#PHグラフを表示する関数
@app.route("/showGraphPh")
def showGraphPh():
#センシングエッジのgetPh60mのIFを実行しphをjsonで取得する
url = "http://<ip address>:8081/getPh60m"
#getリクエストを実行し、レスポンスを格納
responseData = requests.get(url)
#レスポンスをPandasに変換
jsonData = responseData.json()
phList = jsonData['ph']
dfPh = pd.DataFrame(phList)
dfPh.columns = ["ph"]
#線グラフのオブジェクト作成
traceData = go.Scatter(x = dfPh.index, y = dfPh["ph"])
#グラフオブジェクト作成
figPh = go.Figure(data = traceData)
#グラフタイトル追加
figPh.update_layout(title = "PHグラフ(X軸:分前 Y軸:PH)")
#html文字列作成
htmlStr = figPh.to_html(full_html = False, include_plotlyjs = True, default_width = 500 ,default_height = 400)
return htmlStr
#メイン処理
if __name__ == '__main__':
#マルチスレッドの定義
#Webカメラから画像を取得して変数に格納する
thread1 = threading.Thread(target = capture)
thread1.start()
#FLASKの起動
app.run(host = '0.0.0.0', port = 80, threaded = True)
<html>
<head>
<title>水槽IoT#3 AI状況監視</title>
</head>
<body>
<h2>水槽IoT#3 AI状況監視</h2>
<img src="{{ url_for('showFrame') }}"><br>
<iframe src="{{ url_for('showGraphPh') }}" width = "500" height = "400" frameborder = 0 marginwidth = 0 marginheight = 0></iframe>
<iframe src="{{ url_for('showGraphTemperature') }}" width = "500" height = "400" frameborder = 0 marginwidth = 0 marginheight = 0></iframe>
</body>
</html>