概要
SPRESENSE+HDRカメラで、数字表示のメーターを撮影し、AI文字認識により自動検針を行う。
- 認識した現在値をメールで通知する。
- 文字認識は、SPRESENSE内で行う方法と、クラウド上での処理の2手段を検討し、エッジAIとクラウドAIのそれぞれのメリット・デメリットを体感する。
実現させたいこと、その背景
家庭用の水道・ガスメーターなどは、その提供企業側では自動検針化が進みつつありますが、使用者が日々使用量を把握するには(少なくとも我が家では)屋外にあるメーターを見に行くしかありません。
この面倒を解消すべく、メーターの近くにカメラを設置し、定時撮影した画像から文字認識で現在値を取得し、メールもしくはクラウド(スプレッドシートなど)を利用し使用者に通知することを考えました。これにより煩わしさなく日々の使用量を独自に把握できるようになります。省エネを意識し改善できるとともにトラブルを回避することもできるようになると思います。
最近のガス環境については、供給者設備、ガス器具とも高機能化され、安全面での心配は減っています。しかし水道の(見えない場所での)漏水は気づきにくく、高額な請求書に驚き慌てることになりかねません。日々容易に確認できることは有効であると思います。(実際、当家では地下10cm付近での水道管の割れによる漏水に気づかず痛い目にあいました。)
構成
- Spresense+HDRカメラ+ネットワーク部 でカメラを構成します。
- 開発環境として、LCDパネルとSpresense拡張ボードをプラスし、画像・状況等の確認を行います。
- 撮影画像はネットワーク通信を介して送信します。
- カメラ部は、Spresenseの低消費電力性を活かし電池駆動での長期連続稼働を目指します。
ネットワーク部は、Wi-Fi Add-onボードを使う予定でしたがモニター品応募で選外となり、急遽保有していた W5500-Ether(クレイン電子社製アドオン)ボードで代用しました。
Wi-Fiボードの購入か自作も考えましたが時間もないので、Wi-SUNで使用中のW5500-Etherを外しての使用です。この選択で後にいろいろと苦労することにはなりましたが・・・
構成図
接続情報
- LCDパネル の赤字部はタッチ機能用、今回は未使用です。
- (*1) LCDパネルのVCC(#1)にはAREF(+3.3Vを選択)を接続します。なお、秋月電子の販売製品では、この端子は LDO(+3.3V出力) への入力となるので、ボード上のJ1を実装してLDOの IN/OUT をショートしておきます。
- (*2) LCDパネルのLEDピン(#8)はバックライトのオンオフ制御ピン、常時点灯とするのでAREFに接続します。
- (*3) LCDパネルのCSピンはdefaultピンではなく JP13-#1(D07) に接続しIO制御としています。 (W5500-etherを拡張ボード側(SPI4)に接続する予定だったため)
現状、W5500-Etherは SPI4(拡張ボード側) での使用がトラブったため、メインボード(SPI5)へジャンパーワイヤーで接続しています。CSピンはSPI5のdefaultピンではなくメインボードのJP2-#6(D19)に接続しIO制御としています。
W5500-EtherをSPI4で使用したかった理由は、メインボードに装着するとRJ45コネクタが邪魔で、カメラを拡張ボードに固定することができないためです。LCDサブボードに搭載しSPI4接続として、ジャンパーワイヤーもなくすっきりできるつもりでした。
使用部品
製品名 | 品番 | メーカー |
---|---|---|
SPRESENSEメインボード | SPRESENSE | SONY |
SPRESENSE拡張ボード | SPRESENSE | SONY |
SPRESENSE HDRカメラボード | SPRESENSE | SONY |
ILI9341搭載2.8インチSPI制御タッチパネル付TFT液晶 | MSP2807 | 秋月電子 |
有線ネットワーク拡張ボード for Spresense | W5500-Ether | クレイン電子 |
LCDサブボード | ユニバーサル基板で自作 |
LCDサブボード
- LCDパネルを拡張ボードに接続するため、ユニバーサル基板でLCDサブボードを手作りしました。構成は下図の通りです。信号線は ポリウレタン線 で配線しています。
- 拡張ボードのJP2とJP13の位置がインチピッチではない(Arduino互換)ので、ユニバーサル基板への接続に一工夫が必要でした。アングルとストレートのヘッダピンをはんだ付けしてしのいでいます。(分かりにくいですが、下図右下の写真の左側部)
HDRカメラ
- カメラは樹脂製のアングルを加工して拡張ボードに固定しました(下図左)。
- メーター前面に10cmくらいの距離で設置したいので、その位置でピントが最適となるように再調整しました。下図はこのときの画角を調べたもの、約39度でした(下図右)。
現在の状況
機能
- Spresense+HDRカメラ+ネットワーク部 で構成したカメラをメーター前面に設置し定期的に撮影する。
- 撮影画像から、SpresenseのAI画像処理によりメーター値を文字認識して取得する。(エッジAI)
- 取得した現在値を、メールもしくはクラウドへ送信し、使用者に通知する。
- 同時に、画像をGoogle Drive に Google Drive API を使って送信、その画像から Google Apps Script(GAS) を使って文字認識を行い、結果を通知する。(クラウドAI)
- カメラ部は、Spresenseの低消費電力性を活かし電池駆動での長期連続稼働を実現する。
- 検討時は、LCDパネルに画像を常時表示し、画像・状況等の確認を行います。
プログラム開発
開発環境
-
今回の開発は、VSCode+PlatformIOで行いました。PCはWindows10を使用しています。
-
PlatformIOでのSPRESENSEへの対応は下記サイトを参照させて頂き整えました。
Spresense development on VSCode PlatformIO -
upload不可に関しては、同じくこちらの情報を参考させて頂き対応しました。
Spresense uploader Setting on Platform.io -
ライブラリは以下の3つをinstallしています。
https://github.com/kzhioki/Adafruit-GFX-Library/
https://github.com/kzhioki/Adafruit_ILI9341/
openslab-osu/SSLClient@^1.6.11 -
また、W5500のライブラリーは main.cpp と同一フォルダ内に \w5500_src\ として置いています。
本年1月23日、久々にクレイン電子さんの製品ページを見ると、 W5500-ether のライブラリが更新されていました。
今回は以前のもの使っていますが、自分なりに修正を加えてあります。更新版ではその部分も修正されているようでした。
更新版は、ライブラリマネージャーからインストールできるようになったとのことで、機会をみて試してみようと思います。
手段1:Google Drive 及び GAS を利用した認識
SPRESENSE:
- カメラからの画像取得部は、SPRESENSEスケッチ例の camera.ino と、SPRESENSEサイトのチュートリアル「3.2. Camera プレビュー画像を LCD に表示する」を参考に作成しました。TFT_CSの指定と保存画像サイズ(VGA)の変更をしています。SDカードへの画像保存機能は残しています。
- LCDサブボード上の SW1 をシャッターボタンとして画像を1枚撮影します。
- Google Drive への画像送信部は、下記サイトを参考にさせて頂きました。要の関数部はほぼそのまま使わせて頂いています。
ESP32とOV2640でJPEG画像をGoogleDrive保存 【API①全体概要】
~ 【API④プログラミング】 - まず、Googleクラウド関連設定に必要な情報を取得します。その方法は上記サイトの【API②Google設定】で詳しく解説されています。
- OAUTHクライアントIDとシークレットキーを取得
- Curlコマンドでコードと検証URLを取得(OAuth認証)
- 取得した検証URLにWebブラウザでアクセス許可を設定
- Curlコマンドでリフレッシュトークンを取得
- 画像を保存するフォルダID:
ブラウザでフォルダを開いたときのアドレスバーにある最後の部分がそれです。
- 【API④プログラミング】からの主な変更は、
- postGoogleDriveByAPI()に対し、bufAddr, size を引数として画像の情報を渡しています。
- 画像ファイル名は PICT001.jpg からの連番(通電中のみの保持)としました。
- SSL通信部は、openslab-osu の SSLClient を使うように変更しています。
WiFiClientSecure を使う場合は、setInsecure() でサーバー認証をパスできるようですが、SSLClientでは同様の機能がないようなので、下記サイトを参考にさせて頂き googleapis.com の証明書(trust_anchors.h)を作成しました。
知的好奇心 for IoT
Google Drive に保存すれば、そのタイムスタンプで撮影日時は分かります。
また、Google Drive はすべてIDで管理されるようで、同名で保存しても別ファイルとして扱われるようです。
GAS:
- カメラから送信された画像は、GASを利用しOCR機能で文字認識を行います。この部分は下記サイトを参考にさせて頂きました。Drive APIは [ V2 ] を選択しています。
GASで画像データをOCRする方法!文章(英語+日本語)を読み取りテキスト化 - 当家のガスメータ、水道メータに対応させています。
- 抽出された文字列(複数行)には不要な部分も含まれるので、正規表現の処理でメーターの数値行の検出と、整形(先頭の0を削除など)を行っています。この処理を加えたことで、画像撮影などへの制約をラフにでき、また他のメーターへの対応時も応用が利くと思います。
- メーター値として認識できれば、その結果をメールで送信します。
- なお、画像を受信したことをトリガーにこの処理を行いたいところですが、用意された関数だけでは難しいようなので、定時にこのスクリプトを実行し新しい画像があればこの処理を行うようにしています。トリガーの設定に関しては下記サイトを参考にさせて頂きました。
【GAS】トリガーの種類と使い方 - その他の処理として
- プロパティ設定による新規ファイルの判別機能を追加しています。
- 複数ファイルの連続処理が可能です。
- 抽出できた画像ファイルは削除(ゴミ箱へ移動)します。誤認識などで確認したいときはゴミ箱に30日間は残っています。
- OCR用のコピー(OCR_TEST)は、OCR後に削除(ゴミ箱ではなく完全に)します。
- 実行すると下図のように処理されます。(画像 (QVGAサイズ) はスマホで撮影したものです)
手段2:SPRESENSEで認識
- 撮影画像をSPRESENSEで文字認識し、結果をメールで通知します。
- 下記でNNC、DNNRTライブラリについて学びました。
- Youtube でのチュートリアル「6. カメラの使い方【ソニー公式】」
- 書籍:SPRESENSEではじめるローパワーエッジAI 太田 義則氏 著
- 手順に従って進め、「テスト用数字画像データ」を印刷し、この数字を本機で認識できることを確認しました。
- この状態でメーターの数値を認識させてみましたが、残念ながら認識はできませんでした。
字体がかなり異なるので当然の結果かと思いました(下図)。
この手段2がコンテストへの本命でしたが、難易度(理解度)と期間を考え手段1を先行させました。原稿書きと並行して、事前に撮りためてあったメーターの写真から学習用データの作成作業と、プログラムの作成を行っていますが、残念ながら提出期限には間に合いそうもありません。完成させて後日報告できるよう頑張りたいと思います。
ソースコード
手段1:Google Drive 及び GAS を利用した認識
SPRESENSE:
camera_gas
// main.cpp
#include <Arduino.h>
#include <SDHCI.h>
#include <stdio.h> /* for sprintf */
#include <Camera.h> //Cameraライブラリを利用
#include <SPI.h>
#include "Adafruit_GFX.h"
#include "Adafruit_ILI9341.h"
#include "w5500_src/Ethernet.h"
#include <SSLClient.h>
#include "trust_anchors.h" // アクセスするサイトのルート証明書情報
#define TFT_CS 7 //TFTのCSピン、w5500も拡張ボード(SPI4)に接続予定であったため変更
#define TFT_RST 8 //TFTのRSTピン
#define TFT_DC 9 //TFTのDCピン
Adafruit_ILI9341 tft = Adafruit_ILI9341 (&SPI, TFT_DC, TFT_CS, TFT_RST); //tftオブジェクト生成
#define BAUDRATE (115200)
#define RSTN 21 //w5500のresetピン(xRSTN)
#define SCSN 19 //w5500のCSピン
#define LED_R 4 //LED赤、Lアクティブ
#define LED_B 3 //LED青、Lアクティブ
#define SW1 2 //Push SW1
#define SW2 1 //Push SW2
byte mac[] = { 0x**, 0x**, 0x**, 0x**, 0x**, 0x** }; // EEPROMの値に変更
IPAddress ip(***, ***, ***, ***);
EthernetClient ethClient;
SSLClient httpsClient(ethClient, TAs, (size_t)TAs_NUM, PIN_A2);
String filename = "";
int cnt = 1;
int waitingTime = 30000; // Wait 30 seconds to google response.
String clientId = " ===== 取得した値 ===== "; //google
String clientSecret = " ===== 取得した値 ===== ";
String refreshToken = " ===== 取得した値 ===== ";
String driveFolder = " ===== 実際のGoogleDrive保存フォルダのId ===== ";
const char* refreshServer = "oauth2.googleapis.com";
const char* refreshUri = "/token";
const char* apiServer = "www.googleapis.com";
const char* apiUri = "/upload/drive/v3/files?uploadType=multipart";
String accessToken = ""; // 接続時に取得
SDClass theSD;
bool ledFlag_R = true; //LED_R制御フラグ(Lアクティブ)
bool ledFlag_B = true; //LED_B制御フラグ(Lアクティブ)
volatile bool shutFlag = false; //SW1(shutterボタン)が押されると true
const bool google = true; //google drive へ画像をuploadしない場合は false
void postGoogleDriveByAPI(const uint8_t* bufAddr, size_t size) { //画像をPOST処理(送信)、constを指定
Serial.printf("%p\n", bufAddr);
Serial.println(size);
unsigned long dataLength = (unsigned long)size;
Serial.println("Connect to " + String(apiServer));
if (httpsClient.connect(apiServer, 443)) {
Serial.println("Connection successful");
String metadata = "--foo_bar_baz\r\n"
"Content-Type: application/json; charset=UTF-8\r\n\r\n"
"{\"name\":\"" + filename + "\",\"parents\":[\"" + driveFolder + "\"]}\r\n\r\n";
// parentsは保存するフォルダ
// "{\"name\":\"" + saveFilename + "\"}\r\n\r\n"; // 保存フォルダ指定なしもOK(TOPにファイル作成)
String startBoundry = "--foo_bar_baz\r\n"
"Content-Type:image/jpeg\r\n\r\n";
String endBoundry = "\r\n--foo_bar_baz--";
unsigned long contentsLength = metadata.length() + startBoundry.length() + dataLength + endBoundry.length();
String header = "POST " + String(apiUri) + " HTTP/1.1\r\n" +
"HOST: " + String(apiServer) + "\r\n" +
"Connection: close\r\n" +
"content-type: multipart/related; boundary=foo_bar_baz\r\n" +
"content-length: " + String(contentsLength) + "\r\n" +
"authorization: Bearer " + accessToken + "\r\n\r\n";
Serial.println("Send JPEG DATA by API");
httpsClient.print(header);
httpsClient.print(metadata);
httpsClient.print(startBoundry);
for(unsigned long i = 0; i < dataLength ;i = i + 1000) { // JPEGデータは1000bytesに区切ってPOST
if ( (i + 1000) < dataLength ) {
httpsClient.write(( bufAddr + i ), 1000);
} else if (dataLength % 1000 != 0) {
httpsClient.write(( bufAddr + i ), dataLength % 1000);
}
}
httpsClient.print(endBoundry);
Serial.println("Waiting for response.");
// long int StartTime = millis();
uint64_t StartTime = millis();
while (!httpsClient.available()) {
ledFlag_B = !ledFlag_B;
digitalWrite(LED_B, ledFlag_B); //接続待ち間はLED_B点滅
Serial.print(".");
delay(100);
if ((StartTime + waitingTime) < millis()) {
Serial.println();
Serial.println("No response.");
break;
}
}
digitalWrite(LED_B, HIGH);
Serial.println();
while (httpsClient.available()) {
Serial.print(char(httpsClient.read()));
}
/*Serial.println("Recieving Reply");
while (httpsClient.connected()) {
String retLine = httpsClient.readStringUntil('\n');
//Serial.println("retLine:" + retLine);
int okStartPos = retLine.indexOf("200 OK");
if (okStartPos >= 0) {
Serial.println("200 OK");
break;
}
}*/
} else {
Serial.println("Connected to " + String(refreshServer) + " failed.");
}
httpsClient.stop();
}
void getAccessToken() { // リフレッシュトークンでアクセストークンを更新(有効時間は1時間)
accessToken = "None";
Serial.println("Connect to " + String(refreshServer));
if (httpsClient.connect(refreshServer, 443)) {
Serial.println("Connection successful");
String body = "client_id=" + clientId + "&" +
"client_secret=" + clientSecret + "&" +
"refresh_token=" + refreshToken + "&" +
"grant_type=refresh_token";
httpsClient.println("POST " + String(refreshUri) + " HTTP/1.1"); // Send Header
httpsClient.println("Host: " + String(refreshServer));
httpsClient.println("content-length: " + (String)body.length());
httpsClient.println("Content-Type: application/x-www-form-urlencoded");
httpsClient.println();
httpsClient.println(body); // Send Body
Serial.println("Recieving Token");
while (httpsClient.connected()) {
String retLine = httpsClient.readStringUntil('\n');
//Serial.println("retLine:" + retLine);
int tokenStartPos = retLine.indexOf("access_token");
if (tokenStartPos >= 0) {
tokenStartPos = retLine.indexOf("\"", tokenStartPos) + 1;
tokenStartPos = retLine.indexOf("\"", tokenStartPos) + 1;
int tokenEndPos = retLine.indexOf("\"", tokenStartPos);
accessToken = retLine.substring(tokenStartPos, tokenEndPos);
Serial.println("AccessToken:"+accessToken);
break;
}
}
} else {
Serial.println("Connected to " + String(refreshServer) + " failed.");
}
httpsClient.stop();
if (accessToken == "None") {
Serial.println("Get AccessToken Failed. Restart SPRESENSE!");
delay(3000);
// SPRESENSE をソフトウエアリセット(未作成です!)
}
}
void w5500_init(){
digitalWrite(RSTN, LOW); //w5500をreset
delay(500);
digitalWrite(RSTN, HIGH);
Ethernet.init(SCSN); //CSピンを指定 (defaultのD24は使わない)
Serial.println("W5500 reset done.");
Ethernet.begin(mac, ip); //mac,ip で初期化
if (Ethernet.hardwareStatus() == EthernetNoHardware) { //w5500 の検出確認
Serial.println("Ethernet shield was not found.");
while (true) { delay(1); }
}
if (Ethernet.linkStatus() == LinkOFF) {
Serial.println("Ethernet cable is not connected.");
}
Serial.print("my spresense is at ");
Serial.println(Ethernet.localIP());
}
void printError(enum CamErr err) { //エラーコードによりその内容を表示
Serial.print("Error: ");
switch (err) {
case CAM_ERR_NO_DEVICE:
Serial.println("No Device");
break;
case CAM_ERR_ILLEGAL_DEVERR:
Serial.println("Illegal device error");
break;
case CAM_ERR_ALREADY_INITIALIZED:
Serial.println("Already initialized");
break;
case CAM_ERR_NOT_INITIALIZED:
Serial.println("Not initialized");
break;
case CAM_ERR_NOT_STILL_INITIALIZED:
Serial.println("Still picture not initialized");
break;
case CAM_ERR_CANT_CREATE_THREAD:
Serial.println("Failed to create thread");
break;
case CAM_ERR_INVALID_PARAM:
Serial.println("Invalid parameter");
break;
case CAM_ERR_NO_MEMORY:
Serial.println("No memory");
break;
case CAM_ERR_USR_INUSED:
Serial.println("Buffer already in use");
break;
case CAM_ERR_NOT_PERMITTED:
Serial.println("Operation not permitted");
break;
default:
break;
}
}
void CamCB(CamImage img) { //Previewが出力された際に呼び出される関数
if (img.isAvailable()) { /* Check the img instance is available or not. */
img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); //defaultはYUV422なので変換
tft.drawRGBBitmap(0, 0, (uint16_t *)img.getImgBuff(), 320, 240); //img.getImgBuff()が転送するdata
} else {
Serial.println("Failed to get video stream image");
}
}
void shutter() { //shutterボタン(SW1)が押されたとき
shutFlag = true;
}
void setup() {
CamErr err;
pinMode(RSTN, OUTPUT); //w5500のresetピン
pinMode(SCSN, OUTPUT); //w5500のCSピン
pinMode(LED_R, OUTPUT);
pinMode(LED_B, OUTPUT);
pinMode(SW1, INPUT); //Push SW1(上)、押すとL
pinMode(SW2, INPUT); //Push SW2(下)、押すとL
digitalWrite(RSTN, HIGH);
digitalWrite(SCSN, HIGH);
digitalWrite(LED_R, HIGH);
digitalWrite(LED_B, HIGH);
Serial.begin(BAUDRATE);
while (!Serial); /* Needed for native USB port only */
attachInterrupt(digitalPinToInterrupt(SW1), shutter, FALLING); //shutterボタン(SW1)を押したときの割り込み処理
if (google) w5500_init();
tft.begin();
tft.setRotation(3);
while (!theSD.begin()) { /* Initialize SD */
Serial.println("Insert SD card."); /* wait until SD card is mounted. */
}
Serial.println("Prepare camera");
err = theCamera.begin(); //begin() 引数なし:30fps, QVGA, YUV4:2:2(default)
if (err != CAM_ERR_SUCCESS) printError(err);
Serial.println("Start streaming"); /* Start video stream. */
err = theCamera.startStreaming(true, CamCB); //Preview用call back関数の設定と有効指定
if (err != CAM_ERR_SUCCESS) printError(err);
Serial.println("Set Auto white balance parameter");
err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_DAYLIGHT); //AEを日中設定
if (err != CAM_ERR_SUCCESS) printError(err);
Serial.println("Set still picture format"); //Set parameters about still picture
err = theCamera.setStillPictureImageFormat(
CAM_IMGSIZE_VGA_H, //VGA
CAM_IMGSIZE_VGA_V,
CAM_IMAGE_PIX_FMT_JPG);
if (err != CAM_ERR_SUCCESS) printError(err);
}
void loop() {
if (shutFlag) {
/* Take still picture. Unlike video stream(startStreaming) ,
this API wait to receive image data from camera device. */
digitalWrite(LED_R, LOW);
Serial.println("Start get JPG");
CamImage img = theCamera.takePicture(); //写真撮影(=シャッターを切る)
if (img.isAvailable()) {
Serial.printf("%p\n", img.getImgBuff());
Serial.println(img.getImgSize());
char fname[16] = {0};
sprintf(fname, "PICT%03d.jpg", cnt); /* Create file name */
filename = String(fname);
//digitalWrite(LED_R, HIGH);
Serial.print("Save taken picture as ");
Serial.print(filename);
Serial.println("");
theSD.remove(filename); //Remove the old file with the same file name
File myFile = theSD.open(filename, FILE_WRITE); //create new file
myFile.write(img.getImgBuff(), img.getImgSize());
myFile.close();
if (google) { //===== upload to google drive =====
Serial.println("Start get AccessToken"); //アクセストークン取得
getAccessToken();
Serial.println("Start Post GoogleDrive"); //GoogleDrive保存
postGoogleDriveByAPI(img.getImgBuff(), img.getImgSize());
}
Serial.println("took a photo and sent google drive");
cnt++;
} else {
/* The size of a picture may exceed the allocated memory size.
* Then, allocate the larger memory size and/or decrease the size of a picture.
* [How to allocate the larger memory]
* - Decrease jpgbufsize_divisor specified by setStillPictureImageFormat()
* - Increase the Memory size from Arduino IDE tools Menu
* [How to decrease the size of a picture]
* - Decrease the JPEG quality by setJPEGQuality() */
digitalWrite(LED_R, HIGH);
Serial.println("Failed to take picture");
}
}
shutFlag = false;
digitalWrite(LED_R, HIGH);
//theCamera.end(); //Cameraライブラリの処理を終了
}
GAS:
ocr-meter
// ocr_meter
function imageOcr() {
let msg = '新しいファイルが追加されました\n'; // 通知メール本文
let imageFileIds = checkForNewFiles(); // 新規ファイルを確認、戻り値は配列
// console.log(imageFileIds.length); // 確認用 (新規ファイルの数)
if (imageFileIds.length > 0) { // 新規ファイルがあれば処理する
console.log(msg);
let resource = { // OCR用のファイル名
title: "OCR_TEST"
};
let option = { // OCR用ファイル生成時のオプション
"ocr": true, // OCR設定で有効にするため、trueを設定
"ocrLanguage": "ja", // OCRを行う言語を日本語に設定
}
for (let fileId of imageFileIds) { // ファイルをOCRしメータ値取得、複数ファイルを処理可能
let imageData = Drive.Files.copy(resource, fileId, option); // DriveAPIでファイルコピー時にOCRを実行
let ocrData = DocumentApp.openById(imageData.id).getBody().getText(); // DocumentAppクラスで画像読込、テキスト取得
console.log(ocrData); // 確認用 (OCRで取得した全テキストデータ)
let data = extractData(ocrData); // 正規表現を使ってメータ値を抽出
let file = DriveApp.getFileById(fileId); // ファイルを取得
msg += file.getName();
if (data != undefined) {
msg += ': ' + data + '\n';
file.setTrashed(true); // 抽出できたファイルは削除(ゴミ箱)
} else {
msg += ': 抽出できませんでした\n';
}
delFileByName(); // OCR用ファイル(OCR_TEST)を削除
}
} else {
msg = '新しいファイルはありませんでした\n';
console.log(msg);
}
sendEmail(msg); // メータ値を送信
}
function checkForNewFiles() {
let folderId = ' ===== 監視するフォルダのID ===== '; // 保存フォルダ
let folder = DriveApp.getFolderById(folderId); // forderId でフォルダを取得
let files = folder.getFiles(); // フォルダ内の全ファイルのコレクションを取得
let newFiles = [];
while (files.hasNext()) { // 次のファイルが存在?
let file = files.next(); // 次のファイルを取得
let fileId = file.getId(); // ファイルのIDを取得
let lastChecked = PropertiesService.getScriptProperties().getProperty(fileId); // 関連付けられた値を取得
if (!lastChecked) { // 空なら新規ファイルということ
newFiles.push(fileId); // 配列の最後尾に追加
PropertiesService.getScriptProperties().setProperty(fileId, new Date().getTime()); // プロパティ設定
}
}
return newFiles
}
function extractData(ocrData) { // OCR結果からメータ値を抽出
ocrData = ocrData.replace(/_| /g, ''); // メータ数値部にある’_'の誤認識、' 'が入った場合は削除
console.log(ocrData);
let reg = /\d{4,5}\.?\d*/; // 正規表現で最初のマッチだけ取得、水道メータにも対応
if (reg.test(ocrData)) { // 検索テスト結果を真偽値で返す
result = ocrData.match(reg); // メータ値を抽出、result[0]にマッチした文字列(メーター値)が格納
// reg = /^0*|0*$/g; // 先頭と最後に'0'があれば削除
reg = /^0*/g; // 先頭の'0'だけ削除に変更 (水道メータで'00490'のようなケースがある)
return result[0].replace(reg, ''); // '0'を削除して戻る → 46.05
}
}
function delFileByName() {
let fileData = DriveApp.getFilesByName("OCR_TEST"); // ファイルを取得する
Drive.Files.remove(fileData.next().getId()); // ごみ箱ではなく完全に削除する
}
function sendEmail(emailBody) {
let today = new Date();
emailBody += (Utilities.formatDate(today, 'JST', 'yyyy/MM/dd HH:mm:ss') + '\n'); // 日時 (2024/08/22 16:19:42)
console.log('本文: \n' + emailBody); // 確認用
GmailApp.sendEmail(' ===== メールアドレス ===== ’, 'メータ値', emailBody);
}
手段2:SPRESENSEで認識
作成中ですが、以下の手順で進めるつもりです。
- まずは、実際のメーター画像から学習データを作成し、NNCにより学習済みモデルを作成します。
- プログラムは、 Youtube でのチュートリアル「6. カメラの使い方【ソニー公式】」の「資料」から Spresense_number_recognition.ino を使わせて頂き、これに必要な以下の変更などを加えることで可能と考えています。
- メーターの数字部の各桁に合わせた矩形をLCDパネルに表示させる。
- この矩形に入るようにカメラ位置を合わせ画像を撮影する。
- 各数字(に対する矩形領域)を切り出し、整形後の画像を認識対象として処理する。
- これを順に桁上位から下位まで繰り返し、メーター値全桁を認識させる。
今後
- まずは手段2の完成を急ぎます。
- 屋外設置なのでネットワーク部のWi-Fi化が必須です。できればESP8266 or ESP32で自作してみたいと思います。その上で電池を含めケースに入れ設置します。
- 今回の目的では以下の点が重要と分かりました。この改善を考えます。
- カメラの設置状況の安定性(画面内にあるメーター数値部の位置、傾き)
- 太陽光直射からの回避、メータ前面のガラス面での反射の抑制
- カメラ設置環境をラフにし実用性を高めるには、前処理としてメーター数字部の自動認識、位置・傾きの自動補正が必要
- 学習データ取得の簡便性を高める。
- ESP系のスリープモードに相当する機能を追加し、起動・送信終了でスタンバイさせ、長期の電池駆動を実現させる。
- SPRESENSEのAI画像認識については、まだチュートリアルの手順に従っているだけの状態です。NNCでの扱いを含めしっかりと習得し「AI」と冠しても恥ずかしくないレベルのものを目指したいと思います。
なお、当家の水道メーターは数字表示とは言え、数字が書かれた円盤が回るような仕組みなので、数字の切替わり時は写真のような状態です。これをどう処理するかは難題に思えます。人間なら境界の位置から四捨五入するなんて簡単なのですが・・・。
謝辞
未達部分を多く残していますが、モニター品を提供頂いておりますので投稿いたしました。
参考にさせて頂いたサイトの方々、モニター品を提供頂いた各社様、コンテストを企画して頂いたelchikaさん、そしてSONYさんに深く感謝いたします。
投稿者の人気記事
-
akino
さんが
2025/01/26
に
編集
をしました。
(メッセージ: 初版)
ログインしてコメントを投稿する