KLabサーバーサイドキャンプを開催しました
12/27 から年末年始をはさんだ5日間で、技術系インターン「KLabサーバーサイドキャンプ」を開催しました。 今春3月に第2回も企画しているので、 その宣伝も兼ねて開催報告をします。 (尚、エントリー最終締切日が1/24(月)に迫っているので興味を持って頂けた方はお早めにご応募ください)
キャンプの目的
このキャンプは、主にこれから就職活動を始める学生を対象にサーバーサイド開発を体験してもらい、今後の進路を考える上で参考にしてもらうことを目的としています。 そのため、Pythonでのある程度のプログラミング経験は前提としつつ、SQLやSSHなどを触ったことがない方でも参加できるように講義や課題を準備していました。
キャンプの内容
題材として、実際に遊べるリズムゲーム(音ゲー)を用意しています。
このゲームにはユーザー登録機能と、複数人で同一曲を同時にプレイする機能があります。この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までです)
@methane
デプスカメラを「雄弁なスチルカメラ」として利用する
デプスカメラ Intel RealSense D415 を使ってあれこれを試しているうちにふとこんなことを考えました。
RealSense D400 シリーズの出力からは、(1)一般的な RGB カラー画像 (2)自然光照度の影響を受けにくい赤外線画像、そして、(3)被写体の位置関係が色彩的に表現された深度情報画像を得ることができます。この三種類の情報を照合するとスコープ内の一瞬の状況を多角的にとらえることが可能です。例を示します。
このユニークな特性を以下の想定のもとで「ゆるい見守り」に活かせるのではないかと考えました。
前に人感センサを利用した安否確認のしくみを手がけたことがあります。これは現在もプライベートで利用を続けているのですが、そこにこれらの画像情報が加われば安心感がずっと大きくなりそうです。
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+」とある。
- Raspberry 3 B+ 現段階ではUbuntu Mate 16.04.2(Xenial)は起動しない (2018-09-09) - shittaca.seesaa.net
この記事の内容は最終的には正確だった。ただしこうした話題にありがちな落とし穴もいくつかあった。備忘をかねその内容を以下に控える。
- Raspbian(RaspberryPi3) Installation - github.com/IntelRealSense
- 現在 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 様による以下の記事にこれに関連する記述がみられる
手元では OpenGL よりも OpenCV に馴染んでいるため逆に OpenGL ドライバを無効化 ([raspi-config] - [7.Advanced Options] - [A7 GL Driver] - [G3 Legacy])し、プログラミングには OpenCV を利用している。一方でこの設定では公式の RealSense Viewer 等が激重になるため適宜使い分けが必要。
- デプスカメラRealSenseD435で・・ (記事名を一部省略させて頂きました) - qiita.com/PINTO
【注意点】 OpenGL Driver を有効にすると、OpenCVの 「imshow」 メソッドが正常に動作しなくなる点を受け入れる必要がある。 こちらの記事の実装 のように、OpenCVの 「imshow」 メソッドに頼らないでOpenGLの描画系メソッドだけで描画処理を実装しなくてはならない点に注意。
こういった過程を経て手元の Raspberry Pi 3 B+ で D415 を利用できる状態になりました。手頃なサイズでいい感じです。
下の動画にはこの環境で公式の Intel RealSense Viewer を実行した様子を収めています。なお、ここでは前掲の事情によりラズパイ側で OpenGL Driver を有効にしています。
D400 からの赤外線投光と深度情報精度との関係
RealSense D400 シリーズは深度計測にふたつの赤外線センサ(カメラ)を用いたステレオアクティブ方式を採用しており、計測精度向上の補助を目的とするドットパターンの赤外線投光を行うことが可能となっています。この投光はデフォルトで有効です。
前述の想定のように今回は RGB, 赤外線, 深度のみっつの画像情報をあわせて捕捉することがポイントですが、この赤外線投光機能は赤外線カメラ経由で得られる画像と深度情報の両方に影響を及ぼします。手元での観察結果からこのことを整理してみます。
まず、赤外線投光を有効化 / 無効化した状態で D415 から採取した各フレームの静止画像例を以下に示します。赤外線画像はふたつの赤外線カメラの左側のものを使用。なお、この製品の赤外線カメラはあくまでも深度計測を目的とするものであり生のままの画像は視覚的に暗いため、OpenCV で明度とコントラストを補正した版を並べて掲げています。(画像はいずれもクリックで大きく表示)
RGB
|
深度 | 赤外線 L | 赤外線 L(補正ずみ) |
RGB
|
深度 | 赤外線 L | 赤外線 L (補正ずみ) |
上のサンプルから次のように判断しました。
ちなみに、赤外線投光の有効・無効をストリーミングの途中で切り替えることはできません。サンプルを採取するために用意した 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. 前回分のフレーム画像
|
2. 今回分の画像 (体と手を少し動かしてみた)
|
3. 上記 1, 2 画像の差分
|
4. ピクセル値を所定の閾値で 0 と 255 に分ける
|
試作
内容
ここまでの話題を元に以下の想定に基づく内容のアプリケーションを試作しました。MQTT ブローカーには定番の Beebotte を利用します。JavaScript 用の API セットも提供されているため Web ブラウザ上のコンテンツでメッセージを取り回すことが可能です。
メモ
関連する話題を控えます。
以下のように書き換えると表示されます。
http://drive.google.com/uc?export=view&id={ID}
ソースコード
以上を踏まえざっくり用意したプログラムです。
#!/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 ページと実際に撮影した各フレーム画像を静的に再構成したアーカイブを下記へ収めています。
- デモページ ( target="_blank" )
上のデモページの末尾に今回の試作で使用した実際の HTML 記述をコメントとして引用しています(各種 ID はダミーです)。
動作の様子
(tanabe) |
オプティカルフローを簡易ジェスチャ認識に利用する
少し前に Intel 製デプスカメラ RealSense D415 と 3DiVi 社製 Nuitrack SDK の組み合わせでジェスチャ認識を試していました。下の動画には三脚に据えた D415 ごしに所定のジェスチャでスマート照明の操作をテストした様子を収めています。
このように期待どおりの反応は得られるのですが、次のことが気になっていました。
このあたりの事情は Kinect も同様のようです。もちろん骨格の検出は腕や手を適切に識別するために必要ですが、一方でもっとラフなものもあればと考えました。日常感覚ではコタツや布団にもぐり込んだまま姿勢を正すことなくスッと指示を出すといったこともできれば便利そうです。そんなわけで試しに簡単なしくみを作ってみることにしました。
考え方として、スコープ内の被写体に所定の水準以上の移動をざっくり検知した場合にその向きを判定することを想定しました。実現方法を考えながら社内でそういう話をしたところ、「OpenCV でオプティカルフローを利用してはどうか?」というレスポンスがありました。手元ではトラッキング API (リンク: @nonbiri15 様によるドキュメントの和訳) を試していたところでオプティカルフローのことは知らずにおり、@icoxfog417 様の次の記事中の比較がちょうどわかりやすくとても参考になりました。
画像間の動きの解析については、様々な目的とそれを実現する手法があります。ここでは、まずOptical Flowがその中でどのような位置づけになるのか説明しておきます。
:
以下は GitHub 上の OpenCV 公式のサンプルプログラムです。これは Dense(密)型で Gunnar Farneback 法が用いられています。
一般的なウェブカメラを PC へ接続してこのプログラムを実行した様子を以下の動画に収めています。スコープ内の被写体の動きが格子点の座標群を起点に捕捉されている様子が視覚的に表現されます。
このサンプルプログラムに上下左右四方向への移動を大きく判定する処理を加えてみました。次の動画の要領で動作します。
加筆したプログラムのソースコードです。まだまだ改良の余地はあるものの今回オプティカルフローの利便性に触れたことは大きな収穫でした。いろいろな使い方ができそうです。
#!/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)
xv6にネットワーク機能を実装した
在宅勤務に移行してから1ヶ月半ほど経過しました。通勤という概念が消滅したおかげで午前中から活動できるようになった @pandax381 です。
要約
フルスクラッチで自作した TCP/IP プロトコルスタックを xv6 に組み込み、一通りの機能が動作するようになりました。
I publish the implementation of TCP/IP network stack on xv6. I ported my user-mode TCP/IP stack, which was originally developed for learning, and added the e1000 driver and socket system calls. Some parts are still not enough, but they are working.https://t.co/nht9JDMVbl
— YAMAMOTO Masaya (@pandax381) March 11, 2020
経緯
かれこれ7〜8年くらい経つんですけど、ライフワークとしてお勉強用のTCP/IPプロトコルスタックの開発をほそぼそと続けています。
一般的に、プロトコルスタックはOSの機能の一部としてカーネル内部に存在しているのですが、この自作プロトコルスタックは、作りやすさを重視した都合でユーザ空間のアプリケーション用のライブラリとして実装しています。
最下層に位置するリンクレベルの入出力は PF_PACKET / BPF / TAP を利用するお手軽仕様で、純粋にパケット処理に専念できるようになっています。
やや作りが粗い部分もありますが、Ethernetフレームの送受信からTCPセグメントのやり取りまで全てを自前で処理しています。「プロトコルスタックってどんな作りになっているんだろう」と興味を持った人が、手を出して雰囲気を掴むのに丁度いいくらいのボリュームになっているんじゃないかと思います。
(物理デバイスや論理インタフェースの抽象化とかそれなりに作り込んであるので、興味があったらコード見てください)
毎年、インターンシップを希望する学生を受け入れていて、彼らの頑張りによって DHCPクライアント機能やパケット転送機能、更にはDPDKサポートといった改良が加えられいますが、いずれも1〜2週間という短い期間で成果を出してくれました。
その流れで、昨夏には KLab Expert Camp という合宿イベントを開催しました。
KLab Expert Camp 開催決定!第1回テーマは「TCP/IPプロトコルスタック自作開発」で、講師は僕が担当します。日程は 8/26〜29 の4日間での開催、交通費・宿泊費は全て KLab が負担します。プロトコルスタック自作に興味のある学生のみなさん、一緒に楽しい夏を過ごしましょう!https://t.co/SVyMOzaeq8
— YAMAMOTO Masaya (@pandax381) April 26, 2019
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 の初期化処理まで書かれたドライバの雛形がありました。初期化処理を参考にしつつ、ヘッダファイルはそのまま利用させてもらいました。
- https://github.com/mit-pdos/xv6-riscv-fall19/blob/net/kernel/e1000.c
- https://github.com/mit-pdos/xv6-riscv-fall19/blob/net/kernel/e1000_dev.h
また、大神さん が公開されている本がめちゃくちゃわかりやすくて助かりました。PCIの情報も詳しく書かれていて、こんな情報をタダで読ませてもらって良いのか...と感動しながら e1000 を完全に理解しました(してません)。EPUB版でたら買います。
OSDev.org で紹介されている以下のコードも参考にしました。
他にも、世の中には xv6 向けの e1000 ドライバを書いている人は沢山いるので、github 上でもいくつかプロジェクトを見つけることができます。ただし、どれもポーリングする前提で、割り込みがちゃんと動く状態のものは見つけられませんでした。
一連の作業の中で一番苦戦したのが、パケット受信時に割り込みを発生させることで、1週間くらいハマっていました。ちゃんと書いたつもりでもなかなか割り込みが発生しなく心が折れそうになっていたので、動いたときはめちゃくちゃ嬉しかったです。
(たぶん送受信で使うDMA用のバッファ設定がうまくできていなくて動いていなかったんじゃないかと思っています)
xv6 用の e1000 ドライバが動いたーーー!!!これで自作プロトコルスタックを xv6 へ載せるための準備が整った💪https://t.co/nht9JDMVbl pic.twitter.com/TJYkWVAKAV
— YAMAMOTO Masaya (@pandax381) February 29, 2020
この辺の詳しいことは、また別の記事か薄い本でも書こうかなと思っています。
プロトコルスタック本体の移植
NIC のドライバが動いたので、いよいよ本題のプロトコルスタック本体の移植に取り掛かるのですが、あまり躓くことなくあっさり移植できてしまいまったので、あまり書くことがなかったりします。
pthread を使っていたので mutex を spinlock に置き換えたり、cond をタスクの sleep/wakeup に置き換えたりしたくらいです。あと、タイマーが使えていないので、TCPの再送とかタイマーに依存した処理はまだ動かせていません。
まず、ARP に応答できるようになって
ARPに応答できるようになった!#プロトコルスタック自作#xv6 pic.twitter.com/zjBszDkK6t
— YAMAMOTO Masaya (@pandax381) March 2, 2020
ICMP に応答できるようになって
順調に進んで ping に応答できるようになった!#プロトコルスタック自作#xv6 https://t.co/I7BGOz4jVw pic.twitter.com/0xBivGA9LZ
— YAMAMOTO Masaya (@pandax381) March 3, 2020
UDP で通信できるようになり
自作プロトコルスタック on xv6 の進捗が素晴らしくて UDP 通信に成功した🎉🎉🎉(雑に実装したソケット風のシステムコールを通じてユーザ空間のアプリケーションがカーネル内のプロトコルスタックを利用して通信してる) pic.twitter.com/VUbbeWr0TO
— YAMAMOTO Masaya (@pandax381) March 6, 2020
最終的に TCP も動くようになりました
うぉぉぉ!!!TCP も動いたぞ!!!もともとユーザ空間で動かす前提でこしらえた自作プロトコルスタックが xv6 のカーネル空間で完全に動作してる!!! https://t.co/juL0ntccyY pic.twitter.com/5tDU1T0B8K
— YAMAMOTO Masaya (@pandax381) March 6, 2020
ソケット
カーネル内でプロトコルスタックが動くようになってもソケットがなければユーザ空間で通信アプリケーションを書くことができません。
自作プロトコルスタックにはソケット風のAPIもあるのですが、これは単なるライブラリ関数なのでプロトコルスタックをカーネルに組み込んだ状態ではユーザ空間のアプリケーションから呼びだせません。
そんなわけで、ソケット風 ではなくガチのソケット(関連のシステムコール)を実装しました。
システムコールの追加にあたっては、xv6 の既存のシステムコールの中から似たようなプロトタイプのものを探して同じように実装しています。
socket() で作られるディスクリプタは、もともと存在しているファイルディスクリプタと互換性を持つように作っているので、ソケットのディスクリプタを close() で閉じたり、recv() / senc() の代わりに read() / write() を使うことが出来ます。
単純なソケット通信のプログラムであれば、Linux 用に書いたコードがそのまま動く程度にはちゃんと作っています。
自作プロトコルスタック on xv6 がいい感じに仕上がった!ソケット関連のシステムコールを真面目に実装したのでよくあるこんなコードがそのまま動くようになった!ちゃんとファイルディスクリプタに紐づけているので read/write を使っても動くし fork しても大丈夫!https://t.co/nht9JDMVbl pic.twitter.com/LQNvWwOpj9
— YAMAMOTO Masaya (@pandax381) March 10, 2020
ifconfig コマンド
シェルからインタフェースの状態を確認したりIPアドレスを設定するために、当初は適当なコマンド(ifget / ifset / ifup / ifdown)と対応するシステムコールを作って、その場をしのいでいました。
雑なシステムコールを実装してコマンドでネットワークインタフェースを制御できるようになった💪#xv6#プロトコルスタック自作 pic.twitter.com/T6LZNEtwOK
— YAMAMOTO Masaya (@pandax381) March 5, 2020
ただ、これだとちょっとダサいので、最終的に ifconfig コマンドを作りました。ip コマンドじゃないのは NETLINK の実装はさすがに厳しいからという理由です。
だいぶ雑だけど ifconfig 作って動くようになった(たぶん大幅に書き直すと思うけどとりあえず push した)#xv6#networking https://t.co/TEbKeGWe3m pic.twitter.com/2mh5tVGiuy
— YAMAMOTO Masaya (@pandax381) March 30, 2020
ifconfig は ioctl() を通じてインタフェースの情報を取得/設定しているので、ioctl のシステムコールを追加し、SIOCGXXX や SIOCSXXX をひたすら作り込んでいます。
おわりに
あれもこれもと作り込んでいたら「自作プロトコルスタックを xv6 に移植した」というよりは「xv6 にネットワーク機能を実装した」という気持ちになったので、このようなタイトルになりました。
あと、勢い余って reddit デビューもしました。
reddit でコメントくれた方の自作OSがしっかり作られていて感動したので紹介しておきます。
まだ先の予定を立てるのは難しいですが、イベントが開催できるような状況になったら、また KLab Expert Camp でプロトコルスタック自作の合宿をやりたいと思っています。その際は自作OSへの組み込みもサポートできるように準備しておきますので楽しみにしていてください!
@pandax381
デプスカメラを「バーチャル背景」用 Web カメラとして使う
先日ちょっとしたきっかけから Intel 製 RealSense D415 を買いました。デプスカメラに触れるのは初めてでしたが、Microsoft Azure Kinect の国内販売がまもなく開始される旨もアナウンスされておりこの分野の今後の動向が楽しみです。
試作の内容
さっそくこの D415 をあれこれ試しています。公式の RealSense SDK 2.0 の勉強をかね習作のひとつとして次の内容のプログラムを作ってみました。
静止状態でも常に微妙な揺れを伴う深度情報を RGB 画像切り抜き領域の判定に用いているため静的なクロマキー合成とは異なり境界にノイズが発生しがちですが、デプスカメラ利用の一例として紹介します。
動作の様子
デモ動画:1分4秒 無音
ソースコード
プログラムでは画像処理と表示に OpenCV を併用しています。
// // 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 カメラとして認識されるため汎用的に扱えそうです。
以下にざっくりと組み合わせの手順を示します。(クリックで大きく表示)
2. ミーティング用クライアントからカメラとして SplitCam を選択
(tanabe)