ghlee.dev
Canvas API를 활용한 이미지 블러 효과
2024. 6. 6.웹에서 이미지에 블러(Blur) 효과를 적용하고 싶다면 어떻게 해야 할까? CSS에서 제공하는 filter
속성의 blur()
함수를 이용하면 쉽게 블러 효과를 적용할 수 있다. 이렇게 적용된 블러 효과는 디자이너가 많이 사용하는 그래픽 도구에서 쉽게 볼 수 있는 가우시안 블러(Gaussian Blur) 효과가 적용된다.
그 외에도 SVG 필터를 활용한 방법이 있지만 흔한 방법은 아니다.
가우시안 블러는 우리가 고등학교 수학 시간에 배운 분산, 표준 편차, 정규 분포 같은 개념을 활용한 블러 효과이다. 정규 분포의 그래프를 보면 직관적으로 느끼겠지만 그래프의 곡선처럼 이미지의 본래 형태를 유지하면서 자연스러운 블러 효과를 구현할 수 있어서 인기가 많고 일반적으로 사용된다. 이러한 이유로 CSS 명세에도 채택된 게 아닐까 싶다.
이 글에서는 TypeScript와 Web API 중 하나인 Canvas API를 활용하여 이미지에 블러 효과를 적용해 볼 것이다. "그냥 CSS의 blur()
함수를 쓰면 되는 거 아니야?"라고 생각할 수 있다. 단순히 이미지에 블러 효과를 적용하는 것뿐이라면 TypeScript와 Canvas API를 활용해서 구현하는 것은 비효율적인 건 맞다. 그러나 이 과정을 통해 CSS 엔진이 내부적으로 처리하는 블러 효과의 원리를 이해할 수 있을 것이다.
컨볼루션(Convolution)
이미지 처리에서 컨볼루션(Convolution)이란 주어진 이미지에 커널(Kernel)을 적용하여 이미지를 변환하는 처리 방식을 의미한다. 여기서 커널은 무엇일까?
커널은 3x3 혹은 5x5 같은 정방형의 행렬인데, 이 행렬과 주어진 이미지의 각 픽셀 및 대상 픽셀을 포함한 인근 픽셀을 이용해서 곱셈과 덧셈 연산을 통해 값을 변환한다. 나의 부족한 표현력으로 설명하려니 더 복잡해져서 커널을 적용하는 모습을 시각화한 GIF를 가져와봤다.
Source: https://en.wikipedia.org/wiki/Kernel_(image_processing)
GIF를 보면 인풋(Input) 이미지의 대상 픽셀과 그 주변 픽셀에 3x3 행렬의 커널의 값을 각각 곱하고 모두 더해서 아웃풋(Output) 이미지의 하나의 픽셀 값으로 만든다. 그리고 그 과정을 인풋 이미지의 모든 픽셀에 한 번씩 적용하는데, 이것이 컨볼루션이며 커널을 적용한다는 의미이다.
GIF의 첫 부분을 다시 보면 인풋 이미지의 가장자리 픽셀에 커널을 적용할 때는 바깥쪽의 주변 픽셀이 부족하여 6과 7로 된 임시 값을 넣어주는 것을 볼 수 있다. 이 과정을 Edge Handling이라고 표현하는데 이미지의 가장자리 부근을 처리하는 방법을 의미한다.
Edge Handling에는 GIF처럼 인풋 이미지의 가장자리 값을 확장(Extend) 하여 사용하거나 가장자리를 계산할 때는 커널을 잘라서 사용하는 방법도 있다. 또한 특정한 상수를 넣어서 처리하기도 한다. 추가적인 방법은 이 링크를 통해 확인해 보자.
이번에는 GIF 예시에 있는 컨볼루션 연산 과정을 다시 한번 보자. 아래는 인풋 이미지의 1행 1열부터 3행 3열까지의 3x3 행렬에 해당하는 데이터이다.
GIF에서는 아래의 3x3 커널(혹은 행렬)이 주어졌다.
두 3x3 행렬을 곱셈하면 다음과 같다.
이렇게 한 번의 연산이 종료된다. 연산 결과인 2
는 아웃풋 데이터의 2행 2열에 들어가게 된다. 왜냐하면 연산에 사용한 인풋 데이터의 중앙에 해당하는 위치가 2행 2열이기 때문이다.
인풋 데이터는 전체가 6x6 행렬이므로 이 연산은 총 36번 진행될 것이다. 모두 진행하고 나면 컨볼루션은 마무리된다. 이를 의사 코드(슈도 코드)로 보면 아래와 같다.
의사 코드에 익숙하지 않아도 몇 줄의 코드만 보면 대략 이해할 수 있다.
each pixel
은 각각의 픽셀을 의미하며 each element in kernel row
는 커널의 한 행에서 각각의 값을 의미한다.
element position corresponding* to pixel position
이 코드가 어려울 수 있지만 해석해 보면, 대상 픽셀 값에 적절한 커널의 요소, 즉 여기서는 대상 픽셀과 그 주변 픽셀 값을 의미한다고 볼 수 있다.
multiply element value corresponding* to pixel value \n add result to accumulator
은 행렬 곱셈을 의미하고 그 값을 누산기에 더해준다.
최종적으로 해당 픽셀에 누산기에 더해진 값을 할당하고 그 과정을 반복한다.
사실 GIF의 예제에서 사용한 커널은 이미지에 Sharpen(샤픈) 효과를 줄 수 있는 커널이다. 이처럼 적용하고자 하는 효과에 사용하는 커널이 어느 정도 정해져 있다. 예를 들어 가우시안 블러에 사용하는 3x3 커널은 다음과 같다.
가우시안 블러에는 5x5 행렬도 된 커널도 있으며 그 외에 박스 블러, 가장자리 감지 커널 등 다양한 커널이 존재한다. 이 링크를 통해 자세히 살펴볼 수 있다.
분리 가능한 컨볼루션
컨볼루션에는 연산 횟수를 획기적으로 줄여서 최적화하는 일종의 트릭이 있는데 이 방법을 분리 가능한 컨볼루션(Separable Convolution)이라 부른다.
분리 가능한 컨볼루션을 적용할 수 있는 커널은 한정적인데 앞서 얘기한 가우시안 블러의 커널은 분리 가능하다. 만약 가우시안 블러의 3x3 커널을 분리하여 계산하려면 어떻게 해야 할까?
우선 기존의 2D였던 3x3 행렬을 1D 행렬로 분리해 보자.
분리한 행렬을 원본 이미지의 픽셀 데이터에 각각 곱해야 하는데, 예를 들어 1x3 행렬을 원본 데이터에 한번 곱한 뒤 그 결괏값에 3x1 행렬을 다시 곱해주는 식이다. 결과적으로 이 결괏값은 이전에 3x3 행렬을 곱했던 값과 동일하다. 대신 곱셈 연산 횟수가 상당히 줄어든다.
이전에 예시로 보았던 6x6 행렬의 원본 이미지를 기준으로 3x3 행렬로 컨볼루션을 진행했을 때 6 * 6 * 9 = 324
이므로 총 324
번의 곱셈 연산을 하게 된다. 하지만 분리한 행렬로 컨볼루션을 진행하면 6 * 6 * 3 + 6 * 6 * 3 = 216
이므로 총 216
번의 곱셈 연산을 하게 된다.
이번엔 이전에 살펴봤던 예시와 3x3 가우시안 블러 커널을 이용해서 일반적인 컨볼루션과 분리한 컨볼루션이 결괏값이 같을지 증명해 보자. 아래는 이전에 사용했던 GIF 예시의 1행 1열부터 3행 3열까지의 픽셀 데이터이다.
아래는 가우시안 블러를 적용하기 위한 3x3 커널이다.
두 3x3 행렬을 일반적인 컨볼루션을 하면 4.4375
값이 나온다.
이번에는 3x3 행렬을 분리하여 적용해 보자. 아래는 첫 번째 컨볼루션에 사용할 분리된 3x1 커널이다.
3x3 픽셀 데이터와 곱하여 첫 번째 컨볼루션의 결과인 1x3 행렬을 얻는다.
이번엔 두 번째 컨볼루션에 사용할 1x3 커널이다.
첫 번째 컨볼루션의 결과로 얻은 1x3 행렬과 1x3 커널을 이용해서 두 번째 컨볼루션을 하면 아래와 같은 결과를 얻을 수 있다.
결괏값은 4.4375
로 일반적인 컨볼루션과 동일한 결과를 얻을 수 있다.
주의할 것은 픽셀 하나에 대해서는 분리 가능한 컨볼루션이 일반적인 컨볼루션보다 연산 횟수가 많아 보일 수 있다. 이는 픽셀 데이터를 가진 행렬의 크기가 커질수록 연산 횟수를 절약할 수 있게 된다.
이에 대한 수학적 표현(?)을 이용한 증명을 보고 싶다면 이 링크의 글에서 예제와 함께 자세히 증명하고 있으므로 한 번쯤 살펴보아도 좋겠다.
또한 커널을 분리해서 사용하는 유형에는 공간 분리 가능한 컨볼루션과 깊이 분리 가능한 컨볼루션(Depthwise Separable Convolution)이 존재하는데 이 글에서 자세히 설명 해주니 꼭 한번 가서 읽어보자. 이 글에서는 언급하지 않았지만 지금까지 보았던 컨볼루션 유형은 공간적으로 분리 가능한 컨볼루션이었다.
Canvas API로 가우시안 블러 구현
이론만 보고 있으니 지루할 때도 되었다. 이제는 실제로 컨볼루션을 적용해 보자. 이를 위해서는 이미지의 픽셀 데이터를 받아와야 하며, JavaScript 환경에서는 Canvas API를 사용하면 이미지 픽셀 데이터의 RGBA 값을 받아올 수 있다.
우선 이미지를 가져오기 위해 Image
생성자를 사용하여 새 인스턴스를 만들고 경로를 할당한다.
미리 선언해놓은 HTML의 img
태그가 있다면 이를 가져와도 무방하다.
이제 이미지가 로딩된 후 Canvas API를 통해 이미지 객체를 받아서 픽셀 데이터를 가져올 수 있다.
imageData
를 출력해 보면 Uint8ClampedArray
라고 하는 생소한 배열을 만날 수 있다. 이 배열은 0
부터 255
까지의 8비트 부호 없는(Unsigned) 정숫값을 담을 수 있으며 범위를 벗어나는 정수를 넣으면 알아서 최솟값 혹은 최댓값으로 고정(Clamped) 된다. 예를 들어 이 배열에 1024
값을 넣으면 255
로 변환되어 삽입된다.
Canvas API를 통해 받은 이미지 데이터(Uint8ClampedArray
)는 0에서 255까지의 RGBA 각각의 값이 1차원 배열에 연속된 값으로 구성되어 있다. 예를 들어 2개 픽셀의 데이터를 받았다면 다음과 같이 구성되어 있을 것이다.
각각 193, 205, 217, 255
와 194, 206, 218, 255
로 두 픽셀 데이터를 구분해서 보면 된다.
참고로 getImageData
로 가져온 값을 보면 색 공간을 나타내는 colorSpace
프로퍼티로 "srgb"
값인 것을 알 수 있는데, sRGB는 색상의 정의를 빨강, 녹색, 파랑과 화이트 포인트로 나타내며 이는 앞서 보았던 RGBA를 의미한다.
이제 이 이미지 데이터를 사용해서 컨볼루션을 진행하면 되는데, 여기서는 분리 가능한 컨볼루션을 이용해서 가우시안 블러를 구현해 볼 것이다. 가우시안 블러는 커널이 단순하기 때문에 과정을 이해하기 쉽다.
우선 Uint8ClampedArray
로 받은 1차원 배열을 어떻게 다룰 것인지 정해야 한다. 인덱싱(Indexing)만 잘 해주면 1차원 배열을 그대로 사용하는 것이 효율적이지만, 여기서는 각 픽셀을 하나의 배열에 담고 그 픽셀 배열들을 또 배열 하나에 모아서 최종적으로 2차원 배열로 변환하여 사용해 볼 생각이다.
아래의 간단한 코드를 통해 이전에 출력했던 imageData
를 pixels
라는 변수 명의 2차원 정수 배열로 변환한다.
그리고 pixels
를 이용하여 컨볼루션을 진행하면 되는데 여기서는 분리 가능한 컨볼루션을 적용해 보겠다. 예시 코드는 아래와 같다.
peekPixel
함수는 2차원 배열로 된 이미지 데이터인 pixels
에서 원하는 위치의 픽셀 값을 가져올 수 있다.
handleEdgePixelIdx
함수는 가장자리 픽셀을 대상으로 컨볼루션 할 때, 이웃 픽셀이 가장자리를 넘어가면 가장자리 인덱스로 고정해 주는 함수이다. 이는 앞서 Edge Handling을 언급할 때 얘기했던 방법 중 하나이다.
KERNEL
는 이름 그대로 커널을 의미하는데 Wikipedia에서 소개된 5x5 가우시안 블러 커널을 분리 가능한 컨볼루션이 가능하도록 변환한 커널이다.
코드 자체는 생각보다 단순하다. 앞서 보았던 의사 코드와 유사하게 작성했는데, 반복문 for
을 사용하여 행(Row)을 기준으로 한번 컨볼루션하여 중간 픽셀 값들을 얻고 열(Column)을 기준으로 한번 더 컨볼루션한다. 총 두 번의 컨볼루션을 진행하여 가우시안 블러 처리된 이미지 데이터를 얻게된다.
픽셀을 전부 순회해야 하므로 불가피하게 시간 복잡도가 O(width * height)
이 될 수밖에 없는데 이미지 데이터가 조금만 많아져도 JavaScript의 메인스레드는 컨볼루션 외에 다른 처리를 할 여유가 없어진다. 이를 위해 웹 워커(Web Worker)를 사용하여 컨볼루션 연산을 다른 스레드(Thread)로 넘기는 것도 한 방법이다.
실제 시간 복잡도는 O(width _ height _ near)이지만 마지막 반복문은 상수로 볼 수 있어서 표현을 생략했다.
마지막으로 아래는 가우시안 블러 처리한 이미지 데이터를 그리는 코드이다.
가우시안 블러 처리한 이미지 데이터를 Canvas API가 읽을 수 있도록 Uint8ClampedArray
로 변경한 후 ImageData
객체를 생성한다. 그리고 Canvas API를 활용하여 이미지 데이터를 그려주면 모든 작업이 종료된다.
좌측이 가우시안 블러를 적용한 이미지이고, 우측은 원본 이미지이다.
마치며
앞서 보았듯 JavaScript에서 컨볼루션을 하면 메인 스레드에 굉장한 부하를 준다. 그래서 실용적으로 사용하기는 무리가 있으며, 이 글에서도 어디까지나 컨볼루션을 학습하기 위한 용도로 구현해 보았다.
실제 CSS 엔진에서 blur
함수로 가우시안 블러 처리를 할 때는 GPU에서 처리하게 되는데 이를 위해 내부적으로 쉐이더(Shader) 언어를 사용하여 처리하는 것을 추측해 볼 수 있다.
그리고 서론에서 잠깐 언급하고 지나갔던 SVG 필터를 활용한 방법도 있는데,<feConvolveMatrix>
태그를 사용하면 컨볼루션에 사용할 커널도 직접 지정해 줄 수 있다. 궁금하다면 이 글을 참고해 보길 바란다.