ソースに絡まるエスカルゴ

貧弱プログラマの外部記憶装置です。

【Raspberry Pi】Visual Studio CodeをRaspberry Piにインストールする

 プログラムを書く時に自分はよくVisual Studio Codeを使っています。

 Raspberry Piで色々やる時はPCからSCPでファイルを送ったりしていたのですが、Raspberry Pi側でソースコードをいじりたくなる時もあり「こっちでもVisual Studio Codeが使えたらなぁ」と思っていたらどうやら使えるようだったので備忘録として記事にした次第です。

 では、始めます。


Raspberry PiでのVisual Studio Codeのインストール方法
 ちょっと前までは自分でtar.gzのバイナリファイルをダウンロードしてきてそれを実行するというやり方だったようですが、今年に入ってから普通にaptコマンドでインストールできるようになりました。

 なので以下のコマンドを実行するだけでインストールできます。

$ sudo apt update
$ sudo apt install code -y

 上記コマンドでインストールを実行した後、メニューのプログラミングのところにVisual Studio Codeが追加されているので、そこをクリックすると起動できます。

 あとはいつも通りのVisual Studio Codeなので拡張機能を入れていけば他と同じ感じでVisual Studio Codeを使うことができます。


 以上がVisual Studio CodeRaspberry Piにインストールする方法になります。

 記事にする必要もないとは思いましたが、Raspberry PiでもVisual Studio Codeが使えるということに感動して嬉しかったので…。

 また関連として、過去に入れた拡張機能の記事も紹介しておきます。


・参考資料

【Raspberry Pi】コマンドでChromiumをフルスクリーンで起動させる

 今回はコマンドでChromiumをフルスクリーンで起動させる方法の備忘録になります。

 では、始めます。


Chromiumをフルスクリーンで起動させる方法
 結論から言うと、LXTerminalを起動させて以下のコマンドを実行すればフルスクリーンでChromiumを起動できます。

$ chromium-browser [URL] --kiosk


 Googleのページを開く場合は以下の通りです。

$ chromium-browser https://www.google.com/ --kiosk


「--kiosk」というオプションでKIOSKモードで起動させています。

 KIOSKモードはアドレスバーやタブバーなどを表示させないフルスクリーンモードです。ブラウザの表示のみですべて操作するような場合、特に画面のタッチのみで操作を行う場合はこのKIOSKモードを使うことになると思います。

 ちなみに「Alt + F4」キーでブラウザを終了させることができます。


 以上がコマンドでChromiumをフルスクリーンで起動させる方法です。

 Raspberry Piデジタルサイネージなどを行いたい場合はブラウザのみの操作、あるいは操作をさせないということが多いと思うので、今回のコマンドを使うと便利だと思います。


・参考資料

【python/javascript/html】簡単なローカルAPIサーバを作る

 APIを使って色々やる場面があるかと思います。

 以下の記事でも緯度経度から標高を取得するAPIを使いました。

 開発をしていく中でAPIを利用する側はある程度できていてもAPI側がまだ未実装という場合が割とあると思います。そうすると実装が終わってからでないと実際の動作確認ができないということになります。ですが、簡単なローカルAPIを作って定数でも返すようにすればAPI側がまだ実装されていなくてもある程度動作確認ができるので便利です。

 調べてみるとpythonで割と簡単にローカルAPIサーバを作れることが判明したので、今回はその備忘録になります。

 では、始めます。


0:前提
 今回はAPIとして以下のものを考えていきます。

GET API UPDATE API
URL http://localhost:3000/ http://localhost:4000/
Content-type json, utf-8 json, utf-8
request(例) なし {
  "data" : {
    "name" : "テスト",
    "value" : 100
  }
}
response(例) {
  "data" : {
    "name" : "テスト",
    "value" : 100
  },
  "call_count": 0
}
{
  "result" : "OK",
  "data" : {
    "name" : "テスト",
    "value" : 100
  }
}

 簡単に説明すると以下のような動きを想定したAPIになります。

  • GET API、UDATE APIどちらもjson形式でやりとりする
  • GET APIのcall_countはGET APIが呼ばれた回数の値とする
  • UDATE APIではrequestのjsonの値でnameとvalueが更新される
  • UDATE APIの戻り値のresultは更新できた場合OK、失敗した場合NGの文字列を設定する

 かなりざっくりとしたものですが、とりあえず単純なGETとUPDATEのAPIを作っていくという感じです。


1:ローカルAPIサーバの作り方
 書き方は簡単で以下の記述でjsonを返すローカルAPIサーバを作ることができます。

・sample_server.py

# -*- coding:utf-8 -*-
#
# 本プログラムを実行している状態で以下のアドレスを開くとjsonの戻り値が表示される。
# http://localhost:2000/
# 
# プログラムの終了はCtrl+C。それでも止まらない場合はCtrl+Pauseで終了させる。
from wsgiref.simple_server import make_server
import json

API_PORT = 2000


# 文字列のバイトサイズを計算する関数
def getStringByteSize(str):
    return len(str.encode('utf-8'))


# APIが呼ばれた時に実行される関数
def app(environ, response):
  ### 引数「environ」はrequestの情報を含んでおり、以下の方法で取得できる ###
  # wsgi_input = environ["wsgi.input"]
  # try:
  #   # requestのjsonを取得
  #   request_content_length = int(environ["CONTENT_LENGTH"])
  #   request_data = json.loads(wsgi_input.read(request_content_length))
  #   print(request_data)
  # except ValueError:
  #   print("--- error ---")
  ######################################################################

  json_data = {'message':'hoge'} # jsonのレスポンス
  content_length = getStringByteSize(json.dumps(json_data)) # Content-Lengthを計算

  status = '200 OK'
  header = [
      ('Access-Control-Allow-Origin', '*'),   # 許可するアクセス
      ('Access-Control-Allow-Methods', '*'),  # 許可するメソッド
      ('Access-Control-Allow-Headers', "X-Requested-With, Origin, X-Csrftoken, Content-Type, Accept"), # 許可するヘッダー
      ('Content-type', 'application/json; charset=utf-8'), # utf-8のjson形式
      ('Content-Length', str(content_length)) # Content-Lengthが合っていないとブラウザエラー
  ]
  response(status, header)

  return [json.dumps(json_data).encode("utf-8")]


# APIサーバを実行する関数
with make_server('', API_PORT, app) as httpd:
  print("Start API. URL is http://localhost:" + str(API_PORT) + "/ ...")
  httpd.serve_forever()

 PowerShellなりコマンドプロンプトなりを起動させて上記のプログラムを実行すると、以下のようにURLが表示されてサーバが起動します。

$ python sample_server.py
Start API. URL is http://localhost:2000/ ...

 この状態でブラウザを開いて「http://localhost:2000/」とURLを打ち込んで表示させると、以下のようにソースコードにおいてresponseとして定義したjsonの値が表示されます。
f:id:rikoubou:20210524173053p:plain

 requestでjsonが来た場合は、コメントアウト部分に書いてある方法で取得することができます。

 このサーバを止める場合は「Ctrl + C」キー、それでも止まらない場合は「Ctrl + Pause」キーで止めます。

 ちなみにヘッダー部分ですが、これらについてあまり詳しく調べられていないので今回は詳しく言及しません。
 簡単に言うとセキュリティ関係やデータ形式をどうするかという記述をしている部分なので、これらを記述していないとブラウザ側でエラーとなる場合があるので記述しておいてください。あくまでテスト用として許可しているだけなので、このヘッダー設定のまま実運用するとセキュリティ面で問題が発生するので注意してください。


2:ローカルAPIサーバサンプル
 書き方がわかったので、実際に「0:前提」に書いた想定のローカルAPIサーバサンプルを以下に示します。またそれらがちゃんと動いているかを確認するためのhtmlとjavascriptも載せておきます(2021/05/29:ソースコードを少し修正しました)。

・local_test_api.py

# -*- coding:utf-8 -*-
from wsgiref.simple_server import make_server
import json
import threading
import time

GET_API_PORT    = 3000
UPDATE_API_PORT = 4000

GET_API_JSON_PATH    = "./get_api.json"
UPDATE_API_JSON_PATH = "./update_api.json"

STATUS = '200 OK'

call_count = 0
get_data_json    = None
update_data_json = None


def main():
    # Get APIレスポンス用json
    global get_data_json
    get_data_json = load_json_file(GET_API_JSON_PATH)

    # Update APIレスポンス用json
    global update_data_json
    update_data_json  = load_json_file(UPDATE_API_JSON_PATH)

    # Get APIスレッド実行
    get_app_thread = threading.Thread(target=start_get_api_server)
    get_app_thread.setDaemon(True)
    get_app_thread.start()

    # Update APIスレッド実行
    update_app_thread = threading.Thread(target=start_update_api_server)
    update_app_thread.setDaemon(True)
    update_app_thread.start()

    # 無限ループ
    while True:
        time.sleep(1)


# jsonファイルを読み込む関数
def load_json_file(json_path):
    with open(json_path, "r", encoding="utf-8_sig") as f:
        json_data = json.load(f)
    return json_data


# Get APIサーバを起動する関数
def start_get_api_server():
    with make_server('', GET_API_PORT, get_app) as httpd:
        print("Start Get API. URL is http://localhost:" + str(GET_API_PORT) + "/ ...")
        httpd.serve_forever()


# Get APIの処理
def get_app(environ, response):
    global call_count
    call_count = call_count + 1

    get_data_json['call_count'] = call_count
    content_length = getStringByteSize(json.dumps(get_data_json)) # Content-Lengthを計算

    header = [
        ('Access-Control-Allow-Origin', '*'),   # 許可するアクセス
        ('Access-Control-Allow-Methods', '*'),  # 許可するメソッド
        ('Access-Control-Allow-Headers', "X-Requested-With, Origin, X-Csrftoken, Content-Type, Accept"), # 許可するヘッダー
        ('Content-type', 'application/json; charset=utf-8'), # utf-8のjson形式
        ('Content-Length', str(content_length)) # Content-Lengthが合っていないとブラウザエラー
    ]

    response(STATUS, header) # ヘッダー設定
    return [json.dumps(get_data_json).encode("utf-8")]


# Update APIサーバを起動する関数
def start_update_api_server():
    with make_server('', UPDATE_API_PORT, update_app) as httpd:
        print("Start Update API. URL is http://localhost:" + str(UPDATE_API_PORT) + "/ ...")
        httpd.serve_forever()


# Update APIの処理
def update_app(environ, response):
    wsgi_input = environ["wsgi.input"]
    try:
        # POSTされたjsonを取得
        request_content_length = int(environ["CONTENT_LENGTH"])
        request_data = json.loads(wsgi_input.read(request_content_length))
        print(request_data)

        if request_data['data']['name'] == "":
            # nameが空文字の場合NGとする
            update_data_json['result'] = "NG"
        else:
            # 更新OKの場合は値を設定
            update_data_json['result'] = "OK"
            update_data_json['data']['name'] = request_data['data']['name']
            update_data_json['data']['value'] = int(request_data['data']['value'])
            # GET側も更新
            get_data_json['data']['name'] = request_data['data']['name']
            get_data_json['data']['value'] = int(request_data['data']['value'])

    except ValueError:
        print("--- error ---")

    content_length = getStringByteSize(json.dumps(update_data_json)) # Content-Lengthを計算

    header = [
        ('Access-Control-Allow-Origin', '*'),   # 許可するアクセス
        ('Access-Control-Allow-Methods', '*'),  # 許可するメソッド
        ('Access-Control-Allow-Headers', "X-Requested-With, Origin, X-Csrftoken, Content-Type, Accept"), # 許可するヘッダー
        ('Content-type', 'application/json; charset=utf-8'), # utf-8のjson形式
        ('Content-Length', str(content_length)) # Content-Lengthが合っていないとブラウザエラー
    ]

    response(STATUS, header) # ヘッダー設定
    return [json.dumps(update_data_json).encode("utf-8")]


# 文字列のバイトサイズを計算する関数
def getStringByteSize(str):
    return len(str.encode('utf-8'))


if __name__ == '__main__':
    main()


・get_api.json

{
   "data" : {
      "name"  : "テスト",
      "value" : 100
   },
   "call_count": 0
}


・update_api.json

{
   "result" : "OK",
   "data" : {
      "name"  : "テスト",
      "value" : 100
   }
}


・index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset = "utf-8">
  <meta http-equiv="Cache-Control" content="no-cache">
  <title>ローカルAPIサーバテスト</title>
  <!-- <script src="./jquery-3.6.0.js"></script> -->
  <script src="https://code.jquery.com/jquery-3.6.0.js"></script>
</head>
<body>
  name:<input id="name" value="">
  <br>
  value:<input id="value" value="">
  <br>
  <button id="update_button" onclick="update()">UPDATE</button>
  <button id="get_button" onclick="get()">GET</button>
  <br>
  <div id="text">ここに結果が表示されます</div>
<script src="./script.js"></script>
</body>
</html>


・script.js

var update_connect_flg = false;
var get_connect_flg    = false;

// Get APIを実行する関数
function getApi() {
   return $.ajax({
      url           : 'http://localhost:3000/',
      type          : 'GET',
      dataType      : 'JSON',
      scriptCharset : 'utf-8',
      timespan      : 100
   });
}

// Update APIを実行する関数
function updateApi(send_json) {
   // JSONにエンコード
   var json_data = JSON.stringify(send_json);

   return $.ajax({
      url           : 'http://localhost:4000/',
      type          : 'POST',
      data          : json_data,
      dataType      : 'JSON',
      contentType   : 'application/json; charset=utf-8',
      contentLength : json_data.length,
      timespan      : 100
   });
}

// Getを実行する関数
function get() {
   if (get_connect_flg) { return; }

   get_connect_flg = true;

   getApi().done(function(res) {
      // getApiの結果を処理
      console.log(res);
      let res_data = "[GET response]<br>";
      res_data    += "name: "   + res.data.name + "<br>";
      res_data    += "value: "  + String(res.data.value) + "<br>";
      res_data    += "call_count: " + String(res.call_count);
      $('#text').html(res_data); // 内容を表示
   }).fail(function() {
      // 失敗した時の処理
      $('#text').html("GETに失敗しました");
   }).always(function() {
      // 常に実行される処理
      get_connect_flg = false;
   });
}

// Updateを実行する関数
function update() {
   if (update_connect_flg) { return; }

   // 各値を取得
   const name  = $('#name').val();
   const value = $('#value').val();

   // json作成
   const send_json = {
      'data' : {
         'name'  : name,
         'value' : parseInt(value)
      }
   };
   console.log(send_json);

   update_connect_flg = true;
   updateApi(send_json).done(function(res) {
      // updateApiの結果を処理
      console.log(res);
      if ("OK" == res.result) {
         // OKの処理
         let res_data = "[UPDATE response]<br>";
         res_data    += "result: " + res.result + "<br>";
         res_data    += "name: " + res.data.name + "<br>";
         res_data    += "value: " + String(res.data.value) + "<br>";
         $('#text').html(res_data); // 内容を表示
      } else {
         // NGの処理
         let res_data = "[UPDATE response]<br>";
         res_data    += "result: " + res.result + "<br>";
         res_data    += "name: " + res.data.name + "<br>";
         res_data    += "value: " + String(res.data.value) + "<br>";
         $('#text').html("更新に失敗しました<br>" + res_data); // 内容を表示
      }
   }).fail(function(res) {
      // 失敗した時の処理
      $('#text').html("UPDATEに失敗しました");
   }).always(function() {
      // 常に実行される処理
      update_connect_flg = false;
   });
}

 使い方として、まず「local_test_api.py」を実行してGetとUpdateのAPIサーバを起動させます。

$ python local_test_api.py
Start Update API. URL is http://localhost:4000/ ...
Start Get API. URL is http://localhost:3000/ ...

 このサーバを起動させた状態で「index.html」を立ち上げて「GET」ボタンをクリックするとGetのAPIが、「UPDATE」ボタンをクリックするとinputタグに入力された値でUpdate APIが呼ばれ、結果がdivタグ部分に表示されます。

 実際に動かすと以下のようになります。
f:id:rikoubou:20210524181335g:plain

 少し説明すると「local_test_api.py」でGetとUpdateのAPIサーバをスレッドとして個別に実行しています。
 APIサーバが動いている状態で「index.html」を立ち上げて「GET」ボタンをクリックするとjavascriptのget( )関数が実行され、GetのAPIの結果を取得してdivタグ部分に表示させています。「UPDATE」ボタンをクリックするとjavascriptのupdate( )関数が実行され、inputタグに入力された値で作ったjsonを使ってUpdate APIが呼ばれ、その結果をdivタグ部分に表示させています。
 またNGを再現するために、nameの値が空の場合にNGを返すようにAPIサーバ側で処理をしています。


 以上がpythonjavascript、htmlで簡単なローカルAPIサーバを作る方法です。

 本当はpythonだけで完結しても良かったのですが、画面に表示させたりした方がいいかと思いそちらも載せてみました。
 その結果ソースコード部分がかなり長くなってしまいましたが、このサンプルを少し改造すれば割と応用が利くかと思います。


・2021/05/25:追記
 今回のサンプルソースを一応公開しておきます。


・参考資料

【M5StickC Plus/Arduino】M5StickC Plusで連番画像を使ったアニメーション

 過去にpythonを使ってpngの連番画像をBitmap形式のテキストデータに変換する記事を書きました。

 この記事の最後にこの狙いとして「SDカードを使えないM5StickC Plusで画像を表示させる」ことを書いていたので、今回は実際に表示させていきます。

 では、始めます。


1:サンプル画像の準備と変換
 今回やるサンプルとして135*240(M5StickC Plusを縦にした時の画面サイズと同じ)png画像6枚を用意しました。

f:id:rikoubou:20210426153257p:plainf:id:rikoubou:20210426153308p:plainf:id:rikoubou:20210426153315p:plain
f:id:rikoubou:20210426153322p:plainf:id:rikoubou:20210426153329p:plainf:id:rikoubou:20210426153336p:plain

 この画像をそれぞれ番号順に001.png~006.pngという名前で保存して前の記事で書いた「pngToBitmapData.py」を使い、テキストデータに変換した「ImgData.h」を作成します。

 面倒な人はサンプルとして公開しているzipファイルを解凍した中にある「ImgData.h」ファイルをそのまま使ってください。

 これで連番画像をテキストデータに変換したヘッダーファイルが作成できました。


2:テキストデータに変換した画像の表示
 M5Stack系でディスプレイに画像を表示させる方法は2つあります。一つは「M5Display」を使う方法、もう一つは「スプライト」を使う方法です。

 M5Displayで画像を表示させる方法は以下の通りです。

#include <M5StickCPlus.h>

// 普通に表示
M5.Lcd.pushImage([開始X座標], [開始Y座標], [画像横幅], [画像縦幅], [画像データ]);

// ピクセル単位で色を表示
M5.Lcd.drawPixel([X座標], [Y座標], [色]);

// 特定の色を透過して表示
M5.Lcd.pushImage([開始X座標], [開始Y座標], [画像横幅], [画像縦幅], [画像データ], [透過する色]);


 スプライトで画像を表示させる方法は以下の通りです。

#include <M5StickCPlus.h>

TFT_eSprite sprite = TFT_eSprite(&M5.Lcd); // スプライトインスタンス作成
sprite.createSprite([スプライト横幅], [スプライト縦幅]); // スプライトの範囲を作成

// スプライトに画像表示
sprite.pushImage([開始X座標], [開始Y座標], [画像横幅], [画像縦幅], [画像データ]);

// スプライトを画面に表示
sprite.pushSprite([スプライトを表示するX座標], [スプライトを表示するY座標]);

// ピクセル単位で色を表示
sprite.drawPixel([X座標], [Y座標], [色]);

// 特定の色を透過したスプライトを画面に表示
sprite.pushSprite([スプライトを表示するX座標], [スプライトを表示するY座標], [透過する色]);

 どちらの方法を使っても画像を画面に表示させることができます。

 こう見ると手順が多いスプライトを使う理由がないように思えますが、スプライトの場合は最後の「pushSprite」を行うことで初めて画面に表示されるので、様々な処理を行う場合はスプライトを使った方が処理が速くなる場合があります。
 逆にM5Displayで表示させるのは手順が少なく楽ですが、様々な処理を行った後に画像を表示させたいという場合だと処理が遅くなる(画面にちらつきが発生する)場合があります。

 では実際にこの2つの方法を使って1の画像を表示させてみます。


3:連番画像を表示させるサンプル
 連番画像をアニメーションとして表示させるサンプルは以下の通りです。「ImgData.h」の中身についてはファイルサイズがかなり大きいので実際に変換したものを使ってください。

・ImgData.h

const int IMG_WIDTH  = 135;
const int IMG_HEIGHT = 240;
const int IMG_MAX    = 6;

const unsigned short IMG_DATA[IMG_MAX][IMG_WIDTH*IMG_HEIGHT] = {
    {
    0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,0xFFFF,
    // ~~~中略~~~
    }
};


・M5StickCPlus_images.ino

#include <M5StickCPlus.h>
#include "ImgData.h"

TFT_eSprite _sprite = TFT_eSprite(&M5.Lcd);

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

  M5.begin();
  M5.Axp.ScreenBreath(10); // 7-12で明るさ設定
  M5.Lcd.setRotation(0);   // 画面を縦向きに(0-3)

  // 画像の大きさのスプライト作成
  _sprite.createSprite(IMG_WIDTH, IMG_HEIGHT);

  Serial.println("setup end");
}

int img_number = 0;

void loop() {
  M5.update();

  /* スプライトを使った場合 */
//  showImageBySprite(img_number); // 画像を表示
  showImageBySpriteBgColor(img_number, TFT_YELLOW); // 背景色を考慮して画像を表示

  /* M5Displayを使った場合 */
//  showImageByM5Display(img_number); // 画像を表示
//  showImageByM5DisplayBgColor(img_number, TFT_YELLOW); // 背景色を考慮して画像を表示

  img_number++; // 画像のカウントアップ

  // 最後の画像までいったら最初に戻る
  if (img_number >= IMG_MAX) {
    img_number = 0;
  }

  delay(100);
}

// スプライトを使って画像を表示させる関数
void showImageBySprite(int number) {
  _sprite.pushImage(0, 0, IMG_WIDTH, IMG_HEIGHT, IMG_DATA[number]);
  _sprite.pushSprite(0, 0);
}

// スプライトを使って背景色を考慮して画像を表示させる関数
void showImageBySpriteBgColor(int number, uint32_t bg_color) {
  // 背景色で塗りつぶし
  _sprite.fillSprite(bg_color);

  int count = 0;
  for (int y = 0; y < IMG_HEIGHT; y++) {
    for (int x = 0; x < IMG_WIDTH; x++) {
      // 背景が白なので背景以外の色をpixel単位で設定
      uint32_t color = IMG_DATA[number][count];
      if (TFT_WHITE != color) {
        _sprite.drawPixel(x, y, color);
      }
      count++;
    }
  }

//  // TFT_WHITEの色を透明にして(0,0)を左上の位置として表示
//  _sprite.pushSprite(0, 0, TFT_WHITE);

  // (0,0)を左上の位置として表示
  _sprite.pushSprite(0, 0);
}

// M5Displayを使って画像を表示させる関数
void showImageByM5Display(int number) {
  M5.Lcd.pushImage(0, 0, IMG_WIDTH, IMG_HEIGHT, IMG_DATA[number]);
}

// M5Displayを使って背景色を考慮して画像を表示させる関数
void showImageByM5DisplayBgColor(int number, uint32_t bg_color) {
  M5.Lcd.fillScreen(bg_color);
  M5.Lcd.pushImage(0, 0, IMG_WIDTH, IMG_HEIGHT, IMG_DATA[number], TFT_WHITE);
}

 このサンプルをM5StickC Plusに書き込むと1で準備した画像がアニメーションとしてループ表示されます。

 特に注目してほしいのが「showImageBySpriteBgColor」関数を使って表示させた場合と「showImageByM5DisplayBgColor」関数を使って表示させた場合です。

 showImageBySpriteBgColor関数はスプライトで表示させているので、スプライト全体を背景色にした後に特定の色を除いたピクセルの色を設定した後に画面に表示させています。
 一方、showImageByM5DisplayBgColor関数はM5Displayで表示させているので、画面全体を背景色で表示させた後に特定の色を透過した画像を画面に表示させています。

 実際にこの2つを比べてみると、showImageBySpriteBgColor関数の方が滑らかに表示されており、showImageByM5DisplayBgColor関数の方はちらついて表示されていると思います。

 このようになる理由は2で説明した通り、スプライトはすべての処理をした後にpushSpriteで画面に表示させている(画面表示は1回のみ行っている)のに対して、M5Displayの方はM5.Lcd.fillScreenで背景色を画面に表示した後にさらに画像を表示させている(画面表示を2回行っている)からです。このためM5Displayの方は背景色だけが表示される瞬間が生まれ、ちらついているように見えてしまいます。

 なので用途に合わせてスプライトかM5Displayを使い分ける必要があります。


 以上がM5StickC Plusで連番画像を使ったアニメーションを表示する方法になります。

 SDカードなど外部記憶装置がなくても画像をテキストデータに変換してしまえば表示させることができるとわかったので、色々と面白いことができそうです。


・参考資料

【python】pythonでpngファイルをBitmapテキストデータに変換する

 今回の記事はタイトルにある通りpythonを使ってpngファイルをBitmap形式のテキストデータに変換する方法の備忘録になります。

 では、始めます。


1:RGB565とRGB332について
 画像の色を表現するフォーマットには様々なものがありますが、今回変換に使用したRGB565とRGB332について軽く説明しておきます。

・RGB565
 Rを5bit、Gを6bit、Bを5bitの合計16bitで65536色表現可能な形式。

・RGB332
 Rを3bit、Gを3bit、Bを2bitの合計8bitで256色表現可能な形式。

 それぞれの違いをまとめると以下のようになります。

RGB565 RGB332
R 5bit 3bit
G 6bit 3bit
B 5bit 2bit
合計bit数 16bit 8bit
表現可能色 65536色 256色
0xFFFF 0xFF


2:RGBの値をRGB565やRGB332に変換する方法
 RGBの値(それぞれの値が0~255)をRGB565やRGB332に変換する方法は簡単です。

 RGBそれぞれの値は0~255の範囲なのでそれぞれ8bitの情報を持っています。

 例えばRの値をRGB565に対応させる場合は8bitを右へビットシフトさせて5bitまで減らします。つまり8bit-5bit=3bit分だけ右へビットシフトさせます。同様にしてGは8bit-6bit=2bit分、Bは8bit-5bit=3bit分右へビットシフトさせます。

newR = R>>3
newG = G>>2
newB = B>>3

 各色自体の変換は終わったので、16bitにまとめるために各値を合体させます。

# 桁数を増やしてORで結合させている
newRGB = (newR<<11) | (newG<<5) | newB

 これでRGBの値をRGB565に変換ができました。

 RGBの値をRGB332へ変換するのも同様の方法でできます。

# RGBをRGB332へ変換
newR = R>>5
newG = G>>5
newB = B>>6

newRGB = (newR<<5) | (newG<<2) | newB

 これでRGBをそれぞれのフォーマットに変換する方法がわかりました。
 あとはこの方法を使ってpngファイルをBitmap形式のテキストデータに変換してきます。


3:PillowとNumpyのインストール
 pythonで行う際に画像からpixelの色を取り出すのにPillowというライブラリを使用するのでインストールします。

$ pip install Pillow

# もしくは以下の2コマンドを実行
$ python3 -m pip install --upgrade pip
$ python3 -m pip install --upgrade Pillow

 またNumpyも使うのでインストールします。

$ pip install numpy

 これで準備は完了です。


4:pngファイルをBitmapテキストデータに変換するサンプル
 基本的なところは以下のソースコードをほぼ流用させていただきました。

 以下の「pngToBitmapData.py」ファイルと同じ階層に「img」フォルダを作成し、中に連番png画像を入れた状態で実行すると「ImgData.h」という連番画像のBitmapテキストデータを記述したC言語向けヘッダーファイルが作成されます。

・pngToBitmapData.py (※2021/04/26 ソースコードを修正)

# -*- coding:utf-8 -*-
import numpy as np
from PIL import Image
import sys
import glob

IMG_FOLDER_PATH = "./img/*"
SAVE_FILE_PATH  = "./ImgData.h"


def main(outstr):
    img_list = sorted(glob.glob(IMG_FOLDER_PATH)) # 画像リストを取得

    # 画像がない場合は終了
    if len(img_list) == 0:
        return print("No image File!")
    
    f = open(SAVE_FILE_PATH, 'w')

    file_count = 0
    for fn in img_list:
        file_count += 1
        print("loading..." + fn)

        # 画像ファイル読み込み
        image = Image.open(fn)
        width, height = image.size

        # 最初の画像の時に画像サイズなどの情報を書き込む
        if file_count == 1:
            header_str  = "const int IMG_WIDTH  = {};\n".format(width)
            header_str += "const int IMG_HEIGHT = {};\n".format(height)
            header_str += "const int IMG_MAX    = {};\n\n".format(len(img_list))
            header_str += "const unsigned short IMG_DATA[IMG_MAX][IMG_WIDTH*IMG_HEIGHT] = {\n"
            f.write(header_str)

        # 画像の色配列情報の文字列を取得して書き込む
        image_hex = outputColorPixel(width, height, image, outstr)
        f.write("    {\n")
        f.write(image_hex)

        if file_count != len(img_list):
            f.write("    },\n")
        else:
            f.write("    }\n")

    # 最後の括弧とセミコロンを書き込んで閉じる
    f.write("};\n")
    f.close()

    print("saved " + SAVE_FILE_PATH)


# RGB値を16bit(RGB565)のテキストに変換する
def rgb2hexstr(rgb):
    col = ((rgb[0]>>3)<<11) | ((rgb[1]>>2)<<5) | (rgb[2]>>3)
    return "0x{:04X}".format(col)


# RGB値を8bit(RGB332)のテキストに変換する
def rgb8bithexstr(rgb):
    col = ((rgb[0]>>5)<<5) | ((rgb[1]>>5)<<2) | (rgb[2]>>6)
    return "0x{:02X}".format(col)


# 8, 16bpp出力
def outputColorPixel(width, height, image, outstr):
    result_str = ""

    #  パレット読み込み
    if image.mode == 'P':
        palette = np.array(image.getpalette()).reshape(-1, 3)  # n行3列に変換
        getPixel = lambda x,y: palette[image.getpixel((x, y))]
    else:
        getPixel = lambda x,y: image.getpixel((x, y))

    # 書き込み時の改行位置を計算
    line_break = 16
    while True:
        if (width % line_break) == 0:
            break
        line_break = line_break - 1

    for y in range(height):
        x_cnt = 0
        for x in range(width):
            #  行の先頭に空白を入れる
            if x_cnt == 0:
                result_str += "    "
            pixel = getPixel(x, y)
            result_str += outstr(pixel) + ","
            x_cnt = x_cnt + 1
            if x_cnt >= line_break:
                x_cnt = 0
                result_str += "\n"

    return result_str[:-2] + "\n"


if __name__ == '__main__':
    args = sys.argv

    outstr = rgb2hexstr # 16bit(RGB565の場合)
    if len(args) > 1:
        if "8" in args:
            outstr = rgb8bithexstr # 8bit(RGB332の場合)
            print("RGB332(8bit)")
        else:
            print("RGB565(16bit)")
    else:
        print("RGB565(16bit)")

    main(outstr)

 コードの内容について特に説明することはないかと思いますが、連番画像を読み込んでその画像の横幅と縦幅に合わせた色の配列を作ってヘッダーファイルとして出力しているだけです。

 ちなみに実行時はデフォルトでは「RGB565」に変換されますが、以下のように引数「8」を設定すると「RGB332」の変換になります。

# RGB565への変換
$ python pngToBitmapData.py

# RGB332への変換
$ python pngToBitmapData.py 8


※2021/04/26追記:サンプル連番画像を含めたzipファイルも公開しておきます。


 以上がpythonpngファイルをBitmapテキストデータに変換する方法になります。

 画像変換など理解できれば簡単ですが、何も知らなかったので理解するまでに色々調べたりして大変でした。

 また何故C言語向けのヘッダーファイルに出力しているのかというと、M5StickC PlusにはSDカードスロットがないので画像を表示させたい場合は色の配列として持っておくしかないからです(その分ヘッダーファイルが巨大になりますが…)。

 この方法で変換したヘッダーファイルを使えばM5StickC Plusで画像を表示できるようになるので、次の記事でそれをやっていきたいと思います。


・参考資料

【M5StickC Plus/Arduino】M5StickC Plusでランダムな緯度経度から標高を取得するデバイスを作る

 過去の記事でjsonを取得したり、QRコードを表示させたりしました。

 これらを組み合わせて何か面白いものができないかなと思い、jsonを取得できるサイトなどを調べていったところ国土地理院のページで緯度経度から標高を取得できるAPIが公開されていることがわかりました。

 今回はこのAPIを使って緯度経度から標高を取得するのと同時に、GoogleMapでその地点の地図のURLをQRコードで表示するといったものを作ってみたのでその備忘録です。

 では、始めます。


1:国土地理院の標高取得APIについて
 国土地理院の標高取得APIについてのページは以下になります。

 このページに書いてあることそのままですが、以下のURLを発行することで結果をjsonで取得することができます。

https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?lon=[経度]&lat=[緯度]&outtype=JSON

 ページにあるjsonで取得する例のURLである以下のページを開くと、ちゃんとjsonが取得できます。

・URL
https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?lon=140.08531&lat=36.103543&outtype=JSON

・結果

{"elevation":25.3,"hsrc":"5m\uff08\u30ec\u30fc\u30b6\uff09"}

 結果の「hsrc」が変な値になっているのは日本語文字列が入ってるためです。


2:ArduinoJson Assistantを使ってメモリ容量を計算する
 どのようなjsonを取得できるかわかったので、以下のArduinoJson Assistantを使ってメモリ容量を計算します。

 今回は「文字列のjsonにあるelevationの値だけをM5StickC Plusで使いたい」のでModeのところを「Deserialize and filter」を選択します。
f:id:rikoubou:20210416171153p:plain

 Processorを「ESP32」、Input Typeを「String」にして「Next:JSON」をクリックします。
f:id:rikoubou:20210416171212p:plain

 以下のような画面になるので「Input」に取得したいjsonを入力し、「Filter」には取得したい項目のところをtrueにしたjsonを入力します。「Filtered input」のところにFilterで設定されたものだけが表示されるので、合っているかを確認して「Next:Size」をクリックします。
f:id:rikoubou:20210416171555p:plain

 メモリサイズの計算結果が表示されるので「Next:Program」をクリックしてサンプルコードを表示させます。
f:id:rikoubou:20210416171932p:plain

 以下のようにFilterのサイズが16、jsonを取得するためのDocumentのサイズが48とわかりました。
f:id:rikoubou:20210416172034p:plain


3:GoogleMapへのリンク作成
 緯度経度から高度がわかるので、どうせならその場所を地図で知りたいと思いました。なので緯度経度からGoogleMapのリンクを作成する方法を探してみると、以下の方法でできることがわかりました。

https://maps.google.com/maps?q=[緯度],[経度]

 この他にも建物名などからもリンクが作成できるようなので、詳しくは以下のページ様を参照してください。


4:緯度経度から標高を取得するスケッチ
 1~3の内容がわかったので実際に作成したスケッチが以下になります。SSID_CHARとPASS_CHARは接続したいWiFiの値に各自書き換えてください。

・M5StickCPlus_getElevation.ino

/**
 * ランダムな緯度経度から標高を取得する
 */
#include <M5StickCPlus.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

const char *SSID_CHAR    = "*******"; // 任意のものに書き換える
const char *PASS_CHAR    = "*******"; // 任意のものに書き換える
const char *URL_CHAR     = "https://cyberjapandata2.gsi.go.jp/general/dem/scripts/getelevation.php?";
const char *MAP_URL_CHAR = "https://maps.google.com/maps?q=";

const int   DIGIT         = 6;     // 小数点以下の桁数
const float NO_ELEVATION  = 0.00f; // 高度が取得できなかった場合の値

// 日本の緯度経度の最大値/最小値を定義
const float MIN_LAT =  31.002786f;
const float MIN_LON = 130.659221f;
const float MAX_LAT =  45.350780f;
const float MAX_LON = 147.930048f;

// 最小値からの千葉のとある地点の傾き
const float CHIBA_LAT =  34.901234f;
const float CHIBA_LON = 139.888185f;
float CHIBA_SLOPE = (CHIBA_LAT - MIN_LAT)/(CHIBA_LON - MIN_LON);

// 最小値からの北海道のとある地点の傾き
const float HOKKAIDO_LAT =  43.373813f;
const float HOKKAIDO_LON = 140.481491f;
float HOKKAIDO_SLOPE = (HOKKAIDO_LAT - MIN_LAT)/(HOKKAIDO_LON - MIN_LON);

const int FONT_SIZE = 2;
const int SHOW_X    = 5;
const int SHOW_Y    = 5;

struct GeoData {
  String latVal;    // 緯度
  String lonVal;    // 経度
  float  elevation; // 高度
  bool   errorFlg;  // HTTPでjsonが取得できなかったらtrue/取得できたらfalse
};

GeoData geoData;
TFT_eSprite sprite = TFT_eSprite(&M5.Lcd);

void setup() {
  Serial.begin(115200);
  randomSeed(analogRead(0)); // 未接続ピンのノイズを利用

  M5.begin();
  M5.Axp.ScreenBreath(10); // 画面の明るさ(7-12)
  M5.Lcd.setRotation(0);   // 画面を縦向きに(0-3)

  // スプライト作成
  sprite.createSprite(135, 8*3*FONT_SIZE);

  // 地図データを初期化
  initGeoData();
  Serial.println("setup end");
}

void loop() {
  M5.update();

  // データ内容を表示
  showGeoData();
  sprite.pushSprite(SHOW_X, SHOW_Y);

  // ボタンAが押された時
  if (M5.BtnA.wasPressed()) {
    // 画面を黒く塗りつぶす
    M5.Lcd.fillScreen(TFT_BLACK);
    // 初期化
    initGeoData();
    // WiFi接続
    connectWifFi(SSID_CHAR, PASS_CHAR);
    // 標高取得
    String urlString = createApiUrl(geoData.latVal, geoData.lonVal, URL_CHAR);
    getElevationData(urlString.c_str());
    // WiFi切断
    disconnectWifFi();
    // QRコード表示
    showQRCode();
  }
  delay(100);
}

// 地図データを初期化する関数
void initGeoData() {
  Serial.println("----------");
  String latVal = getRandomLat();
  String lonVal = getRandomLon();

  while (!isInRange(latVal.toFloat(), lonVal.toFloat())) {
    latVal = getRandomLat();
    lonVal = getRandomLon();
  }

  geoData.latVal = latVal;
  geoData.lonVal = lonVal;

  geoData.elevation = -1000.0f;
  geoData.errorFlg = true;

  Serial.print("lat:  ");
  Serial.println(geoData.latVal);
  Serial.print("lon: ");
  Serial.println(geoData.lonVal);
}

// ランダムな緯度を取得する関数
String getRandomLat() {
  String res = String(random(31, 46)) + ".";
  for (int i=0; i < DIGIT; i++) {
    res += String(random(0, 9));
  }
  return res;
}

// ランダムな経度を取得する関数
String getRandomLon() {
  String res = String(random(130, 146)) + ".";
  for (int i=0; i < DIGIT; i++) {
    if (i == 0) {
      res += String(random(6, 10));
    } else {
      res += String(random(0, 10));
    }
  }
  return res;
}

// 緯度と経度が高度を取得できそうな範囲にいるかを判定する関数
bool isInRange(float latVal, float lonVal) {
  // 最大値/最小値を判定
  if ((latVal < MIN_LAT) || (latVal > MAX_LAT)
    || (lonVal < MIN_LON) || (lonVal > MAX_LON)) {
      return false;
  }

  // 傾きを計算
  float slope = (latVal - MIN_LAT)/(lonVal - MIN_LON);

  if ((slope >= CHIBA_SLOPE) && (slope <= HOKKAIDO_SLOPE)) {
    return true; 
  } else {
    return false;
  }
}

// APIのURLを作成する関数
String createApiUrl(String latVal, String lonVal, const char* url) {
  String res = String(url);
  res += "lon="  + lonVal; // 経度
  res += "&lat=" + latVal; // 緯度
  res += "&outtype=JSON";
  Serial.println(res);
  return res;  
}

// WiFiに接続する関数
void connectWifFi(const char *ssid, const char *password) {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);
  sprite.print("connecting");

  int counter = 0;
  WiFi.begin(ssid, password);
  while (counter < 10) {
    if (WiFi.status() == WL_CONNECTED) {
      sprite.println(" connected!");
      break;
    } else {
      counter++;
      sprite.print(".");
      delay(500);
    }
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }
  delay(1000);
}

// 標高を取得して設定する関数
void getElevationData(const char* url) {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);
  sprite.print("Please wait... ");
  sprite.pushSprite(SHOW_X, SHOW_Y);

  if(WiFi.status() == WL_CONNECTED) {
    HTTPClient http;
    http.setTimeout(5000); // 5秒でタイムアウト
    http.begin(url);
  
    int httpCode = http.GET();
    if (httpCode == HTTP_CODE_OK) {
      // 正常にGET出来た場合はresponseから高度を設定
      String payload = http.getString();
      setElevation(payload, &(geoData));
    } else {
      geoData.errorFlg = true;
      sprite.println("http GET Error!");
      sprite.pushSprite(SHOW_X, SHOW_Y);
    }
    http.end();
  } else {
    geoData.errorFlg = true;
    sprite.println("Not connected to Wifi.");
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }
  delay(1000);
}

// httpのresponseから標高を設定する関数
void setElevation(String payload, GeoData *gd) {
  // jsonの必要な部分を抽出するためのフィルタ
  StaticJsonDocument<16> filter;
  filter["elevation"] = true;

  // 領域を確保してjsonに変換
  StaticJsonDocument<48> doc;
//  DynamicJsonDocument doc(48);
  DeserializationError error = deserializeJson(doc, payload, DeserializationOption::Filter(filter));

  if (error) {
    // エラーの場合
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(error.f_str());
    sprite.println("http GET Error!");
    sprite.pushSprite(SHOW_X, SHOW_Y);
    gd->errorFlg = true;
  } else {
    // エラーではない場合は標高を取得
    float result = doc["elevation"];
    gd->errorFlg = false;
    gd->elevation = result;
    Serial.print("ele: ");
    Serial.println(result);
    sprite.println("http GET OK.");
    sprite.pushSprite(SHOW_X, SHOW_Y);
  }

  doc.clear(); // メモリ開放
}

// WiFiを切断する関数
void disconnectWifFi() {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextFont(1);
  sprite.setTextSize(FONT_SIZE);
  sprite.setTextWrap(true);

  if(WiFi.status() == WL_CONNECTED) {
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
    sprite.print("disconnected.");
  } else {
    sprite.print("disconnect NG.");
  }
  sprite.pushSprite(SHOW_X, SHOW_Y);
  delay(1000);
}

// 地図データを表示する関数
void showGeoData() {
  sprite.fillSprite(TFT_BLACK);
  sprite.setCursor(0, 0);
  sprite.setTextSize(FONT_SIZE);

  if (geoData.errorFlg) {
    // エラーだった場合
    sprite.setTextWrap(true);
    sprite.println("No data.");
    sprite.println("Push the\nM5 button.");
  } else {
    // 緯度と経度と標高を表示
    sprite.setTextWrap(false);
    sprite.print("lat: ");
    sprite.println(geoData.latVal);
    sprite.print("lon:");
    sprite.println(geoData.lonVal);
    sprite.print("ele:");
    if (NO_ELEVATION == geoData.elevation) {
      sprite.println("No data");
    } else {
      sprite.println(geoData.elevation);
    }
  }
}

// QRコードを表示する関数
void showQRCode() {
  int x = 0;
  int y = 8*3*FONT_SIZE+5;
  int width = 135;
  int versionNum = 3;

  String url_str = String(MAP_URL_CHAR);
  url_str += geoData.latVal + "," + geoData.lonVal;
  Serial.println(url_str);

  if (!geoData.errorFlg) {
    // データが取れた場合はQRコード表示
    M5.Lcd.qrcode(url_str, x, y, width, versionNum);
  }
}

 少し解説するとこのスケッチを書き込んで「M5」のボタンを押すと緯度経度にランダムな値が設定され、WiFiに接続できたらその緯度経度を用いたURLを発行して取得した高度を表示するというような形になっています。画面上のlatが緯度、lonが経度、eleが取得した高度になります。高度を取得するURLを発行した結果、値が取得できなかった場合はeleに「No data」と表示するようにしています。緯度経度の位置を示すGoogleMapのリンクのQRコードも表示されます。

 ちなみに高度が取得できない場合の多くは緯度経度が示す場所が海だったりしていることが多いので、QRコードスマホなどで読み取って地図を表示させてみると色々と面白いです。


 以上がM5StickC Plusでランダムな緯度経度から標高を取得するデバイスを作った備忘録です。

 これが何の役に立つかと言われると自分でもよくわかりませんが、今までの知識を組み合わせてそれなりに遊べるものができたので個人的には割と満足できました。


・参考資料

【M5StickC Plus/Windows】Windows10でM5StickC Plusが認識しなくなった場合の対処

 M5StickC Plusで遊ぼうと思いスケッチ書き込みに使っているいつものUSBケーブルといつものWindows10のPCに接続したところ「USBデバイスが認識されません」という通知とエラーが出てCOMポート自体認識しなくなりました。
f:id:rikoubou:20210419144528p:plain

 このエラー自体初めて見るものだったので戸惑いましたがなんとか回復できました。またこういうエラーになるかもしれないと思ったので備忘録として記事にしようと思った次第です。

 自分がやって回復した方法については2に示している通りなので、結論を知りたい場合はそこを見てください。

 では始めます。


1:「USBデバイスが認識されません」というエラーについて
 エラーの文章に書いてある通りですが、このエラーはなんらかの理由で「PC側でUSB機器を認識できない状態」になっていることで発生しています。この原因として考えられるものはいくつかありますが、基本的には以下のような原因と対処法が主だと思います。

原因 主な対処方法
OS側の異常 とりあえず再起動
PC側USBドライバの異常 バイスマネージャーを開いてドライバを再インストール
USBポートの物理的な異常 別のUSBポートで認識するか試す
USBケーブルの異常 USBケーブルを交換する
接続しているUSB機器の異常 USB機器本体の問題を解決するか別のものに取り換える

 自分が今回色々試した結果、接続しているUSB機器本体の問題、つまりM5StickC Plus自体になんらかの問題が起こっているようだったので以下の対処を行いました。


2:M5StickC Plusが認識しなくなった時の対処方法
 基本的には以下のページ様にある方法をひとつずつ試しました。

 今回自分の問題を解決できたのは「G0とGNDを短絡させた状態でUSBケーブルを接続する」というものでした。

 G0とGNDを短絡させた状態でUSBケーブルを接続すると「強制書き込みモード(DownloadMode)」になり、書き込んだ後に短絡状態を解消して再起動させることで元に戻りました。

 具体的な方法としては、M5StickC Plusの電源を切った状態で以下のようにG0とGNDをジャンパ線などで接続して短絡させます。
f:id:rikoubou:20210419151344p:plain

 その状態でUSBケーブルを繋いでPCと接続します。この時おそらくエラーは出なくなっているはずです。

 その後USBケーブルを抜いてジャンパ線を取り外した後もう一度USBケーブルでPCに接続するとCOMポートとしてちゃんと認識しました。

 一度やっただけだとダメな場合もあったので、これを何度かやってみてください。


 以上がWindows10でM5StickC Plusが認識しなくなった場合の対処です。

 同じような現象が発生した人の助けになれば幸いです。
 

・参考資料