안드로이드에서 blur효과 구현하기 : Gaussian Blur, Stack Blur
이번 포스팅은 지난시간에 다룬 안드로이드에서 blur효과 구현하기 : Box Blur에 이어 두번째 포스팅입니다.
박스블러(Box Blur)는 radius값에 따라 연산량이 많아지는 문제가 있었지만, 연산방법을 개선하여 이미지 처리 시간 문제를 해결했다. 하지만 blur의 품질은 여전히 좋지 못했고, 픽셀화된 느낌이 많이 든다.
Gaussian Blur
박스블러와는 다르게, 가우시안 블러(Gaussian blur)는 주변 픽셀로부터 평균값을 구할 때 가우시안 매트릭스를 사용하여 주변 픽셀에 가중치를 둔다. 이 매트릭스는 연산할 픽셀로부터 가까울수록 높은 가중치를 갖고 멀어질수록 낮은 가중치를 둔다.
하지만 모바일 디바이스에서 이러한 연산을 수행하는 것은 어렵고 비용이 많이 드는 작업이다. 가우시안 커널 계산기를 통해 계산하기가 얼마나 복잡한지 간접적으로나마 느껴볼 수 있다.
ScriptIntrinsicBlur
가우시안 매트릭스를 만드는 것은 비용이 큰작업이고 어렵기 때문인지, Android SDK에서는 가우시안 매트릭스를 RenderScript로 구현할 수 있는 ScriptInstrinsicBlur를 제공한다.
ScriptInstrinsicBlur는 Android에서 현실적으로 사용할 수 있는 것 중 가장 시각적으로 품질이 좋고 빠르다. 일반적으로 멀티쓰레드를 사용하여 C로 구현한 것보다 2~3배 빠르고, 자바로 구현한 것보다 10배 빠르다고 구글은 주장한다.
하지만 이를 사용하기 위한 몇가지 제약조건이 있다.
제약 조건
- API 17이상
- RenderScript사용
- radius범위 0~25
렌더 스크립트는 GPU, ISP 등을 사용하는 기기에서 매우 정교하게 작동하며, 안드로이드 2.2 버전까지 지원하는 v8 support 라이브러리도 제공하고 있다. 하지만 안드로이드는 하드웨어 및 드라이버의 파편화로 인해 몇몇 기기에서는 렌더스크립트가 제대로 동작하지 않을수도 있으니 맹목적으로 사용해서는 안된다.
ScriptInstrinsicBlur를 사용하는 방법은 다음과 같다.
//렌더스크립트 생성 RenderScript rs = RenderScript.create(context); //bitmapOriginal을 radius 8로 블러한다. final Allocation input = Allocation.createFromBitmap(rs, bitmapOriginal); //use this constructor for best performance, because it uses USAGE_SHARED mode which reuses memory final Allocation output = Allocation.createTyped(rs, input.getType()); final ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); script.setRadius(8f); script.setInput(input); script.forEach(output); output.copyTo(bitmapOriginal);
ScriptInstrinsicBlur와 이전 포스팅에서 다룬 최적화된 BoxBlur와 결과물을 비교하면 다음과 같다.
ScriptInstrinsicBlur로 처리 했을때 픽셀화도 낮고 이미지 처리 시간도 매우 빠른것을 알 수 있다.
StackBlur
스택블러는(StackBlur) 빠른 블러처리가 가능한 알고리즘으로 Quasimondo로 알려진 Mario Klingeman에 의해 개발되었다. 대부분의 상황에서 가우시안 블러와 충분히 비슷한 효과를 내면서도 굉장히 빠른 알고리즘이다.
StackBlur 알고리즘 동작 방식
이미지의 가로 픽셀을 w, radius를 r이라고 가정하자. 모든 픽셀 P(x,y) 에 대해서 전체 가로픽셀에 대한 평균값은 r = w/2가 된다.
P(x-r, y) | P(x,y) | P(x+r, y) |
(블러를 대칭으로 하려면 w가 홀수거나 양 끝에 반쪽짜리 픽셀을 추가해야 하지만 여기서는 w만을 사용합니다.)
블러된 픽셀을 얻기 위해서 왼쪽에 있는 P(x-r,y) 부터 P(x+r, y)까지 더하고 w로 나눠줍니다.
이 작업을 수행하는 단순한 방법은 각 픽셀에 대해 처음부터 합산을 하는것이다. 즉, 각 픽셀마다 w픽셀을 찾아야한다. 만약 n개의 픽셀을 갖는 이미지(n = width * height)라면, n * w 만큼의 연산을 해야한다. 컴퓨터 과학 분야에서는 시간복잡도에 대해 Big O 표기법이라는 것으로 표기를 하는데, 이 경우 O(nw)가 된다. n과 w는 서로 곱하기 때문에 두 값이 동시에 매우 느려질 수 있다.
단순한 정사각형 이미지를 블러처리 한다고 할 때 Big O는 O(nw^2)이 된다. 예를들어 w가 100이면 단순 계산해도 10000번 이상은 각 픽셀에 대해서 탐색이 필요하다. (OMG..)
먼저, 한번에 2D 블러링을 수행하는 대신 두번(2Pass) 나누어서 작업을 수행한다. 한번은 가로로 한번은 세로로 진행한다. 이러한 방법으로 진행하면 두번째 작업을 수행할 때는 각 픽셀은 이미 가로방향으로 평균값이 계산되어있다. 약간의 반올림 오류가 추가되긴 하는데 실제로는 거의 영향을 미치지 않는다. 2Pass로 나눠서 작업하기 때문에 시간복잡도는 O(nw)가 된다. (실제로는 O(2nw)이지만 Big O 표기에서 상수항은 무시한다.)
자 조금만 더 힘을 내보자, 우린 더 빠른 계산을 할 수 있다! !!
블러(평균값)를 적용할 픽셀을 P1, 그 다음 계산할 인접한 픽셀은 P2라고 가정하자. P1과 P2의 평균값을 구하기 위해 인접한 가로 픽셀들을 더한다고 생각해보자. 이 두 합계를 구하기 위해 더해진 픽셀들은 거의 동일하다. 유일한 차이점은 왼쪽 끝에 픽셀하나와 오른쪽 끝의 픽셀 하나다. 다시 말해서 한 픽셀의 평균값을 구하기 위한 합계를 알면 가장 왼쪽 픽셀을 빼고 가장 오른쪽 픽셀을 추가하여 그 다음 픽셀의 평균값을 구할 수 있는 것이다. (모든 픽셀을 다 더하고 나눌필요가 없다, 그저 하나 빼고 하나 더할뿐…)
P(x-r, y) | P(x-r+1,y) | P(x,y) | P(x+r, y) | ||||
P(x-r+1, y) | P(x,y) | P(x+r, y) | P(x+r+1, y) |
픽셀들의 평균값을 구할 때 이전 픽셀의 합산으로부터 가장 왼쪽의 픽셀을 빼기 위해 Queue(큐)에 인접 픽셀들을 보관하게 된다. 위의 예제 이미지를 통해 설명하면 P(x-r,y) 부터 순서대로 P(x+r,y)까지 픽셀들이 큐에 들어가 있으며, P(x+1, y)의 평균값을 구할 때는 이전 픽셀 P(x,y)의 합산에서 P(x-r,y)를 빼기 위해서 큐에 있던 값 P(x-r,y)를 꺼낸다. 그리고 P(x+r+1,y)를 추가로 더한고 이 픽셀을 큐에 넣는다. 만약 한줄에 100픽셀을 합산해야 한다고 가정하면 큐에서는 98개의 픽셀을 저장하게 될 것이다.
픽셀을 하나 더하고 빼기만 하면 되기 때문에 아무리 많은 픽셀수라도 문제가 되지 않는다. 즉 어떠한 큰 이미지도 스택블러 알고리즘의 성능에 영향을 미치지 않는다. 열과 행에 대한 매 첫번째 픽셀의 초기설정도 있지만, 알고리즘의 시간 복잡도는 O(n+kw)로 줄일수 있다. (k = width + height)
스택블러 알고리즘은 이러한 합산 꼼수를 적용하고 그에 따른 결과로 이미지의 중심부에 있는 픽셀이 가장자리에 있는 픽셀보다 더 많이 참조 된다. 그래서 이를 그래프로 가시화 했을 때 다음과 같은 피라미드 형태가 된다.
StackBlur 알고리즘으로 블러 처리를 한 결과를 확인하자.
다른 알고리즘과 비교하여 품질도 훌륭하고 무엇보다 이미지 프로세싱 시간이 굉장히 짧은 것을 확인할 수 있다.
샘플 앱 및 Gaussian blur, Stack blur 코드는 github에서 확인 하실 수 있습니다.
Blur의 성능을 더 개선 하고 싶다면, 안드로이드에서 blur효과 구현하기 : 성능 개선 및 LiveBlur 구현하기 를 참조하세요.
2개의 댓글
동우 · 2020년 5월 1일 5:59 오후
맨 마지막 참고그림이 안나오는거 같슴다 ㅠㅠ
Charlezz · 2020년 5월 3일 6:53 오후
알려주셔서 감사합니다 ㅠㅠ 수정했습니다