그래픽스 - 블러, 블룸

그래픽스꿀잼·2026년 5월 11일

그래픽스

목록 보기
17/20
post-thumbnail

블러

블러라는 말을 들어봤지?

이렇게 원본이미지에서 흐릿하게 만드는걸 블러라고 함

이걸 구현하는 방법은 여러가지가 있음

일단 블러가 어떻게 동작되는지를 알아야함

블러 동작

블러는 기본적으로 흐릿해지는거임

그럼 흐릿한게 뭐냐?
이 질문에 대한 답을 하면, 어떻게 블러를 구현해야할지 감이 올거임

흐릿함은 특정 픽셀의 색상이 해당 픽셀 주변의 픽셀의 색과 비슷하여 흐릿하게 보이는 것

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

kernel

위키피디아의 image proccessing - kernel에 관한 설명임

블러 효과는 이미지와 이미지의 각 픽셀 색상에 대하여 주변 픽셀의 색을 구한 후, 평균을 내어 만드는 효과임

작은 3*3 : kernel
input : 이미지

이렇게 kernel의 가중치를 곱한 후 합산하는 것을 Convolution(합성곱)이라고 함

합성곱 최적화

이게 좀 불편함

2차원 기준으로

이미지가 NMN*M크기이고
kernel이 nmn*m의 크기일때,
연산 횟수는 O(NMnm)O(N*M*n*m)이 됨

즉, 600*360크기의 이미지가 있을때
5*5크기의 kernel을 사용하면

60036055=5,400,000600*360*5*5 = 5,400,000번의 연산이 이뤄지게 됨

이걸 연산횟수를 줄일 수 있음


박스 블러의 kernel을 보자

총 3*3크기의 행렬을 이용중이고, 가중치를 1/9로 모두 동일하게 나누고 있음

이때 kernel을 두 1차원의 행렬의 곱으로 재구성 할 수 있음

이렇게 두 1차원 행렬의 곱이 곧 2차원 kernel과 같다는 것을 알 수 있음

이렇게 되면 O(NM(n+m))O(N*M*(n+m))으로 연산횟수가 줄어들음

즉, 2차원 kernel을 이용할때는 kernel이 정사각형 행렬인만큼
n2n^2만큼의 연산이 소요되었는데,
1차원 kernel을 2개 사용하면, n2n * 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);
}

결국 박스 필터는 픽셀 위치에 대한 가중치가 모두 동일하다고 판단하여

i,ji,j번째의 픽셀에 대하여
현재 위치 - 2 ~ 현재 위치 + 2까지의 픽셀에 대하여 색상의 합을 구하고,
모든 픽셀은 같은 가중치를 가지므로
총 5개의 픽셀이니, /5/5를 통해 평균치를 내어,
해당 픽셀을 가로/세로로 구분지어 계산함

이런 원본 사진이 있을때,

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

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

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

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

가우시안 블러

116[14641]116[14641]=1256[1464141624164624362464162416414641]\frac1{16} \begin{bmatrix} 1 \\ 4 \\ 6 \\ 4 \\ 1 \end{bmatrix} *\frac1{16} \begin{bmatrix} 1 & 4 & 6 & 4 & 1 \end{bmatrix} =\frac1{256} \begin{bmatrix} 1 & 4 & 6 & 4 & 1 \\ 4 & 16 & 24 & 16 & 4 \\ 6 & 24 & 36 & 24 & 6 \\ 4 & 16 & 24 & 16 & 4 \\ 1 & 4 & 6 & 4 & 1 \end{bmatrix}

로 표현가능함

즉, 1차원 행렬 2개로 계산을 할 수 있다는거임

즉, 2차원 행렬에서 계산시에는
origin을 기준으로 36/256=0.14062536/256 = 0.140625이 되는데

1차원 행렬에서 계산시에는
origin을 기준으로 6/16=0.3756/16 = 0.375가 됨

이렇게 각각의 가중치를 구하면

[0.0625f,0.25f,0.375f,0.25f,0.0625f][0.0625f, 0.25f, 0.375f, 0.25f, 0.0625f]가 됨

따라서 이러한 가중치를 곱하면 됨

이 값은 실제 값이 아닌 근사치임 approximation
실제 값은 [0.0545f,0.2442f,0.4026f,0.2442f,0.0545f][0.0545f, 0.2442f, 0.4026f, 0.2442f, 0.0545f]정도가 됨

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);
}

이런식으로 각 픽셀의 색상마다 가중치를 곱해주고 더해주면...
그게 블러 처리가 된 색상임!

박스 블러, 가우시안 블러 차이

박스 블러 가우시안 블러

블룸

블룸 효과를 만드는법은 간단함

  1. 원본 이미지의 밝은 색상부분은 남겨두고, 어두운 색상은 검정색으로 만들어줌
  2. 이렇게 만들어진 이미지에 대하여 가우시안 블러를 적용함
  3. 원본 이미지와 블러 이미지를 합침

그럼 어두운 색상인지는 어떻게 아냐?

임계값을 정해주고, 해당 임계값을 기준으로 밝은지 어두운지 계산하면 됨

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을 더해주면

박스 블러 vs 가우시안 블러 vc 블룸

박스 블러 가우시안 블러 블룸
profile
그래픽스 공부중

0개의 댓글