実はまだ作ったことなかったのでAndroidでカメラアプリを作ってみました。
作成中順調にすべての地雷を踏み抜いてる感じが…。
Surfaceの縦横比がおかしい…。縦画面だと90度回転しちゃう…。プレビューサイズ指定するとエラー。AF効いてない…。プレビューのRAWイメージどうやって処理すんの…。
ここでは参考サイトの写経とともに順を追ってカメラアプリを作っていきます。
環境
OS:Windows8 64bit
IDE:Eclipse 4.2 + ADT ver.22
Android機:SONY Xperia GX (Android4.3)
とりあえず撮影出来るバージョン
Eclipse+ADT(ver.22)でプロジェクトを作りました。
ここでは、「screen2camera」という名称でプロジェクトを作成。
デフォルトのテンプレートで、「Brank Activity」を選択。
1つのActiviyとその中にはいる1つのFragmentが作成されます。
これをベースにカメラ撮影のコードを組み込んでいきます。
参考サイト
以下のサイトをおもいっきり参考にさせてもらっています。
カメラの使用方法(1)
[カメラの使用方法(2)] (http://techbooster.org/android/device/362/)
ソース
生成されたコードを以下のように変更します。
package com.dokokano.screen2camera;
import java.io.FileOutputStream;
import android.support.v7.app.ActionBarActivity;
import android.support.v7.app.ActionBar;
import android.support.v4.app.Fragment;
import android.app.ActionBar.LayoutParams;
import android.hardware.Camera;
import android.hardware.Camera.Size;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.os.Build;
public class MainActivity extends ActionBarActivity {
final static private String TAG = "screen2camera";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (savedInstanceState == null) {
getSupportFragmentManager().beginTransaction().add(R.id.container, new CameraFragment()).commit();
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.action_settings) {
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* カメラ撮影用フラグメント
*/
public static class CameraFragment extends Fragment {
// ------------------------------------------------------------
// メンバー変数
// ------------------------------------------------------------
private Camera camera_; // カメラインスタンス
View rootView_; // ルートView
SurfaceView surfaceView_; // プレビュー用SurfaceView
// ------------------------------------------------------------
// リスナー
// ------------------------------------------------------------
// Surfaceリスナー
private SurfaceHolder.Callback surfaceListener_ = new SurfaceHolder.Callback() {
// Surface作成
public void surfaceCreated(SurfaceHolder holder) {
// カメラインスタンスを取得
camera_ = Camera.open();
try {
camera_.setPreviewDisplay(holder);
} catch (Exception e) {
e.printStackTrace();
}
}
// Surface破棄時
public void surfaceDestroyed(SurfaceHolder holder) {
// カメラインスタンス開放
camera_.release();
camera_ = null;
}
// Surface変更時
// プレビューのパラメーターを設定し、プレビューを開始する
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
Log.d(TAG, "surfaceChanged width:" + width + " height:" + height);
Camera.Parameters parameters = camera_.getParameters();
// デバッグ用表示
Size size = parameters.getPictureSize();
Log.d(TAG, "getPictureSize width:" + size.width + " size.height:" + size.height);
size = parameters.getPreviewSize();
Log.d(TAG, "getPreviewSize width:" + size.width + " size.height:" + size.height);
// プレビューのサイズを変更
// parameters.setPreviewSize(width, height); // 画面サイズに合わせて変更しようとしたが失敗する
parameters.setPreviewSize(640, 480); // 使用できるサイズはカメラごとに決まっている
// パラメーターセット
camera_.setParameters(parameters);
// プレビュー開始
camera_.startPreview();
}
};
// シャッターが押されたときに呼ばれるコールバック
private Camera.ShutterCallback shutterListener_ = new Camera.ShutterCallback() {
public void onShutter() {
}
};
// JPEGイメージ生成後に呼ばれるコールバック
private Camera.PictureCallback pictureListener_ = new Camera.PictureCallback() {
// データ生成完了
public void onPictureTaken(byte[] data, Camera camera) {
// SDカードにJPEGデータを保存する
if (data != null) {
FileOutputStream fos = null;
try {
fos = new FileOutputStream(Environment.getExternalStorageDirectory().getPath()+ "/camera_test.jpg");
fos.write(data);
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
// プレビューを再開する
camera.startPreview();
}
}
};
// 画面タッチ時のコールバック
OnTouchListener ontouchListener_ = new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (camera_ != null) {
// 撮影実行
camera_.takePicture(shutterListener_, null, pictureListener_);
}
}
return false;
}
};
// ------------------------------------------------------------
// Fragment
// ------------------------------------------------------------
// Fragmentコンストラクタ
public CameraFragment() {
}
// View作成
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// View作成
rootView_ = inflater.inflate(R.layout.fragment_main, container, false);
// View内のView取得
surfaceView_ = (SurfaceView) rootView_ .findViewById(R.id.surface_view);
// SurfaceHolder設定
SurfaceHolder holder = surfaceView_.getHolder();
holder.addCallback(surfaceListener_);
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
// タッチリスナー設定
rootView_.setOnTouchListener(ontouchListener_);
return rootView_;
}
}
}
fragmenに、カメラプレビュー用のSurfaceViewを追加します。
上下左右のマージンは不要なので除外しました。
※ activity_main.xmlは編集不要です。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.dokokano.screen2camera.CameraFragment" >
<SurfaceView
android:id="@+id/surface_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
Manifestに以下のパーミッションを追加します。
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
実行結果
縦画面
プレビュー表示。
縦画面なのに90度回転してしまっています。
しかもSurfaceViewのサイズにあわせて引き伸ばされてしまっています。
横画面
端末を横にするとActivityが再作成されて、横画面に。
縦画面よりはマシですが、横に引き伸ばされてしまっています。
撮影された写真
画面タッチで撮影実行です。
撮影時には自動的にシャッター音が鳴りました。
4128x3096で撮影されました。(以下の写真はリサイズしてあります)
縦横比は合ってますね。
ただしAFが作動しておらずピントがあっていません。
ハマり点・問題点
プレビューサイズ指定
surfaceChangedで画面サイズにあわせプレビューサイズも以下のように変更しました。
parameters.setPreviewSize(width, height);
が、このままcamera_.setParametersするとパラメーターエラーの例外が発生します。
java.lang.RuntimeException: setParameters failed
プレビューサイズはカメラごとに決められた特定のサイズでしか指定できません(指定できるサイズは別途APIで列挙できる)。たまたま画面サイズが合っていれば良いですが、画面サイズが変則的だとエラーになります。
640x480決め打ちではOKでした。
parameters.setPreviewSize(640, 480);
※XperiaGXの場合、初期値は640x480でした。
プレビューサイズがSurfaceViewのサイズに引き伸ばされるので縦横比が狂う
→SurfaceViewのサイズを動的に変更して縦横比を修正しましょう。
縦位置の場合プレビューが90度回転してしまう
→プレビューのパラメーターを設定することにします。
撮影画像のピントが合っていない
→AFの設定をすることにします
撮影処理中に再撮影しようとすると例外発生
Exception in MessageQueue callback: handleReceiveCallback
→処理中はフラグたてて再撮影禁止にしよう
プレビューの縦横比調整
SurfaceViewはwrap_contentでサイズ指定されていますので画面サイズにあわせて引き伸ばされてしまいます。
画面への表示後に、LayoutParamsを再設定して縦横比をあわせます。
ここで注意事項はonCreateViewでViewを作成した時点ではまだ、サイズが確定してない(0px)なので画面への表示がおわってから処理を行う必要があります。
Activityの場合は、onWindowFocusChanged() メソッドのなかで取得すればよいのですが、Fragmentにはありません。
解決策としてViewTreeObserverを使用してレンダリング終了後にリスナーを呼んでもらい、その中で処理を行います。
参考サイト
ソース
とりあえずプレビューサイズは640x480に決め打ちします。
// ------------------------------------------------------------
// 定数
// ------------------------------------------------------------
// プレビューサイズ
static private final int PREVIEW_WIDTH = 640;
static private final int PREVIEW_HEIGHT = 480;
onCreate()ないでViewTreeObserverのリスナーを登録
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// View作成
rootView_ = inflater.inflate(R.layout.fragment_main, container, false);
// View内のView取得
surfaceView_ = (SurfaceView) rootView_ .findViewById(R.id.surface_view);
// SurfaceHolder設定
SurfaceHolder holder = surfaceView_.getHolder();
holder.addCallback(surfaceListener_);
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
// タッチリスナー設定
rootView_.setOnTouchListener(ontouchListener_);
// 画面縦横比設定のためViewTreeObserverにリスナー設定
rootView_.getViewTreeObserver().addOnGlobalLayoutListener(
new OnGlobalLayoutListener() {
// レイアウト完了時
@Override
public void onGlobalLayout() {
boolean isLandscape = rootView_.getWidth()>rootView_.getHeight(); // 横画面か?
ViewGroup.LayoutParams svlp = surfaceView_.getLayoutParams();
if ( isLandscape ) {
// 横画面
svlp.width = surfaceView_.getHeight() * PREVIEW_WIDTH / PREVIEW_HEIGHT;
svlp.height = surfaceView_.getHeight();
}else{
// 縦画面
svlp.width = surfaceView_.getWidth();
svlp.height = surfaceView_.getWidth() * PREVIEW_HEIGHT / PREVIEW_WIDTH;
}
surfaceView_.setLayoutParams(svlp);
}
});
return rootView_;
}
実行結果
カメラのAFを有効にする
画面をタッチするといきなりシャッターを切るのではなくて、AFを作動させてからAF完了後に撮影するようにします。
CameraのautoFocusメソッドにリスターを渡すと、AFが開始され、AF完了するとリスナーが呼ばれます。
リスナーの中で撮影を実行します。
AF処理を行うには追加のパーミッションが必要なので追加します。
また最初のサンプルでは、撮影処理中に再度撮影処理を行うと例外が発生していました。フラグ管理します。
##参考サイト
Android カメラに autofocus を実装する。
ソース
AF処理を行うには追加のパーミッションが必要なので追加します。
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
画面タッチ時にAutoFocusを開始(ここでは撮影はまだしない)
※ inPregress_フラグをみて撮影処理中は実行しない
// 画面タッチ時のコールバック
OnTouchListener ontouchListener_ = new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (camera_ != null && !inPregress_) {
// 撮影実行(AF開始)
camera_.autoFocus(autoFocusListener_);
}
}
return false;
}
};
AF完了時のコールバック
この中で撮影処理を行う。
※ 撮影中フラグをたてる。
// AF完了時のコールバック
private Camera.AutoFocusCallback autoFocusListener_ = new Camera.AutoFocusCallback() {
@Override
public void onAutoFocus(boolean success, Camera camera) {
inPregress_ = true; // 処理中フラグ
camera.autoFocus(null);
camera_.takePicture(shutterListener_, null, pictureListener_);
}
};
PictureCallback#onPictureTaken() ないで処理中フラグをクリアする。
// プレビューを再開する
camera.startPreview();
inPregress_ = false; // 処理中フラグをクリア
実行結果
タッチするとAF動作し、その後シャッターが切られるようになりました。
撮影された写真はちゃんとピントが合っています。
AFポイントは指定していないので、カメラまかせになっています(中央かな?)。
気になる点
撮影が終了すると、一旦あっていたAFが元に戻ってしまいます。
(プレビュー画面ではピントがずれている)
撮影画像はちゃんとピントが合っているので実用上は問題ないのですが、気になりますね。
camera.autoFocus(null);
を削除しても同様でした。
takePicture
メソッドを読んだ時点でAFが初期状態に戻ってしまっているようです。
常にAFを合わせて置く場合は、撮影後に自動的に autoFocusを呼ぶ必要がありそうです。
また、一旦AFが合った場合、カメラを移動しても自動的に再度AFを合わせてくれるわけではないので、なんらかの操作ボタンが必要そうです。
※ AF状態を取得できるメソッドがあればいいのですが…
縦画面の際に90度回転しないようにする
まだ縦画面の際にプレビューが90度回転してしまいます。
参考サイト
[画面を縦向きにしたときのSurfaceViewのCameraプレビュー]http://niudev.blogspot.jp/2012/11/surfaceviewcamera.html
ソース
サーフェイスサイズ変更イベントの中で、縦画面、横画面を判定してそれに応じてpreviewのパラメーターとCameraの設定を変更しています。
※ 縦横判定方法は適当な暫定版です
// Surface変更時
// プレビューのパラメーターを設定し、プレビューを開始する
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
Log.d(TAG, "surfaceChanged width:" + width + " height:" + height);
Camera.Parameters parameters = camera_.getParameters();
// 縦画面の場合回転させる
if ( rootView_.getWidth() < rootView_.getHeight()) {
// 縦画面
// parameters.setRotation(90);
camera_.setDisplayOrientation(90);
}else{
// 横画面
// parameters.setRotation(0);
camera_.setDisplayOrientation(0);
}
またSurfaceViewの縦横比を設定する処理は、縦画面の際は縦長になるように変更します。
※ 定数のPREVIEW_WIDTHは長辺の長さなので、ここでは高さとして使用されています。わかりずらいですね…
※ プレビューサイズより画面のほうが縦長という前提の処理です(高さ方向のサイズを調整)。プレビューサイズのほうが縦長の場合は、横幅方向を調整するようにしないといけません(現状はそうなっていません)
// 縦画面
// svlp.width = surfaceView_.getWidth();
// svlp.height = surfaceView_.getWidth() * PREVIEW_HEIGHT / PREVIEW_WIDTH; // (*480/640)
svlp.width = surfaceView_.getWidth();
svlp.height = surfaceView_.getWidth() * PREVIEW_WIDTH / PREVIEW_HEIGHT; // (*640/480)
実行結果
よし縦画面でのプレビューも縦長になったぞ。
※ プレビュー画面なのでAFは合ってません
問題点
保存された画像が横向き
縦画面で撮影しても保存された画像が横向きです><
参考にしたサイトではparameters.setRotation(90);
で保存画像もただしい向きになるとのことでしたが効きませんでした。
保存画像の向きを修正
縦画面で撮影された画像が横向き画像で保存されてしまうのを修正します。
参考サイトでの方針では撮影した画像をCanvasで回転させています。
参考サイト
Androidで縦向き(Portrait)でカメラを使う方法 (主にAndroid2.x向け)
Androidのカメラの向きに関する覚え書き
ソース
撮影後のJpegイメージ生成後、そのままバイナリーで保存しないで一旦BITMAPにデコードし、回転してから保存しています。
// JPEGイメージ生成後に呼ばれるコールバック
private Camera.PictureCallback pictureListener_ = new Camera.PictureCallback() {
// データ生成完了
public void onPictureTaken(byte[] data, Camera camera) {
// SDカードにJPEGデータを保存する
if (data != null) {
int rotation = getActivity().getWindowManager().getDefaultDisplay().getRotation();
int degrees = 0; //端末の向き(度換算)
switch (rotation) {
case Surface.ROTATION_0: degrees = 0; break;
case Surface.ROTATION_90: degrees = 90; break;
case Surface.ROTATION_180: degrees = 180; break;
case Surface.ROTATION_270: degrees = 270; break;
}
Matrix m = new Matrix(); //Bitmapの回転用Matrix
m.setRotate(90-degrees); // 向きが正しくなるように回転角度を補正
Bitmap original = BitmapFactory.decodeByteArray(data, 0, data.length);
Bitmap rotated = Bitmap.createBitmap( original, 0, 0, original.getWidth(), original.getHeight(), m, true);
FileOutputStream fos = null;
try {
fos = new FileOutputStream(Environment.getExternalStorageDirectory().getPath()+ "/camera_test.jpg");
rotated.compress(CompressFormat.JPEG, 100, fos);
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
original.recycle();
rotated.recycle();
// プレビューを再開する
camera.startPreview();
inPregress_ = false; // 処理中フラグをクリア
}
}
};
デフォルトの撮影データを回転処理するとOutOfMemoryになるので、撮影サイズをプレビューサイズに合わせています。
parameters.setPictureSize(PREVIEW_WIDTH, PREVIEW_HEIGHT);
実行結果
縦画面で撮影すれば、保存画像も縦方向になるようになりました。
問題点
一旦BITMAPにデコードするのでExif情報が欠落する
撮影画像が大きいとOutOfMemoryになる
しかたがないので、parameters.setPictureSize(PREVIEW_WIDTH, PREVIEW_HEIGHT); で撮影サイズ事態を小さくしている。
うーん。これはみんなどうしてるんだー
撮影後の画像ファイルのEXIF情報の向き情報を書き換えるの?
プレビュー画像の取得(無音カメラ化)
プレビュー画像はSurfaceViewに表示されていますが、この画像を取得してみます。
シャッターを切らずに撮影画像が取得できるので、無音カメラアプリはこの方式をとっているはずです。
プレビュー画像を取得する場合は、Camera.PreviewCallback
コールバックを作成し、Cameraに設定します。
渡されてくるデータはRAWデータ(バイナリー)なので、Bitmapに保存するためにはデコード処理が必要になります。
これについては、decodeYUV420SPコードを利用してBitmapに変換しています。
RAWデータ(機種依存)が、YUV 4:2:0フォーマットである必要がありますが、大抵の機種はこれで対応できるようです。
##参考サイト
カメラプレビューをキャプチャする
Issue 823: byte[] image data in onPreviewFrame is currently useless
※ここで紹介されていたdecodeYUV420SPのコードを利用させていただいています。
ソース
// プレビューコールバック
private final Camera.PreviewCallback previewCallback_ = new Camera.PreviewCallback() {
public void onPreviewFrame(byte[] data, Camera camera) {
Log.d(TAG, "onPreviewFrame");
camera_.setPreviewCallback(null); // プレビューコールバックを解除
// 画像のデコード
Bitmap decodeBitmap = null;
int[] rgb = new int[(PREVIEW_WIDTH * PREVIEW_HEIGHT)];
try {
decodeBitmap = Bitmap.createBitmap(PREVIEW_WIDTH, PREVIEW_HEIGHT, Bitmap.Config.ARGB_8888);
decodeYUV420SP(rgb, data, PREVIEW_WIDTH, PREVIEW_HEIGHT);
decodeBitmap.setPixels(rgb, 0, PREVIEW_WIDTH, 0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT);
} catch (Exception e) {
}
if (decodeBitmap != null) {
int rotation = getActivity().getWindowManager().getDefaultDisplay().getRotation();
int degrees = 0; //端末の向き(度換算)
switch (rotation) {
case Surface.ROTATION_0: degrees = 0; break;
case Surface.ROTATION_90: degrees = 90; break;
case Surface.ROTATION_180: degrees = 180; break;
case Surface.ROTATION_270: degrees = 270; break;
}
Matrix m = new Matrix(); //Bitmapの回転用Matrix
m.setRotate(90-degrees); // 向きが正しくなるように回転角度を補正
Bitmap rotated = Bitmap.createBitmap( decodeBitmap, 0, 0, decodeBitmap.getWidth(), decodeBitmap.getHeight(), m, true);
FileOutputStream fos = null;
try {
fos = new FileOutputStream(Environment.getExternalStorageDirectory().getPath()+ "/camera_preview.jpg");
rotated.compress(CompressFormat.JPEG, 100, fos);
fos.close();
} catch (Exception e) {
e.printStackTrace();
}
decodeBitmap.recycle();
rotated.recycle();
}else{
// Bitmapデコード失敗
Log.d(TAG, "onPreviewFrame bitmap decode error");
}
// 処理完了
inPregress_ = false; // 処理中フラグをクリア
}
};
// YUV420フォーマット RAWデコード
static public void decodeYUV420SP(int[] rgb, byte[] yuv420sp, int width, int height) {
final int frameSize = width * height;
for (int j = 0, yp = 0; j < height; j++) {
int uvp = frameSize + (j >> 1) * width, u = 0, v = 0;
for (int i = 0; i < width; i++, yp++) {
int y = (0xff & ((int) yuv420sp[yp])) - 16;
if (y < 0) y = 0;
if ((i & 1) == 0) {
v = (0xff & yuv420sp[uvp++]) - 128;
u = (0xff & yuv420sp[uvp++]) - 128;
}
int y1192 = 1192 * y;
int r = (y1192 + 1634 * v);
int g = (y1192 - 833 * v - 400 * u);
int b = (y1192 + 2066 * u);
if (r < 0) r = 0; else if (r > 262143) r = 262143;
if (g < 0) g = 0; else if (g > 262143) g = 262143;
if (b < 0) b = 0; else if (b > 262143) b = 262143;
rgb[yp] = 0xff000000 | ((r << 6) & 0xff0000) | ((g >> 2) & 0xff00) | ((b >> 10) & 0xff);
}
}
}
// AF完了時のコールバック
private Camera.AutoFocusCallback autoFocusListener_ = new Camera.AutoFocusCallback() {
@Override
public void onAutoFocus(boolean success, Camera camera) {
if ( false ) {
// 通常撮影
camera.autoFocus(null);
camera_.takePicture(shutterListener_, null, pictureListener_);
}else{
// 無音撮影
camera_.setPreviewCallback(previewCallback_);
}
inPregress_ = true; // 処理中フラグ
}
};
AF完了時のコールバックで、通常撮影の代わりにcamera_.setPreviewCallback(previewCallback_);
でプレビューコールバックを設定しています。
プレビューが取得されると、プレビューコールバックpreviewCallback_
にRAWイメージが渡されます。
(プレビューデータは1枚あればいいので、ここでプレビューコールバックを解除しています)
decodeYUV420SP
でYUV420フォーマットとしてデコードしてBitmapを得ます。
縦画面の場合は、回転したイメージのため90度回転してからファイルに保存しています。
camera_.stopPreview();
とcamera.startPreview();
で一時的にプレビューを停止・再開していますが、この処理は不要かもしれません。
※ 止めないとdataが乱れることがあるという記事があったので念のためいれてある
実行結果
また撮影時も無音です。
また、takePictureを呼んでいないので一旦合ったAFは解除されません。
これはうまくいきましたねー
それはそうと、RAWデータにはExif情報はいってないぽいのですが、よくある無音カメラアプリでExif情報が保存されるものはどうやってうんでしょう?自前で追加してるの?
今日はこのへんにしておいてやる
プレビューをリアルタイム画像加工して、オーバーレイ表示したかったのですがうまくいきませんでした。
次はがんばろう…