はじめに (対象読者・この記事でわかること)

この記事は、Androidアプリ開発経験があり、Javaでカメラアプリ開発や画像処理に挑戦したい方を対象としています。特に、リアルタイムでの画像変換処理に興味がある方や、CameraX APIの具体的な活用方法を知りたい方に役立つでしょう。

この記事を読むことで、AndroidのCameraX APIを用いたカメラプレビューの取得、ImageAnalysisユースケースを活用したリアルタイムでの白黒変換処理、そして変換後の画像を撮影・保存するまでの一連の流れを理解し、実際に実装できるようになります。簡単なフィルターアプリの基礎を築くための一歩として、ぜひご活用ください。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 * Java言語の基本的な文法とオブジェクト指向プログラミングの概念 * Androidアプリ開発の基本的な知識(Activity, Fragment, レイアウトXML, Gradleの依存関係設定など) * Androidの非同期処理(Handler, Executor, Coroutineなど)の基本的な理解

Javaで実現するリアルタイム白黒カメラ:概要と技術選定

現代のスマートフォンには高性能なカメラが搭載されており、アプリ開発者はその機能を活用して様々な体験をユーザーに提供できます。本記事の目標は、単に写真を撮るだけでなく、プレビュー画面でリアルタイムに白黒変換された映像を確認しながら撮影・保存できるカメラアプリをJavaで実装することです。

リアルタイムでの画像処理は、パフォーマンスが重要になります。各フレームごとにピクセルデータを処理し、それを画面に再描画する必要があるため、効率的なAPI選択が鍵となります。Androidプラットフォームにおいて、カメラ機能にアクセスするための主要なAPIとしては、Camera2 APICameraX APIの2つがあります。

  • Camera2 API: 細かいカメラ設定(露出、ISO、フォーカスなど)まで柔軟に制御できる強力なAPIですが、その複雑さから学習コストが高く、ボイラープレートコードが多くなりがちです。
  • CameraX API: Googleが提供するJetpackライブラリの一部で、Camera2 APIの上に構築された抽象化レイヤーです。ライフサイクル対応、ユースケースベースのシンプルな設計、デバイス間の互換性向上など、開発のしやすさが大きなメリットです。プレビュー表示、写真撮影、動画撮影、画像解析といった主要な機能を簡単に実装できます。

本記事では、開発の容易さと現代的なアプローチを考慮し、CameraX APIを選択します。特に、リアルタイム画像解析にはImageAnalysisユースケースが非常に適しています。

白黒変換の原理は比較的シンプルです。カラー画像は通常、赤(R)、緑(G)、青(B)の3つの色成分で構成されています。これを白黒(グレースケール)に変換するには、各ピクセルのR, G, Bの値を平均化し、その平均値を新たなR, G, Bの値として設定します。例えば、newValue = (R + G + B) / 3 といった計算を行い、最終的なピクセルを (newValue, newValue, newValue) とすることで、グレースケール表現が可能です。より高度な変換では、人間の目の感度に合わせて各色に重み付けをする方法(例:newValue = 0.299 * R + 0.587 * G + 0.114 * B)も用いられますが、今回はシンプルな平均化で実装します。

CameraXとImageAnalysisを活用したリアルタイム白黒変換カメラの実装

それでは、具体的な実装手順に入りましょう。Android Studioを使用することを前提とします。

ステップ1: プロジェクトのセットアップと権限設定

まず、新しいAndroidプロジェクトを作成し、必要な依存関係と権限を追加します。

  1. 新しいAndroidプロジェクトの作成: Android Studioで「Empty Activity」テンプレートを選択し、言語をJavaに設定してプロジェクトを作成します。

  2. build.gradle (Module: app)にCameraXの依存関係を追加: 最新バージョンは適宜公式ドキュメントで確認してください。

    gradle // build.gradle (Module: app) dependencies { def camerax_version = "1.3.3" // 最新バージョンを使用 implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}" // PreviewViewなどを含む implementation "androidx.camera:camera-extensions:${camerax_version}" // オプション }

  3. AndroidManifest.xmlにカメラ権限を追加: インターネットアクセス権限は、画像のアップロードなどを行わない場合は不要です。ストレージへの保存を行う場合はWRITE_EXTERNAL_STORAGEも必要ですが、Android 10 (API 29) 以降ではMediaStoreへの保存時にこの権限は不要な場合があります。

    ```xml

    ... ```

  4. レイアウトファイルの準備 (activity_main.xml): カメラプレビューを表示するためのPreviewViewと、撮影ボタンを配置します。

    ```xml

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
    
    <ImageView
        android:id="@+id/imageViewOverlay"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scaleType="fitCenter"
        app:layout_constraintTop_toTopOf="@id/viewFinder"
        app:layout_constraintBottom_toBottomOf="@id/viewFinder"
        app:layout_constraintStart_toStartOf="@id/viewFinder"
        app:layout_constraintEnd_toEndOf="@id/viewFinder" />
    
    <Button
        android:id="@+id/camera_capture_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="32dp"
        android:text="撮影"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
    

    `` ここでImageViewOverlayは、白黒変換した結果をリアルタイムで表示するために追加します。CameraXのPreviewView`はそのままカラープレビューを表示するため、その上に白黒化した画像を重ねる形をとります。

ステップ2: カメラの初期化とプレビュー表示

MainActivity.javaでCameraXを初期化し、プレビューを表示するロジックを実装します。同時に、権限リクエストの処理も追加します。

Java
// MainActivity.java package com.example.monochromeapp; // プロジェクト名に合わせて変更 import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.camera.core.CameraSelector; import androidx.camera.core.ImageAnalysis; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageProxy; import androidx.camera.core.Preview; import androidx.camera.lifecycle.ProcessCameraProvider; import androidx.camera.view.PreviewView; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import android.Manifest; import android.content.ContentValues; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.ImageFormat; import android.graphics.Matrix; import android.graphics.Rect; import android.graphics.YuvImage; import android.media.Image; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; import android.util.Log; import android.util.Size; import android.widget.Button; import android.widget.ImageView; import android.widget.Toast; import com.google.common.util.concurrent.ListenableFuture; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.text.SimpleDateFormat; import java.util.Locale; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class MainActivity extends AppCompatActivity { private static final String TAG = "CameraXApp"; private static final int REQUEST_CODE_PERMISSIONS = 10; private static final String[] REQUIRED_PERMISSIONS = { Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE // Android Q以上では不要だが、古いOS対応のため }; private PreviewView viewFinder; private ImageView imageViewOverlay; private ImageCapture imageCapture; private ExecutorService cameraExecutor; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); viewFinder = findViewById(R.id.viewFinder); imageViewOverlay = findViewById(R.id.imageViewOverlay); Button captureButton = findViewById(R.id.camera_capture_button); cameraExecutor = Executors.newSingleThreadExecutor(); if (allPermissionsGranted()) { startCamera(); } else { ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS); } captureButton.setOnClickListener(v -> takePhoto()); } private boolean allPermissionsGranted() { for (String permission : REQUIRED_PERMISSIONS) { if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { return false; } } return true; } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_CODE_PERMISSIONS) { if (allPermissionsGranted()) { startCamera(); } else { Toast.makeText(this, "Permissions not granted by the user.", Toast.LENGTH_SHORT).show(); finish(); } } } private void startCamera() { ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(this); cameraProviderFuture.addListener(() -> { try { ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); // Preview Preview preview = new Preview.Builder().build(); preview.setSurfaceProvider(viewFinder.getSurfaceProvider()); // ImageCapture imageCapture = new ImageCapture.Builder() .setTargetRotation(viewFinder.getDisplay().getRotation()) .build(); // ImageAnalysis ImageAnalysis imageAnalysis = new ImageAnalysis.Builder() .setTargetResolution(new Size(640, 480)) // 解析解像度を調整 (パフォーマンスと品質のバランス) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST_IMAGE) .build(); // ImageAnalysis用のExecutorを指定 imageAnalysis.setAnalyzer(cameraExecutor, new MonochromeAnalyzer(bitmap -> { // UIスレッドでImageViewを更新 runOnUiThread(() -> imageViewOverlay.setImageBitmap(bitmap)); })); // Select back camera as a default CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA; // Unbind use cases before rebinding cameraProvider.unbindAll(); // Bind use cases to camera cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture, imageAnalysis); } catch (ExecutionException | InterruptedException e) { Log.e(TAG, "Error starting camera: " + e.getMessage()); } }, ContextCompat.getMainExecutor(this)); // UIスレッドでリスナーを実行 } // 省略:takePhoto()とMonochromeAnalyzerの実装は次のステップで // ... @Override protected void onDestroy() { super.onDestroy(); cameraExecutor.shutdown(); } }

ステップ3: ImageAnalysisユースケースによるリアルタイム画像処理

ImageAnalysisユースケースは、プレビューフレームからピクセルデータにアクセスし、リアルタイム処理を行うためのものです。ここで白黒変換ロジックを実装します。

画像データは通常YUV形式で取得されますが、Bitmapに変換して処理するのが一般的です。

まず、MonochromeAnalyzerクラスをMainActivity内にネストするか、別のファイルで定義します。

Java
// MainActivity.java (MonochromeAnalyzerクラスの部分) // ... (MainActivityクラスの他のコード) private class MonochromeAnalyzer implements ImageAnalysis.Analyzer { private final BitmapCallback callback; interface BitmapCallback { void onBitmapReady(Bitmap bitmap); } MonochromeAnalyzer(BitmapCallback callback) { this.callback = callback; } @Override public void analyze(@NonNull ImageProxy imageProxy) { Image image = imageProxy.getImage(); if (image == null) { imageProxy.close(); return; } // YUV_420_888 フォーマットをBitmapに変換 // ImageProxyからYUVデータを取得し、バイト配列に変換 ByteBuffer yBuffer = image.getPlanes()[0].getBuffer(); ByteBuffer uBuffer = image.getPlanes()[1].getBuffer(); ByteBuffer vBuffer = image.getPlanes()[2].getBuffer(); int ySize = yBuffer.remaining(); int uSize = uBuffer.remaining(); int vSize = vBuffer.remaining(); byte[] nv21 = new byte[ySize + uSize + vSize]; yBuffer.get(nv21, 0, ySize); vBuffer.get(nv21, ySize, vSize); uBuffer.get(nv21, ySize + vSize, uSize); YuvImage yuvImage = new YuvImage(nv21, ImageFormat.NV21, image.getWidth(), image.getHeight(), null); ByteArrayOutputStream out = new ByteArrayOutputStream(); yuvImage.compressToJpeg(new Rect(0, 0, yuvImage.getWidth(), yuvImage.getHeight()), 75, out); byte[] imageBytes = out.toByteArray(); Bitmap originalBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); // Bitmapを白黒に変換 Bitmap monochromeBitmap = toMonochrome(originalBitmap); // 必要に応じて回転 (PreviewViewの回転に合わせて) Matrix matrix = new Matrix(); matrix.postRotate(imageProxy.getImageInfo().getRotationDegrees()); Bitmap rotatedMonochromeBitmap = Bitmap.createBitmap(monochromeBitmap, 0, 0, monochromeBitmap.getWidth(), monochromeBitmap.getHeight(), matrix, true); callback.onBitmapReady(rotatedMonochromeBitmap); imageProxy.close(); // 必ずImageProxyを閉じる } private Bitmap toMonochrome(Bitmap originalBitmap) { int width = originalBitmap.getWidth(); int height = originalBitmap.getHeight(); Bitmap monochromeBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { int pixel = originalBitmap.getPixel(x, y); // 各色成分を取得 int red = Color.red(pixel); int green = Color.green(pixel); int blue = Color.blue(pixel); // シンプルな平均化でグレースケール値を計算 int gray = (red + green + blue) / 3; // 新しい白黒ピクセルを作成 int monochromePixel = Color.rgb(gray, gray, gray); monochromeBitmap.setPixel(x, y, monochromePixel); } } return monochromeBitmap; } }

MonochromeAnalyzerは、ImageProxyからYUVデータを取り出し、JPEG形式に圧縮してからBitmapにデコードしています。その後、toMonochromeメソッドで各ピクセルのRGB値を平均化して白黒変換を行い、最終的に変換されたBitmapImageViewOverlayに表示するためのコールバックを呼び出します。

ステップ4: 写真撮影と白黒画像の保存

ユーザーが撮影ボタンを押したときに、白黒変換された画像を保存するロジックを実装します。ImageCaptureユースケースを使用します。

Java
// MainActivity.java (takePhoto() メソッド) private void takePhoto() { if (imageCapture == null) { return; } // 保存ファイル名生成 (タイムスタンプ) String name = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.JAPAN).format(System.currentTimeMillis()); ContentValues contentValues = new ContentValues(); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/CameraX-Monochrome"); } // OutputFileOptionsの作成 ImageCapture.OutputFileOptions outputOptions = new ImageCapture.OutputFileOptions.Builder(getContentResolver(), MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) .build(); // 写真撮影リスナー imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), // UIスレッドでリスナーを実行 new ImageCapture.OnImageSavedCallback() { @Override public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { Uri savedUri = outputFileResults.getSavedUri(); if (savedUri != null) { Toast.makeText(MainActivity.this, "写真が保存されました: " + savedUri, Toast.LENGTH_LONG).show(); Log.d(TAG, "Photo capture succeeded: " + savedUri); // 保存された画像を読み込み、白黒変換してから再保存 // ※注意: CameraXのImageCaptureは、リアルタイムプレビューのImageAnalysisとは独立して // カラー画像を撮影します。そのため、撮影後にもう一度白黒変換が必要です。 try { Bitmap originalImageBitmap = MediaStore.Images.Media.getBitmap(getContentResolver(), savedUri); Bitmap monochromeBitmap = toMonochrome(originalImageBitmap); saveMonochromeImage(monochromeBitmap, name); // 白黒画像を保存するカスタムメソッド // 元のカラー画像は削除するか、そのまま残すかはアプリの要件による getContentResolver().delete(savedUri, null, null); // 今回は元のカラー画像を削除 } catch (Exception e) { Log.e(TAG, "Failed to convert and save monochrome image: " + e.getMessage()); Toast.makeText(MainActivity.this, "白黒変換と保存に失敗しました。", Toast.LENGTH_SHORT).show(); } } else { Toast.makeText(MainActivity.this, "写真の保存に失敗しました。", Toast.LENGTH_SHORT).show(); Log.e(TAG, "Photo capture failed: Saved URI is null."); } } @Override public void onError(@NonNull ImageCaptureException exception) { Log.e(TAG, "Photo capture failed: " + exception.getMessage(), exception); Toast.makeText(MainActivity.this, "写真の撮影に失敗しました。", Toast.LENGTH_SHORT).show(); } } ); } // Bitmapを白黒に変換するヘルパーメソッド (MainActivityのメソッドとして定義) private Bitmap toMonochrome(Bitmap originalBitmap) { int width = originalBitmap.getWidth(); int height = originalBitmap.getHeight(); Bitmap monochromeBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); for (int x = 0; x < width; x++) { for (int y = 0; y < height; y++) { int pixel = originalBitmap.getPixel(x, y); int red = Color.red(pixel); int green = Color.green(pixel); int blue = Color.blue(pixel); int gray = (red + green + blue) / 3; // シンプルな平均化 int monochromePixel = Color.rgb(gray, gray, gray); monochromeBitmap.setPixel(x, y, monochromePixel); } } return monochromeBitmap; } // 白黒BitmapをMediaStoreに保存するカスタムメソッド private void saveMonochromeImage(Bitmap monochromeBitmap, String fileName) { ContentValues contentValues = new ContentValues(); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName + "_monochrome.jpg"); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/CameraX-Monochrome"); } Uri imageUri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues); if (imageUri != null) { try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) { monochromeBitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); getContentResolver().openOutputStream(imageUri).write(stream.toByteArray()); Toast.makeText(this, "白黒写真が保存されました: " + imageUri, Toast.LENGTH_LONG).show(); Log.d(TAG, "Monochrome photo saved: " + imageUri); } catch (Exception e) { Log.e(TAG, "Failed to save monochrome image: " + e.getMessage()); Toast.makeText(this, "白黒写真の保存に失敗しました。", Toast.LENGTH_SHORT).show(); } } }

重要な注意点: CameraXのImageCaptureは、通常、生のカラー画像を撮影します。ImageAnalysisでプレビューを白黒に変換して表示していても、ImageCaptureで撮影されるのは元画像です。そのため、撮影後にその画像を読み込み、再度白黒変換を適用してから保存するという二段階の処理が必要になります。上記のコードでは、一度カラー画像を保存し、すぐに読み込んで白黒変換後、白黒画像を保存し直しています。元のカラー画像を削除するかどうかは、アプリの要件に応じて判断してください。

ハマった点やエラー解決

  1. 権限エラー: java.lang.IllegalArgumentException: Missing permissions: android.permission.CAMERA など、権限不足でクラッシュすることがよくあります。AndroidManifest.xmlでの宣言と、実行時の権限リクエスト(ActivityCompat.requestPermissions)の両方が必要です。Android 6.0 (API 23) 以降では実行時権限が必要です。

  2. YUV_420_888フォーマットの複雑さ: ImageProxy.getImage()から得られるImageはYUV_420_888形式であることが多く、これを直接Bitmapに変換するのは手間がかかります。各プレーン(Y, U, V)からByteBufferを取得し、YuvImageを介してJPEGに圧縮後、BitmapFactory.decodeByteArrayでBitmapにするのが一般的なアプローチですが、パフォーマンスオーバーヘッドがあります。

  3. UIスレッドでの重い処理によるANR (Application Not Responding): ImageAnalysis.analyze()メソッド内でピクセルごとの処理やBitmap変換のような重い処理を直接実行すると、UIスレッドがブロックされ、アプリが応答しなくなります。これはImageAnalysis.setAnalyzerに渡すExecutorをUIスレッドとは別のバックグラウンドスレッドプールに設定することで回避できます。上記のコードではExecutors.newSingleThreadExecutor()を使用しています。また、imageViewOverlay.setImageBitmapはUIの更新なのでrunOnUiThreadでUIスレッドに戻す必要があります。

  4. ImageProxyを閉じるのを忘れる: ImageAnalysis.analyze()メソッドで受け取ったImageProxyは、処理が完了したら必ずimageProxy.close()を呼び出す必要があります。これを怠ると、メモリリークや、新しいフレームが処理されないなどの問題が発生します。

  5. リアルタイムプレビューと撮影画像の不一致: 上記「ステップ4」の注意点で説明した通り、ImageAnalysisImageCaptureは異なるユースケースであり、ImageCaptureは通常、処理されていない元の画像を撮影します。リアルタイムプレビューで見た通りの画像を保存するには、撮影後にもう一度画像処理を施す必要があります。

解決策

  • 権限: AndroidManifest.xmlに宣言し、アプリ起動時にActivityCompat.requestPermissionsを使ってユーザーに許可を求め、onRequestPermissionsResultで結果を処理します。
  • YUV to Bitmap: YuvImageByteArrayOutputStreamを使った標準的な変換ロジックを利用します。より高度なパフォーマンスが必要な場合は、RenderScriptやOpenGL ESを使った直接的なYUV to RGB変換を検討しますが、実装が複雑になります。
  • ANR対策: ImageAnalysis.setAnalyzer(cameraExecutor, new MonochromeAnalyzer(...))のように、cameraExecutor(バックグラウンドスレッド)を使用します。UI更新はrunOnUiThread()を介して行います。
  • ImageProxyclose(): analyzeメソッドの最後に必ずimageProxy.close()を記述します。try-finallyブロックで囲むのがより安全です。
  • 撮影画像の処理: ImageCapture.OnImageSavedCallback内で、保存された画像を再度読み込み、白黒変換を適用した上で新しいファイルとして保存するロジックを実装します。

まとめ

本記事では、AndroidのCameraX APIとJavaを活用し、リアルタイムでカメラプレビューを白黒変換しながら写真を撮影・保存するカメラアプリの実装方法 を解説しました。

  • CameraXの活用: 現代的なAndroidカメラ開発におけるCameraX APIの優位性を理解し、PreviewImageCaptureImageAnalysisの各ユースケースを効果的に使用しました。
  • ImageAnalysisによるリアルタイム画像処理: ImageAnalysisユースケースを通じて、カメラから取得したYUV_420_888形式の画像データをBitmapに変換し、ピクセル単位での白黒変換処理を実装しました。
  • 非同期処理とパフォーマンス: UIスレッドをブロックしないよう、画像処理をバックグラウンドスレッドで実行し、UI更新はメインスレッドに戻すというAndroidアプリ開発の基本的なベストプラクティスを適用しました。
  • 画像の保存: 撮影した画像を白黒変換後、MediaStoreを利用してデバイスのギャラリーに保存する手順を学びました。

この記事を通して、Androidにおける実践的なカメラアプリ開発の基礎と、リアルタイム画像処理の一歩を踏み出すことができたでしょう。今後は、白黒以外の様々なフィルターの追加、OpenGL ESを用いたより高速な画像処理、顔検出などの高度な機能統合にも挑戦してみてください。

参考資料