8비트 RGB 값을 부동소수점으로 변환할 때 255로 나누는 표준 방식과 256으로 나누는 대안 방식을 비교하고, 양자화기 관점에서 각각의 장단점과 실제 이미지 처리에서 어떤 선택이 적절한지 살펴봅니다.
이미지 처리 프로그램을 작성한다고 해 봅시다. 프로그램은 이미지를 입력받아 부동소수점으로 변환하고, 어떤 처리를 한 뒤, 마지막으로 수정된 픽셀을 8비트 색으로 디스크에 저장합니다. 오늘의 질문은 정수에서 부동소수점으로의 변환을 정확히 어떻게 해야 하느냐입니다. 두 가지 접근법이 있는데, Python과 NumPy로 쓰면 다음과 같습니다:
| 255로 나누는 표준 방식 | 256으로 나누는 대안 방식 |
|---|---|
pixels = img / 255.0 result = process(pixels) output = np.trunc(result * 255 + 0.5) | pixels = (img + 0.5) / 256.0 result = process(pixels) output = np.trunc(result * 256) |
두 경우 모두 최종 형변환 전에 출력값을 클램프한다고 가정하겠습니다:
# Clamp and cast to 8 bits
output_8bit = output.clip(0, 255).astype(np.uint8)
표준 방식은 정수 0을 0.0으로, 255를 1.0으로 매핑합니다. 이것은 완전히 잘 동작하며 GPU가 하는 방식이기도 합니다. 대안 방식은 대신 0.5 바이어스를 더하고 256으로 나누므로, 정수 0은 로 매핑됩니다. 이는 불편한데, 예를 들어 여러분의 이미지 처리 코드가 위 상수를 알지 못하면 검은 픽셀을 감지할 수 없기 때문입니다. 그 결과, 부동소수점으로 계산하더라도 로직이 8비트 입력에 묶이게 됩니다. 표준 방식에서는 검은색이 항상 0.0이라고 가정할 수 있습니다.
그런데도 일부 프로그래머는 여전히 대안 방식에 끌립니다. 왜 그럴까요? 그들은 거기서 무엇을 보는 걸까요?
표준 방식은 수직선에 그려 보면 꽤 이상해 보이긴 합니다. 아래에는 범위 의 3비트 정수가 로 매핑되는 과장된 버전을 볼 수 있습니다:

X축에는 수직선이 있고, 그 위 갈색 원들의 위치가 디코딩된 부동소수점 값을 나타냅니다. 원 안의 숫자는 정수 입력값입니다. 각 정수에는 화살표가 가리키고 있는데, 이는 그 정수로 반올림되는 부동소수점 값의 범위를 보여 줍니다. 이 글의 나머지에서는 이 범위를 “bin”이라고 부르겠습니다.
그림에서 정말 눈에 띄는 첫 번째 문제는 표준 공식의 양 끝 bin이 범위를 넘어 돌출된다는 점입니다. 아마 이 시각화는 불공정할 수도 있습니다. 두 접근법 모두 출력을 클램프하므로 극단 bin은 무한히 뻗어도 되기 때문입니다. 그래도 표준 범위가 얼마나 “늘어나 있는지”는 분명하게 보여 줍니다. 이 늘어난 범위는 이미지 처리에서 가정하는 동작 범위 보다 넓습니다.
즉, 범위의 부동소수점 값을 다시 정수로 변환할 때, 양 끝 bin의 유효 폭은 다른 bin의 절반이 됩니다. 그 결과, 알고리즘이 극단값을 출력하기가 더 “어려워집니다”. 예를 들어 균일한 노이즈를 생성해서 표준 공식으로 반올림하면, 값 0과 255는 다른 정수보다 절반 정도의 빈도로만 나타납니다.
이 주장은 균일 난수 백만 개를 생성해 히스토그램으로 그려 보면 경험적으로 확인할 수 있습니다. 실제로 0 bin과 255 bin이 다른 bin보다 절반 높이밖에 되지 않음을 볼 수 있습니다:

강조된 확대 부분:

Histogram code
import numpy as np
import matplotlib.pyplot as plt
result = np.random.uniform(0, 1, 1000000)
final_values = np.trunc(result * 255 + 0.5).clip(0, 255).astype(np.uint8)
plt.hist(final_values, bins=256, range=(0, 255))
plt.show()
그럼에도 저는 극단값에서 멀어지는 이 바이어스가 실제로 문제가 되는 예시를 떠올리기 어렵습니다. 물론 표준 방식의 부동소수점 값은 더 넓은 범위에 퍼져 있지만, 원본 이미지는 여전히 손실 없이 왕복 변환됩니다 (uint8 → float → uint8).
또한 0.0이나 1.0을 조금만 벗어나는 결과값도 여전히 올바른 bin으로 반올림되어, 출력 분포를 다시 고르게 만듭니다. 제가 무슨 뜻인지 예를 들어 보겠습니다. 처리 과정에서 부동소수점 색값에서 0.005를 뺀다고 가정합시다. 표준 방식에서는 검은색이 0보다 작아져 범위 밖으로 나가지만, 대안 방식에서는 값이 양수로 남습니다. 최종적으로는 둘 다 정수 0을 출력합니다:
Standard:
trunc(255 * (-0.005) + 0.5) = 0
Alternative:
trunc(256 * (0.5 / 256 - 0.005)) = 0
표준 방식에서 0 bin의 크기가 “절반밖에 안 된다”는 점은 중요하지 않았습니다.
두 번째 문제는 표준 방식의 부동소수점 값이 정확하지 않다는 점입니다. 예를 들어 이지만 입니다. 이 반올림 오차 때문에 부동소수점 값들 사이의 간격도 아주 조금씩 달라집니다. 하지만 이것은 실제 문제라고 보기 어렵습니다. 오차가 정말로 아주 작기 때문입니다. 32비트 부동소수점 수는 23비트 가수(“significand”)를 갖습니다. 우리가 말하는 것은 그 최하위 비트에서의 반올림 오차, 즉 보다 작은 크기의 미세한 흔들림입니다. 상대 오차가 0.00001 %라면, 아무리 정교한 이미지 처리 작업이라도 중요하지 않을 것입니다. 이 경우 부정확성은 기술적 문제가 아니라 미적인 문제입니다.
대안 방식은 각 부동소수점 값을 항상 두 정수의 정확한 한가운데에 놓습니다. 위 수직선 그림에서 세로 막대들이 정렬되는 모습을 보세요. 이 중간 위치는 일종의 타협으로 볼 수 있습니다. 원래 양자화된 값이 정확히 무엇이었는지 우리는 모르므로, 연속된 두 정수의 평균 지점은 그럴듯한 추정값입니다.
이 성질이 유용한 응용이 분명 있을 것이라 생각하지만, 저 역시 구체적인 예를 쉽게 떠올리지는 못합니다. 그래도 적어도 디더링은 더 편리하다고, Andrew Kesler가 쓴 2015년 블로그 글 “Converting Color Depth”은 주장합니다(그는 명함 레이트레이서로 알려져 있습니다). 그 논리는 경계 사례를 걱정하지 않고도 노이즈를 더할 수 있다는 것입니다. 반면 표준 공식의 어색한 극단값은 노이즈 분포를 일관되게 유지하려면 세심한 처리가 필요합니다.
지금까지도 표준 “255로 나누기” 공식은 여전히 탄탄해 보입니다. 적어도 계속 쓸 가치가 충분해 보입니다. 이 질문을 생각하는 또 다른 방법은 조금 멀리서 보면서 두 접근법을 서로 다른 균일 스칼라 양자화기 두 개로 보는 것입니다. Wikipedia의 quantization 문서를 보면, 양자화기에는 크게 두 가지 유형이 있다는 것을 금방 알 수 있습니다:
Most uniform quantizers for signed input data can be classified as being of one of two types: mid-riser and mid-tread. The terminology is based on what happens in the region around the value 0, and uses the analogy of viewing the input-output function of the quantizer as a stairway. Mid-tread quantizers have a zero-valued reconstruction level (corresponding to a tread of a stairway), while mid-riser quantizers have a zero-valued classification threshold (corresponding to a riser of a stairway).
출처로 Wikipedia는 1977년 논문을 인용하는데, 제목과 초록의 배치가 너무나도 놀라워서 여기 다시 보여 주지 않을 수 없습니다:

어쨌든, 그래프로 그리면 mid-riser와 mid-tread 양자화기는 0을 가로지르는 지점에서 차이가 납니다:

mid-tread는 실제로 0을 0으로 매핑하고, mid-riser는 0을 두 정수의 가운데로 매핑합니다(낯익지 않나요?). Wikipedia가 선택한 표기에서 입력 실수는 , 인코딩된(“classified”) 정수값은 , 복원된 실수는 로 나타냅니다. 이에 대응하는 양자화기 공식은 다음과 같습니다:
| Type | Classify (encode) | Reconstruct (decode) |
|---|---|---|
| Mid-riser staircase quantizer | ||
| Mid-tread staircase quantizer |
은 서로 다른 출력 레벨의 개수를 뜻합니다(예를 들어 256).
이 정의를 지금까지의 두 경쟁 접근법에 적용하면, 표준 공식은 L=255인 “mid-riser”, 대안 방식은 L=256인 “mid-tread”라고 부를 수 있습니다. 사실 위의 새 공식들과의 연결이 분명해지도록 새 이름을 붙여 다시 코드를 보여 드리겠습니다. 코드 조각 자체는 처음과 같습니다.
| Mid-riser quantizer (L=255) | Mid-tread quantizer (L=256) |
|---|---|
pixels = img / 255.0 result = process(pixels) output = np.trunc(result * 255 + 0.5) | pixels = (img + 0.5) / 256.0 result = process(pixels) output = np.trunc(result * 256) |
이 관점에서 보면 표준 방식은 부호 없는 입력에 대한 mid-riser 양자화기(인용문은 “for signed input data”라고 했습니다)와 개의 정수 코드를 택한 다소 이상한 조합이라고 할 수 있습니다. 분명 이것은 8비트 입력에 최적이라고 보기 어렵습니다. 다시 말해, 극단값이 0.0과 1.0으로 매핑되게 하려는 프로그래밍상의 편의를 위해 이런 선택을 하는 것입니다. 그리고 이것이 표준 공식에 대한 마지막 비판으로 이어집니다.
만약 우리가 균일하게 분포한 실수 를 받아 8비트 정수 로 인코딩하고, 마지막에 다시 다른 실수 로 복원하는 시스템을 설계한다면, 표준 공식은 대역폭을 낭비하게 됩니다. 0과 255 bin이 범위의 가장자리를 살짝 넘어가 있던 것을 기억해 보세요. 표준 방식에서 표현 가능한 값의 범위는 실제로 입니다. 즉, 입력에 꼭 필요한 것보다 bin 간격이 더 넓어져 복원 오차가 더 커집니다. 다만 오차 증가량은 작습니다. StackOverflow 사용자 Peter Mudrievskij의 계산에 따르면, 평균 절대 오차는 255 나눗셈에서 , 256 나눗셈에서 입니다. 따라서 이론적으로는 256으로 나누는 편이 더 정밀합니다.
미묘한 부분은, 우리가 실제로 하고 있는 일이 이런 종류의 복원이 아니라는 점입니다. 애초의 전제는 8비트 RGB 이미지를 읽어 와서 처리하고 다시 저장하는 것이었습니다. 저장될 때 그것들이 어떻게 양자화되었는지는 우리가 통제할 수 없고, 잃어버린 정보는 영원히 사라진 상태입니다. 다시 말해, 어떤 이미지의 색이 255를 곱한 뒤 반올림되어 저장되었다면, 로드할 때 256으로 나눈다고 해서 정밀도가 되살아나지 않습니다. 저장과 로드를 모두 우리가 통제할 때만 더 낮은 복원 오차를 논하는 것이 의미가 있습니다.
실제로 다른 사람이 만든 이미지를 대안 공식으로 로드하면 오히려 오차가 더 커집니다. 대개 그 이미지들은 표준 공식으로 양자화되었을 가능성이 높으므로, 잘못된 스케일 팩터로 디코딩하는 것은 이론적으로 잘못입니다. 실제로는 색이 절대 측정값은 아니고(sRGB 명세가 그렇게 주장하더라도), 결국 일어나는 일은 약간 더 작은 범위에서 약간의 오프셋을 둔 채 처리하게 되는 것뿐입니다. 미묘한 부분은 여기까지입니다.
마지막으로, 두 양자화기의 인코딩 단계와 디코딩 단계를 절대 섞어 쓰면 안 됩니다. 그러면 그냥 잘못된 코드가 됩니다. 다만 그런 실수는 하기가 쉽습니다.
제목의 질문에 답하자면, 낯선 사람이 준 이미지를 처리하는 경우 RGB 값을 255로 정규화해야 합니다. 부정확한 부동소수점 값이나 더 큰 복원 오차라는 어떤 추상적 느낌은 대안 방식을 선택할 충분한 이유가 되지 못합니다. 하지만 이미지의 저장과 로드를 모두 여러분이 통제하고, 0이 0으로 매핑될 필요가 없으며, 처리 코드가 8비트 동적 범위에 묶여도 괜찮다면, 256으로 나누어 약간의 정밀도를 더 얻는 방법을 고려할 수는 있습니다. 다만 동료들이 결국 여러분의 이미지를 표준 공식으로 로드해서 여러분의 원대한 계획을 망쳐 버리더라도 저를 탓하지는 마세요.
Jonathan Blow의 2002년 글은 mid-riser와 mid-tread 양자화기를 이름은 언급하지 않은 채 설명합니다. 저는 거기서 그림 아이디어를 얻었습니다.
앞서 언급한 Andrew Kesler의 2015년 블로그 글은 대안 공식을 지지합니다. 안타깝게도 비교 대상이 표준 공식이되 반올림을 제외한 형태라서, 분석의 상당 부분이 무효가 됩니다.
저는 색상 감소 알고리즘에 관한 책을 쓰고 있습니다. 관심 있으시면 여기에서 신청하세요.