블러라는 말을 들어봤지?

이렇게 원본이미지에서 흐릿하게 만드는걸 블러라고 함
이걸 구현하는 방법은 여러가지가 있음
일단 블러가 어떻게 동작되는지를 알아야함
블러는 기본적으로 흐릿해지는거임
그럼 흐릿한게 뭐냐?
이 질문에 대한 답을 하면, 어떻게 블러를 구현해야할지 감이 올거임
흐릿함은 특정 픽셀의 색상이 해당 픽셀 주변의 픽셀의 색과 비슷하여 흐릿하게 보이는 것

빨간 원을 보면, 왼쪽은 픽셀간 색상 차이가 뚜렷한데 반해
오른쪽은 픽셀간 색상 차이가 미미함

위키피디아의 image proccessing - kernel에 관한 설명임
블러 효과는 이미지와 이미지의 각 픽셀 색상에 대하여 주변 픽셀의 색을 구한 후, 평균을 내어 만드는 효과임

작은 3*3 : kernel
input : 이미지
이렇게 kernel의 가중치를 곱한 후 합산하는 것을 Convolution(합성곱)이라고 함
이게 좀 불편함
2차원 기준으로
이미지가 크기이고
kernel이 의 크기일때,
연산 횟수는 이 됨
즉, 600*360크기의 이미지가 있을때
5*5크기의 kernel을 사용하면
번의 연산이 이뤄지게 됨
이걸 연산횟수를 줄일 수 있음

박스 블러의 kernel을 보자
총 3*3크기의 행렬을 이용중이고, 가중치를 1/9로 모두 동일하게 나누고 있음
이때 kernel을 두 1차원의 행렬의 곱으로 재구성 할 수 있음

이렇게 두 1차원 행렬의 곱이 곧 2차원 kernel과 같다는 것을 알 수 있음
이렇게 되면 으로 연산횟수가 줄어들음
즉, 2차원 kernel을 이용할때는 kernel이 정사각형 행렬인만큼
만큼의 연산이 소요되었는데,
1차원 kernel을 2개 사용하면, 만큼의 연산으로 줄어들게 됨그리고 kernel의 크기가 클수록 연산횟수 차이가 커짐
그리고 이 개념은 3차원까지 확대가능함
2차원은 x,y 혹은 u,v에 대해서 수행하지만
3차원은 x,y,z이므로, 3차원 kernel이 3개의 1차원 행렬로 표현할 수 있기만 한다면 충분히 최적화가 가능하다는거임

즉, 2차원 kernel이든 3차원 kernel이든,
n차원이라고 햇을때, 1차원 kernel을 n개 이용하여
2차원, 3차원 kernel을 만들 수 있으면
해당 1차원 kernel들을 연산에 이용하여 convolution이 가능하다는거임
박스 블러는


이렇게 seperable filter를 적용하여 연산횟수를 줄일 수 있는 블러임
이걸 3*3이 아닌 5*5크기의 kernel을 이용해 계산하겠음
Vec4& Image::GetPixel(int i, int j)
{
i = std::clamp(i, 0, this->width - 1);
j = std::clamp(j, 0, this->height - 1);
return this->pixels[i + this->width * j];
}
void Image::BoxBlur5()
{
std::vector<Vec4> pixelsBuffer(this->pixels.size());
//가로 블러
for (int j = 0; j < this->height; j++)
{
for (int i = 0; i < this->width; i++)
{
Vec4 neighbor = {0,0,0,1};
for (int k = -2; k < 3; ++k)
{
auto color = this->GetPixel(i + k, j);
neighbor.v[0] += color.v[0];
neighbor.v[1] += color.v[1];
neighbor.v[2] += color.v[2];
}
pixelsBuffer[i + this->width * j].v[0] = neighbor.v[0] * 0.2f;
pixelsBuffer[i + this->width * j].v[1] = neighbor.v[1] * 0.2f;
pixelsBuffer[i + this->width * j].v[2] = neighbor.v[2] * 0.2f;
}
}
std::swap(this->pixels, pixelsBuffer);
//세로 블러
for (int j = 0; j < this->height; j++)
{
for (int i = 0; i < this->width; i++)
{
Vec4 neighbor = {0,0,0,1};
for (int k = -2; k < 3; ++k)
{
auto color = this->GetPixel(i, j + k);
neighbor.v[0] += color.v[0];
neighbor.v[1] += color.v[1];
neighbor.v[2] += color.v[2];
}
pixelsBuffer[i + this->width * j].v[0] = neighbor.v[0] * 0.2f;
pixelsBuffer[i + this->width * j].v[1] = neighbor.v[1] * 0.2f;
pixelsBuffer[i + this->width * j].v[2] = neighbor.v[2] * 0.2f;
}
}
std::swap(this->pixels, pixelsBuffer);
}
결국 박스 필터는 픽셀 위치에 대한 가중치가 모두 동일하다고 판단하여
번째의 픽셀에 대하여
현재 위치 - 2 ~ 현재 위치 + 2까지의 픽셀에 대하여 색상의 합을 구하고,
모든 픽셀은 같은 가중치를 가지므로
총 5개의 픽셀이니, 를 통해 평균치를 내어,
해당 픽셀을 가로/세로로 구분지어 계산함
이런 원본 사진이 있을때,

한번 밑의 세로부분을 제외하고 가로부분만 살펴보면

그리고 위의 가로부분을 제외하고 세로부분만 살펴보면

마지막으로 전체적으로 살펴보면

이렇게 가로/세로만 따로 했을때는 해당 방향으로 이미지가 늘어난 것을 볼 수 있고,
전체적으로 블러를 적용하면 어우러지는것을 볼 수 있음

로 표현가능함
즉, 1차원 행렬 2개로 계산을 할 수 있다는거임
즉, 2차원 행렬에서 계산시에는
origin을 기준으로 이 되는데
1차원 행렬에서 계산시에는
origin을 기준으로 가 됨
이렇게 각각의 가중치를 구하면
가 됨
따라서 이러한 가중치를 곱하면 됨
이 값은 실제 값이 아닌 근사치임 approximation
실제 값은 정도가 됨
void Image::GaussianBlur5()
{
std::vector<Vec4> pixelsBuffer(this->pixels.size());
//const float weights[5] = { 0.0545f, 0.2442f, 0.4026f, 0.2442f, 0.0545f }; //실제값
const float weights[5] = {0.0625f, 0.25f, 0.375f, 0.25f, 0.0625f}; //근사치
// 가로 방향 (x 방향)
for (int j = 0; j < this->height; j++)
{
for (int i = 0; i < this->width; i++)
{
Vec4 neighborColor = {0,0,0,1};
for (int k = 0; k < 5; ++k)
{
Vec4 color = this->GetPixel(i + k - 2, j);
neighborColor.v[0] += color.v[0] * weights[k];
neighborColor.v[1] += color.v[1] * weights[k];
neighborColor.v[2] += color.v[2] * weights[k];
}
pixelsBuffer[i + this->width * j].v[0] = neighborColor.v[0];
pixelsBuffer[i + this->width * j].v[1] = neighborColor.v[1];
pixelsBuffer[i + this->width * j].v[2] = neighborColor.v[2];
}
}
std::swap(this->pixels, pixelsBuffer);
// 세로 방향 (y 방향)
for (int j = 0; j < this->height; j++)
{
for (int i = 0; i < this->width; i++)
{
Vec4 neighborColor = {0,0,0,1};
for (int k = 0; k < 5; ++k)
{
Vec4 color = this->GetPixel(i, j + k - 2);
neighborColor.v[0] += color.v[0] * weights[k];
neighborColor.v[1] += color.v[1] * weights[k];
neighborColor.v[2] += color.v[2] * weights[k];
}
pixelsBuffer[i + this->width * j].v[0] = neighborColor.v[0];
pixelsBuffer[i + this->width * j].v[1] = neighborColor.v[1];
pixelsBuffer[i + this->width * j].v[2] = neighborColor.v[2];
}
}
std::swap(this->pixels, pixelsBuffer);
}

이런식으로 각 픽셀의 색상마다 가중치를 곱해주고 더해주면...
그게 블러 처리가 된 색상임!
박스 블러 | 가우시안 블러 |
|---|
블룸 효과를 만드는법은 간단함
그럼 어두운 색상인지는 어떻게 아냐?
임계값을 정해주고, 해당 임계값을 기준으로 밝은지 어두운지 계산하면 됨
image.Bloom(0.5f, 50, 0.95f);호출부
0.5f = threshold 밝기 임계값
50 = 가우시안 블러 적용 횟수
0.95f = 블러된 이미지의 곱할 계수이미지에 값을 곱하여 더 밝게, 어둡게 설정가능
이 개념은 Relative Luminance라는 공식을 이용하면 됨
상대휘도라고 부름
상대 휘도는 인간의 시각에 맞춰 밝기 스펙트럼을 이용해 가중치를 사용하며 나타낸 광도(빛의 밝기)임
Relative Luminance - wikipedia
이걸 보면

이런 공식이 나옴
즉, RGB에 따라 각각 다른 가중치를 곱해주고 더한 값이
밝기라는 뜻임
void Image::Bloom(const float& th, const int& numRepeat, const float& weight)
{
const std::vector<Vec4> pixelsBackup = this->pixels;// 메모리 내용물까지 모두 복사
//Brightness가 th 보다 작은 픽셀들을 모두 검은색으로 바꾸기
for (int j = 0; j < height; j ++)
for (int i = 0; i < width; i++)
{
auto& color = this->GetPixel(i,j);
const float luminance = color.v[0] * 0.2126 + color.v[1] * 0.7152 + color.v[2] * 0.0722;
if (luminance < th)
{
color.v[0] = 0.0f;
color.v[1] = 0.0f;
color.v[2] = 0.0f;
}
}
}
이 상태로 호출하여 살펴보면

이렇게 이미지가 바뀜
void Image::Bloom(const float& th, const int& numRepeat, const float& weight)
{
const std::vector<Vec4> pixelsBackup = this->pixels;// 메모리 내용물까지 모두 복사
//Brightness가 th 보다 작은 픽셀들을 모두 검은색으로 바꾸기
for (int j = 0; j < height; j ++)
for (int i = 0; i < width; i++)
{
auto& color = this->GetPixel(i,j);
const float luminance = color.v[0] * 0.2126 + color.v[1] * 0.7152 + color.v[2] * 0.0722;
if (luminance < th)
{
color.v[0] = 0.0f;
color.v[1] = 0.0f;
color.v[2] = 0.0f;
}
}
// 가우시안 블러
for (int i = 0; i < numRepeat; i++)
{
this->GaussianBlur5();
}
}
이 상태로 호출하여 살펴보면

블러까지 적용된 이미지에 계수를 곱하고, 원본 이미지와 더하면...
void Image::Bloom(const float& th, const int& numRepeat, const float& weight)
{
const std::vector<Vec4> pixelsBackup = this->pixels;// 메모리 내용물까지 모두 복사
//Brightness가 th 보다 작은 픽셀들을 모두 검은색으로 바꾸기
for (int j = 0; j < height; j ++)
for (int i = 0; i < width; i++)
{
auto& color = this->GetPixel(i,j);
const float luminance = color.v[0] * 0.2126 + color.v[1] * 0.7152 + color.v[2] * 0.0722;
if (luminance < th)
{
color.v[0] = 0.0f;
color.v[1] = 0.0f;
color.v[2] = 0.0f;
}
}
// 가우시안 블러
for (int i = 0; i < numRepeat; i++)
{
this->GaussianBlur5();
}
// 이미지 합체
for (int i = 0; i < pixelsBackup.size(); i++)
{
this->pixels[i].v[0] = std::clamp(pixels[i].v[0] * weight + pixelsBackup[i].v[0], 0.0f, 1.0f);
this->pixels[i].v[1] = std::clamp(pixels[i].v[1] * weight + pixelsBackup[i].v[1], 0.0f, 1.0f);
this->pixels[i].v[2] = std::clamp(pixels[i].v[2] * weight + pixelsBackup[i].v[2], 0.0f, 1.0f);
}
}
clamp를 이용해 값의 제한을 뒀고,
현재 pixels은 가우시안 블러가 적용되어 있고,
복사본인 pixelsBackup은 원본이니까,
pixels에 weight를 곱한 후 pixelsBackup을 더해주면

박스 블러 | 가우시안 블러 | 블룸 |
|---|