Google Glassで作る近未来アプリケーション(5)
Google Glassでハンドジェスチャーを認識させてみよう
Google Glassの上で動くジェスチャーUIの実装方法を、サンプルコードを交えながら解説する。
はじめに
前回は、NDKやOpenCV、OpenGL ESを使用したGoogle Glass上で動くARアプリの実装を解説した。今回は、Google Glass上で動くジェスチャーUIの実装にチャレンジしてみよう。
サンプルプロジェクトの概要
今回はスワイプのハンドジェスチャーをGoogle Glassのカメラで検出して、ビューを左右に切り替えることのできる、GlassHandGestureというアプリを題材とする。今回もソースコードは全てGitHub上に公開している。
GlassHandGestureは音声コマンド「Ok glass, start hand gesture」で起動できる。起動すると通常のカードUIが表示されるが、Glassの前で手を左右に振ることでカードの左右を行き来できる。
動作イメージが分かりやすいように、以下にデモムービーを載せる。
ハンドジェスチャー検出の仕組み
このサンプルプロジェクトでは、ハンドジェスチャーを検出するのに非常に原始的な方法を取っている。まずカメラのフレーム画像から、肌色とマッチする領域を検出し、その部分を手と見なす。手の位置が一定時間内にフレームの左から右へと移動していれば右へのスワイプ、右から左へ移動していれば左へのスワイプと見なしている。
肌色検出については、照明などの環境変化に影響を受けやすいため、精度を高めるためにいくつか工夫をしている。まず上記のムービーには出てこないが、アプリ起動中にタップしてメニューを起動すると、「肌色キャプチャ画面」に遷移できる(図1)。この画面で自分の肌色部分をキャプチャすることにより、現在の環境下における肌色のデータに最適化された肌色検出が可能になる。
また、カメラフレームからの肌色検出では、RGBではなく、HSV色空間でのマッチングを行っており、キャプチャされた肌色画像に対してH(Hue:色相)とS(Saturation:彩度)のレンジが近い領域を、肌色として検出している。これはRGBと比較して、HSV色空間は人間の色の認識と親和性があり、色相と彩度の組み合わせは照明変化に対して変化が少ないからである。
図2は、色相・彩度の値が照明変化に対して強いことを直感的に図示したものである。右側が色相を一定の値で切り取った断面で、色相・彩度が一定ならば、V(Value:明度)の値が変化しても知覚する色としては変化がないことが分かる。
環境構築と、プロジェクトのビルド
用意すべき環境とビルドの方法は、前回と同じである。Android SDK(GDK), Android NDK, OpenCV4Android SDKをセットアップしてビルドしてみよう。
プロジェクトの構成
前回同様、本プロジェクトは、srcフォルダー以下のJava部分とjniフォルダー以下のNative部分(C/C++)に大別される。Java部分がカメラプレビューの取得と、手の位置に基づくスワイプジェスチャーの検出を、Native部分は画像から手の部分を検出する役割を担っている。
Java/Native | クラス名/ソース名 | 役割 |
---|---|---|
Java | MainActivity | ハンドジェスチャーの動作対象となるビューを表示させる画面 |
CaptureActivity | 肌色部分のキャプチャを行う画面 | |
HandGestureDetector | 毎フレームごとに検出された手の位置から、ジェスチャーの検出を行う | |
NativeBridge | JNI(Java Native Interface)のメソッドを宣言し、Java/ネイティブ間の橋渡しを行う | |
JavaCameraViewEx | カメラプレビューの取得と描画を行う。OpenCVライブラリが提供しているJavaCameraViewクラスを本プロジェクト用に独自に修正したクラス | |
Native | handgesture_jni.cpp | JNIの実体となるネイティブ関数を定義する |
Tracker.cpp | 肌色検出によって、手の領域を検出する |
処理の概要
カメラパラメーターの調整
本プロジェクトでは、肌色検出とジェスチャー検出の精度を高めるために、カメラのパラメーター調整を行っている。
まず1つがホワイトバランスの固定化である(次のコード)。Google Glassのカメラはデフォルトの状態では、被写体の明るさによってホワイトバランスが自動で調整されて、キャプチャされた画像の色あいも変化してしまう。この状態では肌色検出の精度も大きく下がってしまうので、APIを使ってホワイトバランスを固定化する。
protected boolean initializeCamera(int width, int height) {
……省略……
Camera.Parameters params = mCamera.getParameters();
params.setWhiteBalance(Camera.Parameters.WHITE_BALANCE_SHADE);
……省略……
}
|
また、カメラから取得できるプレビュー画像サイズも、前回のARプロジェクトでは640×320のサイズだったのに比べ、320×192と小さくしている(次のコード)。これは、640×320のサイズでは、ジェスチャー検出のために十分なフレームレートを獲得できないためである。
public void onCreate(Bundle savedInstanceState) {
……省略……
mOpenCvCameraView = (CameraBridgeViewBase) findViewById(R.id.fd_activity_surface_view);
mOpenCvCameraView.setCameraIndex(Constants.CAMERA_INDEX);
mOpenCvCameraView.setCvCameraViewListener(this);
mOpenCvCameraView.setMaxFrameSize(320, 192);
……省略……
}
|
肌色画像のキャプチャ
実際の毎フレームごとに肌色検出を行うために、比較対象となる肌色画像をキャプチャする。以下のように、CaptureActivityクラスのonCameraFrameメソッドでカメラプレビュー画像のコピーを保存しておき、[Capture]ボタンが押されたタイミングでファイルシステムに画像を保存している。
public Mat onCameraFrame(CvCameraViewFrame inputFrame) {
mFrame = inputFrame.rgba();
if (Constants.FLIP) {
Core.flip(mFrame, mFrame, 1);
}
// カメラフレーム画像のコピーを保存
mCaptured = mFrame.clone();
Core.rectangle(mFrame, new Point(130, 90), new Point(190, 150), new Scalar(255, 0, 0), LINE_WIDTH, 8, 0);
Log.d(L.TAG, "onCameraFrame col:" + mFrame.cols() + ", rows:" + mFrame.rows());
return mFrame;
}
……省略……
private void finishSelecting() {
Log.d(L.TAG, "imwrite");
Mat cropped = mCaptured.submat(new Rect(130, 90, 60, 60));
// ファイルシステムに選択された肌色画像を保存
Highgui.imwrite(mMarkerFile.getAbsolutePath(), cropped);
}
|
ViewPagerの準備
本プロジェクトでは、ハンドジェスチャーの動作対象としてAndroid Support Libraryに含まれるViewPagerクラスを使う。GDKにも、左右にスクロール可能なカードUIを実装するためのCardScrollViewが含まれているのだが、このクラスはプログラムから、スクロール処理を呼び出せないため、代わりにViewPagerを使用している。
public void onCreate(Bundle savedInstanceState) {
……省略……
mCardScrollView = new ViewPager(this);
mCardScrollView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// ビューをタップした際に、キャプチャ画面へ遷移させるためのメニューを表示する
openOptionsMenu();
}
});
// ViewPager内の各ビューを生成するためのAdapterをセット
mCardScrollView.setAdapter(new SamplePagerAdapter(this));
……省略……
}
|
private static final String menus[] = new String[] { "Google", "Take picture", "Record video", "Message", "Google",
"Take picture", "Record video", "Message", "Google", "Take picture", "Record video", "Message" };
private static final int images[] = new int[] { R.drawable.image1, R.drawable.image2, R.drawable.image3,
R.drawable.image4, R.drawable.image5, R.drawable.image6, R.drawable.image1, R.drawable.image2,
R.drawable.image3, R.drawable.image4, R.drawable.image5, R.drawable.image6 };
……省略……
@Override
public Object instantiateItem(ViewGroup container, int position) {
// ViewPager内の位置に応じたカード型のビューを生成する
Card card = new Card(mContext);
card.setText(menus[position]);
card.setFootnote("card #" + position);
card.addImage(images[position]);
View view = card.toView();
container.addView(view);
return view;
}
|
肌色検出・手の領域検出
カメラフレームから肌色領域を検出するには、キャプチャした肌色画像とのマッチングを行うのだが、より正確には、カラーヒストグラムを使ったバックプロジェクションという手法を用いる。
カラーヒストグラムとは、画像内の色の分布を表したもので、色をいくつかの値の範囲に分割して、それぞれの範囲に該当するピクセルが画像内にいくつあるのかをカウントしたものだと思えばよい。
バックプロジェクションというのは、画像内のピクセルが、ヒストグラムモデルのピクセル分布とどのくらい適合しているかを記録する方法だ。
今回のプロジェクトでは、あらかじめキャプチャされた肌色画像に対してHue値(色相)とSaturation(彩度)のヒストグラムを計算しておき、カメラフレームに対して、肌色ヒストグラムのバックプロジェクションを求めることで、肌色に近い領域を検出している。
バックプロジェクションで検出された領域(図3)はノイズが目立つため、平滑化フィルターを施してノイズ除去を行う。図4がノイズ除去を行った画像だが、飛び地のノイズが除去されたのと、ノイズによって分断されていた領域をつなげられたのが分かると思う。
ノイズ除去された画像から、手の領域を求めるために、輪郭線を抽出する。この輪郭線で囲まれた領域のうち最大面積のものを求め、面積が一定のしきい値を超えていれば、手の領域として検出する。
以下がこれらの処理を行っているTracker.cppファイルのソースである。
Rect findMarker (Mat& img, MatND& hist, int* find) {
Mat hsv;
// HSVのバックプロジェクションを行うために色空間をBGRからHSVに変更
cvtColor(img, hsv, CV_BGR2HSV);
// バックプロジェクションの結果を格納するための画像準備
Mat backProject(hsv.size(), CV_8UC1);
// バックプロジェクションのためのHue・Saturation値の範囲を定義
float hranges[] = { 0, 179 };
float sranges[] = { 0, 255 };
const float* ranges[] = { hranges, sranges };
int channels[] = {0, 1};
// バックプロジェクションによってhsvの各ピクセルのhistとの適合度合いを、backprojectに格納する
// (histには、キャプチャされた肌色画像のヒストグラムが格納されている)
calcBackProject(&hsv, 1, channels, hist, backProject, ranges, 1, true);// Calculate back projection
// calcBackProjectで得られるのはグレイスケールの画像なので、一定のしきい値で白黒画像化する
threshold(backProject, backProject, 30, 255, CV_THRESH_BINARY);
// ノイズ除去
GaussianBlur(backProject, backProject, Size(5,5), 1, 1);
// 輪郭線の検出
vector<vector<Point> > contours;
vector<Vec4i> hierarchy;
findContours(backProject, contours, hierarchy, CV_RETR_TREE, CV_CHAIN_APPROX_SIMPLE, Point(0, 0));
// 輪郭線で囲まれている領域のうち、面積最大の領域を求める
Rect maxRect;
double maxArea = 0.0F;
for (int i = 0; i < contours.size(); i++) {
Rect rect = boundingRect(Mat(contours[i]));
double area = contourArea(contours[i]);
if (area > maxArea) {
maxRect = rect;
maxArea = area;
}
}
// 最大面積の領域があるしきい値を下回る場合は、検出失敗とする
if (maxArea > AREA_THRESHOLD) {
*find = 1;
} else {
*find = 0;
}
return maxRect;
}
|
スワイプジェスチャーの検出
最後に、毎フレームごとに検出された手の位置を記録して、スワイプジェスチャーを検出する。
この処理は、画像を左・中央・右の3区間に分割し、一定時間内(ここでは800ミリ秒としている)に左から右に移動したら右へのスワイプ、右から左なら左へのスワイプと見なしている。
また左から右へ移動したのか、左からいったんスクリーン外に出て右から現れたのかなどを区別するため、ある一定回数(ここでは3回としている)以上連続して検出に失敗すると、前回記録した位置をリセットする処理も行っている。
// 手の位置に対して、左・中央・右のどの区間に属する求める
private State currentState(Rect rect) {
int centerX = rect.x + rect.width / 2;
if (centerX > FRAME_WIDTH - PADDING) {
return State.RIGHT;
} else if (centerX < PADDING) {
return State.LEFT;
}
return State.MIDDLE;
}
public void handle(Rect rect) {
if (rect == null) {
// 手が検出できなかった場合
// ある一定回数連続して検出に失敗すると、前回記録した左・中央・右の位置をリセットする
lostCount++;
if (lostCount > MAX_SEQ_LOST) {
lastState = State.LOST;
}
return;
}
lostCount = 0;
State state = currentState(rect);
long currentTime = System.currentTimeMillis();
switch (state) {
case MIDDLE:
case LOST:
break;
case LEFT:
if (lastState == State.RIGHT && (currentTime - lastStateTime) < TIME_THRESHOLD) {
// 右から左へ一定時間内に移動した場合、左へのスワイプと見なす
if (mHandGestureListener != null) {
mHandGestureListener.onLeftMove();
}
}
lastState = State.LEFT;
lastStateTime = currentTime;
break;
case RIGHT:
if (lastState == State.LEFT && (currentTime - lastStateTime) < TIME_THRESHOLD) {
// 左から右へ一定時間内に移動した場合、右へのスワイプと見なす
if (mHandGestureListener != null) {
mHandGestureListener.onRightMove();
}
}
lastState = State.RIGHT;
lastStateTime = currentTime;
break;
}
}
|
まとめ
本連載では、Google Glassアプリケーション開発の基礎知識から、ARを活用したアプリや、ジェスチャー認識アプリの開発まで解説してきた。画像処理の内容が多かったが、もちろんそれ以外にもGoogle Glassの特徴が生きる分野は数多くあり、チャンスは大きいといえるだろう。
また、グーグルは先日、イタリアの有名ブランドとの提携や、1日限りだがUS在住者なら誰でもGoogle Glassを購入できるようにする(日本時間では、ちょうどこの記事の公開の少し前)などの発表を行った。グーグルがいかに本気であるかも伺えるし、今後もGoogle Glassのコミュニティはますます拡大していくと予想される。
本連載でも度々書いているように、Google GlassアプリのUI以外の機能の大半は、既存のAndroidデバイス上でも開発できる。興味のある読者はぜひ、今からGoogle Glassアプリ開発を始めてほしい。Google Glassによる近未来の世界はもうそこまで近づいてきている。
※以下では、本稿の前後を合わせて5回分(第2回~第6回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
2. GDKで開発できるGoogle Glassアプリの機能とは?
GDKを使うとGoogle Glassの機能をフルに活用したアプリを開発できる。3種類のUI(Static Card/Live Card/Immersion)/タッチジェスチャー/音声認識/位置情報/センサー/カメラなど、GDKの全体像を、コードを示しながら解説する。
3. 初めてのGoogle Glassアプリ開発(GDK編)
いよいよGlass開発を実践。GDKの開発環境の構築手順と、Glassware(=Glassアプリ)の作成/実行方法を説明。また、サンプルアプリのソースコードを読み解きながら、よりGlassらしいアプリの実装方法について説明する。
4. Google Glassで動くARアプリケーションの実装
Google Glassの「眼鏡型」という特性を生かしたAR(Augmented Reality: 拡張現実)アプリの開発を、サンプルソースを交えながら解説。
6. Google Glass XE16で追加された新機能「Bluetooth LE」「多言語対応」「新たなアイ・ジェスチャー」
Google GlassのアップデートXE16で追加された主要な新機能をサンプルコードを交えて解説。特にBLE/iBeaconサポートは要注目だ。