Raspberry Pi と Arduino で作る360度回転リモートワークカメラ(3) ソフトウェア構築編


この記事は WESEEK Advent Calendar 2020 17日目の記事です。

はじめに

今回は、 前回前々回 の記事で紹介したリモートワークカメラのソフトウェア面の実装過程や工夫した点などについて説明したいと思います。

構成

前回 の記事にも書きましたが、構成は下記です。
Raspberry Pi で Xbox One コントローラーからの入力処理とArduinoへのシリアル送信処理は Python、 Pygame で行っています。

Xbox One コントローラーと Raspberry Pi を Bluetooth で接続する

Xbox One コントローラーは Bluetooth で Raspberry Pi と接続し、ジョイスティックとして使用することが可能です。

具体的な設定は下記を参考に行いました。
詳しく書かれているので、ここでの説明は割愛します。
https://www.thegeekpub.com/16265/using-xbox-one-controllers-on-a-raspberry-pi/

注意する点としては、 Xbox One コントローラーを使用する際は ERTM (enhanced retransmission mode) を無効化する必要があります。

$ sudo bash -c echo 1 > /sys/module/bluetooth/parameters/disable_ertm

また、 bluetoothctl で下記でコントローラーを登録することで、コントローラーの電源を再度投入したときに自動的に Raspberry Pi と Bluetooth が再接続されるようになります。

trust XX:XX:XX:XX:XX:XX(コントローラーのHWアドレス)

この再接続はなかなか早く、ほとんどの場合数秒で完了します。

Pygame でコントローラーの入力を読み取る

コントローラーからの複数の入力を同時に処理できるものを探していたところ、 Pygame を見つけました。
下記コードで、右スティックのX軸の入力を読み取り、入力値を表示しています。

joystick.py
import pygame
from pygame.locals import *

AXIS_RIGHT_X = 2

pygame.init()

# ジョイスティックモジュールの初期化
pygame.joystick.init()

# ジョイスティックオブジェクトの生成
joystick = pygame.joystick.Joystick(0)
joystick.init()

# イベントの取得
while True:
    for e in pygame.event.get():
        if e.type == pygame.locals.JOYAXISMOTION: # スティック
            if e.axis == AXIS_RIGHT_X:
                axis_value = joystick.get_axis(AXIS_RIGHT_X)

                if axis_value == 0:
                    print('スティックの方向: 中心,\t値: {0}'.format(axis_value))
                elif axis_value > 0:
                    print('スティックの方向: 左,\t値: {0}'.format(axis_value))
                else:
                    print('スティックの方向: 右,\t値: {0}'.format(axis_value))
スティックの方向: 中心, 値: 0.0
スティックの方向: 左,   値: 0.361175537109375
スティックの方向: 左,   値: 0.437744140625
スティックの方向: 左,   値: 0.784454345703125
スティックの方向: 左,   値: 0.813995361328125
スティックの方向: 左,   値: 0.999969482421875
スティックの方向: 左,   値: 0.83050537109375
スティックの方向: 中心, 値: 0.0
スティックの方向: 右,   値: -0.1097412109375
スティックの方向: 右,   値: -0.46771240234375
スティックの方向: 右,   値: -0.504913330078125
スティックの方向: 右,   値: -0.557586669921875
スティックの方向: 右,   値: -1.0
スティックの方向: 右,   値: -0.08984375
スティックの方向: 左,   値: 0.31512451171875
スティックの方向: 中心, 値: 0.0

pygame.event.get() はリストで返却されるため、複数の入力を同時にした場合は下記のようになります。
これを1つずつループで処理し、行いたい操作を実装していきます。

[
    <Event(7-JoyAxisMotion {'joy': 0, 'axis': 2, 'value': 0.8314767906735435})>,
    <Event(7-JoyAxisMotion {'joy': 0, 'axis': 3, 'value': 0.14395580919827874})>,
    <Event(11-JoyButtonUp {'joy': 0, 'button': 0})>,
    <Event(11-JoyButtonUp {'joy': 0, 'button': 3})>
]

シリアルで Raspberry Pi と Arduino と通信する

Raspberry Pi と Arduino 間は USB で接続し、シリアル通信でコマンドのやり取りを行うようにしました。

コマンド

コマンドは下記のように CSV で定義しました。1列目は回転のコマンド、2列目は速度・ステップ数などの値です。データの区切りは改行コード \n で、その単位で Arduino が受信したコマンドを処理します。

0,45000 # 右に定速回転
1,10000 # 左に定速回転
2,6400 # 現在位置から右に指定したステップ数移動
3,3200 # 現在位置から左に指定したステップ数移動

Raspberry Pi 側

dir , step には別途コントローラーからの入力が入ってきます。それをスレッドで 0.1 秒ごとにシリアルでコマンドを送るようにしています。

serial.py
import serial
import time
import threading

ser = serial.Serial('/dev/ttyACM0', 9600, timeout=0.1)
time.sleep(3)
ser.flush()

# コントローラーからの入力値をここに入れる
dir = 0
step = 0

def serialWrite():
    t = threading.Timer(0.1, serialWrite)
    t.start()

    ser.write("{0},{1}\n".format(dir, step).encode())

t1 = threading.Thread(target = serialWrite)
t1.start()

Arduino 側

Arduino 側は、シリアルのデータを受信すると Serial.available() が 0 より大きくなるので、 Serial.readStringUntil('\n') で改行コードまでの文字列を一度に読み込み、処理するようにしています。

void setup() {
  Serial.begin(9600);
}

void loop() {
  if(Serial.available() > 0){
    String data = Serial.readStringUntil('\n');
    Serial.flush();

    // ここにコマンドのパース処理とモーターの制御処理を書く 
  }
}

シリアル通信のバッファあふれ問題

短時間にコントローラーから複数の入力を行うと、 Arduino がシリアルの入力を受け付けなくなり、モーターが停止してしまったり、誤作動することがありました。

こうなってしまうと、シリアル通信では状態を確認できないため、 Arduino に キャラクタLCD を接続し、シリアル通信のバッファサイズをモニターするようにしました。

下記動画では、少々見にくいですが、連続してコマンドを送った場合にバッファーサイズが 63 になっていることが確認できます。

Arduino のバッファーサイズは 64byte です。バッファーの上限値に達した状態のままコマンドを送り続けると、動画後半のように高速回転し続けるなどの誤作動を起こしています。

バッファがあふれてしまう原因は下記です。

  1. Raspberry Pi 上で、コントローラーの入力に関わらず、スレッドで連続してコマンドを送っている
  2. コマンドを文字列で送っているため、データ量が多い
  3. Arduino 側で loop() 内にシリアル通信の受信処理とモーターの回転処理を書いており、モーターの回転中はシリアル通信のパースが行われないためバッファにたまる

そのため、今後は実装を下記のように改良しようと考えています。

  1. コントローラーの入力をきっかけとして、コマンドを送るようにし、不必要なコマンド生成を抑制する
  2. コマンドを固定長データで送る
  3. Arduino では、 シリアル通信の受信処理を serialEvent() に独立して書き、モーターの回転中などでバッファーに一定以上データが溜まったときは、先に受信したデータを積極的に破棄する

v4l2-ctl で UVC Webカメラの設定を変える

日中などにカメラを窓が映り込む方向に向けていると、自動露出によって室内の人物が暗くなってしまう場合があります。

リモートワークカメラで使用している Logicool c920s などの UVC Webカメラは、露出、ホワイトバランス、オートフォーカスの有無など、様々なコントロールパラメーターを設定することができます。

コントロールパラメータの設定を行うには、 v4l2-ctl コマンドを使用します。今回はこれを使用して露出の設定をしてみます。

まず、下記で v4l-utils をインストールします。

$ sudo apt-get install v4l-utils

まず、接続されているデバイスを確認します。 今回は、 /dev/video0 が c920s のデバイス名ですね。

$ v4l2-ctl --list-devices
bcm2835-codec-decode (platform:bcm2835-codec):
    /dev/video10
    /dev/video11
    /dev/video12

bcm2835-isp (platform:bcm2835-isp):
    /dev/video13
    /dev/video14
    /dev/video15
    /dev/video16

HD Pro Webcam C920 (usb-0000:01:00.0-1.3.1):
    /dev/video0
    /dev/video1

-d で先程確認した /dev/video0 を指定し、 -l で現在のコントロールパラメータを確認することができます。

$ v4l2-ctl -d /dev/video0 -l
                     brightness 0x00980900 (int)    : min=0 max=255 step=1 default=128 value=128
                       contrast 0x00980901 (int)    : min=0 max=255 step=1 default=128 value=128
                     saturation 0x00980902 (int)    : min=0 max=255 step=1 default=128 value=128
 white_balance_temperature_auto 0x0098090c (bool)   : default=1 value=1
                           gain 0x00980913 (int)    : min=0 max=255 step=1 default=0 value=0
           power_line_frequency 0x00980918 (menu)   : min=0 max=2 default=2 value=1
      white_balance_temperature 0x0098091a (int)    : min=2000 max=6500 step=1 default=4000 value=4000 flags=inactive
                      sharpness 0x0098091b (int)    : min=0 max=255 step=1 default=128 value=128
         backlight_compensation 0x0098091c (int)    : min=0 max=1 step=1 default=0 value=0
                  exposure_auto 0x009a0901 (menu)   : min=0 max=3 default=3 value=3
              exposure_absolute 0x009a0902 (int)    : min=3 max=2047 step=1 default=250 value=250 flags=inactive
         exposure_auto_priority 0x009a0903 (bool)   : default=0 value=1
                   pan_absolute 0x009a0908 (int)    : min=-36000 max=36000 step=3600 default=0 value=0
                  tilt_absolute 0x009a0909 (int)    : min=-36000 max=36000 step=3600 default=0 value=0
                 focus_absolute 0x009a090a (int)    : min=0 max=250 step=5 default=0 value=0 flags=inactive
                     focus_auto 0x009a090c (bool)   : default=1 value=1
                  zoom_absolute 0x009a090d (int)    : min=100 max=500 step=1 default=100 value=100
                      led1_mode 0x0a046d05 (menu)   : min=0 max=3 default=0 value=3
                 led1_frequency 0x0a046d06 (int)    : min=0 max=255 step=1 default=0 value=0

今回設定を行いたい、露出に着目をすると、下記の2つのパラメータを設定する必要があります。

                  exposure_auto 0x009a0901 (menu)   : min=0 max=3 default=3 value=3
              exposure_absolute 0x009a0902 (int)    : min=3 max=2047 step=1 default=250 value=250 flags=inactive

exposure_auto は、露出をオートにするかマニュアルにするかを設定します。 c920s の場合 value=1 がマニュアル、 value=3 がオートです。
exposure_absolute は、露出の値を設定します。 exposure_auto がマニュアルの場合のみ有効です。 c920s の場合、 3 〜 2047 の範囲で 1 ステップずつ指定します。暗↔明です。

コントロールパラメータは下記のようにして設定します。

$ v4l2-ctl -d /dev/video0 -c exposure_auto=1
$ v4l2-ctl -d /dev/video0 -c exposure_absolute=250

今回製作したリモートワークカメラでは、 Xbox One コントローラーの X ボタンに自動露出の有効/無効を、 十字ボタンに露出設定の機能を割り当てました。
Pygame の for e in pygame.event.get(): に対応するボタンの分岐を定義し、押されたときに Python から v4l2-ctl のシェルコマンドを実行するようにします。

下記は、実際にコントローラーで露出調整している動画です。

最後に

※この記事は WESEEK Tips wiki に 2020/12/17 に投稿された記事の転載です。
Tips wiki では、IT企業の技術的な情報やプロジェクトの情報を公開可能な範囲で公開してます。