前に戻る

本資料は、M5StickC Plus2とフォトリフレクタを用いて「瞬き」を検出し、PC上のKindleアプリのページ送り操作を行うシステムの構築手順をまとめたものです。

※講義用の簡易メモです。実環境での動作には調整が必要だと思います。あくまで参考までに。

はじめに・参考文献

瞬きによるインターフェースは、HCI(Human-Computer Interaction)の分野で古くから研究されています。本システムは、以下の先行研究などを参考に、簡易的な実装を行ったものです。

  • 瞬きによる読書への影響等の調査 Junpei Uchida, Tsutomu Terada, and Masahiko Tsukamoto. 2023. Does Use of Blink Interface Affect Number of Blinks When Reading Paper Books? (Link)
  • 笑顔・表情認識のメカニズム Kurara Fukumoto, Tsutomu Terada, and Masahiko Tsukamoto. 2013. A smile/laughter recognition mechanism for smile-based life logging. (Link)

一般的に「JINS MEME」のように眼電位(EOG)を利用する方法もありますが、今回はより安価で簡易な「フォトリフレクタ(赤外線センサ)」による皮膚の動き・反射率の変化を利用します。

システム概要と準備物

システムのゴール

「2回素早く瞬き」したら次のページへ、「長く目を閉じる」と前のページへ戻る、といったハンズフリーな電子書籍操作を目指します。

必要なもの

  • M5StickC Plus2(マイコンボード)
  • フォトリフレクタ(TCRT5000等 ※反射型フォトセンサなら概ね可)
  • メガネ(センサ取り付け用)
  • ジャンパワイヤ
  • (筐体作成用)グルーガンまたは3Dプリンタ

ハードウェアのセットアップ

配線

フォトリフレクタとM5StickC Plus2を以下のように接続します。

フォトリフレクタ側M5StickC Plus2側
VCC5V
OUTG26
GNDGND

装着と筐体

講義で作成した筐体を拡張し、メガネのフレームに引っ掛かるように設計します。

位置調整が非常に重要です。瞬きをした際に、皮膚(まぶたやまつ毛)の動きを最もよく拾える位置を探ります。

仮止めでテストし、位置が決まったらグルーガン等で固定してください。

M5StickC Plus2側の実装

まずはセンサが正常に値を拾っているかテストします。生成AI等に「TCRT5000をM5StickC Plus2で動かすコード」を依頼すると、ベースとなるコードが得られます。

以下は、センサ値をシリアル通信でPCに送信する基本コードです。

#include <M5StickCPlus2.h>

// センサーを接続したピン番号 (G26ピンを使用)
const int SENSOR_PIN = 26; 

void setup() {
  // M5StickC Plus2の初期化
  auto cfg = M5.config();
  M5.begin(cfg);
  
  // シリアル通信の開始(シリアルプロッタ用)
  Serial.begin(115200);

  // ピンモードの設定
  pinMode(SENSOR_PIN, INPUT);
  
  // 画面表示設定
  M5.Display.setRotation(1);
  M5.Display.setTextSize(2);
  M5.Display.print("QRE1113 Test");
}

void loop() {
  // アナログ値を読み取る (0 〜 4095)
  int sensorValue = analogRead(SENSOR_PIN);

  // シリアルプロッタ用に値を出力
  // "Min:0,Max:4095,Value:XXXX" の形式にするとプロッタの縦軸が固定されて見やすい
  Serial.print("Min:0,Max:4095,Value:"); 
  Serial.println(sensorValue);

  // 本体画面にも値を表示(デバッグ用)
  M5.Display.setCursor(0, 40);
  M5.Display.fillRect(0, 40, 240, 30, BLACK); // 前の数値を消去
  M5.Display.printf("Val: %d", sensorValue);

  delay(20); 
}

確認手順:

  1. コードを書き込みます。
  2. Arduino IDEの「シリアルプロッタ」を開きます。
  3. 瞬きに合わせて波形が上下に変動することを確認します(平常時: 約1000 → 瞬き時: 約250 などの変化が見られればOK)。

あとは瞬きが認識するようにいい感じの位置にフォトリフレクタを設置してみます
だいたいこんな感じかなと仮止めしたらグルーで固めてしまいます。カッコよくまとめたいなら3Dプリンタでしょうか。

講義で作った筐体をちょっとだけ拡張させてメガネにひっかるように設計します。既存モデルをガンガン使いまわしましょう

大体こんな感じ

装着して先程のテスコードを動かしてみましょう。瞬きに合わせて値がふらついていることなどわかればとりあえずOKです。

瞬きを認識する

以下のプロンプトをChatGPTに投げました。

以下のコードをもとにデータをPCに送っています。
シリアルプロッタのスクショを添付しています。
データはだいたい1000くらいで、瞬きをすると250くらいに下がってるようです。
ただ、これはメガネをかける位置によって変動してしまうため、平常値の値と、瞬きをしたかどうかの閾値は毎回キャリブレーションするなどして変更する必要がありそうです。

PC側でM5からのデータを受け取って、pytonで処理することで瞬きをまずは認識したいです。
Python側のコードを作ってみてください。
---
(ここにさっきのコード貼りました)

で返ってくるのは以下のようなもの。


まずは「M5 → PC に値を送り、Python 側で瞬きを検出する」シンプル版を書きます。
・M5 からは今のコードのまま "Min:0,Max:4095,Value:1234" が 20ms ごとに送られてくる想定です。
・Python 側で基準値をキャリブレーションして、そこからの落ち込みで瞬きを検出します。

全体のイメージ

  1. シリアルを開く
  2. 4 秒くらい「普通に目を開けている状態」の値を集めて 基準値(baseline)ノイズ量(標準偏差) を計算
  3. threshold = baseline - 3 * noise(or 手動係数)を自動で決める
  4. 以後はリアルタイムに値を読みながら、
    「値が threshold を下回った瞬間」を 1 回の瞬きとして検出(リバウンド対策で 0.3 秒の不感時間を入れる)

Python コード例

import serial
import time
import statistics

# ==== 設定ここだけ変えれば動く想定 ====
SERIAL_PORT = "/dev/tty.usbserial-XXXX"  # ★自分のポート名に変更
BAUD_RATE = 115200

CALIBRATION_DURATION = 4.0   # キャリブレーション時間(秒)
REFRACTORY_PERIOD = 0.3      # 次の瞬きとみなさない最低間隔(秒)
NOISE_FACTOR = 5.0           # ノイズ標準偏差の何倍をしきい値にするか
# ===============================


def parse_value(line: str):
    """
    M5 から来る "Min:0,Max:4095,Value:1234" 形式の行から
    整数値 1234 を取り出す。取れなければ None を返す。
    """
    if "Value:" not in line:
        return None
    try:
        val_str = line.split("Value:")[-1].strip()
        return int(val_str)
    except ValueError:
        return None


def calibrate(ser):
    """
    一定時間センサ値を集めて baseline と noise(標準偏差)を求める。
    目は普通に開けた状態のままにしておく。
    """
    print(f"[INFO] キャリブレーション開始({CALIBRATION_DURATION}秒): 普通に目を開けてください")
    values = []
    start = time.time()

    while time.time() - start < CALIBRATION_DURATION:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        value = parse_value(line)
        if value is not None:
            values.append(value)

    if len(values) < 10:
        raise RuntimeError("キャリブレーション用のデータが少なすぎます")

    baseline = statistics.mean(values)
    noise = statistics.pstdev(values)  # 母標準偏差

    threshold = baseline - NOISE_FACTOR * noise

    print(f"[INFO] キャリブレーション完了")
    print(f"  baseline ≒ {baseline:.1f}")
    print(f"  noise(σ) ≒ {noise:.1f}")
    print(f"  threshold = baseline - {NOISE_FACTOR}*σ ≒ {threshold:.1f}")
    print("  (必要ならコードの NOISE_FACTOR を変えて調整してください)")

    return baseline, threshold


def main():
    ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
    time.sleep(2.0)  # 接続安定待ち

    try:
        baseline, threshold = calibrate(ser)

        print("[INFO] 瞬き検出を開始します。Ctrl+C で終了。")
        last_blink_time = 0.0
        was_below = False  # 直前サンプルがしきい値以下かどうか

        while True:
            line = ser.readline().decode("utf-8", errors="ignore").strip()
            value = parse_value(line)
            if value is None:
                continue

            now = time.time()
            is_below = value < threshold

            # 「上から下に抜けた瞬間」かつ不感時間を満たしていたら瞬きと判定
            if is_below and not was_below:
                if now - last_blink_time > REFRACTORY_PERIOD:
                    last_blink_time = now
                    print(f"[BLINK] {time.strftime('%H:%M:%S')}  val={value}")

            was_below = is_below

    except KeyboardInterrupt:
        print("\n[INFO] 終了します")
    finally:
        ser.close()


if __name__ == "__main__":
    main()

使い方・調整のポイント

  • SERIAL_PORT を自分の環境に合わせて変更(Mac なら /dev/tty.usbserial-XXXX/dev/tty.usbmodemXXXX)。
  • キャリブレーション中(最初の 4 秒)は
    「普通に目を開けた状態で、できるだけ条件を本番と同じ」にしておく。
  • 実際に動かしてみて、
    • 瞬きを取りこぼす → NOISE_FACTOR小さく(例: 2.5)
    • ノイズで誤検出が多い → NOISE_FACTOR大きく(例: 3.5)
    • ダブルカウントする → REFRACTORY_PERIOD を 0.4〜0.5 などに増やす

接続先はターミナル等で、

ls /dev/tty.*

を叩けばわかります。

一応実行する前にArduino IDEのシリアルプロッタで確認しながらメガネ位置を調整しておきます。

調整できたらArduino IDEは終了しておきましょう(シリアル通信が奪われちゃうので)。

pythonを実行してみましょう。

できてそうです。うまくいかない場合は、

NOISE_FACTOR = 5.0 # ノイズ標準偏差の何倍をしきい値にするか

あたりを調整してください。もしくはthresholdは固定でもいいかもしれません。


「素早く2回瞬き」と「1秒ほど目を瞑る長い瞬き」

続いて以下のプロンプトを投げました。


次は、「素早く2回瞬き」と「1秒ほど目を瞑る長い瞬き」をそれぞれ認識してください。

やることは単純で、

  • しきい値以下になった時刻blink_start
  • しきい値を再び上回った時刻blink_end
  • duration = blink_end - blink_start瞬きの長さ を出す
  • duration が長ければ「長い瞬き」
  • duration が短いものが 2 回、短い間隔で来たら「ダブル瞬き」

という状態機械を回します。

パラメータ設計(ざっくり)

LONG_BLINK_MIN = 0.8          # これ以上閉じてたら「長い瞬き」
DOUBLE_BLINK_MAX_INTERVAL = 0.5  # 2 回の短い瞬きの間がこれ以下なら「ダブル」
MIN_BLINK_DURATION = 0.05     # これ未満はノイズとして無視(任意)
  • ここは人によって違うので、動かしてみてから微調整してください。
  • とりあえず標準的な値を入れておきます。

修正版コード(全部版)

前回の blink_test.py をこんな感じに差し替えれば動きます。

import serial
import time
import statistics

# ==== 設定 ====
SERIAL_PORT = "/dev/tty.usbserial-XXXX"  # 実際のポート名に書き換え
BAUD_RATE = 115200

CALIBRATION_DURATION = 4.0   # キャリブレーション時間(秒)
NOISE_FACTOR = 5.0           # しきい値の厳しさ(前回ここ上げてうまくいったやつ)

# ここから新しい判定用パラメータ
LONG_BLINK_MIN = 0.8         # 「長い瞬き」とみなす最小時間(秒)
DOUBLE_BLINK_MAX_INTERVAL = 0.8  # 2回の短い瞬きの間隔がこれ以下ならダブル
MIN_BLINK_DURATION = 0.05    # これ未満は瞬きとみなさない(ノイズ除去)
# ===============================


def parse_value(line: str):
    if "Value:" not in line:
        return None
    try:
        val_str = line.split("Value:")[-1].strip()
        return int(val_str)
    except ValueError:
        return None


def calibrate(ser):
    print(f"[INFO] キャリブレーション開始({CALIBRATION_DURATION}秒): 普通に目を開けてください")
    values = []
    start = time.time()

    while time.time() - start < CALIBRATION_DURATION:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        value = parse_value(line)
        if value is not None:
            values.append(value)

    if len(values) < 10:
        raise RuntimeError("キャリブレーション用のデータが少なすぎます")

    baseline = statistics.mean(values)
    noise = statistics.pstdev(values)
    threshold = baseline - NOISE_FACTOR * noise

    print(f"[INFO] キャリブレーション完了")
    print(f"  baseline ≒ {baseline:.1f}")
    print(f"  noise(σ) ≒ {noise:.1f}")
    print(f"  threshold = baseline - {NOISE_FACTOR}*σ ≒ {threshold:.1f}")
    return baseline, threshold


def main():
    ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
    time.sleep(2.0)

    try:
        baseline, threshold = calibrate(ser)

        print("[INFO] 瞬き検出を開始します。Ctrl+C で終了。")

        in_blink = False          # 今まさに「閉じている」状態か
        blink_start_time = 0.0

        # 「短い瞬き」が1回だけ起きて、2回目を待っている状態
        pending_short = False
        pending_short_time = 0.0

        while True:
            line = ser.readline().decode("utf-8", errors="ignore").strip()
            value = parse_value(line)
            if value is None:
                continue

            now = time.time()
            is_below = value < threshold

            # --- 瞬きの開始検出 ---
            if not in_blink and is_below:
                in_blink = True
                blink_start_time = now

            # --- 瞬きの終了検出 ---
            if in_blink and not is_below:
                in_blink = False
                duration = now - blink_start_time

                # あまりにも短いのはノイズとして捨てる
                if duration < MIN_BLINK_DURATION:
                    continue

                # 長い瞬き判定
                if duration >= LONG_BLINK_MIN:
                    print(f"[LONG_BLINK] {time.strftime('%H:%M:%S')}  dur={duration:.3f}s  val~{value}")
                    # 長い瞬きは単独で扱うので、pending_shortはリセット
                    pending_short = False

                else:
                    # 短い瞬きが1つ完了した
                    if pending_short and (now - pending_short_time) <= DOUBLE_BLINK_MAX_INTERVAL:
                        # 直前の短い瞬きからの間隔が短い → ダブル瞬き
                        print(f"[DOUBLE_BLINK] {time.strftime('%H:%M:%S')}  interval={now - pending_short_time:.3f}s")
                        pending_short = False
                    else:
                        # とりあえず「短い瞬き1回目」として保留
                        pending_short = True
                        pending_short_time = now

            # --- ダブル待ちタイムアウト → シングル短瞬き確定 ---
            if pending_short and (now - pending_short_time) > DOUBLE_BLINK_MAX_INTERVAL:
                print(f"[SHORT_BLINK] {time.strftime('%H:%M:%S')}")
                pending_short = False

    except KeyboardInterrupt:
        print("\n[INFO] 終了します")
    finally:
        ser.close()


if __name__ == "__main__":
    main()

使い方・調整のコツ

  1. まずはこのまま動かして、
    • 普通の 1 回瞬き → [SHORT_BLINK]
    • 素早く 2 回瞬き → [DOUBLE_BLINK]
    • 1 秒くらい目を閉じる → [LONG_BLINK]
      が出るか確認する。
  2. うまく分かれないときは:
    • 長めが長瞬きとして拾われない → LONG_BLINK_MIN を 0.7 とか 0.6 に下げる
    • ダブルがシングル 2 発扱いになる → DOUBLE_BLINK_MAX_INTERVAL を 0.6〜0.7 に増やす
    • 逆に誤ってダブル判定される → DOUBLE_BLINK_MAX_INTERVAL を 0.4 とかに下げる
  3. 最終的にはこの print を
    • SHORT_BLINK → 何もしない
    • DOUBLE_BLINKpyautogui.press("right")
    • LONG_BLINKpyautogui.press("left")
      みたいに置き換えれば、そのまま操作インタフェースになります。

一旦これでログを取りながら「この動きのとき何msくらいになってるか」見て、しきい値を詰めましょう。

M5stickC Plus2からKindleアプリを操作する

あとは仕上げです。アプリを操作するコマンドを割り当てます。以下プロンプトをGPTに投げます

あとは、長い瞬きならキーボードの右矢印、短い2回の瞬きなら左矢印、が入力されるようにして

✔ 実装仕様

  • 長い瞬きRight(右矢印キー)
  • 素早い二回瞬き(ダブル)Left(左矢印キー)
  • 普通の短い一回 → 何もしない(明確に無視)

✔ 改訂済みコード(貼り替えでOK)

import serial
import time
import statistics
import pyautogui  # ← 追加

# ==== 設定 ====
SERIAL_PORT = "/dev/tty.usbserial-XXXX"  # 実際のポート名に書き換え
BAUD_RATE = 115200

CALIBRATION_DURATION = 4.0
NOISE_FACTOR = 5.0

LONG_BLINK_MIN = 0.8
DOUBLE_BLINK_MAX_INTERVAL = 0.5
MIN_BLINK_DURATION = 0.05
# ===============================


def parse_value(line: str):
    if "Value:" not in line:
        return None
    try:
        val_str = line.split("Value:")[-1].strip()
        return int(val_str)
    except ValueError:
        return None


def calibrate(ser):
    print(f"[INFO] キャリブレーション開始({CALIBRATION_DURATION}秒): 普通に目を開けてください")
    values = []
    start = time.time()

    while time.time() - start < CALIBRATION_DURATION:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        value = parse_value(line)
        if value is not None:
            values.append(value)

    baseline = statistics.mean(values)
    noise = statistics.pstdev(values)
    threshold = baseline - NOISE_FACTOR * noise

    print(f"[INFO] キャリブレーション完了")
    print(f"  baseline ≒ {baseline:.1f}")
    print(f"  noise(σ) ≒ {noise:.1f}")
    print(f"  threshold ≒ {threshold:.1f}")
    return baseline, threshold


def main():
    ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1)
    time.sleep(2.0)

    try:
        baseline, threshold = calibrate(ser)

        print("[INFO] 瞬き検出を開始します。Ctrl+C で終了。")

        in_blink = False
        blink_start_time = 0.0

        pending_short = False
        pending_short_time = 0.0

        while True:
            line = ser.readline().decode("utf-8", errors="ignore").strip()
            value = parse_value(line)
            if value is None:
                continue

            now = time.time()
            is_below = value < threshold

            # ====== 瞬き開始 ======
            if not in_blink and is_below:
                in_blink = True
                blink_start_time = now

            # ====== 瞬き終了 ======
            if in_blink and not is_below:
                in_blink = False
                duration = now - blink_start_time

                if duration < MIN_BLINK_DURATION:
                    continue

                # --- 長い瞬き ---
                if duration >= LONG_BLINK_MIN:
                    print(f"[LONG_BLINK] dur={duration:.3f}s  → RIGHT")
                    pyautogui.press("right")
                    pending_short = False  # 状態クリア

                else:
                    # --- 短い瞬き(候補) ---
                    if pending_short and (now - pending_short_time) <= DOUBLE_BLINK_MAX_INTERVAL:
                        # → ダブル瞬き確定
                        print(f"[DOUBLE_BLINK] interval={now - pending_short_time:.3f}s  → LEFT")
                        pyautogui.press("left")
                        pending_short = False
                    else:
                        pending_short = True
                        pending_short_time = now

            # ====== ダブル成立待ちタイムアウト ======
            if pending_short and (now - pending_short_time) > DOUBLE_BLINK_MAX_INTERVAL:
                # シングルは無視
                print(f"[SHORT_BLINK] (ignored)")
                pending_short = False

    except KeyboardInterrupt:
        print("\n[INFO] 終了します")
    finally:
        ser.close()


if __name__ == "__main__":
    main()

✔ 動作確認(想定ログ)

[LONG_BLINK] dur=1.034s  → RIGHT
# ← Kindle右送り

[DOUBLE_BLINK] interval=0.341s  → LEFT
# ← Kindle左送り

一回瞬き:

[SHORT_BLINK] (ignored)

(完全に無視)

これでおそらく動くと思います。

例えば以下でテストしてもいいです。

https://claude.ai/public/artifacts/817a0db1-9dca-4f01-a64a-61f9ff9cc8b1

thresholdはぶっちゃけ手打ちでもいいです。

最後に無線化

一応Bluetoothで無線化しておくと楽です。

1. M5 側:Bluetooth シリアルを有効にするコード

ESP32 の BluetoothSerial を使います。

追加&変更ポイント

  • #include "BluetoothSerial.h" を追加
  • BluetoothSerial SerialBT; を定義
  • SerialBT.begin("M5Blink"); でデバイス名を決める
  • これまで Serial.print していたところを SerialBT.print に変更(USB シリアルも残したければ両方に出してOK)

全体スケッチ例

#include <M5StickCPlus2.h>
#include "BluetoothSerial.h"

// センサーを接続したピン番号
const int SENSOR_PIN = 26;

// Bluetooth シリアルオブジェクト
BluetoothSerial SerialBT;

void setup() {
  // M5StickC Plus2の初期化
  auto cfg = M5.config();
  M5.begin(cfg);

  // USB シリアル(デバッグ用、任意)
  Serial.begin(115200);

  // Bluetooth シリアル開始(PC からは "M5Blink" として見える)
  if (!SerialBT.begin("M5Blink")) {
    // 失敗したときのメッセージ(USB シリアルで確認用)
    Serial.println("BT start failed!");
  } else {
    Serial.println("BT started. Device name: M5Blink");
  }

  pinMode(SENSOR_PIN, INPUT);

  M5.Display.setRotation(1);
  M5.Display.setTextSize(2);
  M5.Display.print("QRE1113 BT Test");
}

void loop() {
  int sensorValue = analogRead(SENSOR_PIN);

  // Bluetooth 経由で Python に送る
  SerialBT.print("Min:0,Max:4095,Value:");
  SerialBT.println(sensorValue);

  // USB シリアルにも出したければコメントアウト外す
  // Serial.print("Min:0,Max:4095,Value:");
  // Serial.println(sensorValue);

  // 本体ディスプレイ表示
  M5.Display.setCursor(0, 40);
  M5.Display.fillRect(0, 40, 240, 30, BLACK);
  M5.Display.printf("Val: %d", sensorValue);

  delay(20);
}

⚠️ ビルド時に
CONFIG_BT_ENABLED / CONFIG_BLUEDROID_ENABLED まわりの warning が出る場合がありますが、
M5StickC / ESP32 用の Arduino core がちゃんと入っていれば基本そのまま動きます


2. Mac 側:Bluetooth シリアルポートを探す

  1. M5 の電源 ON &上のスケッチを書き込み
  2. Mac の Bluetooth 設定を開く
    • 「M5Blink」みたいな名前のデバイスが見えるはずなので ペアリング
  3. ペアリングできたら、ターミナルで:
ls /dev/tty.*
ls /dev/cu.*

ここに

  • /dev/tty.M5Blink-ESP32SPP
  • /dev/cu.M5Blink-ESP32SPP

みたいな名前のポートが増えているはずです(名前は多少違うかも)。

Python からはだいたい tty. でも cu. でも動きますが、macOS では cu.* を使うことが多いです。


3. Python 側:ポート名を差し替えるだけ

今の blink_test.py の先頭の設定を:

SERIAL_PORT = "/dev/tty.usbserial-58A40032251"

から、Bluetooth のやつに変えるだけです。例:

SERIAL_PORT = "/dev/cu.M5Blink-ESP32SPP"
# または /dev/tty.M5Blink-ESP32SPP

それ以外のコード(キャリブレーション、瞬き判定、pyautogui.press)はそのままでOK。
USB ケーブルを抜いても、Bluetooth 経由で同じ "Min:0,Max:4095,Value:1234" が飛んできます。


4. よくあるハマりポイント

  • ペアリングしてないのに /dev/tty.M5Blink... を掴もうとして No such file or directory
  • macOS が Bluetooth SPP をたまに不安定に扱う
    • 接続できないときは
      1. Python を落とす
      2. Mac の Bluetooth を一度 OFF/ON
      3. M5 をリセット
      4. 再度 ls /dev/cu.* でポートを確認
  • ポート名に 余計なスペースが入る(前に一回あったやつ)
    → コピペしたあとに " /dev/... になってないか確認

デモ

最終的には以下のように動くはず