‘IoT’ カテゴリーのアーカイブ

suganuma

M5StickVで独自のモデルを使って画像認識

M5StickVを入手したので、独自のモデルを作成し画像認識を試してみました。
V-Training を利用するのが一番お手軽ですが、別の方法を試してみました。

M5StickVではkmodelというフォーマットのモデルを使います。
TensorFlow Liteのモデル(tflite)からMaix Toolboxを使ってkmodelに変換できます。

最初にGCPのAutoML Visionを使ってEdge modelを作成し、tfliteモデルをダウンロードしてkmodelに変換しようとしましたが、以下のエラーが発生しました。

Fatal: expect FLOAT32 tensor but got UINT8, use '--inference_type=FLOAT' when converting via toco.
AutoML Vision Edgeでは、「QUANTIZED_UINT8」の8bit量子化モデルになりますが、それだと駄目らしいです。

次に、Keras組み込みのMobileNet v1を使って転移学習を試してみましたが、kmodel変換時に以下のエラーが出ました。

Layer Pad is not supported: Only paddings of [[0,0],[1,1],[1,1],[0,0]] is supported
パディングに関して、なにか違うみたいです。

結局ネットで調べて、K210で利用可能な学習済みのMobileNet v1をダウンロードしました。
kmodel作成に関して色々制限があるようです。

以下の方針でモデル作成しました。

  • 開発はGoogle Colaboratoryを利用
  • MobileNet v1の転移学習
  • 学習データはgoogleの画像検索から取得
  • 分類は以下の3つ 狛犬、アイボ、柴犬

kmodelを作成したGoogle Colabノートブックが以下です。
https://colab.research.google.com/drive/1aEM8gkJJPnrtLGYR3yDdsWydU01lq9Y9

上記ページは編集・実行不可ですが、「ファイル」>「ドライブにコピーを保存…」 すると各自のGoogleアカウントのGoogleドライブにipynbファイルが保存され、編集・実行ができるようになります。

作成したファイル「model.kmodel」と以下のファイル「boot.py」をSDカードにコピーし、M5StickVに挿して起動します。
画像認識しaccuracyが0.9以上の場合に、異なる色のライトを点灯するようにしました。

アイボを認識すると、赤く点灯

boot.py

import lcd
import image
import time
import uos
import sys
import sensor
import KPU as kpu
import os

fm.register(board_info.BUTTON_B, fm.fpioa.GPIO2)
but_b = GPIO(GPIO.GPIO2, GPIO.IN, GPIO.PULL_UP) #PULL_UP is required here!

#fm.register(board_info.LED_W, fm.fpioa.GPIO3)
#led_w = GPIO(GPIO.GPIO3, GPIO.OUT)
#led_w.value(1) #RGBW LEDs are Active Low

fm.register(board_info.LED_R, fm.fpioa.GPIO4)
led_r = GPIO(GPIO.GPIO4, GPIO.OUT)
led_r.value(1) #RGBW LEDs are Active Low

fm.register(board_info.LED_G, fm.fpioa.GPIO5)
led_g = GPIO(GPIO.GPIO5, GPIO.OUT)
led_g.value(1) #RGBW LEDs are Active Low

fm.register(board_info.LED_B, fm.fpioa.GPIO6)
led_b = GPIO(GPIO.GPIO6, GPIO.OUT)
led_b.value(1) #RGBW LEDs are Active Low


lcd.init(freq=15000000)
lcd.rotation(2) #Rotate the lcd 180deg

sensor.reset() # Reset and initialize the sensor. It will
# run automatically, call sensor.run(0) to stop
sensor.set_pixformat(sensor.RGB565) # Set pixel format to RGB565 (or GRAYSCALE)
sensor.set_framesize(sensor.QVGA) # Set frame size to QVGA (320x240)
sensor.skip_frames(time = 2000) # Wait for settings take effect.
sensor.set_windowing((224, 224)) # zoom in on the center of the lens
clock = time.clock() # Create a clock object to track the FPS.


lcd.clear()
lcd.draw_string(70, 20, "aibo:Red")
lcd.draw_string(70, 40, "siba:Green")
lcd.draw_string(70, 60, "koma:Blue")
labels = ["aibo", "siba", "koma"]
leds = [led_r, led_g, led_b]
task = kpu.load("/sd/model.kmodel")

while(True):
clock.tick()
img = sensor.snapshot()
fmap = kpu.forward(task, img)
plist = fmap[:]
pmax = max(plist)
max_index = plist.index(pmax)
fps =clock.fps()
lcd.display(img)
print("%s (%.2f), fps:%2.1f" % (labels[max_index], pmax, fps))
for i, led in enumerate(leds):
if i == max_index and pmax > 0.9:
led.value(0)
else:
led.value(1)

suganuma

ESP32でディープラーニング

先日作った熱画像をリアルタイムで見れるシステムを改造し、ディープラーニングしてみました。
ジャンケンの3つのジェスチャーを、赤外線アレイモジュールからの入力データをもとに推論します。
ジャンケンの画像データを使いディープラーニングする、というアイデアは雑誌Interface1月号の記事のまねをさせていただきました。他にもいろいろと参考にさせていただきました。
なお、雑誌Interface1月号の記事ではソニーのSPRESENSEボード、および通常のカメラボードを使って行っています。

ディープラーニングのツールとしてNeural Network Console(NNC)のクラウドを使い学習させました。
学習済みモデルをNNPファイルとしてダウンロードし、NNPファイルをC言語ファイルに変換後、ESP32に組み込みました。
【2019/02/04 追記】NNP以外にNNB(Cランタイムフォーマット)でもESP32に組み込むことができることを確認しましたので、それも追記しました。

.hljs {max-height: 350px; overflow-y: scroll;}

以下のステップを行いました。

  1. WebSocketクライアントでデータ収集
  2. ブラウザで収集したデータの確認
  3. データを水増しする(Data Augmentation)
  4. データ変換しCSVファイル作成
  5. NNC(クラウド)で学習
  6. 学習済みモデルをESP32に実装

最終的なファイル構成は以下の構成です。

platformio.ini // IDE設定ファイル
src ─ main.cpp // メインプログラム
lib ┬ MLX90640_API ┬ MLX90640_API.cpp // 以下のファイルはSparkFunにあるサンプルから取得
│ └ MLX90640_API.h
├ MLX90640_I2C_Driver ┬ MLX90640_I2C_Driver.cpp
│ └ MLX90640_I2C_Driver.h
├ MainRuntime_inference ┬ MainRuntime_inference.c // 推論するプログラム、NNPから変換(NNPを使う場合)
│ └ MainRuntime_inference.h
└ MainRuntime_parameters┬ MainRuntime_parameters.c // 重みの配列、NNPから変換(NNPを使う場合)
└ MainRuntime_parameters.h
data ┬ index.html // SPIFFSに置く静的コンテンツ
├ result.nnb // (NNBを使う場合)
├ app.js
├ app.css
├ ace.js.gz // 以降のファイルはSPIFFSEditor使用時に使う
├ ext-searchbox.js.gz
├ mode-css.js.gz
├ mode-html.js.gz
├ mode-javascript.js.gz
└ worker-html.js.gz
work ┬ image_data_generator.py // WebSocketクライアントでデータ収集するプログラム
├ check.htm // 作成したデータを確認するHTML
├ image_data_generator.py // データを水増し(Data Augmentation)するプログラム
└ make_csv.py // データ変換しCSVファイル作成するプログラム



WebSocketクライアントでデータ収集データ収集のやり方は、雑誌Interface1月号と同様に連写でジャンケンの同じジェスチャーをポーズを変えて撮影し、行いました。
データファイルの書き出しは、Pythonプログラムで作成したWebSocketクライアントで行いました。ブラウザでジェスチャーを確認しつつ、もう一つのWebSocketクライアントで接続してデータの収集・書き出しをします。

実行時の引数で、結果のラベルを指定します。
「グー」を0、「チョキ」を1、「パー」を2とします。
以下を実行し、その間カメラの前で「グー」のジェスチャーを、ポーズを変えてデータ収集します。
各ジェスチャー毎に500枚、計1500枚撮影しました。
0.5秒に1枚撮れるので、500×0.5=250秒 の3セットで10分ちょっとかかりました。
データは、あとでブラウザで確認するためJSONデータで出力します。

$ python collect_data.py 0
・collect_data.py

import websocket
import struct
from datetime import datetime
import json
import sys

args = sys.argv
if len(args) >= 2:
y = int(args[1])
else:
y = 9

path = "./temperature_%s.json" % datetime.now().strftime("%Y%m%d%H%M%S")
counter = 0

def on_message(ws, message):
global counter
tmps = [chunk[0] for chunk in struct.iter_unpack('<f', message)]
data= [datetime.now().timestamp(), y, tmps]

if counter%50 == 0:
print('counter: %s' % counter)

counter += 1
with open(path, mode='a') as f:
f.write(json.dumps(data) + ",\n")

def on_error(ws, error):
print(error)

def on_close(ws):
print("### closed ###")

def on_open(ws):
print("### open ###")

if __name__ == "__main__":
# websocket.enableTrace(True)
ws = websocket.WebSocketApp("ws://esp32.local/ws",
on_message = on_message,
on_error = on_error,
on_close = on_close)
ws.on_open = on_open
ws.run_forever()
ブラウザで収集したデータの確認上記で作成したデータファイル3つを結合します。その際、ファイルの頭に”[“を挿入、ファイルの最後の”,”を”]”に置換します。以下のようなかんじのコマンドを実行します。

$ cat temperature_201901* | sed -e '$s/.$/]/' -e '1s/^/[/' > temperature_201901.json
収集したデータをブラウザで確認します。
以下のようなHTMLファイルを作成し、ダブルクリックしてブラウザに表示します。前回作成したapp.js、app.cssを利用しています。
「ファイルを選択」で、作成したデータファイルを選択します。
画面上部のスライダーまたは「←」「→」キーで戻る・進むができます。
・check.htm

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=350,initial-scale=0.5">
<title>赤外線アレイカメラ MLX90640</title>
<link rel="stylesheet" type="text/css" href="../data/app.css" >
<link rel="stylesheet" type="text/css" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" >
<style>
a#download{
position: relative;
display: inline-block;
font-weight: bold;
padding: 0.25em 0.5em;
text-decoration: none;
color: #00BCD4;
background: #ECECEC;
transition: .4s;
}
a#download:hover{
background: #00bcd4;
color: white;
}
#slider {
width: 640px;
margin: 10px 0;
}
#pos {
display: inline-block;
}
#labeling {
height: 60px;
}
#dt, #yw {
margin: 0 10px;
}
#yw {
display: inline-block;
}
</style>
</head>
<body id="body" onload="onBodyLoad()">
<div id="container">
<div id="labeling">
<form method="post" enctype="multipart/form-data">
<input type="file" id="file" accept="application/json">
<!-- <span><a id="download" href="#" download="test.txt" onclick="tmps.handleDownload()">ダウンロード</a></span> -->
<div id="pos">
<span id="slider-pos"></span>
<span id="dt"></span>
<span id="yw" style="display: none;">
<select id="y">
<option value="0">グー</option>
<option value="1">チョキ</option>
<option value="2">パー</option>
<!-- <option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option> -->
</select>
</span>
</div>
</form>
<div id='slider'></div>
</div>
<canvas id="canvas" width="32" height="24"></canvas>
<div id="scale"></div>
<div id="scale-divisions">
<div id="min-tmp-division"><span id="min-down" class="divisionBtn">◀</span><span id="min-tmp"></span><span id="min-up" class="divisionBtn">▶</span></div>
<div id="max-tmp-division"><span id="max-down" class="divisionBtn">◀</span><span id="max-tmp"></span><span id="max-up" class="divisionBtn">▶</span></div>
</div>
<div id="messages"></div>
</div>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.23.0/moment.min.js"></script>
<script src="../data/app.js"></script>
<script>
const tmps = {
obj:null,
index: 0,
orgFileName: null,
handleDownload: function () {
var content = JSON.stringify(this.obj);
var blob = new Blob([ content ], { "type" : "text/plain" });
var d = ge("download");
d.download = this.orgFileName;
d.href = window.URL.createObjectURL(blob);
},
draw: function() {
const data = this.obj[this.index];
const timestamp = data[0];
const y = data[1];
const temperature = data[2];
this.setDt(timestamp);
cv.draw(temperature);
$("#y").val(y);
},
setDt: function(timestamp) {
ge("dt").innerHTML = moment.unix(timestamp).format();
},
setY: function (value) {
value = parseInt(value);
if (value != this.obj[this.index][1]) {
console.log(`y is ${value}`);
this.obj[this.index][1] = value;
}
}
};
const slider = {
slider: null,
setUp: (length)=> {
this.slider = $("#slider").slider({
value:0,
min:0,
max:length - 1,
step:1,
change: function (e, ui) {
tmps.index = ui.value;
tmps.draw();
}
});
},
setValue: (value) => {
this.slider.slider("value", value);
}
};
onBodyLoad = function(){
const reader = new FileReader();
//HTMLを初期化し、新たなファイルを文字列として読込む
file.addEventListener('change', function(e) {
tmps.orgFileName = e.target.files[0].name;
reader.readAsText(e.target.files[0]);
});
//ファイルをオブジェクト化して表示
reader.onload = function(e) {
try {
tmps.obj = JSON.parse(e.target.result);
} catch (err1) {
console.log(err1);
try {
tmps.obj = JSON.parse('[' + e.target.result.slice( 0, -2 ) + ']');
} catch (err2) {
alert("jsonに誤りがあります。");
console.log(e2);
return;
}
}
console.log(tmps.obj);
slider.setUp(tmps.obj.length);
$("#yw").show();
tmps.draw();
};

cv.createScale();
cv.createCanvas();
}
$(function() {
$("#y").change(function() {
const value = $(this).val();
tmps.setY(value);
});
$(document).on('keydown', function(e) {
if (!tmps.obj) return;
console.log(`pressed keyCode:${e.keyCode}`);
if (48 <= e.keyCode && e.keyCode <= 57) {
const value = e.keyCode - 48;
$("#y").val(value);
tmps.setY(value);
}
switch( e.keyCode ) {
case 37:
if (tmps.index > 0) {
console.log("戻る");
tmps.index--;
tmps.draw();
slider.setValue(tmps.index);
}
break;
case 39:
// 進む
if (tmps.index < tmps.obj.length) {
console.log("進む");
tmps.index++;
tmps.draw();
slider.setValue(tmps.index);
}
break;
}
});
});
</script>
</body>
</html>
確認画面

データを水増しする(Data Augmentation)

KerasのImageDataGeneratorを使って、データの水増しを行います。画像を回転や水平/垂直方向に移動・ズーム等してデータを10倍にします。
このとき、ついでに以下のようにデータを加工しています。「28度以下は0」というのは、環境によってはうまくいかないかもしれないです。

  • 28度以下は0
  • 35度以上は1
  • 0以上1以下の値に変換

これも後で確認するためjsonファイルで出力しときます。
・image_data_generator.py

import json
import numpy as np
import scipy.stats
from sklearn.model_selection import train_test_split
from keras.preprocessing.image import ImageDataGenerator
from datetime import datetime

data_file = '<入力ファイルを設定する>'
with open(data_file) as f:
df = json.load(f)
X = [data[2] for data in df]
X = np.array(X)

# 28度以下を0、35度以上を1とし、0以上1以下の値に変換
max = 35
min = 28
X = np.where(X >max, 1, (X - min)/(max - min))
X = np.where(X < 0, 0, X)

y = [data[1] for data in df]
y = np.array(y)
y = y.reshape(y.shape[0], 1)

datagen = ImageDataGenerator(
rotation_range=90, # 整数.画像をランダムに回転する回転範囲
width_shift_range=0.1, # 浮動小数点数(横幅に対する割合).ランダムに水平シフトする範囲
height_shift_range=0.1, # 浮動小数点数(縦幅に対する割合).ランダムに垂直シフトする範囲
fill_mode='constant', # 入力画像の境界周りを埋めるモード
cval=0.0, # constantで0.0で埋める
horizontal_flip=True, # 真理値.水平方向に入力をランダムに反転します
vertical_flip=True, # 真理値.垂直方向に入力をランダムに反転します
zoom_range=0.3 # 浮動小数点数または[lower,upper].ランダムにズームする範囲.浮動小数点数
) # randomly flip images

# ImageDataGeneratorの入力値は(samples, channels, height, width)なのでそれに合わせる
X = X.reshape(X.shape[0], 24, 32, 1)

# 空のnumpyを作成し、ループでappendする
new_X = np.empty((0, 24, 32, 1), float)
new_Y = np.empty((0, 1), int)
counter = 0
for x_batch, y_batch in datagen.flow(X, y, batch_size=32):
new_X = np.append(new_X, x_batch, axis=0)
new_Y = np.append(new_Y, y_batch, axis=0)
counter += 1
if counter >= (X.shape[0] * 10 / 32): # 10倍に水増し
break

# Xの値を0=<X<=1 から28<=X<=35に戻す
new_X = new_X*(max - min) + min
new_X = new_X.reshape(new_X.shape[0], 32*24)

data = []
for (each_x, each_y) in zip(new_X, new_Y):
data.append([
datetime.now().timestamp(),
each_y[0],
each_x
])

class MyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, np.integer):
return int(obj)
elif isinstance(obj, np.floating):
return float(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
else:
return super(MyEncoder, self).default(obj)

path = "image_data_generator_%s.json" % datetime.now().strftime("%Y%m%d%H%M%S")
with open(path, mode='w') as f:
f.write(json.dumps(data, cls = MyEncoder))
ImageDataGeneratorで生成した画像の例

データ変換しCSVファイル作成

上記で作成したJSONファイルからNNCにアップロードするCSVファイルを作成します。
・make_csv.py

import json
import numpy as np
import scipy.stats
from sklearn.model_selection import train_test_split


data_file = '<入力ファイルを設定する>'
with open(data_file) as f:
df = json.load(f)
X = [data[2] for data in df]
X = np.array(X)

# 28度以下を0、35度以上を1とし、0以上1以下の値に変換
max = 35
min = 28
X = np.where(X >max, 1, (X - min)/(max - min))
X = np.where(X < 0, 0, X)

y = [data[1] for data in df]
y = np.array(y)
y = y.reshape(y.shape[0], 1)

index = ['x__{0}'.format(i) for i in range(0, 768)]
index.append('y')
header = ','.join(index)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
train_data = np.hstack((X_train, y_train))
test_data = np.hstack((X_test, y_test))

np.savetxt('<trainの出力ファイルを設定する>', train_data, fmt='%.3f', delimiter=',', header=header, comments='')
np.savetxt('<testの出力ファイルを設定する>', test_data, fmt='%.3f', delimiter=',', header=header, comments='')
NNC(クラウド)で学習NNC専用のアップローダーでtrainとtestデータのCSVをアップロードします。

サンプルにあったimage_recognition.MNIST.LeNetのモデルをほぼそのまま使いました。

インプットは768個(32×24)の1次元、出力は3つです。

はまったのが、ESP32のメモリのサイズです。
最初にサンプルにあったパラメータのままで作成したら、生成されたESP32に実装する重みパラメータが書かれたファイル(MainRuntime_parameters.c)が2MB近くありました。そのためESP32のコンパイルのリンク時に以下のエラーが発生しました。

region `dram0_0_seg' overflowed by 225088 bytes
Convolution(畳み込み)のフィルター数やフィルターサイズを調整して、最終的にMainRuntime_parameters.cのサイズを30KBにしたら、上記エラーがでなくなりました。

学習した結果の精度(Acuacy)ですが、水増し(Data Augmentation)しなかった場合は、0.9865ですが、水増しした場合、0.8768となりました。データを加工しすぎかもしれません。

学習済みモデルをESP32に実装

■NNPを使った実装の場合
NNPファイルをダウンロードします。
以下のコマンドで展開します。

$ nnabla_cli convert -O CSRC -b 1 result_evaluate.nnp <出力ディレクトリ>
以下の5つのファイルができます。
・GNUmakefile
・MainRuntime_example.c // サンプルプログラム
・MainRuntime_inference.c  // 推論するプログラム
・MainRuntime_inference.h
・MainRuntime_parameters.c  // 重みパラメータの配列
・MainRuntime_parameters.h

MainRuntime_inference.c、MainRuntime_inference.h、MainRuntime_parameters.c、ainRuntime_parameters.hをlibディレクトリ以下に配置します。

これらはnnabla-c-runtimeライブラリに依存しますので、platformio.iniに以下のように追記します。

lib_deps = https://github.com/me-no-dev/ESPAsyncWebServer.git, https://github.com/sony/nnabla-c-runtime.git
推論を実装したプログラム(NNP版)です。
・main.c

#include <Wire.h>
#include "MLX90640_API.h"
#include "MLX90640_I2C_Driver.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
// #include <FS.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <SPIFFSEditor.h>

#include "MainRuntime_inference.h"
#include "MainRuntime_parameters.h"

// WIFI設定
const char* ssid = "*******";
const char* password = "******";

// mDNS
const char *hostName = "esp32";

// SPIFFSEditorの認証
const char *http_username = "admin";
const char *http_password = "admin";

// MLX90640
const byte MLX90640_address = 0x33; //Default 7-bit unshifted address of the MLX90640
#define TA_SHIFT 8 //Default shift for MLX90640 in open air
float mlx90640To[768];
paramsMLX90640 mlx90640;

// SKETCH BEGIN
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

void *_context = NULL;

int mode = 0;

// template <class T, size_t N>
// void standard(T (&data)[N])
// {
// float ave = std::accumulate(std::begin(data), std::end(data), 0.0) / N;
// float sd = sqrt(std::inner_product(std::begin(data), std::end(data), std::begin(data), 0.0) / N - ave * ave);
// std::for_each(std::begin(data), std::end(data), [&ave, &sd](float &temperature) {
// temperature = (temperature - ave) / sd;
// });
// }

// 28度以下を0、35度以上を1とし、0以上1以下の値に変換
template <class T, size_t N>
int normalize(T (&data)[N], float min, float max)
{
int cnt = 0;
std::for_each(std::begin(data), std::end(data), [&min, &max, &cnt](float &temperature) {
if (min > temperature)
{
temperature = 0;
cnt++;
} else if (max < temperature) {
temperature = 1;
} else {
temperature = (temperature - min)/(max - min);
}
});
return cnt;
}

int predict(float *data, float r)
{
memcpy(nnablart_mainruntime_input_buffer(_context, 0), data, 768*4);
nnablart_mainruntime_inference(_context);
float *probs = nnablart_mainruntime_output_buffer(_context, 0);

Serial.printf("predict %.3f, %.3f, %.3f\n", probs[0], probs[1], probs[2]);
for (int cl = 0; cl < NNABLART_MAINRUNTIME_OUTPUT0_SIZE; cl++)
{
if (probs[cl] > r)
{
return cl;
}
}
return 9;
}

void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
{
if (type == WS_EVT_CONNECT)
{
Serial.printf("ws[%s][%u] connect\n", server->url(), client->id());
}
else if (type == WS_EVT_DISCONNECT)
{
Serial.printf("ws[%s][%u] disconnect\n", server->url(), client->id());
}
else if (type == WS_EVT_ERROR)
{
Serial.printf("ws[%s][%u] error(%u): %s\n", server->url(), client->id(), *((uint16_t *)arg), (char *)data);
}
}

//Returns true if the MLX90640 is detected on the I2C bus
boolean isConnected()
{
Wire.beginTransmission((uint8_t)MLX90640_address);
if (Wire.endTransmission() != 0)
return (false); //Sensor did not ACK
return (true);
}

void setUpMLX90640()
{
if (isConnected() == false)
{
Serial.println("MLX90640 not detected at default I2C address. Please check wiring. Freezing.");
while (1)
;
}

//Get device parameters - We only have to do this once
int status;
uint16_t eeMLX90640[832];
status = MLX90640_DumpEE(MLX90640_address, eeMLX90640);
if (status != 0)
Serial.println("Failed to load system parameters");

status = MLX90640_ExtractParameters(eeMLX90640, &mlx90640);
if (status != 0)
Serial.println("Parameter extraction failed");
Serial.println(status);

//Once params are extracted, we can release eeMLX90640 array

//MLX90640_SetRefreshRate(MLX90640_address, 0x02); //Set rate to 2Hz
MLX90640_SetRefreshRate(MLX90640_address, 0x03); //Set rate to 4Hz
//MLX90640_SetRefreshRate(MLX90640_address, 0x07); //Set rate to 64Hz
}

void setUpOTA()
{
ArduinoOTA.onStart([]() { Serial.println("Update Start"); });
ArduinoOTA.onEnd([]() { Serial.println("Update End"); });
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.println("OTA ERROR");
});
ArduinoOTA.setHostname(hostName);
ArduinoOTA.begin();
}

void setup()
{
Wire.begin();
Serial.begin(115200);
Serial.setDebugOutput(true);
WiFi.begin(ssid, password);

while (WiFi.status() != WL_CONNECTED)
{
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println("WiFi connected!");

//OTA
setUpOTA();

// mDNS
if (!MDNS.begin(hostName))
{
Serial.println("Error setting up MDNS responder!");
while (1)
{
delay(1000);
}
}

SPIFFS.begin(true);

ws.onEvent(onWsEvent);
server.addHandler(&ws);

// SPIFFSにあるファイルをブラウザで/editから編集できる
server.addHandler(new SPIFFSEditor(SPIFFS, http_username, http_password));

// SPIFFS
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.htm");

// predictモード開始
server.on("/start", HTTP_GET, [](AsyncWebServerRequest *request) {
_context = nnablart_mainruntime_allocate_context(MainRuntime_parameters);
mode = 1;
request->send(200, "text/plain", String("ok"));
});

// predictモード終了
server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request) {
mode = 0;
nnablart_mainruntime_free_context(_context);
request->send(200, "text/plain", String("ok"));
});

server.onNotFound([](AsyncWebServerRequest *request) {
Serial.printf("NOT_FOUND: ");
request->send(404);
});
server.begin();

// MLX90640の初期設定
setUpMLX90640();
}

void loop()
{
ArduinoOTA.handle();

// WebSocket接続してない時は何もしない
if (ws.count() <= 0)
{
return;
}

long startTime = millis();
for (byte x = 0; x < 2; x++)
{
uint16_t mlx90640Frame[834];
MLX90640_GetFrameData(MLX90640_address, mlx90640Frame);
// float vdd = MLX90640_GetVdd(mlx90640Frame, &mlx90640);
float Ta = MLX90640_GetTa(mlx90640Frame, &mlx90640);

float tr = Ta - TA_SHIFT; //Reflected temperature based on the sensor ambient temperature
float emissivity = 0.95;

MLX90640_CalculateTo(mlx90640Frame, &mlx90640, emissivity, tr, mlx90640To);
}
long calculatedTime = millis();

AsyncWebSocketMessageBuffer *buffer = ws.makeBuffer((uint8_t *)&mlx90640To, sizeof(mlx90640To));
ws.binaryAll(buffer); // バイナリー(uint8_tの配列)で全クライアントに送信

int top_class = 9;
if (mode == 1)
{
// 28度以下を0、35度以上を1とし、0以上1以下の値に変換
int cnt = normalize(mlx90640To, 28, 35);

Serial.printf("predict mode: count of below 28C: %d\n", cnt);

// 28度以下が768ドット中の700ドット以上の場合は、predictしない
if (cnt < 700)
{
// 精度が0.5以上の場合のみ、結果を返す
top_class = predict(mlx90640To, 0.5);
}
ws.textAll("result:" + String(top_class));
}
long finishedTime = millis();
Serial.printf("calculated secs:%.2f, finished secs:%.2f, top_class: %d\n", (float)(calculatedTime - startTime) / 1000, (float)(finishedTime - startTime) / 1000, top_class);
}
■NNBを使った実装の場合
NNBファイルをダウンロードします。
SPIFFSでファイルを読み込めるようにするため、dataディレクトリ以下にNNBファイルを置きます。

nnabla-c-runtimeライブラリに依存しますので、platformio.iniに以下のように追記します。

lib_deps = https://github.com/me-no-dev/ESPAsyncWebServer.git, https://github.com/sony/nnabla-c-runtime.git
推論を実装したプログラム(NNB版)です。
NNBファイルをcharの配列として読み込んで、それをnn_network_t構造体にキャストしています。
NNBファイルのサイズは98KBだと大丈夫でしたが、136KBだと読み込み時にエラーが発生しました。使えるヒープサイズは150KB以上あるのですが。連続したヒープメモリが必要だからかもしれません。

#include <Wire.h>
#include "MLX90640_API.h"
#include "MLX90640_I2C_Driver.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <FS.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <SPIFFSEditor.h>

// #include "MainRuntime_inference.h"
// #include "MainRuntime_parameters.h"
#include <nnablart/network.h>
#include <nnablart/runtime.h>
// WIFI設定
const char *ssid = "******";
const char *password = "******";

// mDNS
const char *hostName = "esp32";

// SPIFFSEditorの認証
const char *http_username = "admin";
const char *http_password = "admin";

// MLX90640
const byte MLX90640_address = 0x33; //Default 7-bit unshifted address of the MLX90640
#define TA_SHIFT 8 //Default shift for MLX90640 in open air
float mlx90640To[768];
paramsMLX90640 mlx90640;

// SKETCH BEGIN
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

void *_context = NULL;

int mode = 0;

char *nnb;


// template <class T, size_t N>
// void standard(T (&data)[N])
// {
// float ave = std::accumulate(std::begin(data), std::end(data), 0.0) / N;
// float sd = sqrt(std::inner_product(std::begin(data), std::end(data), std::begin(data), 0.0) / N - ave * ave);
// std::for_each(std::begin(data), std::end(data), [&ave, &sd](float &temperature) {
// temperature = (temperature - ave) / sd;
// });
// }

// 28度以下を0、35度以上を1とし、0以上1以下の値に変換
template <class T, size_t N>
int normalize(T (&data)[N], float min, float max)
{
int cnt = 0;
std::for_each(std::begin(data), std::end(data), [&min, &max, &cnt](float &temperature) {
if (min > temperature)
{
temperature = 0;
cnt++;
} else if (max < temperature) {
temperature = 1;
} else {
temperature = (temperature - min)/(max - min);
}
});
return cnt;
}

int predict(float *data, float r)
{
memcpy(rt_input_buffer(_context, 0), data, 768*4);
rt_forward(_context);

// Serial.printf("num:%d, ", rt_num_of_output(_context));
// Serial.printf("size:%d\n", rt_output_size(_context, 0));

float *probs = (float *)rt_output_buffer(_context, 0);

Serial.printf("predict %.3f, %.3f, %.3f\n", probs[0], probs[1], probs[2]);
for (int cl = 0; cl < 3; cl++)
{
if (probs[cl] > r)
{
return cl;
}
}
return 9;
}

void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
{
if (type == WS_EVT_CONNECT)
{
Serial.printf("ws[%s][%u] connect\n", server->url(), client->id());
}
else if (type == WS_EVT_DISCONNECT)
{
Serial.printf("ws[%s][%u] disconnect\n", server->url(), client->id());
}
else if (type == WS_EVT_ERROR)
{
Serial.printf("ws[%s][%u] error(%u): %s\n", server->url(), client->id(), *((uint16_t *)arg), (char *)data);
}
}

//Returns true if the MLX90640 is detected on the I2C bus
boolean isConnected()
{
Wire.beginTransmission((uint8_t)MLX90640_address);
if (Wire.endTransmission() != 0)
return (false); //Sensor did not ACK
return (true);
}

void setUpMLX90640()
{
if (isConnected() == false)
{
Serial.println("MLX90640 not detected at default I2C address. Please check wiring. Freezing.");
while (1)
;
}

//Get device parameters - We only have to do this once
int status;
uint16_t eeMLX90640[832];
status = MLX90640_DumpEE(MLX90640_address, eeMLX90640);
if (status != 0)
Serial.println("Failed to load system parameters");

status = MLX90640_ExtractParameters(eeMLX90640, &mlx90640);
if (status != 0)
Serial.println("Parameter extraction failed");
Serial.println(status);

//Once params are extracted, we can release eeMLX90640 array

//MLX90640_SetRefreshRate(MLX90640_address, 0x02); //Set rate to 2Hz
MLX90640_SetRefreshRate(MLX90640_address, 0x03); //Set rate to 4Hz
//MLX90640_SetRefreshRate(MLX90640_address, 0x07); //Set rate to 64Hz
}

void setUpOTA()
{
ArduinoOTA.onStart([]() { Serial.println("Update Start"); });
ArduinoOTA.onEnd([]() { Serial.println("Update End"); });
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.println("OTA ERROR");
});
ArduinoOTA.setHostname(hostName);
ArduinoOTA.begin();
}

void setup()
{
Wire.begin();
Serial.begin(115200);
Serial.setDebugOutput(true);
WiFi.begin(ssid, password);

while (WiFi.status() != WL_CONNECTED)
{
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println("WiFi connected!");

//OTA
setUpOTA();

// mDNS
if (!MDNS.begin(hostName))
{
Serial.println("Error setting up MDNS responder!");
while (1)
{
delay(1000);
}
}

SPIFFS.begin(true);

ws.onEvent(onWsEvent);
server.addHandler(&ws);

// SPIFFSにあるファイルをブラウザで/editから編集できる
server.addHandler(new SPIFFSEditor(SPIFFS, http_username, http_password));

// SPIFFS
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.htm");

// predictモード開始
server.on("/start", HTTP_GET, [](AsyncWebServerRequest *request) {
// _context = nnablart_mainruntime_allocate_context(MainRuntime_parameters);

/* READ FILE */
File fp = SPIFFS.open("/result.nnb", FILE_READ); // 読み取り
Serial.printf("file size:%d\n", fp.size());
rt_return_value_t ret = rt_allocate_context(&_context);
nnb = (char *)malloc(fp.size()); // malloc使わないとバッファーオーバーフローエラーが発生した
fp.readBytes(nnb, fp.size());
fp.close();

nn_network_t *net = (nn_network_t *)nnb;
ret = rt_initialize_context(_context, net);
Serial.println(ret);

mode = 1;
request->send(200, "text/plain", String("ok"));
});

// predictモード終了
server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request) {
mode = 0;

rt_free_context(&_context);
free(nnb);

request->send(200, "text/plain", String("ok"));
});

server.onNotFound([](AsyncWebServerRequest *request) {
Serial.printf("NOT_FOUND: ");
request->send(404);
});
server.begin();

// MLX90640の初期設定
setUpMLX90640();
}

void loop()
{
ArduinoOTA.handle();

// WebSocket接続してない時は何もしない
if (ws.count() <= 0)
{
return;
}

long startTime = millis();
for (byte x = 0; x < 2; x++)
{
uint16_t mlx90640Frame[834];
MLX90640_GetFrameData(MLX90640_address, mlx90640Frame);
// float vdd = MLX90640_GetVdd(mlx90640Frame, &mlx90640);
float Ta = MLX90640_GetTa(mlx90640Frame, &mlx90640);

float tr = Ta - TA_SHIFT; //Reflected temperature based on the sensor ambient temperature
float emissivity = 0.95;

MLX90640_CalculateTo(mlx90640Frame, &mlx90640, emissivity, tr, mlx90640To);
}
long calculatedTime = millis();

AsyncWebSocketMessageBuffer *buffer = ws.makeBuffer((uint8_t *)&mlx90640To, sizeof(mlx90640To));
ws.binaryAll(buffer); // バイナリー(uint8_tの配列)で全クライアントに送信

int top_class = 9;
if (mode == 1)
{
// 28度以下を0、35度以上を1とし、0以上1以下の値に変換
int cnt = normalize(mlx90640To, 28, 35);

Serial.printf("predict mode: count of below 28C: %d\n", cnt);

// 28度以下が768ドット中の700ドット以上の場合は、predictしない
if (cnt < 700)
{
// 精度が0.5以上の場合のみ、結果を返す
top_class = predict(mlx90640To, 0.5);
}
ws.textAll("result:" + String(top_class));
}
long finishedTime = millis();
Serial.printf("calculated secs:%.2f, finished secs:%.2f, top_class: %d\n", (float)(calculatedTime - startTime) / 1000, (float)(finishedTime - startTime) / 1000, top_class);
}
推論の結果をブラウザで表示するためHTML,js、cssを以下のように修正しました。
・index.htm

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=350,initial-scale=0.5">
<title>赤外線アレイカメラ MLX90640</title>
<link rel="stylesheet" type="text/css" href="app.css" >
</head>
<body id="body" onload="onBodyLoad()">
<div id="container">
<div id="header"><span><a id="predict" href="#">判定する</a></span>
</div>
<canvas id="canvas" width="32" height="24"></canvas>
<div id="scale"></div>
<div id="scale-divisions">
<div id="min-tmp-division"><span id="min-down" class="divisionBtn">◀</span><span id="min-tmp"></span><span id="min-up" class="divisionBtn">▶</span></div>
<div id="max-tmp-division"><span id="max-down" class="divisionBtn">◀</span><span id="max-tmp"></span><span id="max-up" class="divisionBtn">▶</span></div>
</div>
<div id="messages"></div>
<div id="result"></div>
</div>
<script src="app.js"></script>
</body>
</html>
・app.js

const ge = (s) => { return document.getElementById(s); }
const ce = (s) => { return document.createElement(s); }
const gc = (s) => { return document.getElementsByClassName(s); }
const addMessage = (m) => {
// メッセージ表示
// console.log(m);
const msg = ce("div");
msg.innerText = m;
ge("messages").appendChild(msg);
ge("messages").append
}
skt = {
ws: null,
start: function () {
// WebSocketを開始
ws = ws = new WebSocket('ws://' + document.location.host + '/ws', ['arduino']);
ws.binaryType = "arraybuffer";
ws.onopen = (e) => {
addMessage("Connected");
};
ws.onclose = (e) => {
addMessage("Disconnected");
};
ws.onerror = (e) => {
console.log("ws error", e);
addMessage("Error");
};
ws.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
// バイナリーデータの場合
this.parseTemparatures(e.data);
} else {
console.log(predict.mode);
const result = ge("result");
const resultStrings = { "0": "グー", "1": "チョキ", "2": "パー" };
const m = e.data.match(/^result:([0-2])/);
if (m) {
result.innerHTML = resultStrings[m[1]];
} else {
result.innerHTML = "";
}
}
};
},
parseTemparatures: (data) => {
// Uint8Arrayにセットされた4バイトのfloatの配列をFloat32Arrayの型付き配列にセットし、描画します。
const dv = new DataView(data);
const byteSize = 4;
const tmps = new Float32Array(data.byteLength / byteSize);
for (let i = 0; i < tmps.length; i++) {
tmps[i] = dv.getFloat32(i * byteSize, true);
}
cv.draw(tmps);
}
};
const HSVtoRGB = (h, s, v) => {
// HSVからRGBに変換 パラメータh,s,vは0以上1以下
let r, g, b, i, f, p, q, t;
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}

const rgb = {
min: 20, // 表示する最低温度
max: 35, // 表示する最高温度
get: function (tmp) {
// 温度から色(RGB)を取得
let rate = 1 - (tmp - this.min) / (this.max - this.min);
if (rate < 0) {
rate = 0;
} else if (rate > 1) {
rate = 1;
}
// const h = 0.7*rate;
const h = (Math.tanh(rate * 2 - 1.5) + 1) / 2 - 0.04; // 適当
return HSVtoRGB(h, 1, 1);
}
};

const cv = {
canvas: null,
content: null,
imageData: null,
createCanvas: function () {
// Canvasを作成
this.canvas = ge('canvas');
this.context = this.canvas.getContext('2d');
this.imageData = this.context.createImageData(32, 24);
},
createScale: function () {
// スケールを作成
const scale = ge('scale');
let color, t, span;
for (let i = 0; i < 100; i++) {
t = i * (rgb.max - rgb.min) / 100 + rgb.min;
span = ce('span');
color = rgb.get(t);
span.style.backgroundColor = span.style.color = 'rgb(' + color.r + ',' + color.g + ',' + color.b + ')';
scale.appendChild(span);
}
this.createDivisions();
},
createDivisions: () => {
// 目盛り作成
ge("min-tmp").textContent = rgb.min;
ge("max-tmp").textContent = rgb.max;

var scaleDivisions = ge('scale-divisions');
const divisions = gc('division');
while (divisions.length > 0) {
scaleDivisions.removeChild(divisions[0]);
}
let div;
for (let temp = rgb.min + 5; temp < rgb.max; temp += 5) {
div = ce('div');
div.innerText = temp;
div.classList.add("division");
div.style.left = 640 * (temp - rgb.min) / (rgb.max - rgb.min) - 7 + 'px';
scaleDivisions.appendChild(div);
}
},
draw: function (tmps) {
// 描画
const data = this.imageData.data; // RGBA の順番のデータを含んだ 1次元配列。それぞれの値は 0 ~ 255 の範囲となります。
if (data.length / 4 != tmps.length) {
alert(なにかおかしいです);
return;
}
let tmp, color, j, mirror;
const maxValue = 255;
for (let i = 0; i < tmps.length; i++) {
if (true) {
j = 4 * i;
} else {
// 左右反転させる
mirror = (31 - i % 32) + parseInt(i / 32) * 32;
j = 4 * mirror;
}
tmp = tmps[i];
color = rgb.get(tmp);
data[j] = color.r;
data[j + 1] = color.g;
data[j + 2] = color.b;
data[j + 3] = maxValue;
}
this.context.putImageData(this.imageData, 0, 0);
}
};

const divisionBtns = gc('divisionBtn');
for (let i = 0; i < divisionBtns.length; i++) {
divisionBtns[i].addEventListener('click', function () {
// 目盛り変更
const d = 5; // 目盛りの間隔
switch (this.id) {
case 'min-down':
if (rgb.min >= 5) rgb.min -= d;
break;
case 'min-up':
if (rgb.min <= rgb.max - 2 * d) rgb.min += d;
break;
case 'max-down':
if (rgb.min <= rgb.max - 2 * d) rgb.max -= d;
break;
case 'max-up':
if (rgb.max <= 90) rgb.max += d;
break;
}
cv.createDivisions();
});
}

const predict = {
mode: false,
sw: function () {
const elem = ge("predict");
const result = ge("result");
if (this.mode) {
fetch("stop");
elem.classList.remove('predictiong');
elem.innerHTML = "判定する";
result.style.display = "none";
this.mode = false;
} else {
fetch("start");
elem.classList.add('predictiong');
elem.innerHTML = "判定中";
result.style.display = "block";
this.mode = true;
}
}
}

let onBodyLoad = function () {
cv.createScale();
skt.start();
cv.createCanvas();

ge("predict").onclick = predict.sw;
}
・app.css

body {
display: flex;
justify-content: center;
align-items: center;
background-color: black;
color: #ffffff;
font-size: 12px;
}
#container {
position: relative;
}
#canvas {
background: #666;
width: 640px;
height: 480px;
}
#scale, #scale-divisions {
width: 100%;
height: 24px;
}
#scale-divisions {
position: relative;
}
#min-tmp-division {
position: absolute;
left: -20px;
}
#max-tmp-division {
position: absolute;
right: -20px;
}
.division {
position: absolute;
}
#scale {
display: flex;
}
#scale span {
display: block;
width: 1%;
height: 23px;
}
#messages {
overflow-y: auto;
}
.divisionBtn {
cursor: pointer;
}
.divisionBtn:hover{
color: #99b2ce;
}
#result {
position: absolute;
right: 1px;
top: 1px;
height: 18px;
width: 50px;
background: #faf62d;
padding: 3px 10px;
color: #000000;
font-weight: bold;
display: none;
top: 30px;
}
#header {
text-align: right;
padding-bottom: 5px;
}
a#predict{
position: relative;
display: inline-block;
font-weight: bold;
padding: 0.25em 0.5em;
text-decoration: none;
color: rgb(242, 248, 250);
background: rgb(20, 85, 7);
transition: .4s;
width: 80px;
text-align: center;
}
a#predict.predictiong {
background: rgb(230, 33, 7);
}
a#predict:hover{
background: #5bf15b;
color: white;
}

@media only screen and (max-device-width: 480px) {
body {
font-size:24px;
}
#messages {
margin-top:20px;
}
}
結果上記プログラムを実行した結果、だいたい正確に推論するようでした。
ただし背景に熱を持つもの、モニタとか蛍光灯等があると、ノイズが入ってうまくいかないです。

suganuma

赤外線アレイセンサー「MLX90640」をESP32につないでWebSocketしてみました

MLX90640搭載の赤外線アレイモジュールをESP32につないで熱画像データを取得、それをWebSocketを使いブラウザから熱画像をリアルタイムで見れるようにしました。

MLX90640は32 x 24のセンサーを搭載し、I2CでESP32と通信を行うことができます。

.hljs {max-height: 350px; overflow-y: auto;}

ESP32とつないでみました。
1枚のデータ取得するのに約0.5秒程度かかります。

ESP32開発ボードは「HiLetgo ESP32 ESP-32S NodeMCU開発ボード」を使用、WEBサーバのためESPAsyncWebServerライブラリを使いました。

ESPAsyncWebServer、よくできています。
簡単に同時接続できるWEBサーバを作ることができました。また、WebSocketするために追加のサーバやポートも必要としません。
便利機能としてSPIFFSEditorがついていて、SPIFFSに置いたファイルをブラウザから/editにアクセスして追加・編集できます。

IDEとして、PlatformIOを使いました。
ESPAsyncWebServerのインストールでPlatformIOが書かれてるというのもありますが、最初Arduino IDEでMLX90640のサンプルソースを動かしてみましたが、ここで書かれてるようなエラーが発生しうまくいきませんでした。ただ、もしかしたら古いarduino-esp32を使ってたのが原因かもしれません。

ファイルは以下の構成です。

platformio.ini // IDE設定ファイル
src ─ main.cpp // メインプログラム
lib ┬ MLX90640_API ┬ MLX90640_API.cpp // 以下のファイルはSparkFunにあるサンプルから取得
│ └ MLX90640_API.h
└ MLX90640_I2C_Driver ┬ MLX90640_I2C_Driver.cpp
└ MLX90640_I2C_Driver.h
data ┬ index.html // SPIFFSに置く静的コンテンツ
├ app.js
├ app.css
├ ace.js.gz // 以降のファイルはSPIFFSEditor使用時に使う
├ ext-searchbox.js.gz
├ mode-css.js.gz
├ mode-html.js.gz
├ mode-javascript.js.gz
└ worker-html.js.gz

・platformio.ini
ESPAsyncWebServerを依存ライブラリとしています。
OTAでアップロードする場合、upload_portを指定します。

[env:esp32dev]
platform = https://github.com/platformio/platform-espressif32.git
board = esp32dev
framework = arduino
monitor_speed = 115200
; upload_port = esp32.local
lib_deps = https://github.com/me-no-dev/ESPAsyncWebServer.git
・main.cpp
基本、ESPAsyncWebServerと、SparkFun_MLX90640_Arduino_Exampleのミックスです。
苦労したのはWebSocketでのESP32からjavascriptへのデータの受け渡し部分で、バイナリーで行いました。

#include <Wire.h>
#include "MLX90640_API.h"
#include "MLX90640_I2C_Driver.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
// #include <FS.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <SPIFFSEditor.h>

// WIFI設定
const char* ssid = "*******";
const char* password = "******";

// mDNS
const char * hostName = "esp32";

// SPIFFSEditorの認証
const char* http_username = "admin";
const char* http_password = "admin";

// MLX90640
const byte MLX90640_address = 0x33; //Default 7-bit unshifted address of the MLX90640
#define TA_SHIFT 8 //Default shift for MLX90640 in open air
float mlx90640To[768];
paramsMLX90640 mlx90640;

// SKETCH BEGIN
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

void onWsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){
if(type == WS_EVT_CONNECT){
Serial.printf("ws[%s][%u] connect\n", server->url(), client->id());
} else if(type == WS_EVT_DISCONNECT){
Serial.printf("ws[%s][%u] disconnect\n", server->url(), client->id());
} else if(type == WS_EVT_ERROR){
Serial.printf("ws[%s][%u] error(%u): %s\n", server->url(), client->id(), *((uint16_t*)arg), (char*)data);
}
}

//Returns true if the MLX90640 is detected on the I2C bus
boolean isConnected()
{
Wire.beginTransmission((uint8_t)MLX90640_address);
if (Wire.endTransmission() != 0)
return (false); //Sensor did not ACK
return (true);
}

void setUpMLX90640 () {
if (isConnected() == false)
{
Serial.println("MLX90640 not detected at default I2C address. Please check wiring. Freezing.");
while (1);
}

//Get device parameters - We only have to do this once
int status;
uint16_t eeMLX90640[832];
status = MLX90640_DumpEE(MLX90640_address, eeMLX90640);
if (status != 0)
Serial.println("Failed to load system parameters");

status = MLX90640_ExtractParameters(eeMLX90640, &mlx90640);
if (status != 0)
Serial.println("Parameter extraction failed");
Serial.println(status);

//Once params are extracted, we can release eeMLX90640 array

//MLX90640_SetRefreshRate(MLX90640_address, 0x02); //Set rate to 2Hz
MLX90640_SetRefreshRate(MLX90640_address, 0x03); //Set rate to 4Hz
//MLX90640_SetRefreshRate(MLX90640_address, 0x07); //Set rate to 64Hz
}

void setUpOTA() {
ArduinoOTA.onStart([]() { Serial.println("Update Start"); });
ArduinoOTA.onEnd([]() { Serial.println("Update End"); });
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.println("OTA ERROR");
});
ArduinoOTA.setHostname(hostName);
ArduinoOTA.begin();
}

void setup(){
Wire.begin();
Serial.begin(115200);
Serial.setDebugOutput(true);
WiFi.begin(ssid, password);

while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi..");
}
Serial.println("WiFi connected!");

//OTA
setUpOTA();

// mDNS
if (!MDNS.begin(hostName)) {
Serial.println("Error setting up MDNS responder!");
while(1){
delay(1000);
}
}

SPIFFS.begin(true);

ws.onEvent(onWsEvent);
server.addHandler(&ws);

// SPIFFSにあるファイルをブラウザで/editから編集できる
server.addHandler(new SPIFFSEditor(SPIFFS,http_username,http_password));

// SPIFFS
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.htm");

server.onNotFound([](AsyncWebServerRequest *request){
Serial.printf("NOT_FOUND: ");
request->send(404);
});
server.begin();

// MLX90640の初期設定
setUpMLX90640();
}

void loop(){
ArduinoOTA.handle();

if (ws.count() > 0) {
// WebSocket接続中のみ温度取得する
long startTime = millis();
for (byte x = 0 ; x < 2 ; x++)
{
uint16_t mlx90640Frame[834];
MLX90640_GetFrameData(MLX90640_address, mlx90640Frame);
// float vdd = MLX90640_GetVdd(mlx90640Frame, &mlx90640);
float Ta = MLX90640_GetTa(mlx90640Frame, &mlx90640);

float tr = Ta - TA_SHIFT; //Reflected temperature based on the sensor ambient temperature
float emissivity = 0.95;

MLX90640_CalculateTo(mlx90640Frame, &mlx90640, emissivity, tr, mlx90640To);
}
long calculatedTime = millis();

AsyncWebSocketMessageBuffer * buffer = ws.makeBuffer((uint8_t *)&mlx90640To, sizeof(mlx90640To));
ws.binaryAll(buffer); // バイナリー(uint8_tの配列)で全クライアントに送信

long finishedTime = millis();
Serial.printf("calculated secs:%.2f, finished secs:%.2f\n", (float)(calculatedTime - startTime)/1000, (float)(finishedTime - startTime)/1000);
}
}

・MLX90640_I2C_Driver.cpp
SparkFunにあるサンプルのままだとESP32の場合、エラーが発生しました。
Arduino.hをインクルードする必要があります。

#include<Arduino.h>
・index.htm

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=350,initial-scale=0.5">
<title>赤外線アレイカメラ MLX90640</title>
<link rel="stylesheet" type="text/css" href="app.css" >
</head>
<body id="body" onload="onBodyLoad()">
<div id="container">
<canvas id="canvas" width="32" height="24"></canvas>
<div id="scale"></div>
<div id="scale-divisions">
<div id="min-tmp-division"><span id="min-down" class="divisionBtn">◀</span><span id="min-tmp"></span><span id="min-up" class="divisionBtn">▶</span></div>
<div id="max-tmp-division"><span id="max-down" class="divisionBtn">◀</span><span id="max-tmp"></span><span id="max-up" class="divisionBtn">▶</span></div>
</div>
<div id="messages"></div>
</div>
<script src="app.js"></script>
</body>
</html>
・app.css

body {
display: flex;
justify-content: center;
align-items: center;
background-color: black;
color: #ffffff;
font-size: 12px;
}
#canvas {
background: #666;
width: 640px;
height: 480px;
}
#scale, #scale-divisions {
width: 100%;
height: 24px;
}
#scale-divisions {
position: relative;
}
#min-tmp-division {
position: absolute;
left: -20px;
}
#max-tmp-division {
position: absolute;
right: -20px;
}
.division {
position: absolute;
}
#scale {
display: flex;
}
#scale span {
display: block;
width: 1%;
height: 23px;
}
#messages {
overflow-y: auto;
}
.divisionBtn {
cursor: pointer;
}
.divisionBtn:hover{
color: #99b2ce;
}
@media only screen and (max-device-width: 480px) {
body {
font-size:24px;
}
#messages {
margin-top:20px;
}
}
・app.js

const ge = (s) => { return document.getElementById(s);}
const ce = (s) => { return document.createElement(s);}
const gc = (s) => { return document.getElementsByClassName(s);}
const addMessage = (m) => {
// メッセージ表示
// console.log(m);
const msg = ce("div");
msg.innerText = m;
ge("messages").appendChild(msg);
ge("messages").append
}
skt = {
ws: null,
start: function() {
// WebSocketを開始
ws = ws = new WebSocket('ws://'+document.location.host+'/ws',['arduino']);
ws.binaryType = "arraybuffer";
ws.onopen = (e) => {
addMessage("Connected");
};
ws.onclose = (e) => {
addMessage("Disconnected");
};
ws.onerror = (e) => {
console.log("ws error", e);
addMessage("Error");
};
ws.onmessage = (e)=>{
if(e.data instanceof ArrayBuffer){
// バイナリーデータの場合
this.parseTemparatures(e.data);
} else {
// テキストデータの場合
addMessage(e.data);
}
};
},
parseTemparatures: (data) => {
// Uint8Arrayにセットされた4バイトのfloatの配列をFloat32Arrayの型付き配列にセットし、描画します。
const dv = new DataView(data);
const byteSize = 4;
const tmps = new Float32Array(data.byteLength / byteSize);
for (let i = 0; i<tmps.length; i++) {
tmps[i] = dv.getFloat32(i*byteSize, true);
}
cv.draw(tmps);
}
};
const HSVtoRGB = (h, s, v) => {
// HSVからRGBに変換 パラメータh,s,vは0以上1以下
let r, g, b, i, f, p, q, t;
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}

const rgb = {
min: 20, // 表示する最低温度
max: 35, // 表示する最高温度
get: function(tmp) {
// 温度から色(RGB)を取得
let rate = 1 - (tmp - this.min)/(this.max - this.min);
if (rate < 0) {
rate = 0;
} else if (rate > 1) {
rate = 1;
}
// const h = 0.7*rate;
const h = (Math.tanh(rate*2 - 1.5) + 1)/2 - 0.04; // 適当
return HSVtoRGB(h, 1, 1);
}
};

const cv = {
canvas: null,
content: null,
imageData: null,
createCanvas: function() {
// Canvasを作成
this.canvas = ge('canvas');
this.context = this.canvas.getContext('2d');
this.imageData = this.context.createImageData(32, 24);
},
createScale: function(){
// スケールを作成
const scale = ge('scale');
let color, t, span;
for (let i=0; i < 100; i++) {
t = i * (rgb.max - rgb.min)/100 + rgb.min;
span = ce('span');
color = rgb.get(t);
span.style.backgroundColor = span.style.color = 'rgb('+color.r+','+color.g+','+color.b+')';
scale.appendChild(span);
}
this.createDivisions();
},
createDivisions: () => {
// 目盛り作成
ge("min-tmp").textContent = rgb.min;
ge("max-tmp").textContent = rgb.max;

var scaleDivisions = ge('scale-divisions');
const divisions = gc('division');
while(divisions.length > 0){
scaleDivisions.removeChild(divisions[0]);
}
let div;
for (let temp = rgb.min+5; temp < rgb.max; temp+=5) {
div = ce('div');
div.innerText = temp;
div.classList.add("division");
div.style.left = 640*(temp - rgb.min)/(rgb.max - rgb.min)-7 + 'px';
scaleDivisions.appendChild(div);
}
},
draw: function(tmps) {
// 描画
const data = this.imageData.data; // RGBA の順番のデータを含んだ 1次元配列。それぞれの値は 0 ~ 255 の範囲となります。
if (data.length/4 != tmps.length) {
alert(なにかおかしいです);
return;
}
let tmp, color, j, mirror;
const maxValue = 255;
for (let i = 0; i < tmps.length; i++) {
if (false) {
j = 4 * i;
} else {
// 左右反転させる
mirror = (31-i%32) + parseInt(i/32)*32;
j = 4 * mirror;
}
tmp = tmps[i];
color = rgb.get(tmp);
data[j] = color.r;
data[j+1] = color.g;
data[j+2] = color.b;
data[j+3] = maxValue;
}
this.context.putImageData(this.imageData, 0, 0);
}
};

const divisionBtns = gc('divisionBtn');
for (let i=0; i < divisionBtns.length; i++){
divisionBtns[i].addEventListener('click', function() {
// 目盛り変更
const d = 5; // 目盛りの間隔
switch(this.id) {
case 'min-down':
if (rgb.min >= 5) rgb.min -= d;
break;
case 'min-up':
if (rgb.min <= rgb.max-2*d) rgb.min += d;
break;
case 'max-down':
if (rgb.min <= rgb.max-2*d) rgb.max -= d;
break;
case 'max-up':
if (rgb.max <= 90) rgb.max += d;
break;
}
cv.createDivisions();
});
}

let onBodyLoad = function(){
cv.createScale();
skt.start();
cv.createCanvas();
}
次回、これを使ってディープラーニングを行いたいと思います。

suganuma

Mongoose OSをESP32で使ってみた

ESP32開発ボードを購入し、Mongoose OSを試してみました。
ESP32シリーズは、Wi-FiとBluetoothを内蔵する低コスト、低消費電力なSoCのマイクロコントローラです。
Mongoose OSは、低消費電力なマイクロコントローラ用のオープンソースのOSです。IoTするための機能が充実しています。

ESP32およびESP32開発ボードは、いくつかのベンダーが出していますが、購入したのは、「HiLetgo ESP32 ESP-32S NodeMCU開発ボード」という一番安かったもの。
中国から国際書留で送られてきましたが、到着するのに10日かかりました。
ちゃんと技術基準適合マークついてました。

Mongoose OSのインストール

以下を参考にしました。
https://mongoose-os.com/docs/quickstart/setup.html

開発環境はMacOSです。
開発環境にmosコマンドをインストールします。

$ curl -fsSL https://mongoose-os.com/downloads/mos/install.sh | /bin/bash
$ ~/.mos/bin/mos --help
USB-シリアルドライバをインストールします。
https://www.silabs.com/products/development-tools/software/usb-to-uart-bridge-vcp-drivers

次にESP32のフラッシュに、Mongoose OSのdefaultのファームウェアを書き込みます。
ESP32開発ボードと端末をUSBで接続し、以下を実行するとブラウザでWEB UI IDEが起動します。

$ ~/.mos/bin/mos

ステップ1で「/dev/cu.SLAB_USBtoUART」を選択し「Select」ボタンを押します。もし、「/dev/cu.SLAB_USBtoUART」がない場合、USB-シリアルドライバがインストールされてません。
次に、「Select firmware, click Flash」で、「mos-esp32-1.17」を選択し「Flash」ボタンを押すわけですが、なぜかどうしてもフラッシュ書き込みが出来ませんでした。

よく分かりませんがコマンドラインだとフラッシュ書き込みが出来ます。

$ ~/.mos/bin/mos flash mos-esp32-1.17 --port /dev/cu.SLAB_USBtoUART
コマンドラインで書き込み後、再度WEB UI IDEを起動すると、「Mongoose OS is installed on this device:」と表示され次のステップへ進めます。


しかしここで、別の問題が発生しました。
今まで、Arduino IDE(arduino-esp32)やESP-IDFのesptool.pyでフラッシュ書き込みが出来てましたが、Mongoose OSのフラッシュ書き込み後、できなくなってしまいました。
なお、mos flashコマンドだとその後も、時々失敗しますが書き込みできます。


ステップ3でWIFIのSSIDとパスワードを登録します。

開発

WEB UI IDEまたはコマンドラインで開発できます。
コマンドラインの場合、以下のような感じです。

  • mos init –arch esp32   // スケルトンのプロジェクトファイルをダウンロード
    あるいは、ここから適当なものをgit clone
  • プログラミング
  • mos build –arch esp32 –local  // ビルド
  • mos flash           // フラッシュ書き込み
  • mos console          // ログ出力

ビルド

クラウド(http://mongoose.cloud/)またはローカルでできます。
デフォルトはクラウドです。ローカルの場合「–local」をつけてビルドします。
クラウドの方が速いです。ビルドするソースが全部、どこかに送られていると思うと嫌ですが。
ローカルのビルドではデバイスの環境依存吸収のためDockerが使用されています。

mjsについて

Mongoose OSは、Javascript(mjs)またはC/C++で開発できます。

ESP32はpartition tablesがあり、フラッシュ上にパーティションを配置でき、ファイルシステムのパーティション作成できます。
Mongoose OSは、フラッシュ上にファイルシステムを持っていて、
mjsの場合、ファイルシステム上に更新ファイルを置いて再起動するだけで、コンパイルもファームウェア更新しなくても、プログラムを更新できます。

AWS-IoTのテスト

以下を参考にしました。
https://mongoose-os.com/blog/secure-remote-device-management-with-mongoose-os-and-aws-iot-for-esp32-esp8266-ti-cc3200-stm32/

設定

事前にAWS CLIをインストールしIAM等の設定しておきます。

$ mos aws-iot-setup --aws-iot-policy=mos-default
これだけでカレントディレクトリに鍵を生成し、AWSにアクセスしてモノ(Thing)登録およびESP32のクライアント側のMQTT Settingsしてくれます。

検証

WEB UIの場合、自動生成されるdefaultアプリのinit.jsに、既にデバイスのボタンを押下したらMQTTのpubllishを行うロジックがあります。


// Publish to MQTT topic on a button press. Button is wired to GPIO pin 0
GPIO.set_button_handler(button, GPIO.PULL_UP, GPIO.INT_EDGE_NEG, 200, function() {
let message = getInfo();
let ok = MQTT.pub(topic, message, 1);
print('Published:', ok, topic, '->', message);
}, null);

AWSコンソール画面でAWS IoTを開き、
「テスト」> 「トピックへサブスクライブする」で、トピックを入力し、「トピックへのサブスクライブ」ボタンを押します。。
入力例 /devices/esp32_21B54C/events  // ‘/devices/’ + Cfg.get(‘device.id’) + ‘/events’

ESP32開発ボードのIO0ボタンを押すと、AWSコンソール画面で以下の様なものが表示され、サブスクライブ出来たことが確認できます。

{
"free_ram": 105960,
"total_ram": 191920
}

AWS IoTでできること

AWS IoTを通じて(mqttで)、リモート越しにデバイスにアクセス・操作できます。

最初にエイリアスを作成

$ alias mos1="mos --cert-file $(mos config-get mqtt.ssl_cert) --key-file $(mos config-get mqtt.ssl_key) --port mqtts://$(mos config-get mqtt.server)/$(mos config-get device.id)"
$ mos1 ls -l       // ファイルシステム上のファイル一覧
$ mos1 call Sys.GetInfo // RPC呼び出し システム情報表示
$ mos1 call Sys.Reboot // RPC呼び出し 再起動
実はdefaultアプリだと、httpがenabledになっていて、AWS IoT以外にhttp、websocketでもデバイスにアクセス・操作できます。

$ mos --port http://192.168.17.142/rpc ls
$ mos --port http://192.168.17.142/rpc call Sys.GetInfo
$ mos --port ws://192.168.17.142/rpc call Sys.GetInfo
ライブラリおよび設定についてmos.ymlファイルで、ライブラリおよび設定を定義します。
defaultアプリで、httpがenabledになってるのは、defaultアプリはライブラリjs-demo-bundleを読み込んでいて、js-demo-bundleは、http-serverを読み込んでいます。
http-serverのmos.ymlに以下の記述があります。

config_schema:
- ["http.enable", "b", true, {title: "Enable HTTP Server"}]

RPC

Mongoose OSはRPC(remote procedure call)が強力です。

シェル上でmosコマンドで実行する場合以下のようにします。

$ mos1 call Sys.Reboot
OTA(Over The Air updates)で、リモートからファームウェア書き換えが出来ます。
またI2CやGPIOもRPCで操作できるので、デバイスにプログラムを置くのではなく、リモートにプログラムを置いて(たとえばAWS Lambda)、デバイス操作するすることが可能です。
バックドア的なことが簡単に実現可能な気がします。セキュリティ管理をちゃんとする必要があります。

デフォルトで用意されてるRPC一覧です。

$ mos call RPC.List
[
  "Wifi.Scan",
  "OTA.SetBootState",
  "OTA.GetBootState",
  "OTA.CreateSnapshot",
  "OTA.Revert",
  "OTA.Commit",
  "OTA.Update",
  "I2C.WriteRegW",
  "I2C.WriteRegB",
  "I2C.ReadRegW",
  "I2C.ReadRegB",
  "I2C.Write",
  "I2C.Read",
  "I2C.Scan",
  "GPIO.RemoveIntHandler",
  "GPIO.SetIntHandler",
  "GPIO.Toggle",
  "GPIO.Write",
  "GPIO.Read",
  "FS.Umount",
  "FS.Mount",
  "FS.Mkfs",
  "FS.Remove",
  "FS.Put",
  "FS.Get",
  "FS.ListExt",
  "FS.List",
  "Config.Save",
  "Config.Set",
  "Config.Get",
  "Sys.SetDebug",
  "Sys.GetInfo",
  "Sys.Reboot",
  "RPC.Ping",
  "RPC.Describe",
  "RPC.List"
]