たくさんの自由帳

Android で MediaPipe のイメージセグメンテーションしてみる

投稿日 : | 0 日前

文字数(だいたい) : 12109

目次

どうもこんばんわ。
久しぶりにTwitter開いたら、げっちゅ屋の実店舗がなくなってしまうと出てきてとても悲しい。そんな。。

https://x.com/getchuakiba/status/1813151430280421629

あの階段もう見れないの・・?

本題

MediaPipeとかいうやつをMedia3 Transformer調べてるときに見つけた。
どうやら機械学習を元に色々できるらしい、その中でも今回はImage Segmentationをやってみる

https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter

Image Segmentation

いめーじせぐめんてーしょん

写真の中の人物と、背景を検出してそれぞれに色を付けて、サーモグラフィーみたいなのを出力してくれる。
テレビ電話によくある背景ぼかし機能は、このImage Segmentationを使って作っているらしい。多分。

iPhoneには写真から人物だけを切り抜いたり、ロック画面の時計が人物とか建物の裏側に表示されるあれ、多分この辺の技術を使ってる。
しらんけど。

どうやらMediaPipeは機械学習のモデルをアプリにバンドルしてるからインターネット接続せずに使える?みたい。

機械学習のやつ多すぎ

https://ai.google.dev/edge?hl=ja

  • TensorFlow
    • 多分ガチの機械学習、何もわからない
  • MediaPipe
    • すでに用意された機械学習のやつ?
    • Android以外にも他のプラットフォーム版があるらしい
    • Image Segmentation以外にも画像分類とかあるらしい
  • MLKit
    • 完全にスマホ向け?

環境

なまえあたい
端末Pixel 8 Pro / Xperia 1 V
Android StudioAndroid Studio Koala 2024.1.1
minSdk24 (MediaPipe都合)

とりあえずイメージセグメンテーションで分類してみる

まずは動かしてみるだけなので、ドキュメントそのまま。
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter

とりあえず人物が写った写真Bitmapを渡したら、イメージセグメンテーション結果のBitmap(サーモグラフィーみたいなの)が表示されるようにしてみる。

適当なプロジェクトを作り、MediaPipeを入れる

MediaPipeのためにminSdk24Android 7)にしないといけない?
app/build.gradle.ktsMediaPipeを追加して

 
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/にダウンロードしたファイルを配置します。

Imgur

ファイルが表示される部分、Project表示にすることでそのままのファイル構造が出るようになります。
普段はAndroid表示がアクセスしやすいんですけどね

Imgur

MediaPipe を使うクラスを作る

といっても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
        )
    }
}

UI 部分

画像を選ぶ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 素材が作れそう雰囲気がある。

Imgur

セグメンテーションのベンチマーク

// 推論はコルーチンでやる
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なのでまあ大きい画像。
多分もっと小さくしてから解析するべきです。

なまえOSSoC1回目2回目
Pixel 8 Pro15 BetaGoogle Tensor G32365 ミリ秒2394 ミリ秒
Xperia 1 V14Qualcomm Snapdragon 8 Gen 2747 ミリ秒525 ミリ秒

Google Tensorが遅いのかSnapdragonが速いのかよく分からない。Betaだから遅いとかもあるのかな。
ま、、、まあ価格差がザックリ 7 万円くらいある(Xperiaのが高い、しかもPixelはキャッシュバックがあったのでそれ含めるともっと差が)ので速くなりそうではあるけど、にしてもここまで差が出るものなの(?)

それ以前にリリースビルドじゃないのでそれも問題かも

ここまでソースコード

https://github.com/takusan23/AndroidMediaPipeImageSegmentation

BB 素材作れるかも

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/

ひつようなもの

  • CanvasVideoProcessor.kt
  • VideoFrameBitmapExtractor.kt
  • AkariCoreInputOutput.kt
  • MediaMuxerTool.kt
  • CanvasRenderer.kt
  • InputSurface.kt
  • MediaExtractorTool.kt
  • FrameExtractorRenderer.kt

詳細記事

Canvasで書いて動画を作るやつと、動画から一枚一枚Bitmapを取り出すやつの詳細です。
それぞれ一本ずつ記事があります。。。

UI を動画選択用に直す

プレビューの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} ミリ秒")
            }
        }
    }
}

使ってみる

動画を選ぶを押して人物が写っている動画を選ぶ。
選ぶと処理が始まるので、数分待つ。

Imgur

出力結果

MediaPipeすげ~
職人が作るのと比べるとダメだけどそれでもすごいと思う。

BB 素材として使えます

青色をくり抜くようにすれば BB 素材です。
他の動画編集アプリでも使えるはず。

Imgur

Imgur

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秒間作ってみた結果。

なまえOSSoC1回目2回目
Pixel 8 Pro15 BetaGoogle Tensor G3115301 ミリ秒115631 ミリ秒
Xperia 1 V14Qualcomm Snapdragon 8 Gen 238167 ミリ秒38313 ミリ秒

なんでこんな差が出るの・・?

BB素材をイメージセグメンテーションで作るアプリのソースコードとAPK

需要あるかな

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")

フラグメントシェーダーを修正

まずはコードを。こちらです。
sSegmentedTexturetexture2Dに渡すと、イメージセグメンテーション結果の画像が参照できます。

これを使い、前面カメラを描画するモードだったら、描画するピクセルに対応するイメージセグメンテーション結果の色を取り出して、色が青かどうかを判定します。
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 でカメラ映像を流す

ImageReaderを作ります。
解像度は下げておきます。クソデカ画像を投げてもイメージセグメンテーションの解析が遅くなるだけなので。

ここではImageFormat.JPEGしています。Camera2 APIの出力先にする場合は多分これにしないといけない?
あと、maxImages30にしています。これを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 側の修正

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()
}

KomaDroidCameraManager 側の修正

次に、ImageReaderからMediaPipeに渡してる処理がこの辺。
ImageReaderからカメラ映像を取り出して、BitmapMediaPipeに渡して、イメージセグメンテーションしてもらう。
そのあと、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搭載機で動かなくなる。

さらに言うと、ImageReaderJPEGで使っているせいなのか、写真が90度回転しています。
これだとカメラ映像とイメージセグメンテーション結果の画像を重ねたとしても、回転している状態では切り抜くことが出来ないので困った!
困るはずなのだが、何故か動いています。はて。

これはカメラ映像のテクスチャのSurfaceTexture90度回転していて、この90度回転している件はSurfaceTexture#getTransformMatrixを使った行列を適用すると?回転が直る。
これと同じ行列をImageReaderから出てきたBitmapの描画でも使えば、同じく回転が直る。

逆に言えば 90 度回転していない画像をぶち込んだら壊れる。両方 90 度回転してるからたまたま動いている

イマイチなソースコード

いまいち過ぎるのでgit revertしました。多分安定しているのがテクスチャを描画するだけのOpenGL ESを経由するのが一番いい気がする。これから話します。
もしくは解析のユースケースがサポートされてる(らしい)CameraXに乗り換えるか。解析用にImageがもらえるらしいです。

Imgur

git revertしてしまったのでもしイマイチ具合を見たい場合は、
ブランチがimage_segmentation、コミットハッシュがa7742f2f863b3ffbff40ddddc0b88d87b67f17c3なので、git cloneしたらgit checkoutでコミットハッシュを入れてください。

https://github.com/takusan23/KomaDroid

もしちゃんとやりたい場合は

OpenGL ESを間に挟むと安定して動いてる気がします。Stackoverflowの回答でもglReadPixelsしているくらいだし(今でもそっちのが速いかは不明)。
というか、間にOpenGL ESを挟むとglReadPixelsなんかせずとも、ImageReaderPixelFormat.RGBA_8888のモードで利用できるので。今のところRGBA_8888なら安定して動いてる気がします。

Imgur

こんな感じに、カメラ映像が一旦OpenGL ESの描画を経由するようになります。ただカメラ映像のテクスチャをそのまま描画しているだけ(手間が増えただけ)。なんでこっちのほうが安定しているのかは謎です。
ImageReaderを入れるだけでガクガクになるプレビュー問題も直るし、Snapdragonで動画撮影が出来ないのも直った。

修正する

が、が、が、かなり直す必要があって、

  • OpenGL ESでテクスチャを描画するだけのTextureRendererクラスを用意する
  • 前面カメラ、背面カメラ映像を同時に描画するKomaDroidCameraTextureRendererの、イメージセグメンテーション結果を扱う部分の修正
    • ImageReader(JPEG)の時はたまたま90度回転していたので、カメラ映像と同じようにtexture2D()を呼べばよかったのですが、RGBA_8888は回転していない。
      • まあコレに関しては回転しているのがおかしい(???)

テクスチャを描画するだけの処理

こちらです、もう貼るの面倒なのでGitHub見てください、
テクスチャをtexture2Dで描画するだけ。それだけ。InputSurfaceの方は使い回せるのでこれだけ持ってくれば良いです。

https://github.com/takusan23/KomaDroid/blob/image_segmentation/app/src/main/java/io/github/takusan23/komadroid/gl/TextureCopyTextureRenderer.kt

KomaDroidCameraTextureRenderer 側の修正2

回転していない画像が来るようになったので、新しい、回転していない行列を作ります。
バーテックスシェーダー、フラグメントシェーダーだけで変更すれば、あとはイマイチな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;
}
"""

KomaDroidCameraManager 側の修正2

InputSurfaceと、TextureCopyTextureRendererを持つだけのクラスを作りました。

/** 解析用 [OpenGlDrawPair] */
private data class AnalyzeOpenGlDrawPair(
    val inputSurface: InputSurface,
    val textureRenderer: TextureCopyTextureRenderer
)

それから、解析用ImageReaderの他に、解析用ImageReaderOpenGL用スレッド、AnalyzeOpenGlDrawPairのインスタンスを作ります。
ImageReaderPixelFormat.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の用意をします。
InputSurfaceTextureCopyTextureRendererを作ります。作ったら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から、ImageReaderOpenGLへテクスチャを送る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月なのに(記述時時点)クソ暑すぎだろ。虫やだ!!!!網戸すり抜けてくる奴ら何????