読者です 読者をやめる 読者になる 読者になる

電子工作と画像処理でVR用の3Dスキャナーを自作する!

電子工作 Python

f:id:jujunjun110:20161012230115g:plain

はじめまして。VOYAGE GROUP VR室長の @jujunjun110 です!

いきなりですが、VOYAGE GROUPでは10月からVR室を立ち上げ、VRという新領域に取り組みはじめました。

また、それに伴いVR室ブログも立ち上げました。

こちらは毎週水曜日更新ですので、ぜひチェックしてみて下さい!

vr-lab.voyagegroup.com


...さて、以上で私の言いたいことは120%言い終わったのですが、これだけで更新するのも申し訳ないので、今回はVRアプリケーションで使うための3Dスキャナーを自作したときの話を寄稿させていただきます!

非エンジニアにもかかわらずこの場に書かせていただけて大変光栄です!

目次

今回作る3Dスキャナーの仕組み

突然ですが、一つのモノを様々な角度から撮影した大量の2次元写真が欲しいなと思ったこと、みなさんも一度はありますよね?

今回作っていくのは、みなさんのそんな課題をズバッと解決してくれるマシンです!

Multiple View Geometryについて

今回作る3DスキャナーはMultiple View Geometryというコンピュータービジョンの技術を利用したものです。

f:id:jujunjun110:20161013104615p:plain

(wikipedia より)

Multiple View Geometry とは、その名の通り、一つのモノを様々な角度から撮影した大量の2次元写真を組み合わせて、1つの3Dモデルを構成する技術です。

具体的にはこんな感じ。

  1. 写真に写っているオブジェクトの中で、複数の写真で「同じ部分」と認識できる箇所(特徴点)を見つける
  2. 1で発見した特徴点を元に、写真が撮られたときのカメラ位置を特定 (Structure from Motion: SfM)
  3. 3で特定したカメラ位置から、オブジェクト自体の形状を復元

この手順自体は、RealityCaptureという市販ソフト*1が非常によくできているので、それほど難しくはありません。

しかし、一つのモノを様々な角度から撮影した大量の2次元写真を撮影するのがとにかく面倒だし難しい。

やってみるとわかりますが、取り漏れる面があったり、変に背景が写り込んだり、理想的な写真を撮影するのはなかなか困難です。なにより綺麗なモデルを作るには数十枚の写真が必要なのですが、これを撮るのはかなりの重労働です。

そこで、今回はMultiple View Geometry用の大量の画像を自動で撮影するマシーンを作成していきたいと思います。

Let's 電子工作!

今回作るマシンの仕組みは至ってシンプルです。

  1. 回転テーブルに撮影したいものを乗っける
  2. 回転テーブルを少し回す
  3. カメラのシャッターを切る
  4. 2に戻る

今回はArduinoをベースにこれを作っていきます。

全体像はこんな感じになります。

f:id:jujunjun110:20161012003614p:plain

(↑回路図の読み書きが全くできない哀しき男の書いた図)

非常にシンプルですね。早速見ていきましょう。

なお、電子工作初心者なので説明が不正確・不十分なところがあると思います。はてぶコメントなどで指摘いただければ幸いです。

連続回転サーボ制御による回転テーブル作成

まず、360°ぐるっと写真を撮影する必要があるので、連続回転サーボを使って回転テーブルを使います。

一般的なサーボモーターは0〜180°までしか回らないのに対して、何回でも自由回転できるようになっているのがこの連続回転サーボです。

参考にさせていただいたこの記事( Arduino 連続回転サーボ | アンドロイドな日々 )によると、

制御信号は、周期的なパルスで、周期 20ms、パルス幅 1.0ms – 2.0ms です。
パルス幅 1.5ms で停止、1.0 – 1.5ms で時計周り、1.5 – 2.0ms で反時計周りです。
停止の 1.5ms から離れるに従い回転数が増えます。

とのこと。

こんな感じでPWM信号のパルスを設定してやると、キュっと一瞬だけ動いて止まる動作が実現できます。回転角度はこのパルスを調整することである程度調整可能です。

digitalWrite(TablePin, HIGH);
delayMicroseconds(2000); // PMW信号のパルスを設定
digitalWrite(TablePin, LOW); 

普通のサーボと違って回転角度を厳密に指定してやるのは難しいですが、今回は一定の角度ずつ回し続けられればよいのでこれで十分です。

ちなみにテーブル面は、フィギュア用の回転テーブルのものを使いました。*2

フォトカプラによるシャッターの制御

これで回転テーブルの部分はできたので、次にカメラのシャッターを自動制御する部分を作成していきます。

今回撮影に利用した一眼レフ(Canon EOS Kiss X7)は、2.5mm ステレオミニプラグがシャッタースイッチになっているものなので、適当に使えそうな延長ケーブルを利用します。

これをおもむろにニッパーで半分に切ると、2本のケーブル(赤、白)とその外側の銅線(GND)が出てきます。

f:id:jujunjun110:20161012214339j:plain

2.5mmジャックの側をカメラに挿した状態で、

  • 赤とGNDを触れさせると、ピントを合わせる
  • 白とGNDを触れさせると、シャッターを切る

という動作をすることが確認できます。意外とシンプルな機構なんですね。

つまりシャッターを切りたいタイミングで白のケーブルとGNDを通電させれば、タイミングをコントロールできるので、フォトカプラを使って実現します。

【ノーブランド品】DIP-4817CフォトカプラIC 10個

【ノーブランド品】DIP-4817CフォトカプラIC 10個

フォトカプラからは4本の足が出ており、下の画像のように、ある2本に電流を流すと光の信号を通じてもう2本の間が通電します。

f:id:jujunjun110:20161012154840p:plain

Arduinoに接続されている側のPINがHIGHになると、フォトカプラの逆側も通電し、シャッターがおりて写真が撮影されるというわけです。

void shot() { 
  digitalWrite(ShutterPin, HIGH);
  delay(1000); // 1秒くらい待たないと、ピントが合いきらずシャッターがおりないことがある
  digitalWrite(ShutterPin, LOW);
}

Arduinoのソースコード

今までの部分をソースコードにまとめるとこんな感じになります。

メインのループである loop( ) 関数で、テーブルを回転 → 1秒待機 → シャッターを切る(1秒かかる) → 3秒待機 となっているのが分かると思います。

int TablePin = 12;
int ShutterPin = 13;
int width = 2000;

// 初期設定
void setup() {
  pinMode(TablePin, OUTPUT);
  pinMode(ShutterPin, OUTPUT);
}

// メインループ
void loop() {
  rotateTable();// テーブルを回す
  delay(1000); // 1秒待機
  shot(); // シャッターを切る
  delay(3000); // 3秒待機
}

void rotateTable() { 
  digitalWrite(TablePin, HIGH);
  delayMicroseconds(width); // PMW信号のパルスを設定
  digitalWrite(TablePin, LOW); 
}

void shot() { 
  digitalWrite(ShutterPin, HIGH);
  delay(1000); // 1秒くらい待たないと、ピントが合いきらずシャッターがおりないことがある
  digitalWrite(ShutterPin, LOW);
}

ユニバーサルプレートによる組み立てと配線

これで基礎となる仕組みはできたので、使いやすいように組み立てていきます。

枠組みにはタミヤのユニバーサルプレートを使います。

タミヤ 楽しい工作シリーズ No.157 ユニバーサルプレート 2枚セット (70157)

タミヤ 楽しい工作シリーズ No.157 ユニバーサルプレート 2枚セット (70157)

タミヤ 楽しい工作シリーズ No.172 ユニバーサルプレートL 210×160mm (70172)

タミヤ 楽しい工作シリーズ No.172 ユニバーサルプレートL 210×160mm (70172)

ユニバーサルプレートはネジだけで電子工作の骨組みができるすごいヤツです。

こんな感じでニッパーで穴をあけてサーボモーターを固定し、

f:id:jujunjun110:20161012115032j:plain

下面にはArduinoを固定します。

f:id:jujunjun110:20161012115117j:plain

配線については、設計時はブレッドボードで行いますが、実際稼働させるとなると配線が抜けやすかったり邪魔だったりするので、はんだ付けで固定します。

フォトカプラ周辺などは、むき出しのままだと不意に配線同士が触れて予期せぬ動きをするので、こんな感じにグルーガンで固めて絶縁すると良いです。

f:id:jujunjun110:20161012184501p:plain

これで完成です!

f:id:jujunjun110:20161012115759j:plain

うーん、無骨で漢らしくも、繊細でデリケートな一面も垣間見える、惚れ惚れするデザインですね...!

写真撮影

それでは、早速撮影に移っていきましょう。

セッティング

クロマキーで背景抜きをする必要があるので、ブルーバックのフォトブースを買いました。

ロアス 撮影ブース 大 DCA-069

ロアス 撮影ブース 大 DCA-069

今回は、個人的に髪型に親近感を感じる懐かしキャラ、アフロ犬を撮影していきます。

f:id:jujunjun110:20161014104522j:plain

こんな感じでフォトブースを途中まで組み立て、

f:id:jujunjun110:20161012132443j:plain

回転台を外した状態で青い布の下に本体をセットし、

f:id:jujunjun110:20161012132503j:plain

上に回転テーブルを固定すればセッティング完了です!

f:id:jujunjun110:20161012132506j:plain

ちょっと暗かったので斜め前からLED照明を当てています。

撮影開始

この状態でおもむろに電源を入れると、動き出します!※倍速にしてあります

https://media.giphy.com/media/3o7TKBCXGN1ilg24HS/giphy.gif

ちょっと分かりにくいですが、ターンテーブルが少し回ってはシャッターが切られているのが分かると思います。

そんな感じで撮影された写真がこちら。

壮観ですな。

f:id:jujunjun110:20161012135048p:plain

今回は特徴となりそうな点が多いので、全体が写っている写真がなくても問題ないと判断し、上からと下からの2アングルから、1周ずつ撮影しています。

クロマキーによる背景処理

Multi View Geometryは、本来固定されたオブジェクトに対しカメラを回転させて撮影することが前提なので、 今回のようにオブジェクト自体を回してしまうと、背景部分が回転していないため矛盾が生じてうまく合成することができません

そこで、ブルーバックの部分を削除するため、OpenCVを利用して簡易的なクロマキー合成を行います。

クロマキー処理の仕組み

#OpenCV HSV H:0-180, S:0-255, V:0-255
lower_color = np.array([100, 110, 30]) # 色空間の下限
upper_color = np.array([120, 255, 255]) # 色空間の上限

このような感じで、HSV形式で抜き出す色空間の下限と上限を設定します。

図にするとこんな感じ。

f:id:jujunjun110:20161012181441p:plain

色味的には青っぽいところでも、あまりに白や黒に近い部分はマスク対象にしないような設定であることが分かると思います。

なお、ふつう角度は0〜360°で表しますが、OpenCVにおいては角度は0〜180°で表します。したがって、本来「青」はHSV空間で200〜240°付近にあるのですが、コード上では1/2をかけて100〜120°と表現していることに注意しましょう。

撮影するオブジェクトやライティングによって青色の範囲は変わるはずなので、環境によってこの値は調整してみてもいいかもしれません。

Pythonで一気にクロマキー処理を行う

この処理を、撮影した全ての画像に対して行っていきます。

以下は、指定したディレクトリにある拡張子.JPGの画像全てにクロマキー処理をかけて、./chromakey/ 以下に配置するスクリプトです。

#!/usr/bin/python
import os, glob
import cv2
import numpy as np

def main():
    dir_name = "path/to/directory/"

    if not os.path.exists(dir_name + "chromakey"):
        os.mkdir(dir_name + "chromakey")

    img_paths = glob.glob(dir_name + "*.JPG")

    for img_path in img_paths:
        file_name = img_path.split("/")[-1]
        export_chromakey(dir_name, file_name)

def export_chromakey(dir_name, file_name):
    print file_name

    #OpenCV HSV H:0-180, S:0-255, V:0-255
    lower_color = np.array([100, 110, 30]) # 色空間の下限
    upper_color = np.array([120, 255, 255]) # 色空間の上限

    img = cv2.imread(dir_name + file_name);
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 画像をBGR形式からHSV形式に変換

    mask = cv2.inRange(hsv, lower_color, upper_color) # マスクを設定
    inv_mask = cv2.bitwise_not(mask) # マスクを反転
    result = cv2.bitwise_and(img, img, mask= inv_mask) # 画像からマスク部分を削除

    cv2.imwrite(dir_name + "chromakey/" + file_name, result)

if __name__ == '__main__':
    main()

f:id:jujunjun110:20161012182551p:plain

綺麗にヌケたね★

RealityCaptureで立体起こしを行う

さて、こんな感じで前処理が終わったのでReality Captureにぶっこんでいきましょう。

特徴点とカメラ位置の特定

f:id:jujunjun110:20161012191209p:plain

RealityCaptureにドラッグ&ドロップで画像を読み込ませ「Align Images」ボタンで特徴点の発見とカメラ位置の特定(SfM)を行います。



ドン!

f:id:jujunjun110:20161012191337g:plain

おおおー!!!かなり綺麗にいきました!

今回は80枚の画像を読ませたのですが、全てが一つのコンポーネント(特徴量で紐付けられる画像群)にまとまりました!ちなみに所要時間はハイスペックPCで2分程度。

f:id:jujunjun110:20161012191731p:plain

画像の周りに見えている、白い点々がそれぞれのカメラ位置です。

今回は上からのアングルと下からのアングルで1周ずつしたのがわかると思います。

f:id:jujunjun110:20161012191816p:plain

特徴点がこんな感じで緑の線で紐付けられています。

ちなみにぬいぐるみのような布状のものは特徴点を見つけるのがやりやすく、うまくいきやすいです。一方でテカテカした素材や、同じ色でのっぺりした丸っこい素材のオブジェクトは特徴量を見つけるのが難しいようで、うまくいきにくいので注意です。

ちなみに一回のAlign Images で一つのコンポーネントにまとまらない場合は、それぞれのコンポーネントの、同じような角度から撮られている画像同士に、手動で特徴点(control point)を指定してあげる必要があります。これがかなり根気のいる作業なので、一発で合成できたのはかなりラッキーですね。

モデルの生成

さて、この状態だと特徴点の集まりにすぎないので、次に「Normal Detaiil」もしくは「High Detail」ボタンで点同士の間をより丁寧に埋めていきます。(だいたい20分くらいかかる)

すると、このようにモデルができるので、

f:id:jujunjun110:20161012205728p:plain

ついで「Colorize」「Texture」と選択し、色とテクスチャを設定します。(これは1分くらいで終わる)

f:id:jujunjun110:20161012205557g:plain

さきほどより色がカバーされている部分が増え、ぬいぐるみっぽい質感に近づきましたね!

ちょっと後頭部の薄さは気になるところですが...。

メッシュの書き出し

最後に、オブジェクトを書き出していきます。対応拡張子はply, obj, xyz, partList の4つ。

今回は扱いやすいobjを選択し、Mayaで読み込んでみます!

できました!

f:id:jujunjun110:20161012221224g:plain

先程は穴になってしまっていた部分も周辺色で補完され、きっちり閉じた立体になっています。

アフロのふわふわ感もかなり綺麗に再現されています!

あとは土台の部分を取り除いたりすれば、そのままVRアプリケーションなどに使えますね!

... と言いたいところなのですが、一つ問題が。

今回作ったこのファイル、実は332MBもあります。

かなり細かく凹凸が再現されている分、ポリゴン数が300万を超えてしまっており、HTC Viveが動くようなハイスペックPCでも、Mayaで扱うとかなり重くなってしまっています。

VRにおいては、

  • 常に両目分レンダリングする
  • 酔いを防ぐため90fpsは欲しい(PS4のゲームでも30fpsのものが多い)

という事情もあるため、このモデルサイズは実はかなり厳しいです。

当然RealityCaputureの機能でローポリに落とすこともできるのですが、見た目のクオリティはかなり下がってしまうので、これをリアルタイムレンダリングに用いるのはもう少し処理速度の進歩を待つ必要があるかなと言った感じです。


以上、3Dスキャナ(の撮影部分)を自作してみた話でした!

...まだ難点もあるとはいえ、このクオリティの3Dスキャンが自作のツールで簡単にできるのは、かなり夢があるというのがお分かりいただけたかと思います。

今回は有料ソフトのRealityCaptureを利用しましたが、openMVGというオープンソースのライブラリもあるようなので、このあたりを使ってみて、完全自作でやってみるのも面白そうですね。


やっぱり自分で手を動かしてみて、最新の技術に触れるというのはいいもの。

VR室では、これからも「実写 × VR」で面白いものを作るために、研究開発を進めていきたいと思います!

まとめ

VOYAGE GROUP VR室ブログ、毎週水曜更新なので見てね!!!

vr-lab.voyagegroup.com

今回はオブジェクトを3Dスキャンしましたが、部屋そのものをスキャンした時の記事なんかもありますよ!

vr-lab.voyagegroup.com

f:id:jujunjun110:20161014105141j:plain

おわり。

はーたのしかった。

*1:モデルのエクスポートができない体験版は無料、基本機能が3ヶ月使えるPromoライセンスは99ドル

*2:当初はこれに直接制御された電流を流そうと思っていたが、慣性でピタッと止まらない欠点があるのでサーボモーターを使う方法に切り替えた。