2020年04月30日

デプスカメラを「雄弁なスチルカメラ」として利用する

はてなブックマークに登録


デプスカメラ Intel RealSense D415 を使ってあれこれを試しているうちにふとこんなことを考えました。

  • 一般に、デプスカメラの使途はその出力から連続的に得られるフレームデータを時間軸にそって「動画」として処理することにある
  • 一方で、これが所定のシーンについて異なる切り口の複数の情報を画像として同時に取得可能なデバイスであることはスチルカメラとしても有用ではないか?

RealSense D400 シリーズの出力からは、(1)一般的な RGB カラー画像 (2)自然光照度の影響を受けにくい赤外線画像、そして、(3)被写体の位置関係が色彩的に表現された深度情報画像を得ることができます。この三種類の情報を照合するとスコープ内の一瞬の状況を多角的にとらえることが可能です。例を示します。

このユニークな特性を以下の想定のもとで「ゆるい見守り」に活かせるのではないかと考えました。

  • D400 シリーズを遠隔の高齢者や病人・怪我人の寝所を対象とする見守りに利用する
  • 常時監視の重い印象のあるビデオカメラではなく必要時にのみ静止画を撮影するスチルカメラとして扱うことにより拘束感を抑えられる
  • 赤外線カメラベースでの動体検知を取り入れることで不安要素の多い深夜にも有効な安否確認を実現できる

前に人感センサを利用した安否確認のしくみを手がけたことがあります。これは現在もプライベートで利用を続けているのですが、そこにこれらの画像情報が加われば安心感がずっと大きくなりそうです。

Raspberry Pi 3 B+ のセットアップ

上のような形で一式を設置・運用するとすれば圧迫感の小さい小振りなホスト機と組み合わせることが好ましいでしょう。幸い RealSense D400 シリーズは Raspberry PI 3 にも対応しています。

さっそく手持ちの Raspberry Pi 3 B+ の環境設定に着手しました。完了までに当初の想定よりも手がかかった事情もありメモを残しておきます。

「realsense raspberry pi 3」のキーワードで情報を検索すると、上の記事と同様にラズパイで RealSense D400 シリーズを利用するための OS 環境として Ubuntu Mate 16.x を使う記事が多くヒットする。まずはこれらの記事を参考に準備を進めたが、結論として Raspberry Pi 3 B+ で Ubuntu Mate 16.x を起動することはできなかった。不審に思いながら情報を探して目にした「まろにー」様による次の記事の内容は正確だった。
さらに情報をさぐり以下の記事に行き着いた。Raspbian の使用を前提とする内容で、「These steps are for D435 with Raspberry Pi3 and 3+」とある。
この記事の内容は最終的には正確だった。ただしこうした話題にありがちな落とし穴もいくつかあった。備忘をかねその内容を以下に控える。
  • 現在 www.raspberrypi.org で配布されている「Raspbian Buster with desktop」には適用できない。記事中の「4.14.34-v7+」に近い旧バージョンを探し「2018-11-13-raspbian-stretch-full (4.14.79-v7+) 」を試したところ成功した
    • 一連の手順でのビルドには時間がかかることに注意。手元では試行錯誤分のロスを含めトータルで 20時間程度を要した

  • 記事末尾の指示どおりに raspi-config コマンドで OpenGL Driver を有効にすると OpenCV を利用したプログラムでの表示に不具合が生じる。@PINTO 様による以下の記事にこれに関連する記述がみられる
    • デプスカメラRealSenseD435で・・ (記事名を一部省略させて頂きました) - qiita.com/PINTO
      【注意点】 OpenGL Driver を有効にすると、OpenCVの 「imshow」 メソッドが正常に動作しなくなる点を受け入れる必要がある。 こちらの記事の実装 のように、OpenCVの 「imshow」 メソッドに頼らないでOpenGLの描画系メソッドだけで描画処理を実装しなくてはならない点に注意。
    手元では OpenGL よりも OpenCV に馴染んでいるため逆に OpenGL ドライバを無効化 ([raspi-config] - [7.Advanced Options] - [A7 GL Driver] - [G3 Legacy])し、プログラミングには OpenCV を利用している。一方でこの設定では公式の RealSense Viewer 等が激重になるため適宜使い分けが必要。

こういった過程を経て手元の Raspberry Pi 3 B+ で D415 を利用できる状態になりました。手頃なサイズでいい感じです。

  • 余談ながら RealSense D400 シリーズ製品に付属のミニ三脚(下のふたつめの写真)は少々華奢なため手元ではケンコーコム社製の三脚を使用しています

下の動画にはこの環境で公式の Intel RealSense Viewer を実行した様子を収めています。なお、ここでは前掲の事情によりラズパイ側で OpenGL Driver を有効にしています。

D400 からの赤外線投光と深度情報精度との関係

RealSense D400 シリーズは深度計測にふたつの赤外線センサ(カメラ)を用いたステレオアクティブ方式を採用しており、計測精度向上の補助を目的とするドットパターンの赤外線投光を行うことが可能となっています。この投光はデフォルトで有効です。

前述の想定のように今回は RGB, 赤外線, 深度のみっつの画像情報をあわせて捕捉することがポイントですが、この赤外線投光機能は赤外線カメラ経由で得られる画像と深度情報の両方に影響を及ぼします。手元での観察結果からこのことを整理してみます。

まず、赤外線投光を有効化 / 無効化した状態で D415 から採取した各フレームの静止画像例を以下に示します。赤外線画像はふたつの赤外線カメラの左側のものを使用。なお、この製品の赤外線カメラはあくまでも深度計測を目的とするものであり生のままの画像は視覚的に暗いため、OpenCV で明度とコントラストを補正した版を並べて掲げています。(画像はいずれもクリックで大きく表示)

  • 赤外線投光あり
    RGB
    深度 赤外線 L 赤外線 L(補正ずみ)
  • 赤外線投光なし
    RGB
    深度 赤外線 L 赤外線 L (補正ずみ)

    上のサンプルから次のように判断しました。

    • D415 を赤外線カメラの用途のみで扱うとすればドットパターンの映り込みを避けるために赤外線投光を無効化するほうが好ましい様子
    • ただし、赤外線投光を無効化すると上の結果のように深度情報の質が際立って劣化する
    • この深度情報画像はスコープ内の被写体の位置関係を把握できる重要な情報であるためクリアな内容であることが望まれる
    • 一方、赤外線画像はドットパターンの投光ありでも補正をかければ例のように相応の品質の確保が可能
    • 以上の事情を考えあわせると赤外線投光を有効とするほうが総合的に有利と考えられる

    ちなみに、赤外線投光の有効・無効をストリーミングの途中で切り替えることはできません。サンプルを採取するために用意した Python コードを以下に引用します。

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    #
    # D415_IRemitterTest.py
    #
    # for Intel RealSense D415
    #
    # 2020-03
    #
    
    import pyrealsense2 as rs
    import numpy as np
    import cv2
    
    # 赤外線投光の有無
    USE_IR_EMITTER = True
    #USE_IR_EMITTER = False
    
    WIDTH = 640
    HEIGHT = 480
    FPS = 15
    
    # イメージの明度・コントラストを補正
    # https://www.pynote.info/entry/opencv-change-contrast-and-brightness
    def adjust(img, alpha=1.0, beta=0.0):
      dst = alpha * img + beta
      return np.clip(dst, 0, 255).astype(np.uint8)
    
    def main():
      # RealSense ストリーミング開始
      pipeline = rs.pipeline()
      config = rs.config()
      config.enable_stream(rs.stream.depth, WIDTH, HEIGHT, rs.format.z16, FPS)
      config.enable_stream(rs.stream.color, WIDTH, HEIGHT, rs.format.bgr8, FPS)
      config.enable_stream(rs.stream.infrared, 1, WIDTH, HEIGHT, rs.format.y8, FPS)
      profile = pipeline.start(config)
    
      device = profile.get_device()
      depth_sensor = device.first_depth_sensor()
      if USE_IR_EMITTER:
        # IR 投光を最大に
        depth_sensor.set_option(rs.option.emitter_enabled, 1)
        laser_range = depth_sensor.get_option_range(rs.option.laser_power)
        depth_sensor.set_option(rs.option.laser_power,  laser_range.max)
      else:
        # IR 投光を無効に
        depth_sensor.set_option(rs.option.emitter_enabled, 0)
    
      try:
        while True:
          # RealSense フレームデータ取得
          frames = pipeline.wait_for_frames()
          depth_frame = frames.get_depth_frame()
          color_frame = frames.get_color_frame()
          ir_frame = frames.get_infrared_frame()
    
          depth_img_raw = np.asanyarray(depth_frame.get_data())
          color_img = np.asanyarray(color_frame.get_data())
          ir_img_raw = np.asanyarray(ir_frame.get_data())
          depth_img = cv2.applyColorMap(cv2.convertScaleAbs(depth_img_raw, alpha=0.08), cv2.COLORMAP_JET)
          # IR フレームイメージの明度を調整
          ir_img = adjust(ir_img_raw, alpha=2.0, beta=40.0)
    
          # 各フレームイメージを表示
          cv2.imshow('RGB', color_img)
          cv2.imshow('IRRaw', ir_img_raw)
          cv2.imshow('IR', ir_img)
          cv2.imshow('Depth', depth_img)
    
          # キー入力判定
          key = cv2.waitKey(1)
          if key & 0xFF == ord('q') or key == 27: # ESC
            cv2.destroyAllWindows()
            break
          elif key & 0xFF == ord('s'):
            # フレームイメージを画像出力
            cv2.imwrite('ImgRGB.jpg', color_img)
            cv2.imwrite('ImgIRRaw.jpg', ir_img_raw)
            cv2.imwrite('ImgIR.jpg', ir_img)
            cv2.imwrite('ImgDepth.jpg', depth_img)
    
      finally:
        pipeline.stop()
    
    if __name__ == '__main__':
      main()
    
    

    動体検知について

    見守りに際しもっともシリアスな状況と考えられるのは「対象者がまったく動かない状態」でしょう。そのため、室内の明るさにかかわらずスコープ内の動きの有無を適切に把握できるようにしておきたいと考えました。その手段として赤外線カメラからの出力をもとに動体検知を行うことを思い立ちました。
    こういった処理を手元で扱った経験はありませんでしたが、ネット上の情報を参考に考え方を整理してまず以下の内容での簡易的な実装を試みました。あくまでもフレーム間の情報の変化量を根拠とする内容で人体検出等の要素は含みません。

    1. フレームデータ取得用のループに若干のディレイ(パラメータ A)を設けておく
    2. ループ内で赤外線フレーム画像情報を取得時にそのコピーを保持しておく
    3. 次回取得した赤外線フレーム画像情報と上の前回分との差分を得る
    4. 上記差分に含まれる各ピクセルの値について所定の閾値(パラメータ B)を基準に 0, 255 の二値にふるい分ける
    5. 上の二値化の結果、255 の数が所定の件数(パラメータ C)以上であればこの間のスコープ内に何らかの変化があったものと判定する
      ※各パラメータは実地で適切な値を割り出す

    上の要領を画像と動画の例で示してみます。

    1. 前回分のフレーム画像
    2. 今回分の画像 (体と手を少し動かしてみた)
    3. 上記 1, 2 画像の差分
    4. ピクセル値を所定の閾値で 0 と 255 に分ける

     

    試作

    内容

    ここまでの話題を元に以下の想定に基づく内容のアプリケーションを試作しました。MQTT ブローカーには定番の Beebotte を利用します。JavaScript 用の API セットも提供されているため Web ブラウザ上のコンテンツでメッセージを取り回すことが可能です。

    • 見守り対象とする場所にラズパイ + D415 を設置し所定のプログラム A を稼働
    • 手元では Web ブラウザ上の HTML リソース B で状況をモニタする
    • A は D415 からのフレームデータで常時動体検知を実施
      • 変化があれば RGB, 赤外線, 深度, 二値化の各画像データを Google Drive へアップロード
      • アップロード完了時に MQTT メッセージを送出 〜 B はこのメッセージを受信するとページをリロード
      • 見守り側は HTML リソース B 上のフォームのボタン押下により任意のタイミングで A へのメッセージングで RGB, 赤外線, 深度 画像の撮影と Google Drive へアップロードを指示可能
        • A は上記メッセージを受信すると RGB, 赤外線, 深度の各画像データを Google Drive へアップロード
          • このとき一世代前の画像セットも保持
        • A アップロード完了時に MQTT メッセージを送出 〜 B はこのメッセージを受信するとページをリロード

    メモ

    関連する話題を控えます。

    • 撮影画像の保存先に Google Drive を選んだのは以下の理由による
    • 今回は RGB, 深度フレームの情報を串刺しにしてデータ処理を行う必要がないためフレーム間のアライメントは行なわず各フレームの情報を欠損なく参照することを優先する
    • 赤外線エミッタの出力は明示的に MAX に設定
    • 赤外線フレームの画像は目視するためには暗く見づらいため OpenCV による画像処理で明度とコントラストを補正

    ソースコード

    以上を踏まえざっくり用意したプログラムです。

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    #
    # AnpiRealSense.py
    #
    # for Intel RealSense D415
    #
    # 2020-03
    #
    import threading
    import datetime
    import time
    import os
    import requests
    
    import pyrealsense2 as rs
    import numpy as np
    import cv2
    
    import paho.mqtt.client as mqtt
    import json
    
    from pydrive.auth import GoogleAuth
    from pydrive.drive import GoogleDrive
    
    # for RealSense
    WIDTH = 640
    HEIGHT = 480
    FPS = 15
    
    # for MQTT
    MQTT_TOKEN = 'token_w5xxxxxxxxxx'
    MQTT_HOSTNAME = 'mqtt.beebotte.com'
    MQTT_PORT = 8883
    MQTT_TOPIC = 'Anpi/q01'
    MQTT_CACERT = 'mqtt.beebotte.com.pem'
    MQTT_URL_PUB = 'https://script.google.com/xxxx/exec'
    
    # for Google Drive
    ID_RGB = '1YTEcpNyVv5axxxxxxxxxx'
    ID_IR = '1qt0tzv5-DfJxxxxxxxxxx'
    ID_DEPTH = '17A8Xybfz6qoxxxxxxxxxx'
    
    ID_RGB_PREV = '1sxb67Zspim2xxxxxxxxxx'
    ID_IR_PREV = '1A9C-9vgo97Kxxxxxxxxxx'
    ID_DEPTH_PREV = '1oJSOeebzL0exxxxxxxxxx'
    
    ID_RGB_MOVE = '1cMLgJB3_zcDxxxxxxxxxx'
    ID_IR_MOVE = '1rbaVHzpD4m-xxxxxxxxxx'
    ID_DEPTH_MOVE = '1dSdfAOy4oHqxxxxxxxxxx'
    ID_IR_DIFF = '1JuZLW1g8eaOxxxxxxxxxx'
    
    FN_RGB   = 'RGB'
    FN_IR    = 'IR'
    FN_DEPTH = 'Depth'
    
    FN_SUFFIX      = '.jpg'
    FN_SUFFIX_PREV = '_prev.jpg'
    FN_SUFFIX_MOVE = '_move.jpg'
    FN_SUFFIX_DIFF = '_diff.jpg'
    
    QUEUE = 0
    Q_UPDATE = 100
    Q_DETECT = 200
    
    # MQTT コネクション確立時コールバック
    def on_mqtt_connect(client, userdata, flags, respons_code):
      client.subscribe(MQTT_TOPIC)
      print('mqtt on_connect')
    
    # MQTT メッセージ受信時コールバック
    def on_mqtt_message(client, userdata, msg):
      global QUEUE
      QUEUE = Q_UPDATE
      print('mqtt on_message')
      print(msg.topic + ' ' + str(msg.payload))
    
    # イメージに日付時分表示を挿入
    def putDateTime(img):
      dt_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
      cv2.putText(img, dt_str, (20, 36), 
        cv2.FONT_HERSHEY_DUPLEX, 1, (0, 0, 0), 2, cv2.LINE_AA )
      cv2.putText(img, dt_str, (18, 34), 
        cv2.FONT_HERSHEY_DUPLEX, 1, (255, 255, 255), 2, cv2.LINE_AA )
    
    # jpg ファイル群の世代を更新
    def rotateFile(fname, img):
      fn = fname + FN_SUFFIX
      fn_prev = fname + FN_SUFFIX_PREV
      if os.path.exists(fn):
        if os.path.exists(fn_prev):
          os.remove(fn_prev)
        os.rename(fn, fn_prev)
      cv2.imwrite(fn, img)
      if os.path.exists(fn_prev):
        return True
      return False
    
    # Google Drive へ各フレームの jpg データをアップロード
    def upload_data(id, fname, gdrive):
      f = gdrive.CreateFile({'id': id})
      f.SetContentFile(fname)
      f.Upload()
      print('upload_data [' + fname +']')
    
    def do_upload(mode, rgb_img, ir_img, depth_img, ir_img_diff, gdrive):
      print('do_upload start')
      putDateTime(rgb_img)
      putDateTime(ir_img)
      putDateTime(depth_img)
      if mode == Q_UPDATE: # 任意の時点でのスナップショット
        if rotateFile(FN_RGB, rgb_img):
          upload_data(ID_RGB_PREV, FN_RGB + FN_SUFFIX_PREV, gdrive)
        upload_data(ID_RGB, FN_RGB + FN_SUFFIX, gdrive)
        if rotateFile(FN_DEPTH, depth_img):
          upload_data(ID_DEPTH_PREV, FN_DEPTH + FN_SUFFIX_PREV, gdrive)
        upload_data(ID_DEPTH, FN_DEPTH + FN_SUFFIX, gdrive)
        if rotateFile(FN_IR, ir_img):
          upload_data(ID_IR_PREV, FN_IR + FN_SUFFIX_PREV, gdrive)
        upload_data(ID_IR, FN_IR + FN_SUFFIX, gdrive)
      elif mode == Q_DETECT: # 動体検知時
        cv2.imwrite(FN_RGB + FN_SUFFIX_MOVE, rgb_img)
        cv2.imwrite(FN_DEPTH + FN_SUFFIX_MOVE, depth_img)
        cv2.imwrite(FN_IR + FN_SUFFIX_MOVE, ir_img)
        cv2.imwrite(FN_IR + FN_SUFFIX_DIFF, ir_img_diff)
        upload_data(ID_RGB_MOVE, FN_RGB + FN_SUFFIX_MOVE, gdrive)
        upload_data(ID_DEPTH_MOVE, FN_DEPTH + FN_SUFFIX_MOVE, gdrive)
        upload_data(ID_IR_MOVE, FN_IR + FN_SUFFIX_MOVE, gdrive)
        upload_data(ID_IR_DIFF, FN_IR + FN_SUFFIX_DIFF, gdrive)
    
      requests.post(MQTT_URL_PUB, data={'hoge':'1'})
      print('do_upload done')
    
    # イメージの明度・コントラストを補正
    # https://www.pynote.info/entry/opencv-change-contrast-and-brightness
    def adjust(img, alpha=1.0, beta=0.0):
      dst = alpha * img + beta
      return np.clip(dst, 0, 255).astype(np.uint8)
    
    def main():
      global QUEUE
    
      # init MQTT
      print('init mqtt start')
      mqtt_client = mqtt.Client()
      mqtt_client.username_pw_set('token:%s'%MQTT_TOKEN)
      mqtt_client.on_connect = on_mqtt_connect
      mqtt_client.on_message = on_mqtt_message
      mqtt_client.tls_set(MQTT_CACERT)
      mqtt_client.connect(MQTT_HOSTNAME, port=MQTT_PORT, keepalive=60)
      print('init mqtt done')
    
      # init Google Drive 
      print('init gdrive start')
      gauth = GoogleAuth()
      gauth.CommandLineAuth()
      gdrive = GoogleDrive(gauth)
      print('init gdrive done')
    
      # RealSense ストリーミング開始
      print('rs streaming starting')
      pipeline = rs.pipeline()
      config = rs.config()
      config.enable_stream(rs.stream.depth, WIDTH, HEIGHT, rs.format.z16, FPS)
      config.enable_stream(rs.stream.color, WIDTH, HEIGHT, rs.format.bgr8, FPS)
      config.enable_stream(rs.stream.infrared, 1, WIDTH, HEIGHT, rs.format.y8, FPS)
      profile = pipeline.start(config)
      print('rs streaming started')
    
      # IR 投光を最大に
      device = profile.get_device()
      depth_sensor = device.first_depth_sensor()
      depth_sensor.set_option(rs.option.emitter_enabled, 1)
      laser_range = depth_sensor.get_option_range(rs.option.laser_power)
      depth_sensor.set_option(rs.option.laser_power,  laser_range.max)
    
      ir_img_prev = np.empty(0)
      ir_img_diff = np.empty(0)
    
      time_start = time.time()
      time_deetected = 0
    
      try:
        while True:
          mqtt_client.loop(0.1)
    
          # RealSense フレームデータ取得
          frames = pipeline.wait_for_frames()
          depth_frame = frames.get_depth_frame()
          color_frame = frames.get_color_frame()
          ir_frame = frames.get_infrared_frame()
    
          if not depth_frame or not color_frame or not ir_frame:
            continue
    
          depth_img_raw = np.asanyarray(depth_frame.get_data())
          color_img = np.asanyarray(color_frame.get_data())
          ir_img_raw = np.asanyarray(ir_frame.get_data())
          depth_img = cv2.applyColorMap(cv2.convertScaleAbs(depth_img_raw, alpha=0.08), cv2.COLORMAP_JET)
          # IR フレームイメージの明度を調整
          ir_img = adjust(ir_img_raw, alpha=2.0, beta=40.0)
    
          # 動体検知
          # ノイズよけにストリーミング開始から 10秒程度は看過
          if ir_img_prev.size != 0 and time.time() - time_start > 10:
            # 直近の IR フレームイメージとの差分イメージを取得
            ir_diff = cv2.absdiff(ir_img, ir_img_prev)
            # 明度 80 を閾値に 0, 255 に二値化
            ret, ir_img_diff  = cv2.threshold(ir_diff, 80, 255, cv2.THRESH_BINARY)
            cv2.imshow('diff', ir_img_diff)
            # 255 値ポイントのみの配列を生成
            ar = ir_img_diff[ir_img_diff == 255]
            # 所定の件数以上なら動きありと判定
            if ar.size > 200:
              #print(ar.size)
              # 前回アップロードから 30分未経過ならスキップ
              if time.time() - time_deetected >= 1800:
                print('motion detected')
                time_deetected = time.time()
                QUEUE = Q_DETECT
    
          # 今回分 IR フレームイメージを次回の比較用にコピー
          ir_img_prev = ir_img.copy()
    
          # 各フレームイメージを表示
          cv2.imshow('RGB', color_img)
          cv2.imshow('IR', ir_img)
          cv2.imshow('Depth', depth_img)
    
          # キー入力判定
          key = cv2.waitKey(1)
          if key & 0xFF == ord('q') or key == 27: # ESC
            cv2.destroyAllWindows()
            break
          elif key & 0xFF == ord('s'):
            QUEUE = Q_UPDATE
    
          # アップロード指示あり
          if QUEUE != 0:
            # Google Drive へ各フレームの jpg データをアップロード
            do_upload(QUEUE, 
              color_img, ir_img, depth_img, ir_img_diff,
              gdrive)
            QUEUE = 0
    
          time.sleep(0.2) # 200ms
    
      finally:
        pipeline.stop()
        print('exit')
    
    if __name__ == '__main__':
      main()
    
    

    HTML リソース(アーカイブ)

    下の動画で使用している HTML ページと実際に撮影した各フレーム画像を静的に再構成したアーカイブを下記へ収めています。

    上のデモページの末尾に今回の試作で使用した実際の HTML 記述をコメントとして引用しています(各種 ID はダミーです)。

    動作の様子


    (tanabe)
           
  • klab_gijutsu2 at 09:30│Comments(0)その他 

    この記事にコメントする

    名前:
    URL:
      情報を記憶: 評価: 顔   
     
     
     
    Blog内検索
    Archives
    このブログについて
    DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
    このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
    最新コメント
    「最新トラックバック」は提供を終了しました。