안드로이드에서 blur효과 구현하기

안드로이드 SDK에서는 Blur에 관한 API를 제공하고 있지 않기 때문에 일반적으로 라이브러리를 사용하여 구현하게 된다. 

Blur 효과를 구현하기 위해서는 이미지를 구성하는 픽셀에 대해서 먼저 알아야 한다.

픽셀(Pixel)이란?

픽셀은 화소라고도 하며 화면 또는 이미지를 구성하는 가장 기본이 되는 단위다.

[그림1] 픽셀로 구성되는 이미지

어떠한 이미지를 크게 확대 했을 때 작은 점 또는 사격형으로 구성되어있는 것을 볼 수 있는데 그것이 바로 픽셀이다. 만약 HD사이즈인 1280 * 720 사이즈를 갖는 이미지가 존재한다면, 그 이미지는 921600개의 픽셀로 구성되어있는 이미지다.

픽셀을 표현하는 방식은 다양하지만 일반적으로는 ARGB_8888 방식을 사용한다.
ARGB_8888에서 ARGB는 Alpha(투명), Red(빨강), Green(녹색), Blue(파랑)의 각 두음자를 따온 것이며, 8888은 각 색의 bit수를 의미한다.

[그림2] 픽셀의 구성요소

8bit = 1Byte 이므로 한 픽셀을 표현하기 위해서는 8bit*4 = 4Bytes의 메모리 공간이 필요하다. 정수형 int가 4Bytes 이므로 한 픽셀을 표현하는데 하나의 int 자료형 메모리가 할당되어야 한다. 1280*720 사이즈를 갖는 비트맵 이미지를 메모리에 로드하려면 다음과 같은 사이즈의 메모리가 필요하다. 

가로 픽셀 수 * 세로 픽셀 수 * 하나의 픽셀 사이즈
= 1280 * 720 * 4Bytes = 3686400Bytes = 3600kB = 3.51MB

색의 삼원색인 빨강, 노랑, 파랑은 각각 1Byte로 표현 되며, 1Byte는 0~255까지 256가지의 경우의 수를 갖기 때문에 256^3 = 16777216가지의 색상 조합이 가능하다. 이를 24bits 트루컬러라고도 한다.

색상값 분리하기

다음과 같은 값을 갖는 픽셀이 있다고 가정하자.

11111111 11110000 00001111 10101010 

그림2를 참조했을 때 다음과 같이 픽셀의 구성요소를 분리할 수 있다.

투명 = 11111111
빨강 = 11110000
녹색 = 00001111
파랑 = 10101010

이를 코드로 분리하기 위해서는 비트 연산자를 사용한다

val pixel:Int = ... //11111111 11110000 00001111 10101010

val alpha:Int = pixel ushr 24
val red = pixel ushr 16 and 0xFF
val green:Int = pixel ushr 8 and 0xFF
val blue:Int = pixel and 0xFF

앞의 코드에 대한 부연설명을 하자면,

투명도 값만 얻기 위해서 RGB 성분은 모두 제거 해야한다. 그러므로 픽셀값을 오른쪽으로 24번 비트 쉬프팅하면 다음과 같이 투명도 값을 얻을 수 있다.

11111111 11110000 00001111 10101010 //픽셀
01111111 11111000 00000111 11010101 //1번째, 오른쪽으로 비트 쉬프팅
00111111 11111100 00000011 11101010 //2번째, 오른쪽으로 비트 쉬프팅
00011111 11111110 00000001 11110101 //3번째, 오른쪽으로 비트 쉬프팅
...
00000000 00000000 00000000 11111111 //24번째, 오른쪽으로 비트 쉬프팅한 결과

빨간색에 해당하는 11110000만  분리하기 위해 오른쪽으로 16번 비트 쉬프팅을 하면 다음과 같은 결과를 얻는다.

00000000 00000000 11111111 11110000 // 픽셀값을 16번 쉬프팅한 결과

여기서 0xFF와 and연산자로 마스킹을 하여 빨강색 성분만 발라내자. 참고로 0xFF는 16진수이며, 10진수로는 255, 2진수로는 11111111이다.

00000000 00000000 00000000 11111111 // 0xFF
00000000 00000000 11111111 11110000 // 픽셀값을 16번 오른쪽으로 쉬프팅한 결과
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ// and연산자로 마스킹
00000000 00000000 00000000 11110000 // 빨강색 성분

녹색과 파랑색도 동일한 방법으로 성분을 분리 할 수 있다.

Box Blur 구현하기

모든 이미지는 다음과 같이 픽셀들로 구성된다.

[그림3] 픽셀로 표현되는 이미지

이미지에 블러효과를 주기 위해서는 각 픽셀의 색상에 해당하는 값들이 변경되어야 한다. Box Blur는 주변 인접하는 픽셀들의 평균 색상값으로 변경하는 알고리즘이다.

어떠한 픽셀(P5)에 대해 반경(Radius) 1에 해당하는 주변 픽셀들은 다음 그림과 같은 형태를 가질 수 있다.

P1 P2 P3
P4 P5 P6
P7 P8 P9

픽셀 P5에 Blur효과를 적용하기 위해서는 다음과 같은 연산을 할 수 있다.

새로운 P5 값 = (P1+P2+P3+P4+P5+P6+P7+P8+P9)/9

이러한 인접픽셀에 대한 평균값을 모든 픽셀에 대해 왼쪽에서 오른쪽 방향으로 그리고 위에 부터 아래방향으로 하나씩 적용하자.

[그림4] 이미지에 블러효과 적용하기

모든 픽셀값을 인접픽셀의 평균값으로 적용하고나면, Blur효과가 적용된 새로운 비트맵 이미지를 얻을 수 있다.

만약 더 흐릿한 이미지를 만들고 싶다면 인접한 픽셀 반경을 늘려서 연산하면된다.

P1 P2 P3 P4 P5
P6 P7 P8 P9 P10
P11 P12 P13 P14 P15
P16 P17 P18 P19 P20
P21 P22 P23 P24 P25
새로운 P13 값 = (P1+P2+P3+...+P23+P24+p25)/25

[그림5] radius별 blur효과가 적용된 이미지

BoxBlur의 코드는 다음과 같다.

class BoxBlur : AbstractBlur {
    override fun blur(image: Bitmap, radius: Int): Bitmap {
        val w = image.width
        val h = image.height
        val currentPixels = IntArray(w * h)
        val newPixels = IntArray(w * h)
        image.getPixels(currentPixels, 0, w, 0, 0, w, h)
        blurProcess(w, h, currentPixels, newPixels, radius)
        return Bitmap.createBitmap(newPixels, w, h, Bitmap.Config.ARGB_8888)
    }

    private fun blurProcess(
        w: Int,
        h: Int,
        currentPixels: IntArray,
        newPixels: IntArray,
        radius: Int
    ) {
        for (col in 0 until w) {
            for (row in 0 until h) {
                newPixels[row * w + col] = getSurroundAverage(currentPixels, col, row, h, w, radius)
            }
        }
    }

    private fun getSurroundAverage(
        currentPixels: IntArray,
        col: Int,
        row: Int,
        h: Int,
        w: Int,
        radius: Int
    ): Int {
        val originalPixel = currentPixels[row * w + col]
        val alpha: Int = originalPixel ushr 24
        val originalRed = originalPixel ushr 16 and 0xFF
        val originalGreen = originalPixel ushr 8 and 0xFF
        val originalBlue = originalPixel and 0xFF

        var sumOfRed = originalRed
        var sumOfGreen = originalGreen
        var sumOfBlue = originalBlue

        for (y in (row - radius..row + radius)) {
            for (x in col - radius..col + radius) {
                if (y < 0 || y > h - 1 || x < 0 || x > w - 1) {
                    // 이미지 가장자리를 벗어나는 계산은 originalPixel 값을 더한다.
                    sumOfRed += originalRed;
                    sumOfGreen += originalGreen;
                    sumOfBlue += originalBlue
                } else if (y == row && x == col) {
                    // originalPixel은 시작할 때 이미 더 했음
                } else {
                    val sidePixel = currentPixels[y * w + x]
                    sumOfRed += sidePixel ushr 16 and 0xFF
                    sumOfGreen += sidePixel ushr 8 and 0xFF
                    sumOfBlue += sidePixel and 0xFF
                }
            }
        }

        val denominator = (radius * 2 + 1) * (radius * 2 + 1)

        return ((alpha and 0xff) shl 24) or
                ((sumOfRed / denominator) and 0xff shl 16) or
                ((sumOfGreen / denominator) and 0xff shl 8) or
                ((sumOfBlue / denominator) and 0xff)
    }

    override fun getType() = BlurType.BOX_BLUR

}

 

지금까지 가장 기본적인 BoxBlur에 대해서 알아 보았다. 

Box Blur는 두가지 문제점이 있다.

첫번째, radius값에 비례하여 연산량이 증가하기 때문에 결과물을 얻는데 시간이 오래걸린다.
두번째, 원본 이미지와 비교했을 때 흔히 계단현상(?)이라고 불리는 픽셀화가 잘 보인다.

최적화된 Box Blur 구현하기

앞에서 언급한 두가지 문제점 중 하나인 시간 문제를 해결해보자.

Box Blur알고리즘은 radius의 크기가 커질 때 연산량도 비례하여 커지는 문제점을 가지고 있었다. 이를 해결하기 위해 다음과 같이 인접한 픽셀의 평균값을 구하는 방법을 개선해보자.

  1. 지정된 반경(radius) 내에서 수평으로 인접한 픽셀의 평균을 계산하기 위해 행단위로 픽셀을 계산한다.
  2. 그런 다음 첫번째에서 얻어진 계산 결과를 사용하여 열 단위로 지정된 반경 내에서 수직으로 인접한 픽셀의 평균을 계산한다.
P1 P2 P3
P4 P5 P6
P7 P8 P9

개선한 방식으로 다시 계산을 해보자. Radius가 1이라고 가정하고 P5에 대한 새로운 값을 구한다면 아래와 같다.

수평선상에서 인접 픽셀 평균값 구하기
H1 = P1+P2+P3
H2 = P4+P5+p6
H3 = P7+P8+P9

수직선상에서 인접 픽셀 평균값 구하기
최종값 = (H1+H2+H3)/3

이런 방법 이외에도 인접 픽셀의 평균을 구하는 빠른 방법이 있다. 어쨌든 평균값을 구하기 위한 연산횟수를 줄이면 연산량이 적어서 속도를 높일 수 있다.

다음 방법은 첫번째 픽셀을 합산하고 두번째 픽셀부터는 앞쪽의 픽셀값을 하나 빼고 뒤에 픽셀값을 하나 더하는 방식으로 연산량을 줄인다.

P1 P2 P3 P4 P5 P6 P7

radius=3일 때 P4에 대한 인접 픽셀 평균을 먼저 계산한다.
P4 인접 픽셀 평균값 = Avg(P4) = (P1+P2+P3+P4+P5+P6+P7) / 7

P2 P3 P4 P5 P6 P7 P8

이미 P4에 대한 인접 픽셀 평균값은 구했으므로,
P5 인접 픽셀 평균값 = (Avg(P4) – P1 + P8) / 7

이러한 방식으로 Blur효과를 주면 radius 크기와는 관계없이 일정한 연산 속도를 보장한다.

기존 Box Blur와 비교했을때 퀄리티 또한 크게 다르지 않은것을 확인할 수 있다.

BoxBlurOptimized의 코드는 다음과 같다.

class BoxBlurOptimized: AbstractBlur {

    override fun blur(image: Bitmap, radius: Int): Bitmap {
        val w = image.width
        val h = image.height
        val currentPixels = IntArray(w * h)
        val newPixels = IntArray(w * h)
        image.getPixels(currentPixels, 0, w, 0, 0, w, h)
        blurProcess(w, h, currentPixels, newPixels, radius)
        return Bitmap.createBitmap(newPixels, w, h, Bitmap.Config.ARGB_8888)
    }

    fun blurProcess(
        w: Int,
        h: Int,
        currentPixels: IntArray,
        newPixels: IntArray,
        radius: Int
    ) {
        val firstPassPixel = IntArray(w * h)
        val denominator = (radius * 2 + 1)

        for (row in 0 until h) {
            val rQueue = LinkedList<Int>()
            val gQueue = LinkedList<Int>()
            val bQueue = LinkedList<Int>()

            val startIndex = row * w
            val originalPixel = currentPixels[startIndex]
            val rOrig = originalPixel ushr 16 and 0xFF
            val gOrig = originalPixel ushr 8 and 0xFF
            val bOrig = originalPixel and 0xFF

            repeat(radius + 1) {
                rQueue.add(rOrig)
                gQueue.add(gOrig)
                bQueue.add(bOrig)
            }

            var rSum = rOrig * (radius + 1)
            var gSum = gOrig * (radius + 1)
            var bSum = bOrig * (radius + 1)

            for (col in 1..radius) {
                // In the event of width is smaller than radius
                val nextPixelIndex = startIndex + if (col > w - 1) w - 1 else col
                val nextPixel = currentPixels[nextPixelIndex]
                val rNext = nextPixel ushr 16 and 0xFF
                val gNext = nextPixel ushr 8 and 0xFF
                val bNext = nextPixel and 0xFF

                rQueue.add(rNext)
                gQueue.add(gNext)
                bQueue.add(bNext)

                rSum += rNext
                gSum += gNext
                bSum += bNext
            }

            for (col in 0 until w) {
                val newPixelIndex = row * w + col
                firstPassPixel[newPixelIndex] =
                    (currentPixels[newPixelIndex] and -0x1000000) /* which is 0xff000000 to get the original alpha */ or
                            ((rSum / denominator) and 0xff shl 16) or
                            ((gSum / denominator) and 0xff shl 8) or
                            ((bSum / denominator) and 0xff)

                rSum -= rQueue.remove()
                gSum -= gQueue.remove()
                bSum -= bQueue.remove()

                val nextPixelIndex =
                    if (col + 1 + radius > w - 1)
                        (row + 1) * w - 1
                    else row * w + col + radius + 1

                val nextPixel = currentPixels[nextPixelIndex]
                val rNext = nextPixel ushr 16 and 0xFF
                val gNext = nextPixel ushr 8 and 0xFF
                val bNext = nextPixel and 0xFF

                rQueue.add(rNext)
                gQueue.add(gNext)
                bQueue.add(bNext)

                rSum += rNext
                gSum += gNext
                bSum += bNext
            }
        }

        for (col in 0 until w) {
            val rQueue = LinkedList<Int>()
            val gQueue = LinkedList<Int>()
            val bQueue = LinkedList<Int>()

            val originalPixel = firstPassPixel[col]
            val rOrig = originalPixel ushr 16 and 0xFF
            val gOrig = originalPixel ushr 8 and 0xFF
            val bOrig = originalPixel and 0xFF

            repeat(radius + 1) {
                rQueue.add(rOrig)
                gQueue.add(gOrig)
                bQueue.add(bOrig)
            }

            var rSum = rOrig * (radius + 1)
            var gSum = gOrig * (radius + 1)
            var bSum = bOrig * (radius + 1)

            for (row in 1..radius) {
                // In the event of width is smaller than radius
                val nextPixelIndex = col + if (row > h - 1) (h - 1) * w else row * w
                val nextPixel = firstPassPixel[nextPixelIndex]
                val rNext = nextPixel ushr 16 and 0xFF
                val gNext = nextPixel ushr 8 and 0xFF
                val bNext = nextPixel and 0xFF

                rQueue.add(rNext)
                gQueue.add(gNext)
                bQueue.add(bNext)

                rSum += rNext
                gSum += gNext
                bSum += bNext
            }

            for (row in 0 until h) {
                val newPixelIndex = row * w + col
                newPixels[newPixelIndex] =
                    (firstPassPixel[newPixelIndex] and -0x1000000) /* which is 0xff000000 to get the original alpha */ or
                            ((rSum / denominator) and 0xff shl 16) or
                            ((gSum / denominator) and 0xff shl 8) or
                            ((bSum / denominator) and 0xff)

                rSum -= rQueue.remove()
                gSum -= gQueue.remove()
                bSum -= bQueue.remove()

                val nextPixelIndex =
                    if (row + 1 + radius > h - 1)
                        ((row + 1) * w) + col
                    else (row + radius + 1) * w + col

                if (nextPixelIndex >= w * h) break

                val nextPixel = firstPassPixel[nextPixelIndex]
                val rNext = nextPixel ushr 16 and 0xFF
                val gNext = nextPixel ushr 8 and 0xFF
                val bNext = nextPixel and 0xFF

                rQueue.add(rNext)
                gQueue.add(gNext)
                bQueue.add(bNext)

                rSum += rNext
                gSum += gNext
                bSum += bNext
            }
        }
    }

    override fun getType() = BlurType.BOX_BLUR_OPTIMIZED


}

샘플 앱 및 Blur Box 코드는 github에서 확인 하실 수 있습니다.

더 빠른 Blur알고리즘이 궁금하다면, 안드로이드에서 blur효과 구현하기 : Gaussian Blur, Stack Blur를 참조해주세요.

Buy me a coffeeBuy me a coffee

0개의 댓글

답글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.