user icon

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

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

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

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();
}

次回、これを使ってディープラーニングを行いたいと思います。

Facebooktwitterlinkedintumblrmail
名前
E-mail
URL
コメント

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)