はじめに
2019/5月のGoogle I/Oで紹介された CameraX
が気になったので、試して見ました。
(実際にはチュートリアルをちょっと改造しただけです・・・。)
CameraXとは
CameraX は、カメラアプリの開発を容易にすることを目的とした Jetpack ライブラリです。新しいアプリの場合は、CameraX から始めることをおすすめします。ほとんどの Android デバイスで機能する一貫性のある使いやすい API を提供するとともに、Android 5.0(API レベル 21)への下位互換性も備えています。
公式より引用
要するに、今までのCameraやCamera2を使いやすくして、さらに古いAndroid(5.0)でも使えるようにしたよ、というライブラリになります。
チュートリアル
公式がチュートリアルを出しています。
https://codelabs.developers.google.com/codelabs/camerax-getting-started/
今回はこれを進めていきました。
対応環境
- AndroidAPI21以上の端末 or エミュレータ
- Android Studio ArcticFox2020.3.1以降
1. プロジェクトの作成
Gradleへの記述
KTSで書いているので、groovyをお使いの方は変換して記述してください
以下を記述します。CameraX は Camera2 の機能を使用しているので、Camera2も含めます。
1.1.0-beta01
からはすべてのライブラリが同じバージョンで導入出来る様になったようです。(今まではそれぞれ違うバージョンが必要だったとか)
val cameraxVersion = "1.1.0-beta03"
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation("androidx.camera:camera-video:${cameraxVersion}")
implementation("androidx.camera:camera-view:${cameraxVersion}")
implementation("androidx.camera:camera-extensions:${cameraxVersion}")
レイアウト作成
androidx.camera.view.PreviewView
のViewにプレビューが表示されます。
自分は画面いっぱいに画面が表示されて欲しかったので、縦横を0dp
で指定しています。
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.camera.view.PreviewView
android:id="@+id/camera"
android:layout_height="0dp"
android:layout_width="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Activityクラスの作成
ベースとなるActivityクラスを作成します。
class MainActivity {
private lateinit var viewBinding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
}
}
2. 必要なパーミッションを制御する
ActivityManifest.xml
に以下を追記して、必要なpermissionを設定します。
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
次に、定数群を作成します。
class MainActivity : AppCompatActivity() {
//...
companion object {
private val TAG = MainActivity::class.java.simpleName
private const val REQUEST_CODE_PERMISSIONS = 10
// 必要なpermissionのリスト
private val REQUIRED_PERMISSIONS =
mutableListOf (
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
).apply {
// WRITE_EXTERNAL_STORAGEはPie以下で必要
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}.toTypedArray()
}
}
そして、必要なpermissionがすべて反映されているか確認するメソッドを実装
class MainActivity : AppCompatActivity() {
//...
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
//...
}
permissionの許可結果を受け取るメソッドをオーバーライド
class MainActivity : AppCompatActivity() {
//...
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults:
IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
// カメラ開始処理
} else {
Toast.makeText(this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT).show()
finish()
}
}
}
//...
}
最後にonCreate内でpermission許可要求を出します。
class MainActivity : AppCompatActivity() {
//...
override fun onCreate(savedInstanceState: Bundle?) {
// ViewBindingの設定など...
if (allPermissionsGranted()) {
// permissionは得られているので、カメラ始動
} else {
// permission許可要求
ActivityCompat.requestPermissions(
this,
REQUIRED_PERMISSIONS,
REQUEST_CODE_PERMISSIONS
)
}
}
//...
}
このpermission要求の書き方、スマートな感じで良いですね(語彙力)
3. カメラが映るようにする
ついにカメラの映像を画面に反映させます。
まずは、カメラを始動させるためのstartCamera
メソッドを実装します。
class MainActivity : AppCompatActivity() {
//...
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
// ライフサイクルにバインドするために利用する
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// PreviewのUseCase
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(viewBinding.camera.surfaceProvider)
}
// アウトカメラを設定
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
// バインドされているカメラを解除
cameraProvider.unbindAll()
// カメラをライフサイクルにバインド
cameraProvider.bindToLifecycle(
this,
cameraSelector,
preview
)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
}
あとはカメラを起動したいタイミングでstartCamera
を呼び出します。
class MainActivity : AppCompatActivity() {
//...
override fun onCreate(savedInstanceState: Bundle?) {
// ViewBindingの設定など...
if (allPermissionsGranted()) {
// permissionは得られているので、カメラ始動
startCamera()
} else {
// permission許可要求
ActivityCompat.requestPermissions(
this,
REQUIRED_PERMISSIONS,
REQUEST_CODE_PERMISSIONS
)
}
}
//...
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults:
IntArray
) {
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()
}
}
}
//...
}
これだけでカメラが映るようになるのか・・・(感動)
これで、ライフサイクルに紐付いてくれているので、スリープしたときにも勝手にカメラがOFFになってくれます。めっちゃ便利。
4. 写真を撮れるようにする
先ほどのはプレビューだけですので、写真を撮れるようにします。
まずは写真を撮るためのボタンを追加します。
<ImageButton
android:id="@+id/capture_button"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_margin="24dp"
app:srcCompat="@android:drawable/ic_menu_camera"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
ImageCaptureというUseCaseを使う事で写真を撮ります。
まずはメンバーとしてImageCaptureを持たせます。
class MainActivity : AppCompatActivity() {
//...
private var imageCapture: ImageCapture? = null
//...
}
定数として保存するファイル名のフォーマットを定義します。
class MainActivity : AppCompatActivity() {
companion object {
//...
private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
//...
}
}
写真を撮るためのtakePhoto
メソッドを実装します。
class MainActivity : AppCompatActivity() {
//...
private fun takePhoto() {
// imageCaptureがまだセットされていないときは早期リターン。
// セットされていない時はプレビューもされていないはず。
val imageCapture = imageCapture ?: return
// 写真の名前の設定(タイムスタンプ)とMediaStoreの設定
val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
.format(System.currentTimeMillis())
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
}
}
// 保存するオプションを作成
val outputOptions = ImageCapture.OutputFileOptions
.Builder(
contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
.build()
// 画像のキャプチャ
// 結果はImageCapture.OnImageSavedCallbackのコールバックで返ってくる
imageCapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(this),
object : ImageCapture.OnImageSavedCallback {
override fun onError(exc: ImageCaptureException) {
Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
}
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
val msg = "Photo capture succeeded: ${output.savedUri}"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
Log.d(TAG, msg)
}
}
)
}
//...
}
次に、ImageCaptureを設定するために、startCamera
でライフサイクルにバインドするときにImageCaptureも渡す様にします。
class MainActivity : AppCompatActivity() {
//...
private fun startCamera() {
//...
cameraProviderFuture.addListener({
//...
imageCapture = ImageCapture.Builder()
.build()
//...
try {
//...
cameraProvider.bindToLifecycle(
this,
cameraSelector,
preview,
imageCapture
)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
//...
}
最後にボタンを押したタイミングで写真を撮る用にします。
class MainActivity : AppCompatActivity() {
//...
override fun onCreate(savedInstanceState: Bundle?) {
// カメラ始動処理など
viewBinding.captureButton.setOnClickListener {
takePhoto()
}
}
//...
}
カメラボタンを押すと写真が撮られて、Toastで表示されたディレクトリへ保存されます。
便利!!!!
5. 解析
解析と書きましたが「写っているものが何か」という解析ではなく、輝度の平均値
を求めるという解析です。
ML Kit等と組み合わせることで画像解析も可能になるとは思いますが、今回は触れません。
リスナー用のtypealiaceを作成します。
//...
typealias LumaListener = (luma: Double) -> Unit
//...
分析に利用する用のExecutorService
を設定します。
onCreateでExecutorが起動するようにし、onDestroyでシャットダウンする用にします。
class MainActivity : AppCompatActivity() {
//...
private lateinit var cameraExecutor: ExecutorService
//...
override fun onCreate(savedInstanceState: Bundle?) {
//...
cameraExecutor = Executors.newSingleThreadExecutor()
}
//...
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
//...
}
ImageAnalysis.Analyzer
を実装したLuminosityAnalyzer
クラスをMainActivityのインナークラスとして作成します。
class MainActivity : AppCompatActivity() {
//...
private class LuminosityAnalyzer(
private val listener: LumaListener
) : ImageAnalysis.Analyzer {
private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // バッファを0に戻す
val data = ByteArray(remaining())
get(data) // バッファをバイト配列へコピー
return data // バイト配列の取得
}
override fun analyze(image: ImageProxy) {
val buffer = image.planes[0].buffer
val data = buffer.toByteArray()
val pixels = data.map { it.toInt() and 0xFF }
val luma = pixels.average()
listener(luma)
image.close()
}
}
//...
}
最後に、LuminosityAnalyzer
を設定するために、startCamera
でライフサイクルにバインドするときにLuminosityAnalyzer
も渡す様にします。
class MainActivity : AppCompatActivity() {
//...
private fun startCamera() {
//...
cameraProviderFuture.addListener({
//...
val imageAnalyzer = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma ->
Log.d(TAG, "Average luminosity: $luma")
})
}
//...
try {
//...
cameraProvider.bindToLifecycle(
this,
cameraSelector,
preview,
imageCapture,
imageAnalyzer
)
} catch (exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
//...
}
これで輝度の平均値がログに出力されるようになります!
これは夢が広がりますね!!!
D/MainActivity: Average luminosity: 121.706884765625
チュートリアルは以上
7. 動画の撮影
と 8. 動画の撮影と別のUseCaseを組み合わせる
は今回は扱いません。
(チュートリアルのコードが動かなかったので、一旦ここまでにします。)
リファクタリングの余地はありそうですが、最後に動作テストをして終わりです
CameraXを試してみて
Cameraライブラリはちょっとかじったことがあるのですが、段違いなぐらい楽になっていると感じました!(Camera2は未経験)
あと、チュートリアルがかなり親切で、英語がわからない自分でも理解しながら進める事ができました。ありがとうGoogle。
途中でも記載しましたが、ML Kitとかと組み合わせて画像解析を行うのも楽しそうだなと思いました。