[Android] 이미지의 Luminance 를 계산하는 방법

easyhooon·2025년 9월 4일
4
post-thumbnail

서두

썸네일과 같이 화면의 상단 영역(StatusBar 영역을 포함)을 전부 이미지로 채우는 화면의 경우, 이미지에 의해 StatusBar의 Icon들과, TopAppBar의 Back Icon이 잘 보이지 않는 문제가 발생한다.

Android 15 이상을 타겟팅해야 함에 따라, 이와 같은 문제가 발생하는 앱이 많을 것으로 예상된다.
기존에는 라이트 모드일 때는 darkIcons 활성화, 다크 모드일 때는 비활성화를 적용해줬었다.

이와 같은 문제를 어떻게 해결할까 고민하던 도중, 결국 중요한 건 이미지가 밝은 이미지인지, 어두운 이미지인지를 판단하는 것임을 알 수 있었고, 그러면 밝다, 어둡다를 어떻게 판별할 수 있는지에 대해 조사를 해보았다.

그 결과를 공유해보려고 한다.

본론

우선 이미지의 밝기를 판단하는 척도가 무엇인지 찾아봤는데, Luminance 를 표준적인 척도로 사용하는 것을 확인할 수 있었다. 이를 한글로는 휘도라고 하는 것 같던데, 원문을 그대로 사용하도록 하겠다.

Luminance = 인간의 눈이 인지하는 밝기의 정도

  • 단위: cd/m² (칸델라 per 제곱미터) 또는 nit
  • RGB 값을 인간 시각 시스템에 맞게 가중 평균한 값

이미지의 각 픽셀들의 RGB 값에 인간 시각 시스템에 맞게 가중치(ITU-R BT.709)를 적용하여 가중합을 통해 Luminance 를 구한 뒤에, 0.5 보다 크면 밝은 이미지, 작으면 어두운 이미지로 판단하도록 계산하였다.

// ITU-R BT.709 luminance formula
// https://en.wikipedia.org/wiki/Rec._709
// 0.2126 (Red): 인간의 눈이 빨간색에 가장 덜 민감
// 0.7152 (Green): 인간의 눈이 초록색에 가장 민감 (약 71%)
// 0.0722 (Blue): 인간의 눈이 파란색에 두 번째로 덜 민감
val luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0

인간의 눈이 초록색에 가장 민감하게 반응하는지 처음 알았다. 빨간색에 민감할줄...

초기 구현 코드는 다음과 같다.

package com.unifest.android.core.common.utils

import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.core.graphics.get
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber

data class LuminanceResult(
    val luminance: Float,
    val isDark: Boolean,
) {
    companion object {
        // 밝은 이미지인지, 어두운 이미지인지 판단을 위한 임계값
        const val LUMINANCE_THRESHOLD = 0.5f
    }
}

object ImageLuminanceUtils {

    suspend fun calculateLuminance(imageUrl: String, context: Context): LuminanceResult? {
        return withContext(Dispatchers.Default) {
            try {
                val imageLoader = context.imageLoader

                val request = ImageRequest.Builder(context)
                    .data(imageUrl)
                    .allowHardware(false)
                    .build()

                val result = imageLoader.execute(request)

                Timber.d("ImageLoader result: ${result::class.simpleName}")
                if (result is SuccessResult) {
                    val drawable = result.drawable
                    Timber.d("Drawable type: ${drawable::class.simpleName}")

                    val bitmap = when (drawable) {
                        is BitmapDrawable -> {
                            val originalBitmap = drawable.bitmap
                            // HARDWARE bitmap은 pixel 접근이 불가능하므로 SOFTWARE로 복사
                            if (originalBitmap.config == Bitmap.Config.HARDWARE) {
                                originalBitmap.copy(Bitmap.Config.ARGB_8888, false)
                            } else {
                                originalBitmap
                            }
                        }

                        else -> {
                            Timber.d("Drawable is not BitmapDrawable, returning null")
                            return@withContext null
                        }
                    }

                    val luminanceResult = calculateBitmapLuminance(bitmap)
                    Timber.d("Luminance calculation complete: $luminanceResult")
                    luminanceResult
                } else {
                    Timber.d("ImageLoader request failed: $result")
                    null
                }
            } catch (e: Exception) {
                Timber.d("Exception during luminance calculation: ${e.message}")
                e.printStackTrace()
                null
            }
        }
    }

    private fun calculateBitmapLuminance(bitmap: Bitmap): LuminanceResult {
        val width = bitmap.width
        val height = bitmap.height
        var totalLuminance = 0.0
        var sampleCount = 0

        for (y in 0 until height) {
            for (x in 0 until width) {
                val pixel = bitmap[x, y]
                val red = (pixel shr 16) and 0xFF
                val green = (pixel shr 8) and 0xFF
                val blue = pixel and 0xFF

                // ITU-R BT.709 luminance formula
                // https://en.wikipedia.org/wiki/Rec._709
                // 0.2126 (Red): 인간의 눈이 빨간색에 가장 덜 민감
                // 0.7152 (Green): 인간의 눈이 초록색에 가장 민감 (약 71%)
                // 0.0722 (Blue): 인간의 눈이 파란색에 두 번째로 덜 민감
                val luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0
                totalLuminance += luminance
                sampleCount++
            }
        }

        val averageLuminance = if (sampleCount > 0) (totalLuminance / sampleCount).toFloat() else 0f
        val isDark = averageLuminance < LuminanceResult.LUMINANCE_THRESHOLD

        return LuminanceResult(averageLuminance, isDark)
    }
}

서버에서 받아온 Network Image Url을 Coil을 통해서 Bitmap으로 변환한 이후에, 이미지의 Luminance 값을 얻기 위한 계산을 진행하였다.

각 픽셀의 RGB 값에 가중치를 적용하여 가중합을 구하는 과정은 CPU 집약적인 작업이기에, Coroutine의 Default Dispatcher내에서 연산을 수행하였다.

Coil의 imageLoader 객체를 매번 생성할 필요는 없기에 context.imageLoader 방식을 사용하여 싱글톤 방식으로 imageLoader를 가져와 사용하였다.

package coil
/**
 * Get the singleton [ImageLoader].
 */
inline val Context.imageLoader: ImageLoader
    get() = Coil.imageLoader(this)

실제 화면내 적용

    val imageLuminance = produceState<LuminanceResult?>(initialValue = null, key1 = uiState.boothDetailInfo.thumbnail) {
        if (uiState.boothDetailInfo.thumbnail.isNotBlank()) {
            value = ImageLuminanceUtils.calculateLuminance(uiState.boothDetailInfo.thumbnail, context)
        }
    }.value
    // 밝은 이미지일 때 어두운 아이콘 사용 vice versa 
    val shouldUseDarkIcons = imageLuminance?.isDark?.not() ?: !isDarkTheme

    DisposableEffect(shouldUseDarkIcons) {
        systemUiController.setStatusBarColor(
            color = Color.Transparent,
            darkIcons = shouldUseDarkIcons,
        )
        onDispose {
            systemUiController.setStatusBarColor(
                color = if (isDarkTheme) DarkGrey100 else Color.White,
                darkIcons = !isDarkTheme,
            )
        }
    }

문제 발생

계산량이 너무 많다.

요즘 나오는 기기들의 스펙이 너무 좋아, 성능 개선을 하지 않더라도 크게 문제가 되진 않겠지만, 위의 코드에는 다소 비효율적인 계산이 많이 포함되어 있는 것을 알 수 있다.

1. 모든 이미지 영역을 전부 계산 해야할까?

현재 문제가 되는 영역은 StatusBar 영역 + TopAppBar 영역 정도이기 때문에, 이미지의 상단 30퍼센트 정도의 해당하는 영역만 계산하여 Luminance 값을 구해도 문제를 충분히 해결할 수 있다고 생각했다.

2. 모든 픽셀에 가중치를 적용해서 가중합을 구해야할까?

모든 픽셀을 다 검사하지 않고, 일정 간격한 간격으로 샘플링을 적용하여 이미지의 Luminance를 구하면 계산량을 상당히 많이 줄일 수 있고, 충분한 샘플 수가 보장이 된다면, 전체 모집단의 특성을 대표할 수 있기 때문에 정확도 측면에서도 큰 문제가 없다고 판단했다.

문제 해결

샘플링 적용

package com.unifest.android.core.common.utils

import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.core.graphics.get
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber

data class LuminanceResult(
    val luminance: Float,
    val isDark: Boolean,
) {
    companion object {
        // 밝은 이미지인지, 어두운 이미지인지 판단을 위한 임계값
        const val LUMINANCE_THRESHOLD = 0.5f
    }
}

object ImageLuminanceUtils {

    @Suppress("TooGenericExceptionCaught")
    suspend fun calculateLuminance(imageUrl: String, context: Context): LuminanceResult? {
		//...
    }

    private fun calculateBitmapLuminance(bitmap: Bitmap): LuminanceResult {
        val width = bitmap.width
        val height = bitmap.height
        var totalLuminance = 0.0

        // 상단 30% 영역만 샘플링 (status bar와 top app bar 영역)
        val sampleHeight = (height * 0.3).toInt()
        // 성능을 위한 샘플링 간격(모든 픽셀을 다 검사하지 않고 일정 간격으로만 픽셀을 샘플링해서 계산 속도를 높임)
        val stepX = maxOf(1, width / 50)
        val stepY = maxOf(1, sampleHeight / 20)
        var sampleCount = 0

        for (y in 0 until sampleHeight step stepY) {
            for (x in 0 until width step stepX) {
                val pixel = bitmap[x, y]
                val red = (pixel shr 16) and 0xFF
                val green = (pixel shr 8) and 0xFF
                val blue = pixel and 0xFF

                // ITU-R BT.709 luminance formula
                // https://en.wikipedia.org/wiki/Rec._709
                // 0.2126 (Red): 인간의 눈이 빨간색에 가장 덜 민감
                // 0.7152 (Green): 인간의 눈이 초록색에 가장 민감 (약 71%)
                // 0.0722 (Blue): 인간의 눈이 파란색에 두 번째로 덜 민감
                val luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0
                totalLuminance += luminance
                sampleCount++
            }
        }

        val averageLuminance = if (sampleCount > 0) (totalLuminance / sampleCount).toFloat() else 0f
        val isDark = averageLuminance < LuminanceResult.LUMINANCE_THRESHOLD

        return LuminanceResult(averageLuminance, isDark)
    }
}

세로 영역의 30퍼센트만 계산하기도 하였고, 원래 사진의 가로길이가 세로길이보다 더 크기 때문에, 샘플링 적용시 가로는 1/50, 세로는 1/20으로 적용하였다.

결과

성공적으로 이미지의 Luminance를 계산할 수 있었고, 다음과 같이 라이트 모드/다크 모드 상관없이 Icon들에 이미지와 대조되는 색상을 적용하여, Icon들이 보이지 않는 문제를 해결할 수 있었다.

물론 아래와 같이, 밝은 이미지라고 판단이되어 darkIcon으로 적용하더라도, Icon들이 제대로 보이지 않는 엣지 케이스들이 존재하긴하는데 억까, 해당 케이스들도 문제 없이 대응되려면 어떻게 해야할지 추가적으로 고민을 해봐야할 듯 하다.

더 나은 대응 전략이 있다면, 댓글로 남겨주시면 감사하겠습니다!
다른 곳들에서는 어떻게 관련 문제을 해결 하는지 궁금하네요 ㅎㅅㅎ

P.S

https://www.youtube.com/watch?v=fdfhaEvxdy4&t=1563s
2025 드로이드나이츠에서 글에서 다룬 내용과 관련된 발표가 있어 공유 ㅎ

reference)
https://en.wikipedia.org/wiki/Rec._709
https://www.youtube.com/watch?v=fdfhaEvxdy4&t=1563s

profile
실력은 고통의 총합이다. Android Developer

2개의 댓글

comment-user-thumbnail
2025년 9월 9일
1개의 답글