📹

カメラ画像から、BLEマウスを使ってiPadを自動制御しよう

2022/05/08に公開約14,000字

以前の記事で紹介したBLEマウスを外部制御できるように拡張しました。
下記のデモは、iPadを撮影した画像から、アイコンを探し起動します。

https://twitter.com/tw_kotatu/status/1523110595259600896

関連記事

https://zenn.dev/kotaproj/articles/esp32_keyborad

↑以前作成したbleキーボートです。
bleの部分は、ほぼ共通です。

https://zenn.dev/kotaproj/articles/esp32_blemouse

↑bleマウスの作成手順です。

システム概要

  1. PCは、USBカメラを経由して、iPadの画面を撮影
    • 撮影した画像から、アイコンの座標位置を特定する
      • 特定は、テンプレートマッチングを使用
    • また、撮影した画像から、マウスのカーソル座標位置を特定する
  2. PCは、アイコンの座標位置とカーソル座標位置から、カーソルのシフト量を算出
    • 差分値を使用する
  3. PCは、HTTPを経由して、Bleマウスにリクエストを投げる
  4. Bleマウスは、リクエストを受け、bleを経由してiPadを制御
    • カーソルの移動を行う
  5. 1 - 4を繰り返し、カーソルをアイコン位置に移動する
  6. アイコンをクリックし、アプリケーションを起動する

検討

いろいろとやったことを記載します。

iPad側の設定

  • 背景を単色にする
    • オレンジ単色の壁紙に変更しました
    • 真っ黒だとカメラのゲインが上がるため、比較的明るい色にしました
  • カーソルを大きくする
    • 設定 - アクセシビリティ - ポインタコントロール より、カラーをホワイト/ポインタサイズを最大に変更
    • マウスカーソル
  • AssistiveTocuh設定より、ボタンアプリケーションを追加する
    • AssistiveTocuh設定のESP32 Bluetooth Mouseにてボタン割り当てを設定しました

カメラの撮影

USBカメラを使用すことしました。
手元にあったLogicool HD Webcam C270を使用しました。
本カメラは、最大1280x960で撮影できます。
また、できるだけ大きく撮影できるように、iPadを横置きしました。
マッチング時は、コード側で回転させることにしました。

テンプレート画像の作成

一旦カメラで被写体を撮影し、テンプレート画像を作成しました。
今回は、Podcastを起動します。
利用した画像は以下となります。

ファイル名 画像
icon_podcast.png
pointer.png

bleマウス側 - HTTP制御

PCからのインターフェイスとなります。
ESP32自体は、プアな環境のため、HTTPのGETメソッドのみで実装としました。

query value 説明 備考
code click クリック動作 使用
- move カーソル移動 or ホイール動作 使用
- press マウスボタンダウン動作 本記事では未使用
- release マウスボタンアップ動作 本記事では未使用
type left 左クリック iPad上では、シングルタップ動作(ボタン1)
- right 右クリック iPad上では、メニュー動作(ボタン2)
- middle 第3ボタンクリック iPad上では、HOME動作(ボタン3)
- back 戻る iPad上では、ロック動作(ボタン4)
- forward 進む 本記事では未使用(ボタン5)
linear 0 カーソル移動 - 離散動作 使用
- 1 カーソル移動 - 連続動作 本記事では未使用
x 整数 水平方向の移動量 使用
y 整数 垂直方向の移動量 使用
wheel -128 - 127 ホイール移動量 本記事では未使用
step 整数(>0) 連続動作時の移動量 本記事では未使用
delay 整数(>0) 連続動作時の遅延量(msec) 本記事では未使用
  • 例:シングルタップを実施
GET http://{IP Address}/mouse?code=click&type=left
  • 例:水平方向-100, 垂直方向+100移動
http://{IP Address}/mouse?code=move&x=-100&y=100

bleマウスの作成

完成図

筐体は前回と同じものになります。

🔧パーツ一覧

機材名 備考
ESP32評価ボード ESP32-WROVER 開発ボード/ESP32-WROOM 開発ボードのどちらでも可
🕹ボード ジョイスティックおよびスイッチセット - Amazonで購入
タクトスイッチ 3つ使用

接続図

MPU側でプルアップ設定を行うため、抵抗は不要です。

💻環境

ESP32側

VScode - PlatformIOを使用します。
PlatformaIOの環境構築は、↓にまとめておきました。

https://zenn.dev/kotaproj/articles/esp32_vscode_pio

ライブラリ - ESP32 BLE Mouse

前記事を参照願います。

https://zenn.dev/kotaproj/articles/esp32_blemouse

アプリケーション側

使用したモジュールの抜粋

Package            Version
------------------ ---------
matplotlib         3.5.2
matplotlib-inline  0.1.3
numpy              1.22.0
opencv-python      4.5.5.62
Pillow             9.1.0
requests           2.27.1

📝手順

下記について記載します。

  • ESP32側のファーム
  • 画像アプリケーション

ESP32側のファーム

以前作成したBleキーボードのプロジェクトをベースに作成しました。
FreeRTOSを使用しています。

タスク構成

タスク名 役割
スイッチ監視タイマー スイッチのDOWN/UPの変化を監視する
ジョイスティック監視タイマー ジョイスティックの変化を監視する
HTTPd管理タスク Webサーバの起動及びクエリーを処理する(ここが追加)
メッセージ管理タスク スイッチ/ジョイスティックのイベントをキーボード管理に通知
マウス管理タスク メッセージ管理から受けたイベントをBleマウス制御を送信

コード関連

コード自体は、githubにアップしてあります。
ポイントを記載します。

Web Server - インクルード関連

Wifi接続とWebServer用に下記をインクルードします。

#include <WiFi.h>
#include <WebServer.h>

Web Server - WiFiへの接続

初期化処理にて、WiFiへの接続を行います。

const char *ssid = "xxxxxxxxxxx";    // Enter SSID here
const char *password = "yyyyyyyyyyyyy"; // Enter Password here

---

WiFi.begin(ssid, password);

vTaskDelay(1000 / portTICK_RATE_MS);

while (WiFi.status() != WL_CONNECTED)
{
    // WiFiに接続できているか
    vTaskDelay(1000 / portTICK_RATE_MS);
    Serial.print(".");
}

Serial.print("IP address: ");
Serial.println(WiFi.localIP());

Web Server - WebServerの初期化

生成時にポート番号を指定しています。

  • server.on(url, handler)
    • url : urlを指定
    • handler : urlが指定された場合のサーバ処理
  • server.onNotFound(handler)
    • handler : 存在しないurlが指定された場合のサーバ処理
  • server.begin()
    • サーバの開始
/* Put IP Address details */
WebServer server(80);

---

server.on("/", vHandleHdOnConnect);
server.on("/mouse", vHandleHdOnConnectMouse);
server.on("/form", vHandleHdOnConnectForm);
server.onNotFound(vHandleHdNotFound);
server.begin();

Serial.println("HTTP server started");

Web Server - サーバ処理

簡単な例で記載します。
説明は、コメントに記載しています。

// vHandleHdOnConnectは、"http://{IP Address}/"アクセス時にコールされる
void vHandleHdOnConnect(void)
{
    Serial.println("run:vHandleHdOnConnectMouse");

    String code;

    // "http://{IP Address}/"でアクセス => code : ""
    // "http://{IP Address}/code=move"でアクセス => code : "move"
    code = server.arg("code");

    // 応答データを送信
    server.send(200, "text/html", "ok\n\n");

    Serial.println("over:vHandleHdOnConnect");
    return;
}

マウス制御/ジョイスティック/スイッチの監視は、前記事を参照願います。

コード全体

https://github.com/kotaproj/esp32_mouse

画像アプリケーション

動作は、コメントで記載しました。
"システム概要"に記載した内容となります。

sample.py
import sys
import time
import cv2
import numpy as np
import requests

# camera
WIDTH = 1280
HEIGHT = 960
FPS = 30

# esp32 ip address
TARGET_IP = "192.168.xxx.yyy"

# img_path
PATH_IMG_ICON = "icon_podcast.png"
PATH_IMG_POINTER = "pointer.png"
PATH_IMG_OUT = "out.jpg"


def find_img(img_base, img_search, meth="cv2.TM_CCOEFF_NORMED"):
    """テンプレートマッチング

    Args:
        img_base (np.arrasy): カメラ画像
        img_search (np.array): 探す画像
        meth (str, optional): テンプレートマッチングメソッド. Defaults to "cv2.TM_CCOEFF_NORMED".

    Returns:
        tapple: 探す画像の座標値
    """
    img = frame
    method = eval(meth)

    # Apply template Matching
    res = cv2.matchTemplate(img_base, img_search, method)
    # print("res", res)
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
    print(min_val, max_val, min_loc, max_loc)

    # If the method is TM_SQDIFF or TM_SQDIFF_NORMED, take minimum
    if method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]:
        top_left = min_loc
    else:
        top_left = max_loc
    w, h = img_search.shape[::-1]
    bottom_right = (top_left[0] + w, top_left[1] + h)

    cv2.rectangle(img, top_left, bottom_right, 255, 2)
    print(top_left, bottom_right)

    return ((top_left[0] + bottom_right[0]) // 2, (top_left[1] + bottom_right[1]) // 2)


def calc_shiftcrd(dst_crd, cur_crd):
    """座標値の算出

    Args:
        dst_crd (taple): 目標画像の座標値
        cur_crd (taple): マウスポインタ画像の座標位置

    Returns:
        taple: マウスポインタの移動量
    """
    x = (dst_crd[0] - cur_crd[0]) // 2
    y = (dst_crd[1] - cur_crd[1]) // 2
    x = 100 if x > 100 else x
    x = -100 if x < -100 else x
    y = 100 if y > 100 else y
    y = -100 if y < -100 else y
    return x, y


if __name__ == "__main__":
    # キャプチャデバイスの準備
    capture = cv2.VideoCapture(0)

    # キャプチャデバイス設定
    capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc("Y", "U", "Y", "V"))
    capture.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT)
    capture.set(cv2.CAP_PROP_FPS, FPS)

    # template - icon画像の準備
    icon_podcast = cv2.imread(PATH_IMG_ICON, 0)
    icon_podcast = cv2.rotate(icon_podcast, cv2.ROTATE_90_CLOCKWISE)

    # template - pointer画像の準備
    pointer = cv2.imread(PATH_IMG_POINTER, 0)
    pointer = cv2.rotate(pointer, cv2.ROTATE_90_CLOCKWISE)

    # ->unlock->home
    requests.get(f"http://{TARGET_IP}/mouse?code=click&type=middle")
    time.sleep(0.3)
    requests.get(f"http://{TARGET_IP}/mouse?code=click&type=middle")
    time.sleep(1.0)

    dst_crd_flg = False
    shift_x, shift_y = 1, 1

    for _ in range(10):
        # カーソルの移動
        requests.get(f"http://{TARGET_IP}/mouse?code=move&x={shift_x}&y={shift_y}")
        time.sleep(0.3)

        # 画像のキャプチャ
        ret, frame = capture.read()
        cv2.imwrite(PATH_IMG_OUT, frame)
        frame = cv2.imread(PATH_IMG_OUT, 0)
        frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)

        # アイコン画像(目標画像)の座標取得
        if not dst_crd_flg:
            crd_icon = find_img(frame, icon_podcast)
            print("crd_icon:", crd_icon)
            dst_crd_flg = True

        # マウスポインタ画像の座標取得
        crd_point = find_img(frame, pointer)
        print("crd_point:", crd_point)

        # マウスポインタの移動量の算出
        shift_x, shift_y = calc_shiftcrd(crd_icon, crd_point)
        print("shift_x, shift_y:", shift_x, shift_y)

        # アイコンとマウスポインタが重なった場合の処理
        if abs(shift_x) < 10 and abs(shift_y) < 10:
            # タップしてアプリケーションの起動
            requests.get(f"http://{TARGET_IP}/mouse?code=click&type=left")
            break

気づきメモ

この作業で遭遇したトラブルや気づきについて記載します。

ESP32 - WiFiとADC2のハード制約

ESP32をデバック中、下記のエラーが頻繁上がりました。

[ 81120][E][esp32-hal-adc.c:186] __analogRead(): GPIO25: ESP_ERR_TIMEOUT: ADC2 is in use by Wi-Fi. Please see https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/adc.html#adc-limitations for more info
[ 81151][E][esp32-hal-adc.c:186] __analogRead(): GPIO27: ESP_ERR_TIMEOUT: ADC2 is in use by Wi-Fi. Please see https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/adc.html#adc-limitations for more info

Wi-Fiで使われているから、ADC2は使えないよということです。
ハードウェア制約のため、ピン配置を変更しました。

変更前(ADC2 Pin) 変更後(ADC1 Pin)
PIN25 PIN32
PIN27 PIN34
  • 参考URL

https://github.com/espressif/arduino-esp32/issues/440

ESP32 - 容量問題の回避(OTA)

Web ServerおよびWifi関連のモジュールを入れることで、ビルド時にエラーとなりました。

Error: The program size (xxxxxxxxxx bytes) is greater than maximum allowed (1310720 bytes)

現在使用しているESP32 Moduleは、4MByteのFlash領域があります。
なぜ?1.3MByteほどを超えるとエラーになるのかわからなかったため、調査をしたところ、OTA(over the air)でよう
いろいろと調べたところ、OTA(over the air)というWifi経由のプログラム書き込み機能を無効にすることで、容量を稼ぐことができることが分かりました。

platformio.iniに、以下を追記ました。

platformio.ini
board_build.partitions = no_ota.csv

通常は、↑の修正のみでいいようですが、このファイルが見えないと怒れたため、
platformio.iniと同じ位置に、no_ota.csvを作成し、下記としました。

no_ota.csv
# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xe000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x200000,
spiffs,   data, spiffs,  0x210000,0x1F0000,

テンプレートマッチング

最初、iPad側はすべてデフォルトで動作させました。
ただ、背景に引っ張られる部分が大きかったたため、マウスポインタを大きくしたり、背景を単色するなどして、安定的に座標が取れるようになりました。

また、Opencvでは、6つのメソッドが使用できます。

  • cv2.TM_CCOEFF
  • cv2.TM_CCOEFF_NORMED
  • cv2.TM_CCORR
  • cv2.TM_CCORR_NORMED
  • cv2.TM_SQDIFF
  • cv2.TM_SQDIFF_NORMED

私の環境下では、cv2.TM_CCOEFF_NORMEDが比較的いい精度でとれました。
検出結果に、ボックスを描画しています。

内容 検出結果画像
アイコン
カーソル
-
-

カーソルがアイコンに近づいているのが分かります。

さいごに

今回は、簡単な自動制御を行いました。
本マウスは、相対値の制御になるため、なかなか面倒だなと思いました。
作りこめば、いろんな可能性があると思います。

参考URL

https://www.freertos.org/

https://github.com/T-vK/ESP32-BLE-Mouse

http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_template_matching/py_template_matching.html
GitHubで編集を提案

Discussion

ログインするとコメントできます