🖼️

Compose MultiplatformでImageBitmapとBitmap/UIImageを相互変換するには

に公開

こんにちは!sugitaniと申します。ブラックキャット・カーニバル(略称ブラキャニ)というCompose Multiplatformで作られたSNSアプリを開発しています。

本稿はブラキャニの開発で得られた"あれどうやるんだっけ" を備忘録も兼ねて共有していくシリーズの6作目です。

ImageBitmapとBitmap/UIImageを相互変換する下準備

画像を取り扱う場合、各プラットフォーム固有の画像オブジェクトとComposeのImageBitmapを変換したくなる場面に遭遇します。

下準備として、各プラットフォームの画像オブジェクトに対応する PlatformBitmapを用意します。

commonMain/kotlin/cc/bcc/cmpexamples/example006/ImageUtil.kt

package cc.bcc.cmpexamples.example006

import androidx.compose.ui.graphics.ImageBitmap

@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect class PlatformBitmap

expect fun ImageBitmap.toPlatformBitmap(): PlatformBitmap

expect fun PlatformBitmap.toImageBitmap(): ImageBitmap

Androidの場合(ImageBitmap ⇄ android.graphics.Bitmap)

Compose向けのBitmapであるImageBitmapと、
Android標準のBitmapであるandroid.graphics.Bitmapの相互変換は標準の変換メソッドが用意されており簡単です。(というか実体はcastです)

androidMain/kotlin/cc/bcc/cmpexamples/example006/ImageUtil.android.kt

@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")

package cc.bcc.cmpexamples.example006

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap

actual typealias PlatformBitmap = android.graphics.Bitmap

actual fun ImageBitmap.toPlatformBitmap(): PlatformBitmap = this.asAndroidBitmap()

actual fun PlatformBitmap.toImageBitmap(): ImageBitmap = this.asImageBitmap()

iOSの場合(ImageBitmap ⇄ UIImage) 手抜き版

UIImageへの変換はかなり面倒ですが、pngを経由するという富豪的ですが安全で簡単な方法があります

iosMain/kotlin/cc/bcc/cmpexamples/example006/ImageUtil.ios.kt

@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")

package cc.bcc.cmpexamples.example006

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import org.jetbrains.skia.Data
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import org.jetbrains.skia.makeFromEncoded
import platform.Foundation.NSData
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage
import platform.UIKit.UIImagePNGRepresentation

actual typealias PlatformBitmap = UIImage

@OptIn(ExperimentalForeignApi::class)
actual fun ImageBitmap.toPlatformBitmap(): PlatformBitmap {
    val skiaImage = Image.makeFromBitmap(this.asSkiaBitmap())
    val pngData: Data? = skiaImage.encodeToData(EncodedImageFormat.PNG)
    val pngBytes = pngData?.bytes ?: throw Exception("Failed to encode ImageBitmap to PNG")

    val nsData =
        pngBytes.usePinned { pinned ->
            NSData.dataWithBytes(pinned.addressOf(0), pngBytes.size.toULong())
        }
    return UIImage(data = nsData)
}

actual fun PlatformBitmap.toImageBitmap(): ImageBitmap {
    val pngData = UIImagePNGRepresentation(this) ?: throw Exception("Failed to convert image")
    return Image.makeFromEncoded(pngData).toComposeImageBitmap()
}

メモリ消費も大きくもエンコード・デコード時間も発生しますが、互換性は一番高いと考えられるので、頻繁に利用する処理でなければこちらを利用するのがお勧めです。

iOSの場合(ImageBitmap ⇄ UIImage) ちゃんとやる版

パフォーマンスが気になる場合は、素直にピクセル状態を考えながら変換します

iosMain/kotlin/cc/bcc/cmpexamples/example006/ImageUtil.ios.kt

@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")

package cc.bcc.cmpexamples.example006

import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import platform.CoreGraphics.CGBitmapContextCreate
import platform.CoreGraphics.CGColorRenderingIntent
import platform.CoreGraphics.CGColorSpaceCreateWithName
import platform.CoreGraphics.CGContextDrawImage
import platform.CoreGraphics.CGDataProviderCreateWithData
import platform.CoreGraphics.CGImageAlphaInfo
import platform.CoreGraphics.CGImageCreate
import platform.CoreGraphics.CGImageGetHeight
import platform.CoreGraphics.CGImageGetWidth
import platform.CoreGraphics.CGRectMake
import platform.CoreGraphics.kCGBitmapByteOrder32Little
import platform.CoreGraphics.kCGColorSpaceSRGB
import platform.Foundation.NSData
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage

actual typealias PlatformBitmap = UIImage

@OptIn(ExperimentalForeignApi::class)
actual fun ImageBitmap.toPlatformBitmap(): PlatformBitmap {
    val skiaBitmap = this.asSkiaBitmap()
    val width = skiaBitmap.width
    val height = skiaBitmap.height

    // Extract bitmap pixel data as 32bit BGRA_8888 with premultiplied alpha
    val imageInfo = ImageInfo.makeN32Premul(width, height)
    val finalPixelData =
        skiaBitmap.readPixels(imageInfo) ?: throw Exception("Failed to read pixels")

    // Pin ByteArray to prevent GC interference and create NSData with memory copy
    val nsData =
        finalPixelData.usePinned { pinned ->
            NSData.dataWithBytes(
                bytes = pinned.addressOf(0),
                length = (width * height * 4).toULong(),
            )
        }

    val provider =
        CGDataProviderCreateWithData(
            info = null,
            data = nsData.bytes,
            size = nsData.length,
            releaseData = null,
        )

    val cgImage =
        CGImageCreate(
            width = width.toULong(),
            height = height.toULong(),
            bitsPerComponent = 8u, // 8 bits per R,G,B,A component
            bitsPerPixel = 32u, // Total 32 bits
            bytesPerRow = (width * 4).toULong(),
            space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB), // Use sRGB color space
            bitmapInfo =
                CGImageAlphaInfo.kCGImageAlphaPremultipliedFirst.value // Alpha first, premultiplied
                    or kCGBitmapByteOrder32Little, // Little endian for both ARM and x86/x64
            provider = provider,
            decode = null, // No decoding
            shouldInterpolate = false, // No pixel interpolation
            intent = CGColorRenderingIntent.kCGRenderingIntentDefault, // Default color space conversion
        )

    return UIImage(cGImage = cgImage)
}

@OptIn(ExperimentalForeignApi::class)
actual fun PlatformBitmap.toImageBitmap(): ImageBitmap {
    val cgImage = this.CGImage ?: throw Exception("Failed to get CGImage from UIImage")
    val width = CGImageGetWidth(cgImage).toInt()
    val height = CGImageGetHeight(cgImage).toInt()
    val bytesPerRow = width * 4
    val dataSize = height * bytesPerRow

    // Create bitmap context to extract pixel data
    val colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB)
    val context =
        CGBitmapContextCreate(
            data = null,
            width = width.toULong(),
            height = height.toULong(),
            bitsPerComponent = 8u,
            bytesPerRow = bytesPerRow.toULong(),
            space = colorSpace,
            bitmapInfo =
                CGImageAlphaInfo.kCGImageAlphaPremultipliedFirst.value or kCGBitmapByteOrder32Little,
        ) ?: throw Exception("Failed to create bitmap context")

    // Draw CGImage to context
    CGContextDrawImage(context, CGRectMake(0.0, 0.0, width.toDouble(), height.toDouble()), cgImage)

    // Get pixel data from context
    val pixelData = ByteArray(dataSize)
    pixelData.usePinned { pinned ->
        val contextData = platform.CoreGraphics.CGBitmapContextGetData(context)
        platform.posix.memcpy(pinned.addressOf(0), contextData, dataSize.toULong())
    }

    // Create Skia bitmap from pixel data
    val imageInfo =
        ImageInfo(
            width = width,
            height = height,
            colorType = ColorType.BGRA_8888,
            alphaType = ColorAlphaType.PREMUL,
        )

    val bitmap = Bitmap()
    bitmap.allocPixels(imageInfo)
    bitmap.installPixels(pixelData)

    return bitmap.asComposeImageBitmap()
}

やっていることは、sRGBのBGRA_8888の配列になるように書き出したり読み出したりする、という処理です。

以前に取り組んで上手くいかず簡易版に逃げていたのですが、今回の例のために腰を据えて挑んだら出来た!…というものなので問題無いかは不明です。

利用例

以下のように利用します

package cc.bcc.cmpexamples.example006

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import bccexamples006.composeapp.generated.resources.Res
import bccexamples006.composeapp.generated.resources.bcc
import bccexamples006.composeapp.generated.resources.mikan
import org.jetbrains.compose.resources.imageResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview

@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
internal fun App() {
    MaterialTheme {
        Scaffold(
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text("ImageBitmap⇄Bitmap/UIImage") },
                )
            },
        ) { innerPadding ->

            val bccResource = imageResource(Res.drawable.bcc)
            val bcc =
                remember(bccResource) {
                    val platformBitmap = bccResource.toPlatformBitmap()
                    platformBitmap.toImageBitmap()
                }

            val mikanResource = imageResource(Res.drawable.mikan)
            val mikan =
                remember(mikanResource) {
                    val platformBitmap = mikanResource.toPlatformBitmap()
                    platformBitmap.toImageBitmap()
                }

            Column(
                modifier = Modifier.padding(innerPadding).fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Image(
                        modifier = Modifier.size(100.dp).padding(16.dp),
                        painter = painterResource(Res.drawable.bcc),
                        contentDescription = "bcc",
                    )

                    Text("→")

                    Image(
                        modifier = Modifier.size(100.dp).padding(16.dp),
                        bitmap = bcc,
                        contentDescription = "bcc",
                    )
                }

                Spacer(Modifier.height(16.dp))

                Row(verticalAlignment = Alignment.CenterVertically) {
                    Image(
                        modifier = Modifier.size(100.dp).padding(16.dp),
                        painter = painterResource(Res.drawable.mikan),
                        contentDescription = "mikan",
                    )

                    Text("→")

                    Image(
                        modifier = Modifier.size(100.dp).padding(16.dp),
                        bitmap = mikan,
                        contentDescription = "mikan",
                    )
                }
            }
        }
    }
}


サンプルプロジェクト

本稿のソースコード、および動作するコードは
https://github.com/blackcat-carnival/cmp-examples/tree/main/006.convert_image
にあります。

免責事項

このコードはあくまで"自分はこう実装した"という例ですので、よりよい方法がある可能性があります。見つけたら教えてください!

以下宣伝

ブラックキャット・カーニバル

Discussion