Open3DのAzure Kinectのサンプルを動かし,簡易的な3D reconstrucionをするまで

こんにちは。ume-boshiです。

最近、研究室でAzure Kinectが一般販売され始めたことを情報シェアしたら、1つ返事で即座に買ってもらえました。恐るべしNAISTパワー。

始めは使う予定はなかったのですが、研究で使いそうなので環境構築してサンプルを動作させるまでたどり着きました。今回はその過程でサンプルが動作するまでの環境を構築し、簡易的に3D reconstructionを行いましたのでそれについて走り書きします。

https://img-prod-cms-rt-microsoft-com.akamaized.net/cms/api/am/imageFileData/RWsCgp?ver=3b47&q=90&m=6&h=600&w=1200&b=%23FFFFFFFF&f=jpg&o=f&aim=true オシャレだけど、ちょっとでかい。

開発環境

Windowsで実行した場合、後に説明するazure_kinect_mkv_reader.pyが正常に動作しませんでした。SDKのissueを見漁っていてもwindows環境でその問題を解決した人はいなさそうでした。そのため、いつまでも使用されずに研究室に放置されていたPCを借りてubuntu環境で構築してみました。

pip3やgitやcmakeなど、基本的なアプリケーションは既にapt installしてあるとします。

ubuntu WSL 18.04のほうでも環境構築して動くかを調査しましたが、WSLではKinectのようなUSBデバイスを認識できないため使用できませんでした。

Open3D

Pythonスクリプトを動作させるだけであれば、クローンしてくるだけで問題ないはずです。一部サンプルを実行したい場合はビルドをする必要があります(僕は何も考えず悶絶しながらbuildしましたが)。

Open3Dの環境構築手順は下記のとおりです。


1.cmakeのバージョンを上げる
cmakeはこちらのページから最新版を見つけてリンクをコピーしてきます(現バージョンの最新版は3.19.0)。パスの指定については各個人のビルド先になるので、適宜変更してください。

sudo apt purge cmake
sudo apt install -y libssl-dev
wget https://github.com/Kitware/CMake/releases/download/v3.19.0/cmake-3.19.0.tar.gz
tar xvf cmake-3.19.0.tar.gz
cd ./cmake-3.19.0
./bootstrap
make 
sudo make install

export PATH=$HOME/Open3D/build/bin:$PATH
export LD_LIBRARY_PATH=$HOME/Open3D/build/lib/:$LD_LIBRARY_PATH


2.GitHubからcloneする

git clone --recursive https://github.com/intel-isl/Open3D.git
cd Open3D
git submodule update --init --recursive

また、ubuntuの場合は ↓ を実行する必要があります。

~/Open3D/util/install_deps_ubuntu.sh


3.Pythonで動作するようにパッケージ取得
Pythonスクリプトを動作させるために、pipでパッケージを取得する必要もあります。

pip install open3D==0.10.0

※open3Dのバージョンが0.11.0だと、サンプルが正常に動作しませんでした。
※condaを使っている人は、open3dの他にopen3d-adminが必要だった気がします。


4.Open3Dをビルドする(c++で動作したい人向け?)

mkdir build && cd build
cmake -DCMAKE_INSTALL_PREFIX=.. .. 
make -j$(nproc)
sudo make install
make install-pip-package

python3 -c "import open3d"

最後の行で、何もエラーが出なければビルド成功です。

※INSTALL_PREFIXには、open3d_install_directoryを指定します。


Azure Kinect Sensors SDK

公式的に、Azure Kinectは「k4a」と略称するようです。そのため、ビューワやレコーダは「k4aviewer」や「k4arecorder」というような実行ファイル名となっています。
Azure Kinect Sensors SDKは、.soという動的ライブラリを生成するために(windowsでいう.dllファイル)ビルドをする必要があります。

Azure Kinect Sensors SDKの環境構築手順は下記のとおりです。


1.GitHubからcloneする

git clone https://github.com/microsoft/Azure-Kinect-Sensor-SDK.git
cd ./Azure-Kinect-Sensor-SDK


2.ビルドする

sudo apt install -y libsoundio-dev

mkdir build && cd build
cmake .. -GNinja
ninja
ninja install

このビルドの際、libusb関連のエラーが出ることがあります。その場合は次のようにして対応できる可能性があります。ちなみに、ninjaはmakeよりも実行が速いらしい。

  • sudo apt-get install libusb-1.0-0-devを実行し、libusbを入れる。
  • vim等を用いて、~/Azure-Kinect-Sensor-SDK/extern/libuvc/src/include/libuvc/libuvc-internal.h の"#include <libusb.h>"を"#include <libusb-1.0/libusb.h>"に書き換える
  • 再度ninjaから実行


3.パスを設定する
ビルドで生成されたbinディレクトリ内に、.soという動的ライブラリが生成されています。これにどこからでもアクセスできるようにパスを設定します。下記のどちらかを行なうだけでいいかもです(詳しくない)。

  • sudo vim /etc/ld.so.conf.d/libc.confを実行し、「~/Azure-Kinect-Sensor-SDK/build/bin」を追記する。
  • export LD_LIBRARY_PATH=$HOME//Azure-Kinect-Sensor-SDK/build/bin/:$LD_LIBRARY_PATHでパスを追加する


Depth Engineの.soファイルを設定

k4aのreaderを実行するためには、libdepthengine.so.2.0 という動的ライブラリをDLし、パスを通す必要があります。


また、Azure Kinectのruleファイルをシステム側にコピーする必要があります。

cp ~/Azure-Kinect-Sensors-SDK/scripts/99-k4a.rules /etc/udev/rules.d


Azure Kinectのサンプルで動作確認

↓ のディレクトリにAzure Kinect用のサンプルスクリプトが3つ存在しています。

~/Open3D/example/python/reconstruction-system/sensors/

  • azure_kinect_viewer.py → Azure kinectから映像を取得し、カラーとDepthイメージを表示
  • azure_kinect_recorder.py → viewerに加え、「space/esc」の押下で録画を開始/終了できる
  • azure_kinect_mkv_reader.py → recorder.pyで保存された.MKVファイルを見る + 1フレームごとに画像を分割できる


ex) すでにrecordした映像「movie.mkv」を、存在していないimagesディレクトリに出力 python3 azure_kinect_mkv_reader.py --input movie.mkv --output ./images

→ ./images/color や ./images/depth や、config.jsonなどが出力される

コントローラの形状をreconstructしたい

何の研究をしているんだと思うかもしれませんが、研究課程でDualShockのコントローラのモデルを取得したいなと考えていました。結果的には良い出来ではないですが、部屋などを取得したい人には役に立つ情報だと思います。

では、reconstrucionの手順を説明します。


1.recorderで物体の周囲から映像を取得する
python3 azure_kinect_recorder.pyを実行し、物体の周りを歩き回って動画を撮影します。この際に、足やコードが映像に入らないように、なおかつ、ケーブルの長さ制限にも気を付けましょう。

↓ これが私がrecordした映像の例です。

youtu.be


2.取得した映像の各フレームからJPG画像を出力
python3 azure_kinect_mkv_reader.py --input ほげほげ.mkv --output ./imagesというように実行し、映像をフレーム枚の画像に変換します。

↓ が出力画像例です。depthは色が薄いですね。

1枚目:カラー画像、2枚目:depth画像(jpg)

3.config.jsonの内容を変更
mkv.reader.pyの実行で生成されたconfig.jsonファイルをvim等で編集します。
編集内容は、"icp_method": "color",という箇所を"icp_method": "point_to_plane",に変更することです。

この変更をしない場合、5手順目の--refineオプションでの実行時に、正しく動作しませんでした。


4.Pythonスクリプトの書き換え "~/Open3D/example/python/reconstruction-system/"にあるpythonスクリプトを少し書き換えます。具体的には、o3d.pipelines.となっている箇所をすべてo3d.に短くします。
Open3Dのバージョン違いで書き換えの必要性がなくなるかもしれないので、一度次の手順を行なってから行なうのでも構いません。


5.run_system.pyを実行しまくる "~/Open3D/example/python/reconstruction-system/"にあるrun_system.pyを実行します。実行するのは4回であり、それぞれ mkv_reader.pyで出力されたディレクトリ内のconfigを引数として、別々のオプションを付けています。

python3 run_system.py ./sensors/images/config.json --make
python3 run_system.py ./sensors/images/config.json --register
python3 run_system.py ./sensors/images/config.json --refine
python3 run_system.py ./sensors/images/config.json --integrate

行なっている内容は、下記の模様です。
①画像からいくつかのフラグメントを生成(config.jsonがあるディレクトリに./fragmentディレクトリが出来ており、そこに.plyファイルが新しく存在)
②③平面を綺麗に合わせる段階?あまりよくわかっていません
④複数のfragmentを統合し、実際に1つの.ply形式として出力(./sceneディレクトリが出来ている)


6.見る!
やっと出力できました! が、あまりきれいではないですね。

f:id:ume-boshi:20201120183416p:plainf:id:ume-boshi:20201120183418p:plain
重要なコントローラは取れてねぇ。。。


精度が微妙な理由には、次の可能性があると考えています。

  • コントローラの色や光沢が測距に影響
  • 映像撮影時の手振れが影響
  • 撮影が近距離過ぎた
  • 第5手順の--refineがうまくいっていない(point_to_planeよりもcolorのほうが高精度?)


おわりに

物体のモデリングに使うのではなく、周囲環境を取る場合は、綺麗にできると思います。なんにしろ、環境構築さえできれば簡単にモデル化できるのはとても便利ですね!

f:id:ume-boshi:20201120165853p:plain
こいつは失敗作だ


最近忙しく、あまり記事を投稿できてません。。。ただ、気まぐれでQiitaのカレンダー企画?に参加してみるので、12月中旬にはまた別の1作品をお見せできると思います。

【ESP32,Android】物理フリック入力キーボードの開発が1段落しました!

こんにちは。
最近、寒くて在宅作業を布団の中で行なっているのですが、すぐに寝落ちしてしまうume-boshiです。


さて、ずっと開発してきた物理フリック入力キーボードの作品を完成させて、GUGEN2020という電子工作コンテストヒーローズ・リーグに応募しました!

皆さんの「ほしいね!」の投票が審査にも関わるようなので、このデバイスを欲しいと思った方はぜひ ↓ のサイトから「ほしいね!」をクリックしていただけますと幸いです! gugen.jp

紹介動画もあります ↓
youtu.be


GUGENヒーローズ・リーグとは、日本最大級の開発コンテストのことです。2年前にもこの作品 で応募していましたが、今回は完全に個人名義で応募しました(今も昔も全て1人で開発しましたが…)。

いつの間にか4か月間も開発を続けていたこの作品について、本記事では技術面をメインにお話ししたいと思います。

作品概要

とはいえ、技術の前にちょっと作品紹介させてください。

f:id:ume-boshi:20201104055353p:plain:w400
物理フリック入力キーボード「FFKB」についてGUGEN2020に応募しました!

近年、前方不注意で危険な「歩きスマホ」がはびこっています。私は、歩行中にメモしたいときに立ち止まるまで放置するのですが、そのタイミングまでに内容を忘れてしまうことが多々ありました。その経験から、歩行中でも前を見ながら安全に入力できるデバイスの実現を考えるようになりました。

そのために、下記の3点の要素を兼ね備えたデバイスを考えました。

  • バイスを見ずに入力できる直感性の高さ
  • 両手の自由さによる安全さ
  • スマホを見なくても入力内容が確認できる機能

これらを同時に実現できるデバイス「FFKB(Future Flicking KeyBoard)」は、スマホフリック入力を物理世界に導入したデバイスです。 物理的・機能的に左右に分割されたキーボードを用いて、左手側で「あかさたな...」を指定し、右手側で「あいうえお」の選択と決定・変換・削除を入力することが可能です。

f:id:ume-boshi:20201104065920p:plain:w400
入力機能のイメージ

スマホ入力に慣れたユーザにとって技術の習得が直感的であり、ブラインドタッチの習得も容易であると推測できます。

また、両手が自由に動かせられ安全性を確保できます。例えば歩行中に躓きそうになった際も、両手をとっさに動かせて安全に対応可能です。

そして専用のAndroidアプリを用いることで「入力した内容を音声出力する」機能を拡張可能です。イヤホンを用いて音声を聞ける状態であれば、スマホの画面を見なくとも入力内容を把握できます。


実装について

前回の記事と比較して、基板の作成と、両手の2デバイス間の通信と、文字変換への対応、3Dケース作成、Bluetoothキーボードデバイスとして認識・音声出力方法を変更しました。これらについて、ハードウェアの{基板・ケース}と、ソフトウェアの{2デバイス間の通信・BleKeyboard・入力内容処理アルゴリズム・音声出力変更 }の順番で説明していこうと思います。

システムの処理過程は下図のようになっており、2デバイス間をEPS-NOWで通信してキーの入力内容を共有し、キー入力内容を右手側のデバイスで処理して、BluetoothAndroid端末に送信する形になっております。

f:id:ume-boshi:20201104072724p:plain:w400
システムの処理過程



ほぼすべてのハードウェアとソースコードGitHub上で公開していますので、詳細を見たい方はどうぞご覧ください。

  • stl_filesフォルダ内にケース用の3Dモデル?があります。
  • futureKeyboardPCB内の.kicad_pcbや.schファイルがメインの基板・回路内容です。
  • leftHand.inoとrightHand2.inoがマイコン関連のコードです。
  • futureFlickKeyboardAppのフォルダ内がAndroid関連のコードです。

github.com


ハードウェア

基板

作ってから時間がたっているのですっかり忘れていましたが、ブログで紹介するのは初めてです。なんなら既に1回寸法を間違えて発注しているので、現在がversion2なのです。

基板は右手・左手共に2段構成にしております。上段下段の基板接合において、今回は表面実装のピンヘッダを採用しました。 2段構成の基板は強度が落ちる問題があるため、2か所のピンヘッダで接合するように工夫してあります。

f:id:ume-boshi:20201104094852j:plain:w400
左手上段(左上)、右手上段(右上)、下段裏(左下)、下段表(右下)

f:id:ume-boshi:20201104094717j:plain:w400
実装後の左右の基板。2段構成になっている。

今回のこだわった点としては、下段基板を左右で(ほぼ)同じ部品配置にし、ピンの割り当てを一致させたことです。ただし、上段のキースイッチの配置問題で、左右の下段基板を完全一致させることはできませんでした。しかし、これらを完全一致させることで

  • マイコンの死亡時などの緊急時の取り換えや
  • 左右の機能を1つのプログラムに落とし込むことでソースコードを変えることなく両デバイスで兼用できる

などの可能性があります。なんで諦めてしまったんだ。。。


基板のデータについては、GitHubよりもInventHubからの方がわかりやすくご覧いただけます。電気系の知識がある方はご自由に改善していってください。

inventhub.io


使用部品は下記のとおりです。



ケース

ケースに関しては、持ちやすさをメインに考えて作成しました。

右手側についてはフリック機能により安定しづらいため、oculus questのコントローラ風の持ち手を設け、力強くデバイスをホールドできるようにしました。
左側については、遠くのキーにアクセスできる必要があるため、持ち替えがしやすいように手のひら側のみにシンプルなグリップを付けた形になっております。
どちらの手に関しても親指のみで操作するようなイメージです。

f:id:ume-boshi:20201104073538j:plain:w250f:id:ume-boshi:20201104073534j:plain:w250
モデリングした3Dモデル

3Dモデルの生成は2回目で2年ぶりなので、復習しながら適当に行いました。
基本的には次の手順で設計を進めました。

  1. 基板の概形とモデルを生成(2段構成の基板の場合、2つのスケッチで作るべき)。
  2. 基板上の部品を配置し、それっぽい高さに押し出しする。この際にちょっと大きめに雑に作ってもいい気がする。
  3. 基板の形状を基に、ケースのスケッチをxyzの3方向から作成。
  4. 押し出しと論理積を活用し、ケースの立体形状を作成。
  5. 基板とケースの3Dモデルが交わっていないかを確認するために論理差を取る。
  6. ケース側の3Dモデルを滑らかに調整していく。
  7. 3Dプリントや組み立てしやすいように、いくつかの面に沿ってボディを分離する。


あまり経験がないため、モデル生成時の反省は多々ありました。

  • 基板上の部品の再現をさぼったため(ESPやSMDのスイッチなど)、組み立て時に衝突する箇所が多々あり削る羽目になった
  • 組み立て後の使い勝手を十分に考慮できておらず、プログラムを書き換えるためにケースを分解しなければならない
  • 電源スイッチが飛び出るようにしたが、ケースが分厚すぎて操作しづらい
  • 言うほど持ちやすくない(致命的!)

ただ、今回のケース作成で慣れてきたのか3Dモデリング心理的ハードルが下がったので、気に入ったものについては今後もケース作成したいと思います。


実際にプリントした3DモデルのデータはGitHub上のstl_filesフォルダにあります。いくらか削る部分があり不完全ですが、ただ作りたいだけの人はこのまま印刷してしまってもいいかと思います。


ソフトウェア

この章ではソースコードの一部を抜粋して説明します。完全版のソースコードGitHubを参照してください。

右手側はESP-NOWでwi-fiを使い、デバイスと端末間の通信でBluetoothも使用するためメモリが足りなくなります。これを解決するためには、Arduino IDEの「ツール」→「Partition Schema」→「NO OTA」や「Huge APP」などを選択してメモリの使用領域を指定する必要があります。

2デバイス間の通信

左右のデバイス間の通信は、ESP-NOWというESP系列マイコンの独自通信プロトコル?を使いました。意外にもリアルタイムに通信可能であり、キー入力情報を送信するくらいでは全く問題なく動作します。

左手側がServerで、右手側がClientです(逆のイメージなんだけどなぁ)。


左手のServer関連のソースコード

#include <esp_now.h>
#include <WiFi.h>

uint8_t key=0;
uint8_t slaveMAC[6] = {0x24, 0x0a, 0xc4, 0x11, 0xf8, 0xb1}; // 事前にMACアドレスを保存しておく

// Global copy of slave
esp_now_peer_info_t slave;
#define CHANNEL 0  // 自動割り当てしてくれるらしい
#define PRINTSCANRESULTS 0
#define DELETEBEFOREPAIR 0

// Init ESP Now with fallback
void InitESPNow() {
  WiFi.disconnect();
  if (esp_now_init() == ESP_OK) {
    Serial.println("ESPNow Init Success");
  } else {
    Serial.println("ESPNow Init Failed");
    ESP.restart();
  }
}

// 可能なら消し飛ばしたい
bool manageSlave() {
  if (slave.channel == CHANNEL) {
    if (DELETEBEFOREPAIR) {
      deletePeer();
    }
    Serial.print("Slave Status: ");

    // check if the peer exists
    // ここでslave.peer_addrが存在しているからOKなんだと思う
    bool exists = esp_now_is_peer_exist(slave.peer_addr);
    if ( exists) {
      Serial.println("Already Paired");
      return true;
    } else {
      // Slave not paired, attempt pair
      esp_err_t addStatus = esp_now_add_peer(&slave);
      if (addStatus == ESP_OK) {
        Serial.println("Pair success");
        return true;
      } else {
        Serial.println("ESPNOW manageSlave Error");
        return false;
      }
    }
  } else {
    // No slave found to process
    Serial.println("No Slave found to process");
    return false;
  }
}

// この辺無くていいと思う
void deletePeer() {
  esp_err_t delStatus = esp_now_del_peer(slave.peer_addr);
  Serial.print("Slave Delete Status: ");
  if (delStatus == ESP_OK) Serial.println("Success");
  else {Serial.println("ESPNOW deletePeer Error");
}

// データ送信用
void sendData() {
//  const uint8_t *peer_addr = slave.peer_addr;
  const uint8_t *peer_addr = slaveMAC; // 事前に登録しておいたアドレスを代入
  Serial.print("\nSending: "); Serial.println(key);
  esp_err_t result = esp_now_send(slaveMAC, &key, sizeof(key)); // 送りたい内容をglobal変数で直接指定する糞仕様
  Serial.print("Send Status: ");
  if (result == ESP_OK) Serial.println("Success");
  else {"Some Error is happened");
}

// データ受信用(今回は使用しない)
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print("Last Packet Sent to: "); Serial.println(macStr);
  Serial.print("Last Packet Send Status: "); Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}


void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  Serial.print("STA MAC: "); Serial.println(WiFi.macAddress());
  InitESPNow();
  esp_now_register_send_cb(OnDataSent);

  w[0]=0; w[1]=0; // 入力内容を初期化
}


void loop() {
  //どのボタンが押されているかを認識しkey変数に代入(省略)

  memcpy(slave.peer_addr, slaveMAC, 6);  // 事前に入力したslaveのアドレスをコピー
  slave.channel = CHANNEL; // channel 0
  slave.encrypt = 0; // no encryption(暗号化?)
//  slaveFound = 1;

  if (key != prevKey && slave.channel == CHANNEL) { // check if slave channel is defined
    bool isPaired = manageSlave();
    if (isPaired) {
      sendData();
    } else {
      Serial.println("Slave pair failed!");
    }
  }
  delay(10);
}

Server側のサンプルでは、通信をするたびに毎回ScanForSlave()という関数を呼び出しているのですが、この関数をloop関数内で用いると実行速度が非常に遅くなってしまいます。そのため、通信相手であるClientのMACアドレスをl.5のように事前に登録しておき、sendData()関数内やloop()関数内の3行目あたりのように、無理やり指定します。
ちなみに通信相手のMACアドレスは、ESP32のリセット時に表示される文字列に書かれていますので、それをソースコードに記述していきます。

sendData()関数でデータを送信します。今回はkeyに左手側のキー入力情報が保持されており、それを送信しています。



右手のClient関連のソースコード

#include <WiFi.h>
#include <esp_now.h>

#define CHANNEL 0 // 自動で決めてくれるらしい
int key = 0;

// Init ESP Now with fallback
void InitESPNow() {/*server側と同じ(省略)*/}

// config AP SSID
void configDeviceAP() {
  const char *SSID = "Slave_rightHand"; // なんで名前つけてるか忘れた
  bool result = WiFi.softAP(SSID, "ffkb_rightHand", CHANNEL, 0); // 同上
  if (!result) {
    Serial.println("AP Config failed.");
  } else {
    Serial.println("AP Config Success. Broadcasting with AP: " + String(SSID));
  }
}

// callback when data is recv from Master
// 別スレッド的なので、Serverからデータが受信されるたびに勝手に実行してくれる
void OnDataRecv(const uint8_t *mac_addr, const uint8_t *data, int data_len) {
  char macStr[18];
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print("Last Packet Recv from: "); Serial.println(macStr);
  Serial.print("Last Packet Recv Data: "); Serial.println(*data);
  Serial.println("");

  key = *data;
}

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_AP);
  configDeviceAP();  // configure device AP mode
  Serial.print("AP MAC: "); Serial.println(WiFi.softAPmacAddress());  // This is the mac address of the Slave in AP Mode
  InitESPNow();  // Init ESPNow with a fallback logic
  esp_now_register_recv_cb(OnDataRecv);  // Once ESPNow is successfully Init, we will register for recv CB to get recv packer info.

  key=0;
}

void loop() {
}

Client側はServer側からデータが来るたびにスレッド処理が発生し、半自動的にkeyにデータが入ります。MACアドレスの指定もServer側と違ってする必要がなく、比較的シンプルなコードです。

スレッド内で取得した左手側の入力情報「key」の値を、以降の処理に利用しています。


BleKeyboard

T-vKさんのESP32-BLE-Keyboardというライブラリを使用しました。このライブラリを使用することで、BLEキーボードを簡単に実装できます。過去の自作レーザーポインタもこの人のライブラリ(BLEマウス版)を使用していました。

こちらのライブラリを使用する場合、sampleコードで十分に理解できると判断したので、本節ではソースコードの抜粋は省略します。

github.com

このライブラリを使用する場合、Bluetoothバイス名をBleKeyboard.hのl.101の「deviceName」を変更できます。長い文字列は無理な可能性?


さて、FFKBでは入力内容を日本語として認識させるために、受信端末側では日本語入力用のIME(例えば日本語版Microsoft IME)を事前設定することにしています。そして、FFKB側から1~3文字のアルファベットを連続で送信することで日本語として認識させています。例えば、「a」や「ge」や「xtu」といった具合です。

FFKBから送信する主な内容は下記の通りです。↓ の内容以外でも、BleKeyboard.h内で定義されているキー情報を送信することが可能です(SHIFTキーやF5キーなど)。

  • 文字の送信(bleKeyboard.write('a'); などで送信)
  • 改行(bleKeyboard.write('KEY_RETURN');)
  • 変換(0x20を送信)
  • 削除(KEY_BACKSPACEを送信)
  • 十字キー(KEY_LEFT_ARROWなどを送信。UP, DOWN, RIGHTも同様)


入力内容処理アルゴリズム

前回の記事と比較して、小文字や濁点、記号入力の処理を追加しました。

f:id:ume-boshi:20201104065920p:plain:w400
入力機能のイメージ(再掲)

右手側の入力内容処理アルゴリズム部分の抜粋

#include "BleKeyboard.h" //https://github.com/T-vK/ESP32-BLE-Keyboard

#define LEDs 13
#define button1 32
#define button2 33
#define spk 25
#define enter 16
#define conv 17
#define back 4
#define jx 34
#define jy 35
#define pi 3.141592

BleKeyboard bleKeyboard;

int lines[4]={0};
char w[2]={0};
char beforew[2]={0}; //長押しによる連続入力を避けるための変数
char beforeInput[2]={0}; //ゴミ変数名(前回の送信内容で、濁点や小文字に対応する)
int key = 0;
bool smallW = false;
bool pushed = false;

void setup() {
  Serial.begin(115200);
  bleKeyboard.begin();
  delay(1000);

  pinMode(jx,INPUT); //ジョイスティック
  pinMode(jy,INPUT);
  pinMode(button1,INPUT);
  pinMode(button2,INPUT);
  pinMode(back,INPUT); // 消す
  pinMode(enter,INPUT); // 改行 or あ列
  pinMode(conv,INPUT); //スペース

  w[0]=0; w[1]=0; // 入力内容を初期化  
  key=0;
}


void loop() {
  if(bleKeyboard.isConnected()){ //接続されていた時だけキーを出力する
    printKey(key);
  }
  delay(50); 
}


void printKey(int key){ // 糞みたいなネーミング
  int x = analogRead(jx); // ジョイスティックの値を取得(0~4095)
  int y = analogRead(jy);
  bool output = false;

  int wordIndex = 0;

  if(key!=0){ // 左手側が入力されているとき
    if(sqrt((x-2048)*(x-2048) + (y-2048)*(y-2048)) > 1250){ // ジョイスティックがある一定以上傾いていたとき(値の範囲に注意)
      float direc = (2.0*(atan2(2048-y,2048-x)+0.785398))/pi; // ジョイスティックの角度を求め、45度ごとに値が1ずつ変化するように変換(値の範囲に注意)

      if(direc>=0){ // ジョイスティックの上側
        switch((int)direc){
          case 0:  Serial.print("<-"); w[1]='i'; wordIndex = 1; break; // ←
          case 1:  Serial.print("!"); w[1]='o'; wordIndex = 4; break; // ↓
          default:  Serial.print("->"); w[1]='e'; wordIndex = 3; break; // →
        }
        output = true;
      }else{ // ジョイスティックの下側(-1以上0未満の値がintにした際に0になっちゃうので)
        switch((int)direc){
          case -1: Serial.print("->"); w[1]='e'; wordIndex = 3; break; // →
          default: Serial.print("i"); w[1]='u'; wordIndex = 2; break; // ↑
        }
        output = true;
      }
    }else if(!digitalRead(enter)){ // フリックしない文字(あ行)はEnterキーで入力
      w[1]='a'; 
      wordIndex = 0; 
      output = true;
    }else{
      w[1]=0;
    }

    switch(key){
      case 1:w[0]=w[1];w[1]=0;break; //1文字なのでw[1]には0を
      case 2:w[0]='k';smallW=false;break;
      case 3:w[0]='s';smallW=false;break;
      case 4:w[0]='t';smallW=false;break;
      case 5:w[0]='n';smallW=false;break;
      case 6:w[0]='h';smallW=false;break;
      case 7:w[0]='m';smallW=false;break;
      case 8:w[0]='y';smallW=false;break;
      case 9:w[0]='r';smallW=false;break;
      case 10:w[0]='c';break; // 小文字と濁点の変換
      case 11:w[0]='w';smallW=false;break;
      case 12:w[0]='q';smallW=false;break; // 記号。なぜqかは聞かないで…
    }
    wordIndex += (key-1)*5; // 左手の入力値を代入する

    if(w[0]=='q'){ // 右下スイッチは記号入力
      switch(w[1]){
        case 'a': w[0]=','; w[1]=0; break; 
        case 'i': w[0]='.'; w[1]=0; break; 
        case 'u': w[0]='?'; w[1]=0; break; 
        case 'e': w[0]='!'; w[1]=0; break; 
        case 'o': w[0]=0x60; w[1]=0; break; // `:バッククォーテーション(PCでは日本語と英語キーボードを切り替えられる)
      }
    }
    if(w[0]=='y'){ // ヤ行の左右はカッコを入力
      switch(w[1]){
        case 'i': w[0]='('; w[1]=0; break;
        case 'e': w[0]=')'; w[1]=0; break;
      }
    }
    if(w[0]=='w'){ // ワ行の入力が変化
      switch(w[1]){
        case 'i': w[1]='o'; break;
        case 'u': w[0]='n'; w[1]='n'; break;
        case 'e': w[0]='-'; w[1]=0; break;
        case 'o': w[0]=0x40; w[1]=0; break; // @
        default: break;
      }
    }


    if(w[0]=='c'){ //濁点や小文字の変更
      char sub = 'l'; // 小文字の場合は、基本的にl(エル)を頭につける
      if(!pushed){
        // 濁点にする場合、w[0]を変更
        // 小文字にする場合、smallWをtrueにし、頭に"l"などを付ける
        // output=trueをデフォにしておけば良かった…
        switch(beforeInput[0]){ 
          case 'k':w[0]='g'; w[1]=beforeInput[1]; output=true; break; // か行
          case 'g':w[0]='k'; w[1]=beforeInput[1]; output=true; break;
          case 's':w[0]='z'; w[1]=beforeInput[1]; output=true; break; // さ行
          case 'z':w[0]='s'; w[1]=beforeInput[1]; output=true; break;
          case 't': // た行
            if(beforeInput[1]=='u' && smallW==false){ //つ の場合
              w[0]='t';
              w[1]='u';
              smallW = true;
              sub = 'x'; //っの場合のみ"ltu"で小文字にならず、"xtu"で小文字にする
              output=true; 
            }else if(beforeInput[1]=='u'){ //っ の場合
              w[0]='d';
              w[1]='u';
              smallW = false;
              output=true; 
            }else { //「つ」 以外
              w[0]='d';
              w[1]=beforeInput[1];
              output=true; 
             }
            break; //つの時だけ小文字にする
          case 'd':w[0]='t'; w[1]=beforeInput[1]; output=true; break;
          case 'h':w[0]='b'; w[1]=beforeInput[1]; output=true; break; // は行
          case 'b':w[0]='p'; w[1]=beforeInput[1]; output=true; break;
          case 'p':w[0]='h'; w[1]=beforeInput[1]; output=true; break;
          case 'y': // や行
            if(beforeInput[1]!='i' || beforeInput[1]=='e'){ //やゆよ のみ小文字反転
              smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; 
              output=true; 
            }
            break;
          case 'w':if(beforeInput[1]=='a'){smallW=!smallW; output=true;} break; // わ行は「わ」のみ小文字化できる
          
          case 'a':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる 1文字目が変化するため,あ行は厄介
          case 'i':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'u':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'e':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
          case 'o':smallW=!smallW; w[0]=beforeInput[0]; w[1]=beforeInput[1]; output=true; break; // 小文字かそうでないかを反転させる
  
          default: output=false; Serial.println("cannot change a small word"); break;
        }
  
        if(output){ // 小文字の場合は通常とは別処理と考えて文字出力を行なう.    
          pushed = true;
          bleKeyboard.write(KEY_BACKSPACE); //前回入力した1文字を削除
          delay(5);
          if(smallW==true){ // ヘッダーが存在するときはそれも入力する ex)xtu や lya などの小文字の入力に用いる
            bleKeyboard.write(sub); delay(5);
          }
          bleKeyboard.write(w[0]); delay(5);
          bleKeyboard.write(w[1]);
          beforeInput[0]=w[0];
        }
        output = false; //先に入力して
      }
    }

    // 出力
    if(output && (beforew[0]!=w[0] || beforew[1]!=w[1])) {
      Serial.print(w[0]); Serial.println(w[1]);
      bleKeyboard.write(w[0]); delay(5);
      if(w[1]!=0)
      bleKeyboard.write(w[1]); 
      beforeInput[0]=w[0]; //過去の出力内容を保存(≠長押し回避)
      beforeInput[1]=w[1];
      pushed = true;
    } // 連続入力を避ける

  }else { // 左手側が入力されていないとき
    if(sqrt((x-2048)*(x-2048) + (y-2048)*(y-2048)) > 1250){ // ジョイスティックがある一定以上傾いていたとき(値の範囲に注意)
      w[0]=0;
      if(pushed==false){
        float direc = (2.0*(atan2(2048-y,2048-x)+0.785398))/pi; // ジョイスティックの角度を求め、45度ごとに値が1ずつ変化するように変換(値の範囲に注意)
        if(direc>=0){ // ジョイスティックの上側
          switch((int)direc){
            case 0:w[0]='<'; wordIndex = 1; break; // ←
            case 1:w[0]='!'; wordIndex = 4; break; // ↓
            default:w[0]='>'; wordIndex = 3; break; // →
          }
        }else{ // ジョイスティックの下側(-1以上0未満の値がintにした際に0になっちゃうので)
          switch((int)direc){
            case -1:w[0]='>'; wordIndex = 3; break; // →
            default:w[0]='I'; wordIndex = 2; break; // ↑
          }
        }
        w[1]=0;
        output = true;
      }
    }else {
      pushed=false;
      if(!digitalRead(enter)) {w[0]='\n'; w[1]=0; wordIndex=60; output=true;} // Enter長押し時に、改行されてしまう問題があるため、w[1]=0を入れる
      else if(!digitalRead(back)) {w[0]='\b'; w[1]=0; wordIndex=59; output=true;} // 画面をクリア
      else if(!digitalRead(conv)) {w[0]=' '; w[1]=0; wordIndex=61; output=true;} // スペースを空ける
      else {w[0]=0;}
    }

    if(output && (beforew[0]!=w[0] || beforew[1]!=w[1])) { // 連続入力を避ける
      Serial.println(w[0]);

      switch(w[0]){
        case '\n':bleKeyboard.write(KEY_RETURN); break;
        case ' ':bleKeyboard.write(KEY_BACKSPACE); delay(75); w[0]=0; break; //\bだけ連続入力可能にする
        case '\b':bleKeyboard.write(0x20);break;
        case '<':bleKeyboard.write(KEY_LEFT_ARROW);break;
        case 'I':bleKeyboard.write(KEY_UP_ARROW);break;
        case '>':bleKeyboard.write(KEY_RIGHT_ARROW);break;
        case '!':bleKeyboard.write(KEY_DOWN_ARROW);break;
      }
    }
  }
  
  beforew[0]=w[0]; // 前回の入力を記憶(長押しで同じ文字が入力されいないようにする)
  beforew[1]=w[1];
}

左手側が入力されている場合、文字として扱えるかを判断します。左手側からのキー入力情報は「key」に1~12の値で保存されています。それぞれ、「あかさたな」に対応しており、左下と右下のみ「小文字と濁点変換用」と「記号用」に対応付けられています。文字として判断する基準は ↓ の3条件です。

  1. 右手側のジョイスティックが傾いているか
  2. 右手側の決定が押されているか(普通のフリック入力と違い、決定スイッチを押したときに あ行 として扱う)
  3. 左手側が左下のキー(10)であるか


1と2に関しては単純な処理内容で、前回記事から変化していないため説明は省略します。3について、ちょっと考慮する点が多かったので、こちらは説明していこうかなと思います。

まず、濁点がつけられる文字は「かさたは」列であり、返還後は「がざだば」列になります。この変換は単純で、平仮名をアルファベットで表したときの1文字目を、「k → g, g → k」というように変換してあげます。「は」列は半濁音もあるので、「h → b, b → p, p → h」という感じで変換します。


次いで、小文字にする文字は「あいうえおつやゆよわ」の10文字あります。「つ」以外は単純で、文字頭に「l」を付けるだけで小文字にできます。小文字にできる文字だった場合は、smallWをtrueにしています。
小文字に変換する場合でも、beforeInput[0]からは大小を判断できないため、別の文字を入力するまでsmallWの内容は保持します(糞コードだから)。 「つ」だった場合は1度「っ」に変換し、その次に「づ」に変換します。さらに、私が使用したスマホでは、「ltu」で「ぅ」と入力されてしまい、「xtu」でないと「っ」と入力できなかったりしました。そのため、変換部の分岐が少し複雑になっています。


これらの判断を基に、文字出力できる場合はBleKeyboard.hのライブラリを用いて端末に送信します。普通の入力だった場合は、w[0]とw[1]に保持した内容をそのまま送信します。小文字や濁点の場合は、前回入力文を1文字を削除(bleKeyboard.write(KEY_BACKSPACE);)してから次の文字を送信します。このとき、小文字だった場合はlやxを頭につけます。



続いて、左手側の入力がされていなかった場合、右手側における{決定・変換(space)・削除・十字キー}の入力を受け付けます。削除だけは長押しで連続入力できるようにしました(連続入力を避けるw[0]の値を適当に変更)。割と単純な処理ですね。

こんな感じで入力内容処理アルゴリズムが実装されています。


音声出力方法の変更

BleKeyboardライブラリを用いたことでBluetoothSerialの処理を介せなくなり、前回記事で紹介した実装よりも簡単になりました。

run()メソッド以外ほぼ変わっていませんが、音声出力関連のコード抜粋を下記に提示します。

/* import 略 */

public class editor2 extends AppCompatActivity implements Runnable, View.OnClickListener, TextToSpeech.OnInitListener{
    private static final String TAG = "Future Flicking KeyBoard";

    /* 各種変数の初期化(一部省略) */
    private String currentText = ""; // 現在の入力内容
    private String previousText = ""; // 前回の入力内容

    private TextView mStatusTextView;    /*** ステータス.*/
    private TextView mInputTextView;    /*** Bluetoothから受信した値.*/
    private static final int VIEW_INPUT = 1;    /*** Action(取得文字列).*/

    private Thread mThread;    /* Thread */
    private boolean isRunning;    /* Threadの状態を表す */
    private boolean isMute = false;
    private AudioManager am;
    private TextToSpeech tts;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.editor2);
        /* mInputTextView, mTitleTextView, mStatusTextViewの初期化(省略)*/
        /* メニュー画面から画面遷移時の引数受け取り(省略)*/
        /*copyButton, saveButton, volumeButtonの初期化(省略)*/
        /* ツールバーの初期化(省略)*/

        //音声出力用
        am = (AudioManager)getSystemService(Context.AUDIO_SERVICE);
        tts = new TextToSpeech(this, (TextToSpeech.OnInitListener) this);

        // スレッド(無限ループで音声出力に対応する) 
        mThread = new Thread(this);
        mThread.start();
    }

    public boolean onCreateOptionsMenu(Menu menu) {/* 省略 */}
    public boolean onOptionsItemSelected(MenuItem item) {/* 省略 */}

    // 別のアクティビティが起動した場合の処理
    protected void onPause() {
        super.onPause();
        fileWrite();  //中身省略
    }

    // スレッド処理
    @Override
    public void run() {
        while(true){ // 入力されるのを一生待つ
            //テキストビューから現在の入力内容を取得
            currentText = mInputTextView.getText().toString();
            Log.i(TAG, "value=" + currentText);

            // 以前の入力内容よりも文字列が長かった場合、音声を出力する
            if (currentText.length() > previousText.length()) {
                String addition = currentText.substring(previousText.length(), currentText.length());
                Log.i(TAG, "addition=" + addition);
                speechText(addition);
            }

            // 現在の文字列を保持
            previousText = currentText;

            try { // ビジーにならないように0.5秒待つ(合ってる?)
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    // ボタン押下時の処理
    @Override
    public void onClick(View v) {
        if(v.equals(saveButton)) { // 保存ボタン
            fileWrite(); //中身省略
        }else if(v.equals(volumeButton)) { // ミュートボタン
            if ( isMute ) {
                isMute = false;
                am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_UNMUTE, 0);
                Toast.makeText(this, "ミュート解除", Toast.LENGTH_SHORT).show();
            }else {
                isMute = true;
                am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_MUTE, 0);
                Toast.makeText(this, "ミュート", Toast.LENGTH_SHORT).show();
            }
        }else if(v.equals(copyButton)) { // clipBoardに入力内容をコピー
            /*省略*/
        }
    }

    /* 描画処理はHandlerでおこなう */
    Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            int action = msg.what;
            String msgStr = (String) msg.obj;
            if (action == VIEW_INPUT) {
                mInputTextView.setText(msgStr);
            } else {/*省略*/}
        }
    };

    public void onInit(int status) {
        if (TextToSpeech.SUCCESS == status) {
            //言語選択
            Locale locale = Locale.JAPAN;
            if (tts.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE) {
                tts.setLanguage(locale);
            } else {
                Log.d("Error", "Locale");
            }
        } else {
            Log.d("Error", "Init");
        }
    }

    private void speechText(String readMsg) {
        CharSequence cs = readMsg;
        tts.speak(cs, TextToSpeech.QUEUE_ADD, null, "id:1");
    }
};

説明することはほぼないですが、BleKeyboard.hのライブラリを使用したことで、アプリからの介入ができなくなり、TextViewから取得した文字列を利用しています。

単純に、スレッド内(run())の無限ループが回るたびに、TextViewの内容が追加されていないかを判断し、追加がある場合はその文字を音声出力するだけです。

現在、音声が出力途中に途切れてしまう問題や、アルファベットだけを(「か」ではなく「k」だけ)出力するなどの問題が発生しています。前者はspeechText()メソッドをいじくることで解決し、後者はBleKeyboard.hで日本語をそのままprintすることで解決するのではないかと予想しています。


今回の開発での発見

今回のFFKBの開発にあたり、最後の1か月ほどでもたくさんの発見がありました。やはり、コンテストなどの目標を定めた開発のほうが、通常のグダグダよりも身が入りますね。

メモ程度に列挙します。読まなくてええよ。

  • 3Dモデルはその場の考えで設計するよりも、事前にある程度寸法を決めてから進めること(滑らかにするときに、エッジがめちゃくちゃでバグが生じる)
  • ソフト開発のためにケースを毎回分解していると基板が弱ってくるため、開発者にも基板にも優しいケース作りをするべき。つまり丁寧にやれ!
  • 3Dプリントしやすい形状を考慮(例えばジョイスティックのパッドはサポートが取り出せなかった)
  • ケース設計の際は、メイン基板のみの状態で多くの人に見てもらい、どんな持ち方がよさそうかヒアリングすること
  • 基板関連のモデルを作成するときは、裏表に注意しながら上下関係までしっかり見直して作ることが重要。
  • ケースの形状を事前に見越した基板設計を行なうこと
  • グリップするのではなく手首にバンドで固定できるような拡張を設け、複数指による高速入力を図る(様々な入力スタイルを評価すべき)

  • ESP-NOWとかは公式のサンプルが難しいので、ネットで簡単なのを拾ってくるべし

  • 以外にバッテリーは長持ちする。その分、HWの動作不良時には早期に電池不足を疑うべし
  • 小型化を進めるためには、キーキャップを自作することが近道(市販のキーキャップはキースイッチよりも広めに面積を取っている)
  • キースイッチはPCBの穴位置に注意(様々な企画が存在する…)
  • TTSの機能について、1文字ずつ出力する場合途切れる問題が多々あった。そして発音もめちゃくちゃになってしまっていた。tts関連の実装知識をもう少しつけたい


普段、スマホで仮想フリックキーボードを使っていますが、日本語ということもあり実装内容の違いがちらちら見えてくることがありました。

  • 小文字の切り替えや、物理キーだと入力のために2文字分を送信しなければならない
  • 文字変換の際の1文字削除をどのように実装しているか('\b' + 'あ') 的な?
  • おそらくスマホ側では日本語でも1文字でデータ送信している
  • 元のBluetoothSerial方式だと十字キー操作や、文字列の変換ができないと勝手に判断したが実装する術がある?


おわりに

繰り返しになりますが、皆さんの「ほしいね!」の投票が審査にも関わるようなので、このデバイスを欲しいと思った方はぜひ ↓ のサイトから「ほしいね!」をクリックしていただけますと幸いです!

gugen.jp

GUGENには落ちましたが、ヒーローズ・リーグの方でもなんかよろしくお願いします!

今後も、この世に存在しないデバイスを作成していきたいと考えていますので、お付き合いのほどよろしくお願いします :)

過去の製作過程

ume-boshi.hatenablog.jp

ume-boshi.hatenablog.jp

ume-boshi.hatenablog.jp

【M5stack,Android】物理フリック入力キーボードの途中段階 ~第2弾(Bluetooth Serial通信編)~

こんにちは.

物理フリック入力キーボードについて,8・9月ごろの進捗についてまとめようと思います.
以前紹介した、「前を向いて歩きながら入力できるデバイス」の製作途中段階で,Android端末との通信あり状態のシステムまで作成しました.具体的には,M5stackとAndroidBluetooth Serial(SPP)で通信してデータ送信できるように実装しました.

ume-boshi.hatenablog.jp

ume-boshi.hatenablog.jp


処理手順

今回までに実装した機能は, ↓ のような処理手順で実装しています.

  1. マイコン上で,どの文字が入力されているかを取得
  2. 入力された文字をindexで表現
  3. indexをBluetooth SerialでAndroidに送信
  4. Android側では,受け取ったindexを文字に直して表示
  5. 入力内容を音声で出力

マイコン側は,前回の記事とさほど変化していません.
Android側は今回初登場しますが,このサイトのメモアプリをベースに作っていきました.

f:id:ume-boshi:20201019102748j:plain
入力とアプリテスト様子


実装

SPPでの通信部分

M5stack側はライブラリでかなり単純化されているので,ほとんど困ることはありません.

M5stack側のソースコードはキー入力を取得する箇所を除けば単純です.SerialBT.print("hagehage");でデータを送信できます.wordIndexで数字として送信するのは,送信時に2Byteコードになる場合の処理を考えるのが面倒くさかったからです.「あ」から「ん」までの文字と記号に,およそ60文字分を割り当てました.

ちなみに,まだ濁点や小文字部分は実装していません.

BluetoothSerial SerialBT;

void setup(){
  //各種初期化(省略)
  SerialBT.begin("device_name"); //device_name は自由に指定可能
}

void loop(){
  //キー入力情報を取得してくる処理(省略)
  SerialBT.print(wordIndex); // Androidにデータ送信
}



Android側のSPP通信のソースコードは,前回 ↓ の記事でお世話になったこちらのサイトソースコードを参考にさせて頂きました.

ですので本記事では,M5stackのBluetooth serialと通信するために変更した設定部分と,SPP通信の受信内容処理部分だけを掲載いたします.

public class editor extends AppCompatActivity implements Runnable, View.OnClickListener, TextToSpeech.OnInitListener{
    private static final String TAG = "BluetoothSample";    /* tag */

    private BluetoothAdapter mAdapter;    /* Bluetooth Adapter */
    private BluetoothDevice mDevice;    /* Bluetoothデバイス */

    /* Bluetooth UUID(固定)  ← 一部の既存のアプリで確認することが可能*/
    private final UUID MY_UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb");   /*確認したところ,どのESPを使っていてもBluetoothSerial時のUUIDはこの値でした!!!!!!!!!!!!!!!!!!!!!!!*/
    private final String DEVICE_NAME = "device_name";    /* デバイス名 環境に合わせて変更!!!!!!!!!!!!!!!!!!!!!!!*/

    private String receivedMessage = "";
    private String mFileName = "";
    private BluetoothSocket mSocket;    /* Soket */
    private Thread mThread;    /* Thread */
    private boolean isRunning;    /* Threadの状態を表す */
    private final String hira = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゐゆゑよらりるれろ小濁数半矢わをんー !(、)。\n ";

    // その他変数定義(省略)

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // 色々な初期化処理(省略)

        // Bluetoothのデバイス名を取得
        // デバイス名は、RNBT-XXXXになるため、
        // DVICE_NAMEでデバイス名を定義
        mAdapter = BluetoothAdapter.getDefaultAdapter();
        mStatusTextView.setText("SearchDevice");
        Set<BluetoothDevice> devices = mAdapter.getBondedDevices();
        for (BluetoothDevice device : devices) {

            if (device.getName().equals(DEVICE_NAME)) {
                mStatusTextView.setText("find: " + device.getName());
                mDevice = device;
            }
        }
        connectBluetooth();
    }


    // BluetoothSerialのスレッド処理(connectボタン押下後に実行)
    // このコードだけで動作するわけではありません! ↓ のサイトを参考に実装を進めてください.
    // https://fabkura.gitbooks.io/android-docs/content/article2-2.html
    @Override
    public void run() {
        InputStream mmInStream = null;

        Message valueMsg = new Message();
        valueMsg.what = VIEW_STATUS;
        valueMsg.obj = "connecting...";
        mHandler.sendMessage(valueMsg);

        try {
            // 取得したデバイス名を使ってBluetoothでSocket接続
            mSocket = mDevice.createRfcommSocketToServiceRecord(MY_UUID);
            mSocket.connect();
            mmInStream = mSocket.getInputStream();
            mmOutputStream = mSocket.getOutputStream();

            // InputStreamのバッファを格納
            byte[] buffer = new byte[1024];

            // 取得したバッファのサイズを格納
            int bytes;
            valueMsg = new Message();
            valueMsg.what = VIEW_STATUS;
            valueMsg.obj = "connected.";
            mHandler.sendMessage(valueMsg);

            connectFlg = true;

            while (isRunning) {
                String get = "";

                // InputStreamの読み込み(マイコンからのデータを受信)
                bytes = mmInStream.read(buffer);
                Log.i(TAG, "bytes=" + bytes);
                // String型に変換
                String readMsg = new String(buffer, 0, bytes);

                // null以外なら表示
                if (readMsg.trim() != null) {
                    valueMsg = new Message();
                    valueMsg.what = VIEW_INPUT;
                    get = get + readMsg;

                    Log.i(TAG, "value=" + readMsg); // 入力内容を表示
                    if(Integer.parseInt(get) < 64 && Integer.parseInt(get) >= 0){
                        speechText(hira.charAt(Integer.parseInt(get)));  // 音声出力(次の節で説明)
                        receivedMessage = receivedMessage + hira.charAt(Integer.parseInt(get)); // 過去の入力内容をバッファに保存
                        valueMsg.obj = receivedMessage;
                        mHandler.sendMessage(valueMsg); // 文章の描画処理
                    }
                }
            }
        }

        // エラー処理
        catch (Exception e) {
            valueMsg = new Message();
            valueMsg.what = VIEW_STATUS;
            valueMsg.obj = "Error1:" + e;
            mHandler.sendMessage(valueMsg);

            try {
                mSocket.close();
            } catch (Exception ee) {
            }
            isRunning = false;
            connectFlg = false;
        }
    }

    // その他メソッド(変更がなかったはずなので省略)

おそらく同じ設定で正しく接続できると思いますが,動作しない場合はUUID MY_UUIDDEVICE_NAMEが正しく設定できているか,今一度確認してみてください.

前者は,マイコンAndroidBluetooth設定をした後に,「Bluetooth Pair」などのアプリを用いることでUUIDを再確認できます.Bleutoothデバイス固有の値のはずで,いわゆるMACアドレスのようなものです.ESP32のBleutoothのライブラリではUUIDを自分で設定できますが,BluetoothSerialのライブラリでは固定されるようです.

後者は,M5stack側で設定した「SerialBT.begin("device_name");」の文言と一致していなければなりません.この文言は,AndroidBluetooth設定するときに表示される名前になりますので,ご自身の作品名を表すわかりやすい内容にするべきです.
私はカッコつけて「Future Flick Keyboard」と名乗っています.


Bleutoothの接続はconnectBluetooth()をしたタイミングで行われます.そのため,アプリ起動時にM5stackと接続できなかった場合は,ボタンを用意するなどで任意のタイミングでconnectBluetooth()を呼び出せるようにするべきです(参考サイトで実装してある).connectBluetooth後にはrunスレッドが立ち上がり,接続状況確認とデータ送信処理ができるようになります.

接続状態のとき,runスレッドではmmInStream.read(buffer)でbufferに受信内容を読み込み(byte型),それを一度String型(char型(数値)の配列として保存したかった記憶がある)に変更してreadMsgに保存しています.そして,null文字を取り除いて,receiveMessageに入力内容を保管していっています.この際,事前に用意したhiraというchar型配列から文字を取得してきます.文字は2byte文字ですので,hira.charAt()というメソッドを用いて取得する必要がありました.

今見返すと周りくどいやり方をしているように見えるため,Stringに変換するあたりやget変数あたりは単純化できるかもしれません.


音声出力部分

本作品では,M5stackから送られてきた文字情報を,テキストで表示するだけでなく音声で出力したいと考えています.その実装のために,ひとまず文字が1文字入力されるたびに音声出力するようにしました.音声出力に関係したソースコードは ↓ のようになっています.

public class editor extends AppCompatActivity implements Runnable, View.OnClickListener, TextToSpeech.OnInitListener{
    private TextToSpeech tts;
    // その他変数定義(省略)

    private void onCreate(){
        tts = new TextToSpeech(this, (TextToSpeech.OnInitListener) this);
        // 各種初期化(省略)
    }


    @Override
    public void onInit(int status) {
        if (TextToSpeech.SUCCESS == status) {
            //言語選択
            Locale locale = Locale.JAPAN;
            if (tts.isLanguageAvailable(locale) >= TextToSpeech.LANG_AVAILABLE) {
                tts.setLanguage(locale);
            } else {
                Log.d("Error", "Locale");
            }
        } else {
            Log.d("Error", "Init");
        }
    }

    private void speechText(char readMsg) {
        CharSequence cs = Character.toString(readMsg);
        tts.speak(cs, TextToSpeech.QUEUE_ADD, null, "id:1");
    }

    // その他メソッド(省略)

まず,クラスはTextToSpeech.OnInitListenerを継承する必要があります.そのうえで,onCreateでttsを初期化し,overrideのonInit()の内容を適当なサイトから引っ張ってきます.そして,音声出力用のメソッドをspeechText()を実装し,これを他のメソッドから呼び出すことで喋れるようになるのです.

この機能の実装には,Android端末側で日本語の音声を出力できる設定にしなければなりません.設定アプリの言語設定から,「テキスト読み上げの設定」の項目を設定します.私はこのサイトを参考に設定しました.


ここまでのシステム

GitHubに全体のソースコードを載せています. M5stack側:futureFlickKeyboard/futureKeyboard/futureKeyboard.ino Android側:futureFlickKeyboardApp/

github.com

今回紹介した機能についてのデモ動画です.完成形が見えてきた感じがしますね.


物理フリック入力キーボードのアプリテスト

棒読み感がたまらんです.


おわりに

最終的にはIMEアプリとセットで完成させたいと考えているのですが,実装にどこまで時間をかけられるか心配です...研究も別にあるのでどっちも進捗がでない.

物理フリック入力キーボードですが,Googleのエイプリルフールネタがあるのは知っていたのですが,ほかにも実装したことがある人がいたようです.
二番煎じかもしれませんが,私のシステムは両手を用いて自由な位置に持つことができ,両手であることで入力速度が向上する可能性がある点で差別化できると考えています.また,入力と同時に音声が出力できることで,聴覚障がい者と健常者のスムーズな会話にも貢献できるのではないかと考えてます.未来が捗る.


ちなみに10月現在では,基板を設計・発注・実装までを進めており,両手化に成功しています.あとは入力機能の強化と持ち手の作成段が残っているのですが,完成形についてはGUGEN2020等に提出してからご報告しようかなと考えています.

結構良いものができてきているので,しばしお待ちいただければ幸いです.





f:id:ume-boshi:20201019103956j:plain:w300
いいキースイッチ使っちゃうぞ~~

【M5stack】行動認識用に加速度データをPCにBluetoothSerial送信したときにちょっと躓いた記憶

こんにちは.

奈良先端大には,毎年各研究室ごとにセミナーが開催されているのですが,その担当者に抜擢していただきました.8月の話です.

その準備としてサンプルコードを作成していたのですが,いつものごとく通信周りで躓いたので,問題点と完成形のソースコードを軽く共有しようと思います.

機能

一定時間ごとに加速度センサの値などを,無線(Bluetooth Serial)でM5stackからPCに送信.
それを.csv形式に保存して,Wekaというソフトで学習し行動認識するための学習データとする.

f:id:ume-boshi:20201018084931p:plain
流れ

ぶつかった罠①

問題

加速度センサの値を取得し,Kalmanフィルターで姿勢を導出しようと思ったのですが,おかしな角度が出力されました.

解決策

いろいろ調査していると,1軸を除いて加速度の値がめちゃくちゃぶれていることに気が付きました.

どうやら,M5stack Grayで加速度/ジャイロセンサを用いる場合,"M5.IMU.Init();”よりも前に ↓ のように"M5.Power.begin()"を追加しないといけないようです.

M5.Power.begin();
M5.IMU.Init();

M5stack界隈では常識なのかもしれませんが,慣れていない私には完全に盲点でした.


ぶつかった罠②

問題

次に,送信したいデータを一時的にString型にまとめてから,後で一気に送信しようとしていた際の問題です.
文字列の途中までしか送信できていないことに気づきました.

始めは,インスタンスにうまく文字列が格納できていないと予測していたのですが,調べていくとString型に保存した文字列の長さ計測法を間違っているようでした...

配列の長さを計測する要領で ↓ のように書いていたのですが, 文章長が変わっても「12」の値から変化しません.

sizeof(message)/sizeof(message[0])    // = 12 

解決策・コード

「関数を調べるの面倒くさい」からと記憶にある書き方に挑戦したのですが,当然,↑ のコードではString型のインスタンスのサイズしか取得できません.

String型の文字列の長さを取得するなら ↓ ですよね.

message.length()

しょうもなつらいミス.
あほ


使用したソースコード

ちょっくら編集しているので,完全に動作するか試していないですが,おおよそ似たようなコードで実装できるはずです.このサンプルコードがマイコンPython間でSPP通信したい方の参考程度になればと思います.

そういやぁ,受信側で改行されすぎる問題があった気もします.

Arduino

#define M5STACK_MPU6886 

#include "BluetoothSerial.h"
#include <M5Stack.h>
#include <time.h>
#include <Kalman.h>

#if !defined(CONFIG_BT_ENABLED) || !defined(CONFIG_BLUEDROID_ENABLED)
#error Bluetooth is not enabled! Please run `make menuconfig` to and enable it
#endif

#define rad2deg 57.2958

BluetoothSerial SerialBT;
Kalman kalmanX;
Kalman kalmanY;
String message;

const char* message1 = "Sleeping?";

int count = 0;

float accX = 0.0F;
float accY = 0.0F;
float accZ = 0.0F;
float gyroX = 0.0F;
float gyroY = 0.0F;
float gyroZ = 0.0F;
float pitch = 0.0F;
float roll  = 0.0F;
float yaw   = 0.0F;

double kalAngleX, kalAngleY; // Calculated angle using a Kalman filter
double gyroXangle, gyroYangle; // Angle calculate using the gyro only

unsigned long t;
unsigned long beft;
int action = 0; 
// 0:standing 
// 1:sitting
// 2:walk


void setup() {
  Serial.begin(115200);
  SerialBT.begin("ESP32-SPP"); //Bluetooth device name
  Serial.println("The device started, now you can pair it with bluetooth!");

  M5.begin(true, false, true);
  M5.Power.begin();
  M5.IMU.Init();
  
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextColor(GREEN , BLACK);
  M5.Lcd.setTextSize(2);
  M5.Lcd.setCursor(0, 40);
  M5.Lcd.printf("standing state");
  M5.IMU.getGyroData(&gyroX,&gyroY,&gyroZ);
  M5.IMU.getAccelData(&accX,&accY,&accZ);

  roll  = atan2(accY, accZ) * rad2deg;
  pitch = atan(-accX / sqrt(accY * accY + accZ * accZ)) * rad2deg;
  kalmanX.setAngle(roll); // Set starting angle
  kalmanY.setAngle(pitch);

  delay(100);

  t = micros();
  beft = micros();

  message = String("index");
  message = String(message + ",");
  message = String(message + "time");
  message = String(message + ",");
  message = String(message + "accX");
  message = String(message + ",");
  message = String(message + "accY");
  message = String(message + ",");
  message = String(message + "pitch");
  message = String(message + '\n');
  

  // 1回目のデータを送信
  for(int i = 0; i < message.length(); i++){ // receive data from micro-controller via Bluetooth 
    SerialBT.write(message[i]);
    delay(1);
  }    

}


void loop() {
  int i=0;

  // 各種センサの値を更新する
  M5.IMU.getGyroData(&gyroX,&gyroY,&gyroZ);
  M5.IMU.getAccelData(&accX,&accY,&accZ);
//  M5.IMU.getAhrsData(&pitch,&roll,&yaw);
  M5.update(); // ボタンの値を取得


  // Kalmanフィルタによる姿勢取得
  double dt = (double)(micros() - beft) / 1000000; // Calculate delta time
  beft = micros();

  double gyroXrate = gyroX / 131.0; // Convert to deg/s
  double gyroYrate = gyroY / 131.0; // Convert to deg/s

  roll  = atan2(accY, accZ) * rad2deg;
  pitch = -atan2(accX, sqrt(accY * accY + accZ * accZ)) * rad2deg;

  if ((roll < -90 && kalAngleX > 90) || (roll > 90 && kalAngleX < -90)) {
    kalmanX.setAngle(roll);
  } else
    kalAngleX = kalmanX.getAngle(roll, gyroXrate, dt); // Calculate the angle using a Kalman filter
    
  if (abs(kalAngleX) > 90)
    gyroYrate = -gyroYrate; // Invert rate, so it fits the restriced Acclelerometer reading
  kalAngleY = kalmanY.getAngle(pitch, gyroYrate, dt);

  if (gyroXangle < -180 || gyroXangle > 180)
    gyroXangle = kalAngleX;
  if (gyroYangle < -180 || gyroYangle > 180)
    gyroYangle = kalAngleY;


  // 画面に表示
  M5.Lcd.setCursor(0, 20);
  M5.Lcd.printf("%d", (int)((micros() - t)/1000000));
  M5.Lcd.setCursor(0, 40);
  M5.Lcd.printf(" %5.2f   %5.2f   ", kalAngleX, kalAngleY); // マイコンの姿勢を表示
  M5.Lcd.setCursor(0, 100);
  M5.Lcd.printf(" %5.2f   %5.2f   %5.2f   ", accX, accY, accZ); // マイコンの姿勢を表示
  M5.Lcd.setCursor(0, 120);
  M5.Lcd.printf("%6.2f  %6.2f  %6.2f      ", gyroX, gyroY, gyroZ);
  M5.Lcd.setCursor(0, 160);
  M5.Lcd.printf("%d", count1);
  

  if(M5.BtnA.wasReleased() == 1){ // 現在を起立状態とする
    M5.Lcd.setCursor(0, 80);
    M5.Lcd.printf("standing state");
    action = 0;
  }
  if(M5.BtnB.wasReleased() == 1){  // 現在を着席状態とする
    M5.Lcd.setCursor(0, 80);
    M5.Lcd.printf("sitting state ");
    action = 1;
  }


  // 送信するデータを一度 message に記録していく
  count = count + 1;
  message = String(count, DEC);
  message = String(message + ",");
  message = String(message + action);
  message = String(message + ",");
  message = String(message + t);
  message = String(message + ",");
  message = String(message + action);
  message = String(message + ",");
  message = String(message + roll);
  message = String(message + ",");
  message = String(message +  pitch);
  message = String(message + '\n');


  if(count == 500){  // 500サンプルに1回だけデータを送信
    for(i = 0; i < message.length(); i++){      
      SerialBT.write(message[i]);
      delay(1);
    }
    count = 0;
    count1 = 0;
  }

//  if (SerialBT.available()) { // PCからデータを受信したい場合
//    Serial.write(SerialBT.read());
//  }

  delay(10);
}


PC(Python)側

import serial
import csv

path = 'action.csv'
f = open(path,mode='w') # ファイルを作成、初期化
f.close()


ser = serial.Serial('COM10',timeout=None)

index=0
while True:
    index=index+1
    # ser.write(bytes("Hello"+str(index)+"\r\n", 'UTF-8')) # メッセージを受信したら返信する

    line = ser.readline() # 改行コードまでメッセージを読み込む
    print(line.decode(), end='')

    with open('action.csv', 'a') as f: # 配列の内容をCSVファイルに1行書き込む
        writer = csv.writer(f)
        writer.writerow(line[:-1])

ser.close() 

おわりに

開発経験が長くなっても,勘だけに頼る開発は避けていきたいものです.


奈良先端大のサマーセミナーですが,今年はオンライン開催でリモートで教えました.やはり,リモート環境でマイコン周りの環境を整えたり,プログラムのバグを見てあげることは難しいですね.

ガチガチのHWが関わるリモート開発の場合,遠隔地からの指示がかなり難しくなるため,過去に試作した ↓ のようなデバイスが市販化されたら便利だなぁと思ってます...

ume-boshi.hatenablog.jp

ruby on railsの実行時に,pumaがUNIXServer (NameError)のエラーを吐く

ハッカソンの際に,githubからクローンしてきたrailsのプロジェクトを実行してみたところ,自分だけエラーを吐いて焦った記憶があります.その時は記事にまとめたり記録しておく時間がなかったのですが,再度就活用に動かしてみようと思った際に同じエラーに出会ったので,今回は軽い記事にしておこうと思います.

使用環境


エラー状況

プロジェクト実行時の下記コマンドを実行したところ,

rails s

下記のようなエラーが出力されました.

C:/yourRailsProjectPath/vendor/bundle/ruby/2.5.0/gems/puma-3.12.6/lib/puma/binder.rb:371:in `add_unix_listener': uninitialized constant Puma::Binder::UNIXServer (NameError)

この際,puma.rbファイルは次のようになっています.

threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
port        ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
plugin :tmp_restart

bind "unix://#{Rails.root}/tmp/sockets/puma.sock"
rails_root = Dir.pwd
if Rails.env.production?
  # 略
end

解決策

windows側のパスの設定が出来ていないようでした. bindの設定を無くしてみただけで,実行できるようになりました.

つまり ↓ でいける.

threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
port        ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
plugin :tmp_restart

# bind "unix://#{Rails.root}/tmp/sockets/puma.sock" # 無くても実行できた
rails_root = Dir.pwd
if Rails.env.production?
  # 略
end

手抜き記事が多くてすみません.今回は以上.