たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 12109
目次
本題
Image Segmentation
機械学習のやつ多すぎ
環境
とりあえずイメージセグメンテーションで分類してみる
適当なプロジェクトを作り、MediaPipeを入れる
MediaPipe を使うクラスを作る
UI 部分
使ってみる
セグメンテーションのベンチマーク
ここまでソースコード
BB 素材作れるかも
さっき作ったやつを直していく
動画モードにする
動画に手をいれるライブラリ
ライブラリ入れたくない
ライブラリのソースコード
ひつようなもの
詳細記事
UI を動画選択用に直す
使ってみる
出力結果
BB 素材として使えます
BB素材作成ベンチマーク
時間測る関数
結果
BB素材をイメージセグメンテーションで作るアプリのソースコードとAPK
前作った、前面背面を同時に撮影するカメラにイメージセグメンテーションを組み込む
イメージセグメンテーション結果の画像をテクスチャとして使えるように
イメージセグメンテーション結果を渡す口を作る
描画する関数
フラグメントシェーダーを修正
ImageReader でカメラ映像を流す
MediaPipe 側の修正
KomaDroidCameraManager 側の修正
イマイチ
イマイチなソースコード
もしちゃんとやりたい場合は
修正する
テクスチャを描画するだけの処理
KomaDroidCameraTextureRenderer 側の修正2
KomaDroidCameraManager 側の修正2
修正できた
ソースコード
おわりに
どうもこんばんわ。
久しぶりにTwitter
開いたら、げっちゅ屋の実店舗がなくなってしまうと出てきてとても悲しい。そんな。。
https://x.com/getchuakiba/status/1813151430280421629
あの階段もう見れないの・・?
MediaPipe
とかいうやつをMedia3 Transformer
調べてるときに見つけた。
どうやら機械学習を元に色々できるらしい、その中でも今回はImage Segmentation
をやってみる
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
いめーじせぐめんてーしょん
写真の中の人物と、背景を検出してそれぞれに色を付けて、サーモグラフィーみたいなのを出力してくれる。
テレビ電話によくある背景ぼかし機能は、このImage Segmentation
を使って作っているらしい。多分。
iPhone
には写真から人物だけを切り抜いたり、ロック画面の時計が人物とか建物の裏側に表示されるあれ、多分この辺の技術を使ってる。
しらんけど。
どうやらMediaPipe
は機械学習のモデルをアプリにバンドルしてるからインターネット接続せずに使える?みたい。
https://ai.google.dev/edge?hl=ja
Android
以外にも他のプラットフォーム版があるらしいImage Segmentation
以外にも画像分類とかあるらしいなまえ | あたい |
---|---|
端末 | Pixel 8 Pro / Xperia 1 V |
Android Studio | Android Studio Koala 2024.1.1 |
minSdk | 24 (MediaPipe 都合) |
まずは動かしてみるだけなので、ドキュメントそのまま。
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
とりあえず人物が写った写真Bitmap
を渡したら、イメージセグメンテーション結果のBitmap
(サーモグラフィーみたいなの)が表示されるようにしてみる。
MediaPipe
のためにminSdk
は24
(Android 7
)にしないといけない?
app/build.gradle.kts
にMediaPipe
を追加して
dependencies {
// Image Segmentation
// MediaPipe
implementation("com.google.mediapipe:tasks-vision:0.10.14")
// 以下省略...
モデルを追加します。今回はDeepLab-v3
を使わせてもらうことにした。
ここからダウンロードできます。
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter#deeplab-v3
src/app/main/assets/
にダウンロードしたファイルを配置します。
ファイルが表示される部分、Project
表示にすることでそのままのファイル構造が出るようになります。
普段はAndroid
表示がアクセスしやすいんですけどね
といってもMediaPipe
がほぼやってくれたので、モデルのパスを指定したり、入力Bitmap
を受け付ける部分しか書いてない。
そういえば、こちらの使わせてもらったモデルDeepLabV3
、人物含めて20 種類
(?)分類ができるらしい。ので参考にしたコードではそれぞれ20 種類
に別々の色を当てていたのですが、
今回はとりあえず背景(ラベル0)とそれ以外の2色しか使っていません。
/** MediaPipe で ImageSegmentation する */
class MediaPipeImageSegmentation(context: Context) {
private val imageSegmenter = ImageSegmenter.createFromOptions(
context,
ImageSegmenter.ImageSegmenterOptions.builder().apply {
setBaseOptions(BaseOptions.builder().apply {
// DeepLabV3
// assets に置いたモデル
setModelAssetPath("deeplab_v3.tflite")
}.build())
setRunningMode(RunningMode.IMAGE)
setOutputCategoryMask(true)
setOutputConfidenceMasks(false)
}.build()
)
/** 推論して分類する。処理が終わるまで止まります。 */
suspend fun segmentation(bitmap: Bitmap) = withContext(Dispatchers.Default) {
val mpImage = BitmapImageBuilder(bitmap).build()
val segmenterResult = imageSegmenter.segment(mpImage)
val segmentedBitmap = convertBitmapFromMPImage(segmenterResult.categoryMask().get())
return@withContext segmentedBitmap
}
/** [MPImage]から[Bitmap]を作る */
private fun convertBitmapFromMPImage(mpImage: MPImage): Bitmap {
val byteBuffer = ByteBufferExtractor.extract(mpImage)
val pixels = IntArray(byteBuffer.capacity())
for (i in pixels.indices) {
// Using unsigned int here because selfie segmentation returns 0 or 255U (-1 signed)
// with 0 being the found person, 255U for no label.
// Deeplab uses 0 for background and other labels are 1-19,
// so only providing 20 colors from ImageSegmenterHelper -> labelColors
// 使ったモデル(DeepLab-v3)は、0 が背景。それ以外の 1 から 19 までが定義されているラベルになる
// 今回は背景を青。それ以外は透過するようにしてみる。
val index = byteBuffer.get(i).toUInt() % 20U
val color = if (index.toInt() == 0) Color.BLUE else Color.TRANSPARENT
pixels[i] = color
}
return Bitmap.createBitmap(
pixels,
mpImage.width,
mpImage.height,
Bitmap.Config.ARGB_8888
)
}
}
画像を選ぶPhotoPicker
を開くボタンと、結果を表示するImage()
を置きます。
推論はsuspend fun
なので、rememberCoroutineScope()
も
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AndroidMediaPipeImageSegmentationTheme {
ImageSegmentationScreen()
}
}
}
}
@Composable
private fun ImageSegmentationScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val mediaPipeImageSegmentation = remember { MediaPipeImageSegmentation(context) }
val inputBitmap = remember { mutableStateOf<ImageBitmap?>(null) }
val segmentedBitmap = remember { mutableStateOf<ImageBitmap?>(null) }
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
uri ?: return@rememberLauncherForActivityResult
// 推論はコルーチンでやる
scope.launch {
// Bitmap を取得。Glide や Coil が使えるならそっちで取得したほうが良いです
val bitmap = context.contentResolver.openInputStream(uri)
.use { BitmapFactory.decodeStream(it) }
// 推論
val resultBitmap = mediaPipeImageSegmentation.segmentation(bitmap)
// UI に表示
inputBitmap.value = bitmap.asImageBitmap()
segmentedBitmap.value = resultBitmap.asImageBitmap()
}
}
)
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Button(onClick = {
photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}) { Text(text = "写真を選ぶ") }
if (inputBitmap.value != null) {
Image(
bitmap = inputBitmap.value!!,
contentDescription = null
)
}
if (segmentedBitmap.value != null) {
Image(
bitmap = segmentedBitmap.value!!,
contentDescription = null
)
}
}
}
}
おお~
それっぽい、背景が青だからなんかBB 素材
が作れそう雰囲気がある。
// 推論はコルーチンでやる
scope.launch {
// Bitmap を取得。Glide や Coil が使えるならそっちで取得したほうが良いです
val bitmap = context.contentResolver.openInputStream(uri)
.use { BitmapFactory.decodeStream(it) }
// 推論
val resultBitmap: Bitmap
val time = measureTimeMillis {
resultBitmap = mediaPipeImageSegmentation.segmentation(bitmap)
}
println("time = $time")
// UI に表示
inputBitmap.value = bitmap.asImageBitmap()
segmentedBitmap.value = resultBitmap.asImageBitmap()
}
width = 3840 / height = 2160
なのでまあ大きい画像。
多分もっと小さくしてから解析するべきです。
なまえ | OS | SoC | 1回目 | 2回目 |
---|---|---|---|---|
Pixel 8 Pro | 15 Beta | Google Tensor G3 | 2365 ミリ秒 | 2394 ミリ秒 |
Xperia 1 V | 14 | Qualcomm Snapdragon 8 Gen 2 | 747 ミリ秒 | 525 ミリ秒 |
Google Tensor
が遅いのかSnapdragon
が速いのかよく分からない。Beta
だから遅いとかもあるのかな。
ま、、、まあ価格差がザックリ 7 万円くらいある(Xperia
のが高い、しかもPixel
はキャッシュバックがあったのでそれ含めるともっと差が)ので速くなりそうではあるけど、にしてもここまで差が出るものなの(?)
それ以前にリリースビルドじゃないのでそれも問題かも
https://github.com/takusan23/AndroidMediaPipeImageSegmentation
BB 素材
というか、人物以外の背景部分を単色にして、動画編集時にクロマキー機能で透過させるあれ。
あの単色にする際にこのイメージセグメンテーションが使えそう。というか↑の例が背景青だから余計BB 素材
感が
やるべきことは、MediaPipe
を動画モード(静止画モードとの差がよく分からない)と、
動画一枚一枚を取り出して解析して、動画にする作業。
RunningMode
、ドキュメントを見ても静止画、動画、ライブ(カメラ映像)の三種類があるらしい。
ライブモードはコールバックになってて、速度が速すぎて間に合わない場合は勝手に捨ててくれるらしい。
また、動画モードは映像フレームの時間を渡す必要があります。のでそこも直す。
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter/android#run_the_task
class MediaPipeImageSegmentation(context: Context) {
private val imageSegmenter = ImageSegmenter.createFromOptions(
context,
ImageSegmenter.ImageSegmenterOptions.builder().apply {
setBaseOptions(BaseOptions.builder().apply {
// DeepLabV3
// assets に置いたモデル
setModelAssetPath("deeplab_v3.tflite")
}.build())
setRunningMode(RunningMode.VIDEO) // 動画モード
setOutputCategoryMask(true)
setOutputConfidenceMasks(false)
}.build()
)
/** 推論して分類する。処理が終わるまで止まります。 */
suspend fun segmentation(bitmap: Bitmap, framePositionMs: Long) = withContext(Dispatchers.Default) {
// framePositionMs を引数にとって、segmentForVideo を呼び出すようにする
val mpImage = BitmapImageBuilder(bitmap).build()
val segmenterResult = imageSegmenter.segmentForVideo(mpImage, framePositionMs)
val segmentedBitmap = convertBitmapFromMPImage(segmenterResult.categoryMask().get())
return@withContext segmentedBitmap
}
は前作ったのを入れます。MavenCentral
にあります。
何をしてくれるものなのか、詳しくは前書いた記事で→ https://takusan.negitoro.dev/posts/android_video_editor_akari_droid/#アプリの概要
dependencies {
// Image Segmentation
// MediaPipe
implementation("com.google.mediapipe:tasks-vision:0.10.14")
// ↓これを足す
// 動画を編集すると言うか、MediaCodec を代わりに叩いてくれる
implementation("io.github.takusan23:akaricore:4.0.0")
// 以下省略
分かる、メンテするか分からんしな。
ソースコードをコピーするほうが都合がいい場合、この2️つと、2つが参照しているgl
パッケージの中身をコピーして来れば良いはず。多分以下のクラスを取ってくれば良いはず。
https://github.com/takusan23/AkariDroid/blob/master/akari-core/
Canvas
で書いて動画を作るやつと、動画から一枚一枚Bitmap
を取り出すやつの詳細です。
それぞれ一本ずつ記事があります。。。
プレビューのImage()
とかはいらないので、映像から一枚一枚フレームを取り出し、イメージセグメンテーションにかけて、動画を作る処理をする。
動画から一枚一枚取り出す処理と動画を作る処理はもう(ライブラリを入れるかコピーするか)で出来ているので、組み合わせるだけ!
あ、ちなみに音声は消えます。
音声トラックもmp4
コンテナに入れれば音声も流れますが、まあ BB 素材にいらんやろ
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AndroidVideoBackgroundTransparentEditorTheme {
ImageSegmentationScreen()
}
}
}
}
@Composable
private fun ImageSegmentationScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val mediaPipeImageSegmentation = remember { MediaPipeImageSegmentation(context) }
// エンコード済み時間と処理中かどうか
val encodedPositionMs = remember { mutableLongStateOf(0) }
val isRunning = remember { mutableStateOf(false) }
val videoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
// 選んでもらったら処理開始
uri ?: return@rememberLauncherForActivityResult
scope.launch {
isRunning.value = true
// 動画サイズが欲しい
val metadataRetriever = MediaMetadataRetriever().apply {
context.contentResolver.openFileDescriptor(uri, "r")
?.use { setDataSource(it.fileDescriptor) }
}
val videoWidth = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt()
val videoHeight = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt()
// 一枚一枚取り出すやつ。MetadataRetriever より速い
val videoFrameBitmapExtractor = VideoFrameBitmapExtractor()
videoFrameBitmapExtractor.prepareDecoder(uri.toAkariCoreInputOutputData(context))
// BB 素材保存先
val resultVideoMetadata = contentValuesOf(
MediaStore.Video.VideoColumns.DISPLAY_NAME to "${System.currentTimeMillis()}.mp4",
MediaStore.Video.VideoColumns.MIME_TYPE to "video/mp4",
MediaStore.MediaColumns.RELATIVE_PATH to "${Environment.DIRECTORY_MOVIES}/AndroidVideoBackgroundBlueBackEditor"
)
val resultVideoFileUri = context.contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, resultVideoMetadata)!!
// Canvas から動画を作るやつ
val paint = Paint()
CanvasVideoProcessor.start(
output = resultVideoFileUri.toAkariCoreInputOutputData(context),
outputVideoWidth = videoWidth,
outputVideoHeight = videoHeight,
onCanvasDrawRequest = { positionMs ->
// 一枚一枚取り出す
val videoFrameBitmap = videoFrameBitmapExtractor.getVideoFrameBitmap(positionMs)
if (videoFrameBitmap != null) {
// 推論する
val segmentedBitmap = mediaPipeImageSegmentation.segmentation(videoFrameBitmap, positionMs)
// Canvas に書き込む
// 背景を青にした推論結果を上に重ねて描画することで、BB 素材っぽく
drawBitmap(videoFrameBitmap, 0f, 0f, paint)
drawBitmap(segmentedBitmap, 0f, 0f, paint)
}
// 進捗を UI に
encodedPositionMs.longValue = positionMs
// とりあえず 60 秒まで動画を作る
positionMs <= 60_000
}
)
isRunning.value = false
}
}
)
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Button(onClick = {
videoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
}) { Text(text = "動画を選ぶ") }
if (isRunning.value) {
CircularProgressIndicator()
Text(text = "処理済みの時間 : ${encodedPositionMs.longValue} ミリ秒")
}
}
}
}
動画を選ぶを押して人物が写っている動画を選ぶ。
選ぶと処理が始まるので、数分待つ。
MediaPipe
すげ~
職人が作るのと比べるとダメだけどそれでもすごいと思う。
青色をくり抜くようにすれば BB 素材です。
他の動画編集アプリでも使えるはず。
時間測るためにcurrentTimeMillis
仕込むの面倒なので、インライン関数を用意しました。
普通の関数と違って、ビルド時に呼び出し元に展開されます。多分該当箇所を逆コンパイルすると、関数呼び出しではなく、関数の中身が出てくるんじゃないかなと思います。
メリットはいくつかありますが、呼び出し元に展開されるので、サスペンド関数
でも気にせず使える点ですね。
// 時間測って println してくれるやつ
inline fun <T> printTime(task: () -> T): T {
val taskResult: T
val time = measureTimeMillis {
taskResult = task()
}
println("PrintTime $time")
return taskResult
}
この関数をscope.launch { printTime { /* エンコード処理 */ } }
のようにlaunch
の中身全部の時間を計測するようにしてみた。
まあこれもリリースビルドじゃないのであんまり真に受けないでください。
使った動画はこれの720p
版。https://www.youtube.com/watch?v=LsBq0HdNbp8
10秒間
作ってみた結果。
なまえ | OS | SoC | 1回目 | 2回目 |
---|---|---|---|---|
Pixel 8 Pro | 15 Beta | Google Tensor G3 | 115301 ミリ秒 | 115631 ミリ秒 |
Xperia 1 V | 14 | Qualcomm Snapdragon 8 Gen 2 | 38167 ミリ秒 | 38313 ミリ秒 |
なんでこんな差が出るの・・?
需要あるかな
https://github.com/takusan23/AndroidVideoBackgroundBlueBackEditor
元ネタがこれ。なんかこの記事結構見られてそうなので、令和最新版 Android で前面と背面を同時に撮影できるカメラを作りたい
を書きました。
コルーチンをもう少し真面目に使って、小手先の技というか、雑な部分をちょっと改善しました。
https://takusan.negitoro.dev/posts/android_front_back_camera_2024/
ここから先は、↑の記事の続きです。↑で書いたカメラアプリのプログラムを元に、遊んでいきます。
というわけで、今回の後半はこの自撮りカメラ映像をMediaPipe のイメージセグメンテーション
に投げて、解析結果を元に、自撮りカメラの顔だけを描画するアプリに改善してみようと思います。
自撮りカメラから顔だけ描画して、背景は描画しない(背面カメラ映像になる)みたいな!!!!!
OpenGL ES
周りの修正から初めます。
sSegmentedTextureHandle
変数を追加して、Uniform
変数を探してそれに入れます。フラグメントシェーダー側のuniform sampler2D sSegmentedTexture;
のハンドルがここに入ります。
そしたら、glGenTextures
の数を3
にします。背面カメラ映像+前面カメラ映像+イメージセグメンテーションの結果の画像で 3 枚。
イメージセグメンテーション用のテクスチャID
変数segmentedTextureId
を作り、渡して、glBindTexture
とかします。
sSegmentedTextureHandle = GLES20.glGetUniformLocation(mProgram, "sSegmentedTexture")
checkGlError("glGetUniformLocation sSegmentedTexture")
if (sSegmentedTextureHandle == -1) {
throw RuntimeException("Could not get attrib location for sSegmentedTexture")
}
// テクスチャ ID を払い出してもらう
// 前面カメラの映像、背面カメラの映像で2個
// イメージセグメンテーション用にもう1個
val textures = IntArray(3)
GLES20.glGenTextures(3, textures, 0)
// 以下省略...
// 3個目はイメージセグメンテーションの結果
segmentedTextureId = textures[2]
GLES20.glActiveTexture(GLES20.GL_TEXTURE2)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, segmentedTextureId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
checkGlError("glTexParameteri")
uniform sampler2D sSegmentedTexture
で参照できるBitmap
をセットする関数です。
/**
* イメージセグメンテーションの結果を渡す
*
* @param segmentedBitmap MediaPipe から出てきたやつ
*/
fun updateSegmentedBitmap(segmentedBitmap: Bitmap) {
// texImage2D、引数違いがいるので注意
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, segmentedTextureId)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, segmentedBitmap, 0)
checkGlError("GLUtils.texImage2D")
}
テクスチャのID
をセットしてなかったのでここでします。
GLES20.glUniform1i(sSegmentedTextureHandle, 2)
の部分ですね。
// テクスチャの ID をわたす
GLES20.glUniform1i(sFrontCameraTextureHandle, 0) // GLES20.GL_TEXTURE0 なので 0
GLES20.glUniform1i(sBackCameraTextureHandle, 1) // GLES20.GL_TEXTURE1 なので 1
GLES20.glUniform1i(sSegmentedTextureHandle, 2) // GLES20.GL_TEXTURE2 なので 2
checkGlError("glUniform1i sFrontCameraTextureHandle sBackCameraTextureHandle")
まずはコードを。こちらです。
sSegmentedTexture
をtexture2D
に渡すと、イメージセグメンテーション結果の画像が参照できます。
これを使い、前面カメラを描画するモードだったら、描画するピクセルに対応するイメージセグメンテーション結果の色を取り出して、色が青かどうかを判定します。
length()
を使うことで色がどれくらい近いかの判定ができるそうです(よく分からないので、0.3
をしきい値にしている)。
もし描画するべきピクセルのイメージセグメンテーション結果が青色、つまり背景だった場合はdiscard
で描画しない!とします。
青色じゃない場合は、前面カメラ映像の色をそのピクセルに指定します。
背面カメラの方は背面カメラの映像を写しているだけですね。
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sFrontCameraTexture;
uniform samplerExternalOES sBackCameraTexture;
uniform sampler2D sSegmentedTexture;
// sFrontCameraTexture を描画する場合は 1。
// sBackCameraTexture は 0。
uniform int iDrawFrontCameraTexture;
void main() {
// 出力色
vec4 outColor = vec4(0., 0., 0., 1.);
// どっちを描画するのか
if (bool(iDrawFrontCameraTexture)) {
// フロントカメラ(自撮り)
vec4 cameraColor = texture2D(sFrontCameraTexture, vTextureCoord);
// 推論結果の被写体の色
vec4 targetColor = vec4(0., 0., 1., 1.);
// 推論結果
vec4 segmentedColor = texture2D(sSegmentedTexture, vTextureCoord);
// 青色だったら discard。そうじゃなければフロントカメラの色を
// length でどれくらい似ているかが取れる(雑な説明)
if (.3 < length(targetColor - segmentedColor)) {
outColor = cameraColor;
} else {
// outColor = segmentedColor; // ブルーバックを出す
discard;
}
} else {
// バックカメラ(外側)
vec4 cameraColor = texture2D(sBackCameraTexture, vTextureCoord);
outColor = cameraColor;
}
// 出力
gl_FragColor = outColor;
}
ImageReader
を作ります。
解像度は下げておきます。クソデカ画像を投げてもイメージセグメンテーションの解析が遅くなるだけなので。
ここではImageFormat.JPEG
しています。Camera2 API
の出力先にする場合は多分これにしないといけない?
あと、maxImages
を30
にしています。これを2
とかの極端に少ない数にすると、全然関係ないであろうSurfaceView
プレビューのfps
が低くなります(何故?)。すごく低くなる。
Stackoverflow
にもある通り、多くすると随分マシになる。
https://stackoverflow.com/questions/42688188/
/**
* イメージセグメンテーション用に Bitmap を提供しなくてはいけない、そのための[ImageReader]。
* maxImages が多いが、多めに取らないと、プレビューが遅くなる。https://stackoverflow.com/questions/42688188/
*
* width / height を半分以下にしているのはメモリ使用量を減らす目的と、
* どうせ解析にクソデカい画像を入れても遅くなるだけなので、この段階で小さくしてしまう。
*/
private val analyzeImageReader = ImageReader.newInstance(CAMERA_RESOLUTION_WIDTH / 4, CAMERA_RESOLUTION_HEIGHT / 4, ImageFormat.JPEG, 30)
あとは、自撮りカメラの出力先にこのImageReader
を追加していくだけ。
こんな感じ。
// フロントカメラの設定
// 出力先
val frontCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
analyzeImageReader.surface // これ
)
val frontCameraCaptureRequest = frontCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
frontCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val frontCameraCaptureSession = frontCamera.awaitCameraSessionConfiguration(frontCameraOutputList)
frontCameraCaptureSession?.setRepeatingRequest(frontCameraCaptureRequest, null, null)
MediaPipe
をライブモードにします。ライブ配信や、カメラ映像に向いているモードだそうです。
このモードはコールバックしか対応していないので、コールバックを追加して、Flow
か何かで通知するようにしてあげます。
コールバックを使う場合、画像を渡す関数がsegmentAsync
に変更になります。
時間を渡す必要があるので、インスタンス作成時の時間と比べるようにしています。(これでいいの???)
private val createInstanceTime = System.currentTimeMillis()
private val _segmentedBitmapFlow = MutableStateFlow<Bitmap?>(null)
private val imageSegmenter = ImageSegmenter.createFromOptions(
context,
ImageSegmenter.ImageSegmenterOptions.builder().apply {
setBaseOptions(BaseOptions.builder().apply {
// DeepLabV3
// assets に置いたモデル
setModelAssetPath("deeplab_v3.tflite")
}.build())
setRunningMode(RunningMode.LIVE_STREAM) // ライブモード、カメラ映像を流すため
setOutputCategoryMask(true)
setOutputConfidenceMasks(false)
setResultListener { result, _ -> // コールバックを使う必要がある
// Flow で通知
_segmentedBitmapFlow.value = convertBitmapFromMPImage(result.categoryMask().get())
}
}.build()
)
/** 推論結果を流す Flow */
val segmentedBitmapFlow = _segmentedBitmapFlow.asStateFlow()
/** 推論して分類する。 */
fun segmentation(bitmap: Bitmap) {
val mpImage = BitmapImageBuilder(bitmap).build()
imageSegmenter.segmentAsync(mpImage, System.currentTimeMillis() - createInstanceTime) // segmentAsync を使う、フレームの時間を渡す必要がある
}
/** 破棄する */
fun destroy() {
imageSegmenter.close()
}
次に、ImageReader
からMediaPipe
に渡してる処理がこの辺。
ImageReader
からカメラ映像を取り出して、Bitmap
をMediaPipe
に渡して、イメージセグメンテーションしてもらう。
そのあと、segmentedBitmapFlow
で色で分類されたBitmap
が流れてくるので、OpenGL ES
のテクスチャを更新する。
ちなみにupdateSegmentedBitmap
はテクスチャの更新しかしていません。
なので更新したとしても、反映されるにはプレビュー、静止画撮影の描画処理が呼ばれる必要があるのですが、
カメラ映像を描画する処理は高頻度で呼び出しているので、わざわざ呼ぶまでもないかなって。
/** 用意をする */
fun prepare() {
scope.launch {
// モードに応じて初期化を分岐
when (mode) {
CaptureMode.PICTURE -> initPictureMode()
CaptureMode.VIDEO -> initVideoMode()
}
// プレビュー Surface で OpenGL ES の描画と破棄を行う。OpenGL ES の用意は map { } でやっている。
// 新しい値が来たら、既存の OpenGlDrawPair は破棄するので、collectLatest でキャンセルを投げてもらうようにする。
// また、録画用(静止画撮影、動画撮影)も別のところで描画
launch {
previewOpenGlDrawPairFlow.collectLatest { previewOpenGlDrawPair ->
previewOpenGlDrawPair ?: return@collectLatest
try {
// OpenGL ES の描画のためのメインループ
withContext(previewGlThreadDispatcher) {
while (isActive) {
renderOpenGl(previewOpenGlDrawPair)
}
}
} finally {
// 終了時は OpenGL ES の破棄
withContext(NonCancellable + previewGlThreadDispatcher) {
previewOpenGlDrawPair.textureRenderer.destroy()
previewOpenGlDrawPair.inputSurface.destroy()
}
}
}
}
// MediaPipe のイメージセグメンテーションを行う
// まず前面カメラのカメラ映像を ImageReader で受け取り、Bitmap にする
// そのあと、イメージセグメンテーションの推論をして、結果を OpenGL ES へ渡す
// OpenGL ES 側で描画する処理は、他のプレビューとかの描画ループ中に入れてもらうことにする。
launch { prepareAndStartImageSegmentation() }
// プレビューを開始する
startPreview()
}
}
/** イメージセグメンテーションの開始 */
private suspend fun startImageSegmentation() = coroutineScope {
// MediaPipe
val mediaPipeImageSegmentation = MediaPipeImageSegmentation(context)
try {
listOf(
launch {
// カメラ映像を受け取って解析に投げる部分
while (isActive) {
val bitmap = analyzeImageReader.acquireLatestImage()?.toJpegBitmap()
if (bitmap != null) {
mediaPipeImageSegmentation.segmentation(bitmap)
}
}
},
launch {
// 解析結果を受け取って、OpenGL ES へテクスチャとして提供する
mediaPipeImageSegmentation
.segmentedBitmapFlow
.filterNotNull()
.collect { segmentedBitmap ->
withContext(previewGlThreadDispatcher) {
previewOpenGlDrawPairFlow.value?.textureRenderer?.updateSegmentedBitmap(segmentedBitmap)
}
withContext(recordGlThreadDispatcher) {
recordOpenGlDrawPair?.textureRenderer?.updateSegmentedBitmap(segmentedBitmap)
}
}
}
).joinAll()
} finally {
mediaPipeImageSegmentation.destroy()
}
}
private suspend fun Image.toJpegBitmap() = withContext(Dispatchers.IO) {
val imageBuf: ByteBuffer = planes[0].buffer
val imageBytes = ByteArray(imageBuf.remaining())
imageBuf[imageBytes]
close()
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
まずJPEG
を指定したImageReader
の挙動がすごい怪しい。
プレビューは問題なさそうだが、録画ができてなさそう。私の作り方が悪い気がしますが(と言うか多分これ)、Google Pixel
は動くが、何故かSnapdragon
搭載機で動かなくなる。
さらに言うと、ImageReader
をJPEG
で使っているせいなのか、写真が90度
回転しています。
これだとカメラ映像とイメージセグメンテーション結果の画像を重ねたとしても、回転している状態では切り抜くことが出来ないので困った!
困るはずなのだが、何故か動いています。はて。
これはカメラ映像のテクスチャのSurfaceTexture
も90度
回転していて、この90度
回転している件はSurfaceTexture#getTransformMatrix
を使った行列を適用すると?回転が直る。
これと同じ行列をImageReader
から出てきたBitmap
の描画でも使えば、同じく回転が直る。
逆に言えば 90 度回転していない画像をぶち込んだら壊れる。両方 90 度回転してるからたまたま動いている
いまいち過ぎるのでgit revert
しました。多分安定しているのがテクスチャを描画するだけのOpenGL ES
を経由するのが一番いい気がする。これから話します。
もしくは解析のユースケースがサポートされてる(らしい)CameraX
に乗り換えるか。解析用にImage
がもらえるらしいです。
git revert
してしまったのでもしイマイチ具合を見たい場合は、
ブランチがimage_segmentation
、コミットハッシュがa7742f2f863b3ffbff40ddddc0b88d87b67f17c3
なので、git clone
したらgit checkout
でコミットハッシュを入れてください。
https://github.com/takusan23/KomaDroid
OpenGL ES
を間に挟むと安定して動いてる気がします。Stackoverflow
の回答でもglReadPixels
しているくらいだし(今でもそっちのが速いかは不明)。
というか、間にOpenGL ES
を挟むとglReadPixels
なんかせずとも、ImageReader
をPixelFormat.RGBA_8888
のモードで利用できるので。今のところRGBA_8888
なら安定して動いてる気がします。
こんな感じに、カメラ映像が一旦OpenGL ES
の描画を経由するようになります。ただカメラ映像のテクスチャをそのまま描画しているだけ(手間が増えただけ)。なんでこっちのほうが安定しているのかは謎です。
ImageReader
を入れるだけでガクガクになるプレビュー問題も直るし、Snapdragon
で動画撮影が出来ないのも直った。
が、が、が、かなり直す必要があって、
OpenGL ES
でテクスチャを描画するだけのTextureRenderer
クラスを用意するKomaDroidCameraTextureRenderer
の、イメージセグメンテーション結果を扱う部分の修正
ImageReader(JPEG)
の時はたまたま90度
回転していたので、カメラ映像と同じようにtexture2D()
を呼べばよかったのですが、RGBA_8888
は回転していない。
こちらです、もう貼るの面倒なのでGitHub
見てください、
テクスチャをtexture2D
で描画するだけ。それだけ。InputSurface
の方は使い回せるのでこれだけ持ってくれば良いです。
回転していない画像が来るようになったので、新しい、回転していない行列を作ります。
バーテックスシェーダー、フラグメントシェーダーだけで変更すれば、あとはイマイチなImageReader
だけで使ってた頃のコードを使えます。
バーテックスシェーダ側にvSegmentTextureCoord
を用意しました。フラグメントシェーダーへ値を渡せます。
これをtexture2D()
に入れれば回転していない状態でテクスチャを描画できます。テクスチャ座標に合わせるためy
を反転させています。
フラグメントシェーダー側も、varying vec2 vSegmentTextureCoord
を追加します、
そのあと、vec4 segmentedColor = texture2D(...)
の第1引数をvSegmentTextureCoord
にすれば回転していない状態で取り出せます。
これでイメージセグメンテーション結果を受け入れられるようになった。
private const val VERTEX_SHADER = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
varying vec2 vSegmentTextureCoord;
// Matrix.setIdentityM()
const mat4 uTextureSTMatrix = mat4(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0);
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
// vTextureCoord は SurfaceTexture 用なので、普通のテクスチャ描画用の vTextureCoord を作る
// またテクスチャ座標は反転しているので戻す
vSegmentTextureCoord = (uTextureSTMatrix * aTextureCoord).xy;
vSegmentTextureCoord = vec2(vSegmentTextureCoord.x, 1.-vSegmentTextureCoord.y);
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sFrontCameraTexture;
uniform samplerExternalOES sBackCameraTexture;
varying vec2 vSegmentTextureCoord;
uniform sampler2D sSegmentedTexture;
// sFrontCameraTexture を描画する場合は 1。
// sBackCameraTexture は 0。
uniform int iDrawFrontCameraTexture;
void main() {
// 出力色
vec4 outColor = vec4(0., 0., 0., 1.);
// どっちを描画するのか
if (bool(iDrawFrontCameraTexture)) {
// フロントカメラ(自撮り)
vec4 cameraColor = texture2D(sFrontCameraTexture, vTextureCoord);
// 推論結果の被写体の色
vec4 targetColor = vec4(0., 0., 1., 1.);
// 推論結果
vec4 segmentedColor = texture2D(sSegmentedTexture, vSegmentTextureCoord);
// 青色だったら discard。そうじゃなければフロントカメラの色を
// length でどれくらい似ているかが取れる(雑な説明)
if (.3 < length(targetColor - segmentedColor)) {
outColor = cameraColor;
} else {
// outColor = segmentedColor; // ブルーバックを出す
discard;
}
} else {
// バックカメラ(外側)
vec4 cameraColor = texture2D(sBackCameraTexture, vTextureCoord);
outColor = cameraColor;
}
// 出力
gl_FragColor = outColor;
}
"""
InputSurface
と、TextureCopyTextureRenderer
を持つだけのクラスを作りました。
/** 解析用 [OpenGlDrawPair] */
private data class AnalyzeOpenGlDrawPair(
val inputSurface: InputSurface,
val textureRenderer: TextureCopyTextureRenderer
)
それから、解析用ImageReader
の他に、解析用ImageReader
のOpenGL
用スレッド、AnalyzeOpenGlDrawPair
のインスタンスを作ります。
ImageReader
はPixelFormat.RGBA_8888
にしてください、
/**
* イメージセグメンテーション用に Bitmap を提供しなくてはいけない、そのための[ImageReader]。
*
* width / height を半分以下にしているのはメモリ使用量を減らす目的と、
* どうせ解析にクソデカい画像を入れても遅くなるだけなので、この段階で小さくしてしまう。
*/
private val analyzeImageReader = ImageReader.newInstance(
CAMERA_RESOLUTION_WIDTH / ANALYZE_DIV_SCALE,
CAMERA_RESOLUTION_HEIGHT / ANALYZE_DIV_SCALE,
PixelFormat.RGBA_8888,
2
)
/** [analyzeImageReader]用 GL スレッド */
private val analyzeGlThreadDispatcher = newSingleThreadContext("AnalyzeGlThread")
/** [analyzeImageReader]用[OpenGlDrawPair] */
private var analyzeOpenGlDrawPair: AnalyzeOpenGlDrawPair? = null
companion object {
const val CAMERA_RESOLUTION_WIDTH = 720
const val CAMERA_RESOLUTION_HEIGHT = 1280
private const val ANALYZE_DIV_SCALE = 4 // イメージセグメンテーションに元の解像度はいらないので 1/4 する
}
次に、prepare()
の中で、analyzeOpenGlDrawPair
の用意をします。
InputSurface
とTextureCopyTextureRenderer
を作ります。作ったらstartImageSegmentation()
を呼び出しますが、startImageSegmentation()
側も修正が必要です。
といっても、ImageReader
から直接取ってた部分を、カメラ映像が来ていればOpenGL ES
で描画してImageReader
から取り出す。
ImageReader
からBitmap
を取り出す方法、RGBA_8888
になったのでJPEG
用のは使い回せないので注意です。
fun prepare() {
scope.launch {
// 省略...
// MediaPipe のイメージセグメンテーションを行う
// まず前面カメラのカメラ映像を ImageReader で受け取り、Bitmap にする
// そのあと、イメージセグメンテーションの推論をして、結果を OpenGL ES へ渡す
// OpenGL ES 側で描画する処理は、他のプレビューとかの描画ループ中に入れてもらうことにする。
analyzeOpenGlDrawPair = withContext(analyzeGlThreadDispatcher) {
// また、本当は ImageReader を Camera2 API に渡せばいいはずだが、プレビューが重たくなってしまった。
// OpenGL ES を経由すると改善したのでとりあえずそれで(謎)
val inputSurface = InputSurface(analyzeImageReader.surface)
val textureRenderer = TextureCopyTextureRenderer()
inputSurface.makeCurrent()
textureRenderer.createShader()
textureRenderer.setSurfaceTextureSize(CAMERA_RESOLUTION_WIDTH / ANALYZE_DIV_SCALE, CAMERA_RESOLUTION_HEIGHT / ANALYZE_DIV_SCALE)
AnalyzeOpenGlDrawPair(inputSurface, textureRenderer)
}
launch { startImageSegmentation() }
// プレビューを開始する
startPreview()
}
}
/** イメージセグメンテーションの開始 */
private suspend fun startImageSegmentation() = coroutineScope {
// MediaPipe
val mediaPipeImageSegmentation = MediaPipeImageSegmentation(context)
try {
listOf(
launch {
// カメラ映像を受け取って解析に投げる部分
withContext(analyzeGlThreadDispatcher) {
while (isActive) {
if (analyzeOpenGlDrawPair?.textureRenderer?.isAvailableFrame() == true) {
// カメラ映像テクスチャを更新して、描画
analyzeOpenGlDrawPair?.textureRenderer?.updateCameraTexture()
analyzeOpenGlDrawPair?.textureRenderer?.draw()
analyzeOpenGlDrawPair?.inputSurface?.swapBuffers()
// ImageReader で取りだして、MediaPipe のイメージセグメンテーションに投げる
val bitmap = analyzeImageReader.acquireLatestImage()?.toRgbaBitmap(
imageReaderWidth = CAMERA_RESOLUTION_WIDTH / ANALYZE_DIV_SCALE,
imageReaderHeight = CAMERA_RESOLUTION_HEIGHT / ANALYZE_DIV_SCALE,
)
if (bitmap != null) {
mediaPipeImageSegmentation.segmentation(bitmap)
}
}
}
}
},
launch {
// 解析結果を受け取って、プレビュー、録画用 OpenGL ES へテクスチャとして提供する
mediaPipeImageSegmentation
.segmentedBitmapFlow
.filterNotNull()
.collect { segmentedBitmap ->
withContext(previewGlThreadDispatcher) {
previewOpenGlDrawPairFlow.value?.textureRenderer?.updateSegmentedBitmap(segmentedBitmap)
}
withContext(recordGlThreadDispatcher) {
recordOpenGlDrawPair?.textureRenderer?.updateSegmentedBitmap(segmentedBitmap)
}
}
}
).joinAll()
} finally {
mediaPipeImageSegmentation.destroy()
}
}
/** [Image]から[Bitmap]を作る */
private suspend fun Image.toRgbaBitmap(imageReaderWidth: Int, imageReaderHeight: Int) = withContext(Dispatchers.IO) {
val image = this@toRgbaBitmap
val width = image.width
val height = image.height
val planes = image.planes
val buffer = planes[0].buffer
// なぜか ImageReader のサイズに加えて、何故か Padding が入っていることを考慮する必要がある
val pixelStride = planes[0].pixelStride
val rowStride = planes[0].rowStride
val rowPadding = rowStride - pixelStride * width
// Bitmap 作成
val readBitmap = Bitmap.createBitmap(width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888)
readBitmap.copyPixelsFromBuffer(buffer)
// 余分な Padding を消す
val editBitmap = Bitmap.createBitmap(readBitmap, 0, 0, imageReaderWidth, imageReaderHeight)
readBitmap.recycle()
image.close()
return@withContext editBitmap
}
最後に、カメラ映像の出力先をImageReader#surface
から、ImageReader
のOpenGL
へテクスチャを送るSurfaceTexture
に変更する。
プレビュー、静止画、動画の三箇所かな。
// フロントカメラの設定
// 出力先
val frontCameraOutputList = listOfNotNull(
previewOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
recordOpenGlDrawPair.textureRenderer.frontCameraInputSurface,
analyzeOpenGlDrawPair?.textureRenderer?.inputSurface // こ↑こ↓
)
val frontCameraCaptureRequest = frontCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
frontCameraOutputList.forEach { surface -> addTarget(surface) }
}.build()
val frontCameraCaptureSession = frontCamera.awaitCameraSessionConfiguration(frontCameraOutputList)
frontCameraCaptureSession?.setRepeatingRequest(frontCameraCaptureRequest, null, null)
随分快適になったと思う。
ちゃんと自分撮りの方は顔だけ(解析の精度にもよるけどおおよそ)映るようになりました。おもろい
画像を貼ろうと思ったけどAndroid Emulator
だとMediaPipe
無理かあ...
せめてものAPK
おいておきます:https://github.com/takusan23/KomaDroid/releases/tag/1.0.0
なんかすげーバイナリサイズが大きいんだけど、、、、そんなもんか
疲れたので全文は貼ってない。ので見てみて。これだけじゃまじで何やってるか分からんと思うので、、、
image_segmentation
ブランチです。多分ビルドできる、そして多分実機じゃないと動かない。
https://github.com/takusan23/KomaDroid/tree/image_segmentation
この機能で遊んでると結構スマホがアチアチになる。 開発のためにUSB
で繋いでるから余計に。
てかまだ6月なのに(記述時時点)クソ暑すぎだろ。虫やだ!!!!網戸すり抜けてくる奴ら何????