2022年01月14日

KLabサーバーサイドキャンプを開催しました

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

12/27 から年末年始をはさんだ5日間で、技術系インターン「KLabサーバーサイドキャンプ」を開催しました。 今春3月に第2回も企画しているので、 その宣伝も兼ねて開催報告をします。 (尚、エントリー最終締切日が1/24(月)に迫っているので興味を持って頂けた方はお早めにご応募ください)

キャンプの目的

このキャンプは、主にこれから就職活動を始める学生を対象にサーバーサイド開発を体験してもらい、今後の進路を考える上で参考にしてもらうことを目的としています。 そのため、Pythonでのある程度のプログラミング経験は前提としつつ、SQLやSSHなどを触ったことがない方でも参加できるように講義や課題を準備していました。

キャンプの内容

題材として、実際に遊べるリズムゲーム(音ゲー)を用意しています。

Screenshot_20220114-174050

このゲームにはユーザー登録機能と、複数人で同一曲を同時にプレイする機能があります。この2つの機能のために、参加者にはサーバーサイド・アプリケーションを実装してもらいます。

キャンプで利用する技術スタック

Codespaces

開発環境として GitHub Codespaces を利用しました。 Codespacesのおかげで、参加者の利用するOSやターミナル、SSH等に依存せずに、すぐに開発に取り掛かれる環境を用意できました。 初日はとりあえず環境を動かして軽く触るまでにする予定だったのですが、全くトラブルが起きなかったので2日目の講義の一部を急遽前倒しで実施することになったほどです。

Codespacesは現在のところ個人利用が限定ベータ版ですが、申し込みして有効になれば無料で利用できます。参加者はキャンプ終了後も自分のリポジトリでCodespaceを作り直して開発を続けることができます。

Python & FastAPI

言語はKLabのサーバーサイドでも使われている言語であるPythonを使いました。 一方で、フレームワークはKLabで使っているFlaskではなく、APIサーバーに特化したフレームワークとして FastAPI を選びました。 理由は、人気があり、とっつきやすいことです。特にType hintingを使って定義したデータモデルを使ってシリアライズとデシリアライズを自動でしてくれるので、複雑なことをしないのであればコントローラー部のコードがとても少なくて済みます。

MySQL

キャンプで一番体験してもらいたかった事がSQLを使った開発だったので、KLabが主に利用しているMySQLを使いました。 CREATE TABLE, INSERT, SELECT, UPDATE, DELETE 文などごく基本的なクエリの文法を講義で紹介し、参加者には自力でクエリを書いてもらいました。

また、講義ではインデックスやロックの必要性や適切に扱う難しさを紹介しました。実装が早かった参加者は、実際にそこまで挑戦されていました。サーバーサイド開発者が普段どういった問題を考え解決しているのか、少しでも伝わったら幸いです。

SQLAlchemy Core

SQLを読み書きする体験をしてもらいたかったので、ORMは使いませんでした。 しかしPythonからMySQLへ接続する低レベルのライブラリを直接使うのはさすがに問題があるので、SQL toolkitとして SQLALchemy の Core 部分だけを利用しました。

IPythonとmysql (シェル)

初めての使う技術を学びながら開発するためには、試行とフィードバックのサイクルを高速に回せるインタラクティブシェルが役に立ちます。 SQLを書くために mysql コマンドのシェルを、SQLAlchemyの使い方を調べるために ipython を使ってもらいました。

題材になったゲームについて

このキャンプのアイデアを持ちかけられたとき、「僕だけではショボいものしかできないから、ある程度見栄えのするゲームを作ってほしい」と返事していました。

すると、本当にゲーム開発者がそのために音ゲーを作成してくれました。さらにクリエイティブ部門も巻き込んで社内コンペが行われ、デザインやかわいいキャラクターまで作り込んでもらいました。関わってくださった皆様、本当にありがとうございます。

このゲーム単体で配布しても多くの人に楽しんでもらえそうなクオリティなのですが、残念ながらサーバーサイドとセットにならないと動かすことができません。このゲームは参加者だけの特典になります。

参加者の反響

参加者に書いていただいたBlog記事を紹介します。

また、キャンプ中の様子を #KLabServerSideCamp というハッシュタグをつけてツイートしてくれています。

感想

Codespacesの環境構築はいまいちベストプラクティスがわからずかなり試行錯誤しましたが、参加者が環境構築でつまづくことがなく、キャンプ終了後もCodespacesが利用できれば簡単に同じ環境を使えるので、採用して大正解でした。

音ゲーは本当によくできていて、音ゲーが好きな参加者が実装の間に(実装そっちのけで?)やりこんでくれていました。社内でたくさんの人を巻き込んで作ってもらったので、楽しんでもらえて良かったです。

あと、普段はSound Onlyでしかミーティングしてないので、インターン最終日の懇親会で、ビデオ通話で愛犬を見せびらかせたのが個人的に良かったです。

講義資料に足りない点があったり、APIを叩いて結果をprintするだけのテストコードにリクエストパラメータが1つ足りなかったりして、幾つかつまづきの原因を作ってしまったのが私の反省点です。 年末年始で忙しい中、一緒にサポートしてくれた開発者のメンバーはありがとうございました。

第2回について

冒頭で述べた通り、今春3月中旬ごろに第2回を計画しています。この記事や、参加者のBlog、ツイートを見て興味が湧いた方はぜひご応募ください。(エントリー最終締切日は、1/24(月)23:59までです)

第2回 応募ページ


@methane

songofacandy at 17:51
この記事のURLComments(0)開発 
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
    この記事のURLComments(0)その他 
    2020年04月27日

    オプティカルフローを簡易ジェスチャ認識に利用する

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

    少し前に Intel 製デプスカメラ RealSense D4153DiVi 社Nuitrack SDK の組み合わせでジェスチャ認識を試していました。下の動画には三脚に据えた D415 ごしに所定のジェスチャでスマート照明の操作をテストした様子を収めています。

    このように期待どおりの反応は得られるのですが、次のことが気になっていました。

    • ここでのジェスチャ認識は人体の骨格検出を前提としている
    • そのため立位であれ座位であれおおむね全身をカメラに呈示する必要がある

    このあたりの事情は Kinect も同様のようです。もちろん骨格の検出は腕や手を適切に識別するために必要ですが、一方でもっとラフなものもあればと考えました。日常感覚ではコタツや布団にもぐり込んだまま姿勢を正すことなくスッと指示を出すといったこともできれば便利そうです。そんなわけで試しに簡単なしくみを作ってみることにしました。

    考え方として、スコープ内の被写体に所定の水準以上の移動をざっくり検知した場合にその向きを判定することを想定しました。実現方法を考えながら社内でそういう話をしたところ、「OpenCV でオプティカルフローを利用してはどうか?」というレスポンスがありました。手元ではトラッキング API (リンク: @nonbiri15 様によるドキュメントの和訳 を試していたところでオプティカルフローのことは知らずにおり、@icoxfog417 様の次の記事中の比較がちょうどわかりやすくとても参考になりました。

    • OpenCVでとらえる画像の躍動、Optical Flow - qiita.com/icoxfog417
      画像間の動きの解析については、様々な目的とそれを実現する手法があります。ここでは、まずOptical Flowがその中でどのような位置づけになるのか説明しておきます。
      • トラッキング
        • 目的: 画像の中の特定の物体(人やオブジェクト)を追跡したい
        • 手法: リアルタイムに行うものとそうでないものの、大別して2種類
              :
      • フロー推定
        • 目的: 画像の中で何がどう動いたのかを検知したい(観測対象が決まっているトラッキングとは異なる)
        • 手法: 画像の中の特徴的な点に絞って解析するsparse型と、画素全体の動きを解析するdense型に大別できる
        • -> sparse型: Lucas-Kanade法など
        • -> dense型: Horn-Schunck法、Gunnar Farneback法など

    以下は GitHub 上の OpenCV 公式のサンプルプログラムです。これは Dense(密)型で Gunnar Farneback 法が用いられています。

    一般的なウェブカメラを PC へ接続してこのプログラムを実行した様子を以下の動画に収めています。スコープ内の被写体の動きが格子点の座標群を起点に捕捉されている様子が視覚的に表現されます。

    (ScreenShot)

    このサンプルプログラムに上下左右四方向への移動を大きく判定する処理を加えてみました。次の動画の要領で動作します。

    加筆したプログラムのソースコードです。まだまだ改良の余地はあるものの今回オプティカルフローの利便性に触れたことは大きな収穫でした。いろいろな使い方ができそうです。

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    #
    # 高密度オプティカルフローを利用した簡易ジェスチャ認識の試み
    #
    # Web カメラ視野内の被写体全般について
    # Left, Right, Up, Down 四方向への移動を検知する内容
    #
    # OpenCV 公式の下記サンプルに処理を追加したもの
    # https://github.com/opencv/opencv/blob/master/samples/python/opt_flow.py
    #
    # 2020-04
    #
    
    import sys
    import time
    import numpy as np
    import cv2
    
    # 有効移動量の閾値
    THRESHOLD_DETECT = 300
    
    # 切り捨ての閾値
    THRESHOLD_IGNORE = 2
    
    # 所定アクション区間内の x, y 方向への総移動量
    MOVE = [0, 0]
    
    # 移動発生カウンタ
    COUNT_MOVE = 0
    
    # 直近のジェスチャ検出システム時間 msec
    TIME_DETECT = 0
    
    def millis():
      return int(round(time.time() * 1000))
    
    def put_header(img, str):
      cv2.putText(img, str, (26, 50), cv2.FONT_HERSHEY_SIMPLEX, 2, (255,0,0), 3)
      cv2.putText(img, str, (24, 48), cv2.FONT_HERSHEY_SIMPLEX, 2, (0, 0,255), 3)
    
    # 指定イメージに高密度オプティカルフローのベクトル情報を重ねて線描(原作)
    # あわせてスコープ内の左右上下 四方向への移動状況を判定
    def draw_flow(img, flow, step=16):
      global MOVE, COUNT_MOVE, TIME_DETECT
      # 当該イメージの Hight, Width
      h, w = img.shape[:2]
      # イメージの縦横サイズと step 間隔指定に基づき縦横格子点座標の配列を生成
      y, x = np.mgrid[step/2:h:step, step/2:w:step].reshape(2,-1).astype(int)
      # 格子点座標配列要素群に対応する移動量配列を得る
      fx, fy = flow[y,x].T
    
      # 各格子点を始点として描画する線分情報(ベクトル)の配列を生成
      lines = np.vstack([x, y, x+fx, y+fy]).T.reshape(-1, 2, 2)
      lines = np.int32(lines + 0.5)
      vis = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    
      # 移動ベクトル線を描画
      cv2.polylines(vis, lines, 0, (0, 255, 0))
      #for (x1, y1), (_x2, _y2) in lines:
      #  cv2.circle(vis, (x1, y1), 2, (0, 0, 255), -1)
    
      # 線分情報配列の全要素について
      # 始点から終点までの x, y 各方向への移動量の累計を得る
      vx = vy = 0
      for i in range(len(lines)):
        val = lines[i][1][0]-lines[i][0][0]
        if abs(val) >= THRESHOLD_IGNORE:
          vx += val
        val = lines[i][1][1]-lines[i][0][1]
        if abs(val) >= THRESHOLD_IGNORE:
          vy += val
    
      # 総移動量に加算
      MOVE[0] += vx
      MOVE[1] += vy
    
      # 移動量の累計が所定の閾値以上なら移動中と判断
      if abs(vx) >= THRESHOLD_DETECT or abs(vy) >= THRESHOLD_DETECT:
        # 移動発生カウンタを加算
        COUNT_MOVE += 1
      # 所定の閾値未満なら移動終了状態と仮定
      else:
        mx = my = 0
        if COUNT_MOVE > 0 and \
        millis() - TIME_DETECT > 1000: # ノイズ除け
          # x, y 各方向への移動量の平均を求める
          mx = int(MOVE[0]/COUNT_MOVE)
          my = int(MOVE[1]/COUNT_MOVE)
          # x, y 方向いずれかの移動量平均が所定の閾値以上なら
          # 左右上下のどの方向への移動かを判定して表示
          if abs(mx) >= THRESHOLD_DETECT or abs(my) >= THRESHOLD_DETECT:
            TIME_DETECT = millis()
            if abs(mx) >= abs(my):
              if mx >= 0:
                put_header(vis, 'LEFT')
              else:
                put_header(vis, 'RIGHT')
            else:
              if my >= 0:
                put_header(vis, 'DOWN')
              else:
                put_header(vis, 'UP')
        # 総移動量と移動発生カウンタをクリア
        MOVE = [0, 0]
        COUNT_MOVE = 0
    
      return vis
    
    def main():
      cam = cv2.VideoCapture(0)
      # 最初のフレームを読み込む
      _ret, prev = cam.read()
      # グレイスケール化して保持
      prevgray = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY)
    
      while True:
        # フレーム読み込み
        _ret, img = cam.read()
        # グレイスケール化
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        # 前回分と今回分のイメージから高密度オプティカルフローを求める
        flow = cv2.calcOpticalFlowFarneback(prevgray, gray, None, 0.5, 3, 15, 3, 5, 1.2, 0)
    
        # 今回分を保持しておく
        prevgray = gray
    
        # flow を可視化
        # https://docs.opencv.org/3.1.0/d7/d8b/tutorial_py_lucas_kanade.html
        """
        hsv = np.zeros_like(img)
        hsv[...,1] = 255
        mag, ang = cv2.cartToPolar(flow[...,0], flow[...,1])
        hsv[...,0] = ang*180/np.pi/2
        hsv[...,2] = cv2.normalize(mag,None,0,255,cv2.NORM_MINMAX)
        rgb = cv2.cvtColor(hsv,cv2.COLOR_HSV2BGR)
        cv2.imshow('hsv', rgb)
        """
    
        # 画像にフローのベクトル情報を重ねて表示
        cv2.imshow('flow', draw_flow(gray, flow))
    
        ch = cv2.waitKey(1)
        if ch & 0xFF == ord('q') or ch == 27: # ESC
          break
    
    if __name__ == '__main__':
      main()
      cv2.destroyAllWindows()
    
    

    (tanabe)
    klab_gijutsu2 at 14:56
    この記事のURLComments(0)その他 
    2020年04月03日

    xv6にネットワーク機能を実装した

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

    在宅勤務に移行してから1ヶ月半ほど経過しました。通勤という概念が消滅したおかげで午前中から活動できるようになった @pandax381 です。

    要約

    フルスクラッチで自作した TCP/IP プロトコルスタックを xv6 に組み込み、一通りの機能が動作するようになりました。

    経緯

    かれこれ7〜8年くらい経つんですけど、ライフワークとしてお勉強用のTCP/IPプロトコルスタックの開発をほそぼそと続けています。

    一般的に、プロトコルスタックはOSの機能の一部としてカーネル内部に存在しているのですが、この自作プロトコルスタックは、作りやすさを重視した都合でユーザ空間のアプリケーション用のライブラリとして実装しています。

    microps-01

    最下層に位置するリンクレベルの入出力は PF_PACKET / BPF / TAP を利用するお手軽仕様で、純粋にパケット処理に専念できるようになっています。

    microps-02

    やや作りが粗い部分もありますが、Ethernetフレームの送受信からTCPセグメントのやり取りまで全てを自前で処理しています。「プロトコルスタックってどんな作りになっているんだろう」と興味を持った人が、手を出して雰囲気を掴むのに丁度いいくらいのボリュームになっているんじゃないかと思います。

    (物理デバイスや論理インタフェースの抽象化とかそれなりに作り込んであるので、興味があったらコード見てください)

    毎年、インターンシップを希望する学生を受け入れていて、彼らの頑張りによって DHCPクライアント機能やパケット転送機能、更にはDPDKサポートといった改良が加えられいますが、いずれも1〜2週間という短い期間で成果を出してくれました。

    その流れで、昨夏には KLab Expert Camp という合宿イベントを開催しました。

    4日間、ひたすらプロトコルスタックを開発するというマニアックな合宿イベントにもかかわらず、全国からたくさんの学生が参加してくれ、実装解説を通じて基本的な仕組みを学んだり、別言語への移植や機能追加に取り組んでくれました。

    参加者からのフィードバックがすごく良くて今後も定期的に開催したいなと思う中で、最近の動向を見ていると「自作CPUで動かしている自作コンパイラでビルドした自作OSに自作プロトコルスタックを組み込みたい」というヤバイ人が出現する可能性も十分ありそうな気がしてきます。

    少なくとも、ユーザ空間での動作を前提としてたライブラリの状態からOSのカーネルに組み込もうとした際にどんな罠が潜んでいるかを把握しておかないことには、十分にサポートしてあげられないことは目に見えています。そんなわけで「まずは自分でやってみるか」というのが、この取り組みのそもそもの動機です。

    手頃なOSを選定

    恥ずかしながら、僕はOS自作を嗜んでおらず「自分のOS」を所有していません。30日OS自作本も「いつか読もう」と積んである始末なので、OS自作からはじめていると時間が掛かりすぎてしまします。だからと言って Linux や BSD だと、ベースにするには規模が大きすぎます。

    こういった用途には、コンパクトな xv6 が向いていそうな気がします。

    CPU自作関連の話題でも頻繁に登場していますし、いまさら説明するまでもないと思いますが、xv6 は Version 6 UNIX (V6) を x86(マルチプロセッサ環境)向けに再実装したものです。MITがオペレーティングシステムの講義用に開発し、MIT以外にも数多くの教育機関が教材として採用しています。

    コード量は10,000行程度で、OSの実装にさほど詳しくなくても全体を見渡すことができるボリュームです。充実したドキュメントと先人たちが公開してくれている情報が豊富にあるのも心強いです。

    最近は RISC-V 向けの実装に移行したようで、MITの講義資料も最新のものは RISC-V 版がベースになっています。x86 版は 2018 年度以前のアーカイブを参照しましょう。

    どこからはじめるか

    xv6 にはネットワーク機能が一切含まれていないため、NICのドライバから書く必要があります。自作プロトコルスタックではリンクアクセスはOSの機能(PF_PACKET や BPF)を利用していてドライバにはノータッチだったので、もう一段下の世界へ降りることになります。

    xv6 は QEMU 上で動作させながら開発を進めることになるので、NIC は QEMU で使えて情報も豊富そうな e1000 (Intel 8254x) をターゲットにするのが良さそうです。

    まずは、ドライバのエントリポイントのダミーを用意して Hello, World! でも出力するようなコードを書き、PCI デバイスのドライバのリストに追加してあげるところからはじめたら良さそう ...と思ったのですが、 探してもそれっぽいコードが全く見当たりません。

    はい、xv6 は PCI をサポートしていないのでした。

    したがって xv6 で NIC を扱うためには

    • PCIバスをスキャン
    • 接続されているデバイスを検出
    • 対応するドライバを呼び出す

    という一連の処理から作り込む必要があり、更にもう一段下の世界へ降りることになるわけです。だんだんと上昇負荷が厳しくなりそうですね。

    PCI周りの処理

    PCIバスのスキャン とか言うとなんだかすごく難しそうに聞こえますが、I/Oポートを叩いて情報を読む、という処理の繰り返しです。そして、この辺はOS自作界隈のみなさんが素晴らしい情報を公開してくれています。

    コンフィグレーション領域ってなんぞ?という状態でしたが uchanさん の資料を読んで PCI を完全に理解しました(してません)。

    以下の Linux Kernel を解説したドキュメントも参考になりました。

    これらの解説を読みながら少しづつ進めていけば PCI バスをスキャンしてデバイスを検出するコードが書くのはさほど難しくないと思います。なお、xv6-net では PCI の最終的なコードは JOS という別の OS から拝借することにしました。

    • https://pdos.csail.mit.edu/6.828/2018/jos.git

    JOS は、xv6 と同様に MIT がオペレーティングシステムの講義で使うために開発している OS です。JOS は一部が意図的に削られている未完成のOSであり、xv6 の解説を聞いたあとに、JOS の実装を進めて完成させるという構成のようです。また、xv6 がモノリシックなカーネルであるのに対して JOS はマイクロカーネルという違いもあるようです。

    読み比べてみると JOS の方が諸々整理されていてキレイに書かれている気がするものの、全体の雰囲気はかなり似ています。そして、JOS には PCI 周りのコードと e1000 ドライバのスケルトン(ほぼなにも書かれていない空っぽのファイル)が用意されているため、これを使わせてもらうことにしました。

    上記の pci.c は JOS のコードそのものです。xv6 に手を出す人は高確率で JOS のコードも読んでいるはずで、それであれば僕が書き散らかしたコードよりも JOS のコードを使わせてもらったほうが読む人が理解しやすいだろうと考えた結果です(コードをそのまま組み込むため、辻褄合わせに少し手間を掛けていたりします)。

    pciinit()main() の中から呼んであげると、起動時に PCI バスをスキャンして接続されているデバイスが検出されます。

    PCI: 0:0.0: 8086:1237: class: 6.0 (Bridge device) irq: 0
    PCI: 0:1.0: 8086:7000: class: 6.1 (Bridge device) irq: 0
    PCI: 0:1.1: 8086:7010: class: 1.1 (Storage controller) irq: 0
    PCI: 0:1.3: 8086:7113: class: 6.80 (Bridge device) irq: 9
    PCI: 0:2.0: 1234:1111: class: 3.0 (Display controller) irq: 0
    PCI: 0:3.0: 8086:100e: class: 2.0 (Network controller) irq: 11
    

    ベンダIDとデバイスIDをキーにドライバのエントリポイントを定義しておくと、デバイス検出時にそれを叩いてくれます。

    struct pci_driver pci_attach_vendor[] = {
    	{ 0x8086, 0x100e, &e1000_init },
    	{ 0, 0, 0 },
    };
    

    NICのドライバ

    PCI のデバイスを検出できるようになったら、次は NIC のドライバを書きます。

    JOS のソースには kernel/e1000.c kernel/e1000.h が含まれていますが、どちらも空っぽで、Intel のドキュメントをちゃんと読めという圧が伝わってきます。

    参考になりそうなコードがないか探してみると、xv6 の RISC-V 版のリポジトリには NIC の初期化処理まで書かれたドライバの雛形がありました。初期化処理を参考にしつつ、ヘッダファイルはそのまま利用させてもらいました。

    また、大神さん が公開されている本がめちゃくちゃわかりやすくて助かりました。PCIの情報も詳しく書かれていて、こんな情報をタダで読ませてもらって良いのか...と感動しながら e1000 を完全に理解しました(してません)。EPUB版でたら買います。

    OSDev.org で紹介されている以下のコードも参考にしました。

    他にも、世の中には xv6 向けの e1000 ドライバを書いている人は沢山いるので、github 上でもいくつかプロジェクトを見つけることができます。ただし、どれもポーリングする前提で、割り込みがちゃんと動く状態のものは見つけられませんでした。

    一連の作業の中で一番苦戦したのが、パケット受信時に割り込みを発生させることで、1週間くらいハマっていました。ちゃんと書いたつもりでもなかなか割り込みが発生しなく心が折れそうになっていたので、動いたときはめちゃくちゃ嬉しかったです。

    (たぶん送受信で使うDMA用のバッファ設定がうまくできていなくて動いていなかったんじゃないかと思っています)

    この辺の詳しいことは、また別の記事か薄い本でも書こうかなと思っています。

    プロトコルスタック本体の移植

    NIC のドライバが動いたので、いよいよ本題のプロトコルスタック本体の移植に取り掛かるのですが、あまり躓くことなくあっさり移植できてしまいまったので、あまり書くことがなかったりします。

    pthread を使っていたので mutex を spinlock に置き換えたり、cond をタスクの sleep/wakeup に置き換えたりしたくらいです。あと、タイマーが使えていないので、TCPの再送とかタイマーに依存した処理はまだ動かせていません。

    まず、ARP に応答できるようになって

    ICMP に応答できるようになって

    UDP で通信できるようになり

    最終的に TCP も動くようになりました

    ソケット

    カーネル内でプロトコルスタックが動くようになってもソケットがなければユーザ空間で通信アプリケーションを書くことができません。

    自作プロトコルスタックにはソケット風のAPIもあるのですが、これは単なるライブラリ関数なのでプロトコルスタックをカーネルに組み込んだ状態ではユーザ空間のアプリケーションから呼びだせません。

    そんなわけで、ソケット風 ではなくガチのソケット(関連のシステムコール)を実装しました。

    システムコールの追加にあたっては、xv6 の既存のシステムコールの中から似たようなプロトタイプのものを探して同じように実装しています。

    socket() で作られるディスクリプタは、もともと存在しているファイルディスクリプタと互換性を持つように作っているので、ソケットのディスクリプタを close() で閉じたり、recv() / senc() の代わりに read() / write() を使うことが出来ます。

    単純なソケット通信のプログラムであれば、Linux 用に書いたコードがそのまま動く程度にはちゃんと作っています。

    ifconfig コマンド

    シェルからインタフェースの状態を確認したりIPアドレスを設定するために、当初は適当なコマンド(ifget / ifset / ifup / ifdown)と対応するシステムコールを作って、その場をしのいでいました。

    ただ、これだとちょっとダサいので、最終的に ifconfig コマンドを作りました。ip コマンドじゃないのは NETLINK の実装はさすがに厳しいからという理由です。

    ifconfig は ioctl() を通じてインタフェースの情報を取得/設定しているので、ioctl のシステムコールを追加し、SIOCGXXXSIOCSXXX をひたすら作り込んでいます。

    おわりに

    あれもこれもと作り込んでいたら「自作プロトコルスタックを xv6 に移植した」というよりは「xv6 にネットワーク機能を実装した」という気持ちになったので、このようなタイトルになりました。

    あと、勢い余って reddit デビューもしました。

    reddit でコメントくれた方の自作OSがしっかり作られていて感動したので紹介しておきます。

    まだ先の予定を立てるのは難しいですが、イベントが開催できるような状況になったら、また KLab Expert Camp でプロトコルスタック自作の合宿をやりたいと思っています。その際は自作OSへの組み込みもサポートできるように準備しておきますので楽しみにしていてください!


    @pandax381

    pandax381 at 18:58
    この記事のURLComments(0)network 
    2020年03月04日

    デプスカメラを「バーチャル背景」用 Web カメラとして使う

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

    先日ちょっとしたきっかけから Intel 製 RealSense D415 を買いました。デプスカメラに触れるのは初めてでしたが、Microsoft Azure Kinect の国内販売がまもなく開始される旨もアナウンスされておりこの分野の今後の動向が楽しみです。

    試作の内容

    さっそくこの D415 をあれこれ試しています。公式の RealSense SDK 2.0 の勉強をかね習作のひとつとして次の内容のプログラムを作ってみました。

    • RGB カラーフレームの画角へ Depth フレームを位置合わせして両方の情報を使用
    • カメラのスコープから「1m 以内」にあるものの RGB 画像を抽出して表示する
    • スペースキー押下で背景画像を切り替え上の画像との合成を行う

    静止状態でも常に微妙な揺れを伴う深度情報を RGB 画像切り抜き領域の判定に用いているため静的なクロマキー合成とは異なり境界にノイズが発生しがちですが、デプスカメラ利用の一例として紹介します。

    動作の様子

    デモ動画:1分4秒 無音

    ソースコード

    プログラムでは画像処理と表示に OpenCV を併用しています。

    • SDK 内の以下のサンプルプログラムを参考にしておりコードの一部を引用しています
    • 手元の開発環境は Windows 10 + Visual Studio 2019 Community です
    • プロジェクトファイルは上記サンプルのものをコピー〜編集して利用するのが手早いでしょう
    //
    // Intel RealSense D400 シリーズ
    //
    // カメラから所定の距離内の RGB 画像を抽出して表示.
    // スペースキー押下で背景画像を差し替え上の画像と合成する.
    //
    // 2020-02
    //
    
    #include <librealsense2/rs.hpp>
    #include <opencv2/dnn.hpp>
    #include <opencv2/highgui.hpp>
    #include <opencv2/imgproc.hpp>
    #include <iostream>
    #include <cmath>
    
    #define Width  640
    #define Height 480
    #define Fps     15
    
    // RGB 画像抽出対象圏内の距離
    #define Depth_Clipping_Distance 1.0f  // 1メートル
    
    // 背景画像ファイル数
    #define NumBgImages 8
    
    // Depth スケール情報を取得
    float get_depth_scale(rs2::device dev)
    {
      // センサ情報を走査
      for (rs2::sensor& sensor : dev.query_sensors()) {
        // 深度センサなら Depth スケール情報を返す
        if (rs2::depth_sensor dpt = sensor.as<rs2::depth_sensor>()) {
          return dpt.get_depth_scale();
        }
      }
      throw std::runtime_error("Device does not have a depth sensor");
    }
    
    // RGB フレーム中の Depth_Clipping_Distance 圏外を塗りつぶし
    // 圏内 = 255, 圏外 = 0 のマスクイメージを得る
    cv::Mat remove_background(rs2::video_frame& video_frame,
                const rs2::depth_frame& depth_frame,
                float depth_scale)
    {
      const uint16_t* p_depth_frame = (const uint16_t*)(depth_frame.get_data());
      uint8_t* p_video_frame = (uint8_t*)((void*)(video_frame.get_data()));
    
      // マスク用の Matrix をモノクロで用意
      cv::Mat m = cv::Mat(cv::Size(Width, Height), CV_8UC1);
    
      // 当該 video_frame の幅, 高さ, 1ピクセルのバイト長
      int width = video_frame.get_width();
      int height = video_frame.get_height();
      int other_bpp = video_frame.get_bytes_per_pixel(); // RGB につき 3
    
      // Depth フレームを走査して RGB フレームの画像情報を加工
      //   OpenMP で二重 for ループを並列に処理
      //   VC++ ではコンパイルオプション /openmp が必要
      //   https://docs.microsoft.com/ja-jp/cpp/parallel/openmp/openmp-in-visual-cpp?view=vs-2019
      //   https://docs.microsoft.com/ja-jp/cpp/parallel/openmp/d-using-the-schedule-clause?view=vs-2019
      #pragma omp parallel for schedule(dynamic)
      for (int y = 0; y < height; y++) {
        auto depth_pixel_index = y * width;
        for (int x = 0; x < width; x++, ++depth_pixel_index) {
          // 現座標箇所のセンサからのメートル距離を得る
          auto pixels_distance = depth_scale * p_depth_frame[depth_pixel_index];
          // Depth の死角領域 (<=0) および 対象圏外に該当の場合
          if (pixels_distance <= 0.f || pixels_distance >
    Depth_Clipping_Distance) {
            // RGB フレーム内の対象オフセット
            auto offset = depth_pixel_index * other_bpp;
            // 0x999999 で塗りつぶす
            std::memset(&p_video_frame[offset], 0x99, other_bpp);
            // 背景部分としてマーク
            m.at<uchar>(y, x) = 0;
          } else {
            // 前景部分としてマーク
            m.at<uchar>(y, x) = 255;
          }
        }
      }
      // 作成したマスクイメージを CV_8UC1 から CV_8UC3 に変換して返す
      cv::Mat mask;
      cv::cvtColor(m, mask, CV_GRAY2BGR);
      return mask;
    }
    
    // 所定の画像データをロード
    cv::Mat loadImage(int n)
    {
      char fn[16];
      sprintf(fn, "%02d.jpg", n);
      return cv::imread(fn);
    }
    
    int main(int argc, char * argv[]) try
    {
      rs2::log_to_console(RS2_LOG_SEVERITY_ERROR);
    
      int photoNumber = 0;
      // ストリーミング用のパイプライン
      rs2::pipeline pipeline;
      rs2::config cfg;
      cfg.enable_stream(RS2_STREAM_COLOR); // RGB ストリーム
      cfg.enable_stream(RS2_STREAM_DEPTH); // 深度ストリーム
      rs2::pipeline_profile profile = pipeline.start(cfg);
      // このカメラの Depth スケール情報を取得
      // "Depth スケール * Depth フレーム内の各ピクセルの値" がセンサからのメートル距離
      float depth_scale = get_depth_scale(profile.get_device());
    
      // info
      rs2::device rs_dev = profile.get_device();
      std::cout << "Device Name" << ": " <<
    rs_dev.get_info(RS2_CAMERA_INFO_NAME) << std::endl;
      std::cout << "Firmware Version" << ": " <<
    rs_dev.get_info(RS2_CAMERA_INFO_FIRMWARE_VERSION) << std::endl;
      std::cout << "Recomended Firmware Version" << ": "
    << rs_dev.get_info(RS2_CAMERA_INFO_RECOMMENDED_FIRMWARE_VERSION)
    << std::endl;
      std::cout << "Serial Number" << ": " <<
    rs_dev.get_info(RS2_CAMERA_INFO_SERIAL_NUMBER) << std::endl;
      std::cout << "Product Id" << ": " <<
    rs_dev.get_info(RS2_CAMERA_INFO_PRODUCT_ID) << std::endl;
      std::cout << "USB Type" << ": " <<
    rs_dev.get_info(RS2_CAMERA_INFO_USB_TYPE_DESCRIPTOR) <<
    std::endl;
      std::cout << "Depth Scale" << ": " << depth_scale
    << std::endl;
    
      // RGB ストリーム分の align オブジェクトを用意
      rs2::align align(RS2_STREAM_COLOR);
    
      // 最初の背景画像をロード
      cv::Mat photo = loadImage(photoNumber);
    
      while (1) {
        // カメラからのフレームセット受信を待つ
        rs2::frameset frameset = pipeline.wait_for_frames();
        // アライメントを RGB ストリーム分のビューポートに揃える
        frameset = align.process(frameset);
        // RGB フレームを取得 (video_frame クラスに注意)
        rs2::video_frame video_frame = frameset.get_color_frame();
        // Depth フレームを取得
        rs2::depth_frame depth_frame = frameset.get_depth_frame();
    
        // RGB フレーム中の Depth_Clipping_Distance 圏外を塗りつぶし
        // 圏内 = 255, 圏外 = 0 のマスクイメージを得る
        cv::Mat mask = remove_background(video_frame, depth_frame, depth_scale);
    
        cv::Mat rgbCvMatsrc, rgbCvMatDst;
        // RGB フレームからピクセルデータを取得し OpenCV のマトリックスに変換
        rgbCvMatsrc = cv::Mat(cv::Size(Width, Height), CV_8UC3,
    (void*)video_frame.get_data(), cv::Mat::AUTO_STEP);
        // チャネル並びを RGB から BGR に
        cv::cvtColor(rgbCvMatsrc, rgbCvMatDst, cv::COLOR_RGB2BGR, 0);
    
        // 抽出した RGB 画像
        //cv::imshow("Src", rgbCvMatDst);
    
        // 背景画像
        //cv::imshow("pic", photo);
    
        // マスクと反転マスク
        cv::Mat mask_inv, fg, bg, mix;
        cv::bitwise_not(mask, mask_inv);
        //cv::imshow("mask", mask);
        //cv::imshow("mask_inv", mask_inv);
    
        // 前景となる抽出画像にマスクをかけて背景色を 0 に
        cv::bitwise_and(rgbCvMatDst, mask, fg);
        //cv::imshow("fg", fg);
    
        // 背景画像に反転マスクをかけて前景部分を 0 に
        cv::bitwise_and(photo, mask_inv, bg);
        //cv::imshow("bg", bg);
    
        // 加工した前景と背景をマージして表示
        //cv::bitwise_or(fg, bg, mix);
        cv::add(fg, bg, mix);
        cv::imshow("Enter", mix);
    
        // キー押下チェック
        int key = cv::waitKey(1);
        if (key == 32) { // SPACE
          // 背景画像を変更
          if (++photoNumber > NumBgImages) {
            photoNumber = 0;
          }
          photo = loadImage(photoNumber);
        } else if (key == 'q' || key == 27) { // 'q' or ESC
          cv::destroyAllWindows();
          break;
        }
      }
      return EXIT_SUCCESS;
    }
    catch (const rs2::error & e)
    {
      std::cerr << "RealSense error calling " <<
    e.get_failed_function() << "(" << e.get_failed_args()
    << "):\n  " << e.what() << std::endl;
      return EXIT_FAILURE;
    }
    catch (const std::exception& e)
    {
      std::cerr << e.what() << std::endl;
      return EXIT_FAILURE;
    }
    
    

    メモ:マスク合成の手順

    A: 圏内の RGB 画像
    B: 0, 255 のマスク
    C: 元画像 & マスク C: + c:
    a: 背景画像
    b: 255, 0 のマスク
    c: 背景 & マスク

    仮想 Web カメラと組み合わせてみる

    上のプログラムはあくまでも習作ですが、ふと、以下のような仮想 Web カメラソフトウェアと組み合わせれば、たとえばテレワーク環境からのビデオミーティング参加時に背後のプライベート空間の露出を避ける目的などに利用できるのではないかと考えました。

    ちなみに所属部署ではリモート会議に Google Hangouts Meet を利用しています。上のデモを部内で紹介した折に Zoom にはこれと同様のことをスマートに実現できる「バーチャル背景」機能が用意されていることを知り、この手の需要が少なくないことをあらためて実感しました。

    上記の SplitCam を使えば PC 上の所定のウィンドウの任意の領域をローカルのカメラ映像として流すことができます。最新版では領域選択方法が不明でしたが、「SplitCam links to download OLD VERSIONS!」ページから「SPLITCAM 8.1.4.1」をダウンロードして試すと期待する結果が得られました。他のプログラムからは普通の Web カメラとして認識されるため汎用的に扱えそうです。

    以下にざっくりと組み合わせの手順を示します。(クリックで大きく表示)

    1. SplitCam を起動して領域を設定

    2. ミーティング用クライアントからカメラとして SplitCam を選択

    3. こんな感じで利用できる


    (tanabe)
    klab_gijutsu2 at 17:46
    この記事のURLComments(0)その他 
    Blog内検索
    Archives
    このブログについて
    DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
    このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
    最新コメント
    Syndicate this site