第7回 「ズーム機能の導入」 | エドさんのAndroid AR講座

第7回 「ズーム機能の導入」


今回はカメラのプレビューコールバック機能を使って、ズーム機能を実装します。


本講座の最初にも書いたとおり、第 8 回までの内容では、カメラ画像を解析してオブジェクトを認識するなどの要素が入っていないため、カメラ画面と、アニメーションを描画するAR画面とはまったく独立です。したがって、今回のズーム機能も、アニメーションとは切り離して実装することができます。つまり、第 7 回のコードはほとんど変更せず、そこへの追加だけで大部分は済ませることができるというわけです。


今回追加する機能は次の 3 つです。
(1) カメラから送られてくるプレビュー画像からズーム画像を作成する。
(2) ズーム画像を描画する。
(3) ズームボタンなどの U/I を実装する。


(1) に入る前に簡単に CamView クラスの復習をしておきましょう。第 1 回の講義の末尾にあるとおり、CamView クラスの中に setPreviewCallback() という関数がありますが、ここに画像受け取り用のコールバック関数を登録しておくと、連写されたカメラ画像のデータが次々とコールバック関数に送られてきます。この機能はこれまでまったく利用してきませんでしたが、ようやく今回、(1) でズーム画像の作成に利用することになります。


AimnShoot.java のコールバック関数は次のようになっています。


AimnShoot.java (1)

    /******************************************************************************
* Camera Data Processing
******************************************************************************/
private CamData mCamData = null;
private Camera.PreviewCallback mPrevCallback = new Camera.PreviewCallback()
{
@Override
public void onPreviewFrame(byte[] data, Camera camera)
{
if(isTracking || isFinishing() || (camera == null)){
return;
}
Camera.Parameters params = camera.getParameters();
Camera.Size size = params.getPreviewSize();
mCamData = new CamData(size.width, size.height);
if((mCamData.yuv == null) || (data.length != mCamData.yuv.length)){
mCamData.yuv = new byte[data.length];
}
System.arraycopy(data, 0, mCamData.yuv, 0, data.length);
mHandler.sendEmptyMessage(MSG_PROCESS_CAMDATA);
}
};
protected void procCamdata()
{
if((! isTracking) && (! isFinishing())){
new Thread(new TrackTargets()).start();
}
}


このように定義した mPrevCallback を登録すると、カメラ画像が撮影されるたびに onPreviewFrame() が呼び出されます。この中の処理は以下のとおりです。


まず、isTracking か isFinishing() が true のとき、また引数の camera が null のときには何もせずにデータを捨てて戻ります。isTracking は後述しますが、ズームデータ作成中のとき true にしています (将来のミサイル追跡と同じ枠組みでズームを処理するために isTracking という変数名になっています)。isFinishing() はアクティビティに対して定義されている関数で、終了処理が始まると true になります。また、camera が null (開始時、終了時) の場合にはデータが取得できません。


無事、処理に入ると、まず画像サイズを取得し、そのサイズに基づいてカメラデータクラス (後述) のインスタンスを生成し、取得した画像データ (バイト列) をコピーします。その後、この画像データに対する処理を続行するのですが、このコールバック関数の処理はカメラ側のスレッド上で行われているので、アプリ側に処理を戻すためにメッセージハンドラに MSG_PROCESS_CAMDATA というメッセージを送ります。


ハンドラの変更点については後でまとめて説明することとして、上記のメッセージの結果、すぐ下の procCamdata() が呼び出されることになります。この中では、新たにスレッドを立ち上げて TrackTargets クラスの run() を実行します。実質的な処理はすべてこの run() の中で行われます。これをメインとは別のスレッド上で実行する理由は、アプリ全体のコントロールや U/I を担当するメインスレッドへ大きな負荷がかかることを防ぐためです。


次に、上で出てきたカメラデータクラスについて説明します。


CamData.java

package com.artiscc.aimnshoot;

public class CamData
{
public int width = 0;
public int height = 0;
public byte[] yuv = null;
private int[][] cacheRGB;
private boolean[][] isCachedRGB;
public CamData(int width, int height)
{
this.width = width;
this.height = height;
this.cacheRGB = new int[height][width];
this.isCachedRGB = new boolean[height][width];
}
public int getRGB(int i, int j)
{
if(! isCachedRGB[j][i]){
cacheRGB[j][i] = decodeRGB(i, j);
isCachedRGB[j][i] = true;
}
return cacheRGB[j][i];
}
private int decodeRGB(int i, int j)
{
if(this.yuv == null){
return 0;
}
final int frameSize = this.width * this.height;
int yp = j * this.width + i;
int y = (0xff & ((int) this.yuv[yp])) - 16;
int y1192 = (y < 0) ? 0 : 1192*y;

int uvp = frameSize + (j >> 1)*this.width + i - (i & 1);
int u = (0xff & this.yuv[uvp+1]) - 128;
int v = (0xff & this.yuv[uvp]) - 128;

int r = (y1192 + 1634 * v);
int g = (y1192 - 833 * v - 400 * u);
int b = (y1192 + 2066 * u);
r = (r < 0) ? 0 : ((r > 262143) ? 262143 : r);
g = (g < 0) ? 0 : ((g > 262143) ? 262143 : g);
b = (b < 0) ? 0 : ((b > 262143) ? 262143 : b);

return 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
}
}


カメラ画像のデータは YUV420 という形式で取得されるので、このクラスではそれを RGB に変換して提供します。ただ、最初に全体のデータを変換しておく方式は時間がかかるので、必要なピクセルだけをそのつど変換してキャッシュしています。飛び飛びのピクセルを参照して縮小画像を作ったり、一部の領域のみを取り出したりする処理に用いるには、このようなキャッシュ方式の方が効率が良くなります。


次に、再び AimnShoot.java に戻って TrackTargets クラスの内容を説明します。


AimnShoot.java (2)

    /******************************************************************************
* Tracking Targets
******************************************************************************/
private boolean isTracking = false;
class TrackTargets implements Runnable
{
@Override
public void run()
{
if(isTracking || (mCamData == null) || (mCamData.yuv == null)){
return;
}
isTracking = true;
if(zoomLevel > 1){
drawZoomImage();
}
isTracking = false;
}
}

/******************************************************************************
* Drawing Zoom Image
******************************************************************************/
private Bitmap zoomBitmap = null;
private void drawZoomImage()
{
float ratio = (float)mCamView.getHeight() / (float)mCamView.getWidth();
int zoomWidth = mCamData.width / zoomLevel;
int zoomHeight = Math.min((int)(zoomWidth * ratio), mCamData.height);
int[] zoomRGB = new int[zoomWidth * zoomHeight];
int xbeg = (mCamData.width - zoomWidth)/2;
int ybeg = (mCamData.height - zoomHeight)/2;
int xend = xbeg + zoomWidth;
int yend = ybeg + zoomHeight;
if(mCamView.getHeight() > mCamView.getWidth()){
for(int p = 0, j = ybeg; j < yend; j++){
for(int i = xend - 1; i >= xbeg; i--){
zoomRGB[p++] = mCamData.getRGB(j, i);
}
}
}else{
for(int p = 0, j = ybeg; j < yend; j++){
for(int i = xbeg; i < xend; i++){
zoomRGB[p++] = mCamData.getRGB(i, j);
}
}
}
zoomBitmap = Bitmap.createBitmap(zoomRGB, zoomWidth, zoomHeight, Config.ARGB_8888);
zoomRGB = null;
mHandler.sendEmptyMessage(MSG_SET_ZOOM);
}


前述の procCamdata() の中から、新しいスレッド上で TrackTargets クラスの run() が呼び出されます。ここでは、処理がビジーでないか、データが揃っているかを確認し、問題がなければ処理に入ります。処理そのものは単純で、zoomLevel が 1 よりも大きいときのみ drawZoomImage() を実行するというものです。なお、zoomLevel という変数は後述の U/I の中で設定されますが、1 から 5 までの整数値を取り、ズームの倍率を表します。


drawZoomImage() では、画面中央の小領域の RGB データを抽出してビットマップ画像を作り、それを ARView 画面に送ることにより、画面一杯に拡大して表示させます。小領域のサイズは zoomWidth = mCamData.width / zoomLevel のような式で決定しますが、カメラ画面のサイズが、たとえば480×854 (px) であるのに対し、取得したプレビュー画像のサイズは、たとえば 480×640 (px) であるなどと、両者のアスペクト比が異なるため、プレビュー画像のままズームすると倍率が大きいときに歪みが目立つ画像になってしまいます。そこで、zoomWidth を上記の式で決定した後、zoomHeight の方はカメラ画面のアスペクト比に従って zoomWidth から計算しています。


次に、mCamView.getHeight() > mCamView.getWidth() という条件で端末のオリエンテーション (縦置きか横置きか) を判定します。横置きならば問題ないのですが、縦置きの場合は取得された画像が反時計方向に 90 度回転されている (元の右辺(長い辺)が上辺にくる) ことに注意する必要があります。いずれにしても、そのようにして画面中心部の小領域から RGB データを抽出して、1 次元の int 型配列 zoomRGB に格納することができたら、Bitmap.createBitmap() を用いてそれをビットマップに変換します。


最後にこのビットマップを ARView 画面に表示すればよいわけですが、ARView 画面に対する操作は ARView を生成したスレッド (メイン) にしか許されていないので、制御をメインに移すために MSG_SET_ZOOM というメッセージをハンドラに送信します。なお、ハンドラに関しては後にまとめて説明します。


では次に、ARView クラスへの追加分について説明します。


ARView.java

package com.artiscc.aimnshoot;
...
public class ARView extends View
{
...
@Override
public void onDraw(Canvas canvas)
{
if((main.zoomLevel > 1) && (bmpZoomImage != null)){
canvas.drawColor(0xffcccccc);
canvas.drawBitmap(bmpZoomImage, null, new Rect(0, 0, getWidth(), getHeight()), null);
}
if((main.zoomButton != null) && (bmpBtnZoomIn != null) && (bmpBtnZoomOut != null)){
if(main.zoomLevel < 5){
canvas.drawBitmap(bmpBtnZoomIn, getWidth()/2+2, getHeight()-69, null);
}
if(main.zoomLevel > 1){
canvas.drawBitmap(bmpBtnZoomOut, getWidth()/2-112, getHeight()-69, null);
}
}
if((main.mTarList == null) || (main.mTarList.isEmpty()) || isDrawing){
return;
}
...
}

private Bitmap bmpZoomImage = null;
public void setZoomBitmap(Bitmap b)
{
bmpZoomImage = b;
invalidate();
}
...
}


ここでは、ミサイルや爆発のアニメーションよりも下のレイヤにズーム画像を表示するため、onDraw() の冒頭部分にズーム画像に対する canvas.drawBitmap() を追加します。ただし、今回の drawBitmap() の呼び出しの引数は他の呼び出しとは異なっていて、第 1 引数が描画するビットマップ、第 2 引数がその画像のどの長方形領域を描画するか、第 3 引数がそれを画面上のどの長方形領域に描画するかを表します。ここでは、第 2 引数が null でビットマップ全体を表し、第 3 引数が画面全体を表す Rect となっているため、上記で抽出した小領域の画像が画面全体に描画されることになるわけです。


また、その行の上にある canvas.drawColor(0xffcccccc) はズーム画像が送られてこないときに通常画面がちらちら顔を出すのを防ぐため、画面全体を灰色で覆うことを表しています。


なお、それに続く bmpBtnZoomIn と bmpBtnZoomOut の描画は、画面下辺のボタン領域へのズームボタンの描画を表しています。このボタンはメインの zoomButton タイマーが null でないときのみ表示される+形と-形のボタンで、zoomLevel が 5 以上のときはズームインボタン (+) が表示されず、1 以下のときはズームアウトボタン (-) が表示されないようにしてあります。ボタン画像例は次のとおりです。


$エドさんのAndroid AR講座-btnzoomout.png$エドさんのAndroid AR講座-btnzoomin.png
btnzoomout.pngbtnzoomin.png


次にズームボタンの U/I を例示します。


AimnShoot.java (3)

    /******************************************************************************
* Operation on Targets
******************************************************************************/
public HashMap mTarList = null;
private int mTarId = 0;
public boolean isWaitingForAttack = false;
public int zoomLevel = 1;
public Timer zoomButton = null;
private void screenTapped(int x, int y)
{
int scrWidth = mCamView.getWidth();
int scrHeight = mCamView.getHeight();
if(y < scrHeight - 88){
if(! isWaitingForAttack){
attackTarget(x, y);
}
}else if((x > scrWidth - 145) && (y > scrHeight - 88)){
if(isWaitingForAttack){
attackAllTargets();
isWaitingForAttack = false;
}
}else if((zoomLevel > 1) && (x > scrWidth/2 - 110) && (x < scrWidth/2 - 5) && (y > scrHeight - 88)){
zoomLevel--;
mARView.postInvalidate();
}else if((zoomLevel < 5) && (x > scrWidth/2 + 5) && (x < scrWidth/2 + 110) && (y > scrHeight - 88)){
zoomLevel++;
mARView.postInvalidate();
}
}
private void screenTouched(int x, int y)
{
int scrWidth = mCamView.getWidth();
int scrHeight = mCamView.getHeight();
if((mTarList.size() == 0) && (x > 150) && (x < scrWidth - 150) && (y > scrHeight - 88)){
MultiTimerTask zbTask = new MultiTimerTask(this, -1);
if(zoomButton != null){
zoomButton.cancel();
}
zoomButton = new Timer(true);
zoomButton.schedule(zbTask, 10000L);
mARView.postInvalidate();
}
}
...
/******************************************************************************
* Gesture Detection
******************************************************************************/
...
class OnGestureListener extends GestureDetector.SimpleOnGestureListener
{
...
@Override
public boolean onDown(MotionEvent ev)
{
screenTouched((int)ev.getX(), (int)ev.getY());
return super.onDown(ev);
}
...
}


まず、変数として zoomLevel と zoomButton が追加されています。後者はズームボタンを表示後、一定時間で消すためのタイマーを保持します。


screenTapped() には、前回のコードに加えて、ボタン領域のズームボタンがタップされた場合の処理が追加されています。


screenTouched() は今回新たに実装した関数で、画面のボタン領域がタッチされると、zoomButton に新しいタイマーを設定します。これはズームボタンを表示するためのインタフェースで、最後にタッチされてから 10 秒経過すると消えるように設定されます。なお、タイマーが発火するときに呼ばれるタイマータスクとして、前回までは Target クラス内部の AnimationTimerTask を利用してきましたが、このようにアプリ内部で汎用的に用いるため、この部分を MultiTimerTask クラスとして独立させることにしました。このタイマータスク生成時の引数としては、0 以上の値はターゲット ID として用いられているので、ズームボタンのためには -1 を用いることにしました。


MultiTimerTask クラスを次に示します。


MultiTimerTask.java

package com.artiscc.aimnshoot;

import java.util.TimerTask;
import android.content.Context;

class MultiTimerTask extends TimerTask
{
private Context main;
private int id;
public MultiTimerTask(Context ctx, int id)
{
this.main = ctx;
this.id = id;
}
@Override
public void run()
{
((AimnShoot)main).tickTimer(this.id);
}
}


内容は AnimationTimerTask とまったく同じで、ただ名前が変わっているだけです。また、Target クラスの方は、この部分を削除して呼び出し時のクラス名を変えただけなので、ここでは省略します。


駆け足ですが、最後にハンドラ関連のコードを掲げます。


AimnShoot.java (4)

    @Override
public void onCreate(Bundle savedInstanceState)
{
...
mHandler.sendEmptyMessageDelayed(MSG_START_CAMERA, 250);
}
...

/******************************************************************************
* Message Handler
******************************************************************************/
protected static final int MSG_TIMER_TICKED = 1;
protected static final int MSG_START_CAMERA = 2;
protected static final int MSG_PROCESS_CAMDATA = 3;
protected static final int MSG_SET_ZOOM = 4;
Handler mHandler = new Handler()
{
@Override
public void handleMessage(Message msg)
{
if(isFinishing()){
return;
}
switch(msg.what){
case MSG_TIMER_TICKED:
if(msg.arg1 < 0){
if(zoomButton != null){
zoomButton.cancel();
}
zoomButton = null;
mARView.postInvalidate();
}else{
showNextFrame(msg.arg1);
}
break;
case MSG_START_CAMERA:
mCamView.setPreviewCallback(mPrevCallback);
break;
case MSG_PROCESS_CAMDATA:
procCamdata();
break;
case MSG_SET_ZOOM:
mARView.setZoomBitmap(zoomBitmap);
break;
}
}
};

/******************************************************************************
* Timer Task
******************************************************************************/
public void tickTimer(int tarId)
{
mHandler.sendMessage(mHandler.obtainMessage(MSG_TIMER_TICKED, tarId, 0));
}


これまでにまだ説明に出てきていないメッセージは onCreate() の中で発行される MSG_START_CAMERA です。このメッセージは、カメラ起動を待つため、アプリ起動後 250 ミリ秒だけ遅らせてハンドラに送られます。


handleMessage() の中では、今回は 4 つのメッセージを扱います:

MSG_TIMER_TICKED
msg.arg1 が負か否かによってタイマー発火時の処理を区別します。
MSG_START_CAMERA
mCamView.setPreviewCallback() を呼び出し、mPrevCallback をコールバック関数として設定します。
MSG_PROCESS_CAMDATA
上述のように procCamdata() を呼び出します。
MSG_SET_ZOOM
mARView.setZoomBitmap() を呼び出してARView 画面にズーム画像を表示します。


以上で、ズーム画像が表示できるシューティングゲームのアプリが完成しました。ただ、実際に動かしてみると、ズーム画面が 1 秒あたり 3~4 フレームとかなり重いため、スムーズな表示が得られないことがわかります。次回は、これを解決するためにネイティブコードを用いる方法を説明します。




第1部 ARアプリ作成の基本

第1回: カメラ画面の表示
    画面上にカメラのプレビュー画像を表示し続けるだけのアプリで、ARの基本中の基本です。
第2回: AR画面の表示
    カメラ画面の上にAR画面を載せ、カメラ画面上にいろいろな画像を重ね合わせます。
第3回: ジェスチャの認識
    タップ、ダブルタップ、長押し、フリックなどの画面操作を認識し、対応する処理を行います。
第4回: アニメーションの表示
    タイマーを用いてミサイルが目標に向かって飛んでいく単純なアニメーションを表示します。
第5回: 複雑なアニメーションの表示
    ミサイルが当たった後の爆発や残像の表示など、複雑なアニメーションを表示します。
第6回: 複数の目標の扱い
    同時に複数個の目標を設定し、それぞれをミサイルで撃破する枠組みを導入します。
第7回: ズーム機能の導入
    ズームボタンを設置し、カメラ画面をズームして表示する機構を手作りで実装します。
第8回: ネイティブコードの利用
    ズーム機構を高速化するため、C、C++ を使ったネイティブコードを導入します。

第2部 ARアプリ作成の応用

近日アップ予定! 乞う御期待!