RGB 값을 255로 정규화해야 할까, 256으로 정규화해야 할까?

1 week ago 10
  • RGB 정규화에서 낯선 이미지 파일을 처리해 다시 8비트로 저장하는 일반 상황이라면 255로 나누는 표준 방식이 적합함
  • 255 방식은 0을 0.0, 255를 1.0으로 매핑해 검은색과 흰색을 직접 다루기 쉽고, GPU의 UNORM-to-float 변환 방식과도 맞음
  • 256 방식은 (img + 0.5) / 256.0으로 각 값을 구간 중앙에 놓아 디더링 같은 작업에서 경계 처리를 단순하게 만들 수 있지만, 0이 0.0이 아니어서 처리 로직이 8비트 입력에 묶임
  • 255 방식은 양 끝 구간이 절반 폭이라 균일한 [0, 1] 난수를 다시 8비트로 반올림하면 0과 255가 다른 값보다 절반 빈도로 나오지만, 실제 이미지 왕복 변환은 손실 없이 동작함
  • 256 방식은 이론상 평균 절대 오차가 1 / 1024로 255 방식의 1 / 1020보다 작지만, 이미 255 방식으로 양자화된 이미지를 잘못된 스케일로 읽으면 오히려 오차를 더함

문제 설정

이미지 처리 프로그램은 8비트 이미지를 부동소수점으로 바꾸고, 처리를 수행한 뒤 다시 8비트 색상으로 저장함

두 변환 방식은 다음과 같음

# 표준: 255로 나누기 pixels = img / 255.0 result = process(pixels) output = np.trunc(result * 255 + 0.5) # 대안: 0.5를 더하고 256으로 나누기 pixels = (img + 0.5) / 256.0 result = process(pixels) output = np.trunc(result * 256)

두 방식 모두 최종 변환 전에 값을 0~255로 제한함

output_8bit = output.clip(0, 255).astype(np.uint8)

표준 방식은 정수 0을 0.0, 255를 1.0에 매핑하며 GPU의 UNORM-to-float 변환 방식과 같음

대안 방식은 0을 0.5 / 256 = 0.001953125에 매핑하므로, 검은 픽셀을 감지하려면 이 상수를 알아야 함

255로 나누는 표준 방식의 특성

표준 방식은 [0, 1] 범위 안에서 양 끝 값의 구간이 다른 구간보다 사실상 절반 폭이 됨

균일한 [0, 1] 난수를 만들고 trunc(result * 255 + 0.5)로 반올림하면 0과 255는 다른 정수보다 절반 빈도로 나타남

하지만 원래 8비트 이미지는 uint8 → float → uint8 왕복 변환에서 손실 없이 돌아옴

또한 처리 결과가 0.0이나 1.0을 약간 벗어나도 클램프와 반올림으로 올바른 정수 구간에 들어갈 수 있음

예를 들어 부동소수점 색상에서 0.005를 빼면 표준 방식의 검정은 음수가 되지만, 최종 결과는 여전히 정수 0이 됨

trunc(255 * (-0.005) + 0.5) = 0

부동소수점 정확성과 구간 중앙 배치

255 방식의 값은 일부가 정확히 표현되지 않음

예를 들어 128 / 255.0 ≈ 0.501961이지만 128 / 256.0 = 0.5임

이 차이는 32비트 부동소수점의 23비트 가수에서 최하위 비트 수준의 반올림 오차이며, 크기는 2^-23보다 작음

따라서 이 부정확성은 실제 기술적 문제라기보다 미적인 문제에 가까움

256 방식은 각 부동소수점 값을 두 정수 사이의 정확한 중앙에 놓음

이 성질은 원래 양자화된 값이 정확히 무엇이었는지 모를 때 두 연속 정수 사이의 평균점을 쓰는 절충으로 볼 수 있음

Andrew Kesler의 2015년 글 “Converting Color Depth”는 이 방식이 디더링에서 노이즈를 더할 때 경계 처리를 덜 신경 쓰게 만든다고 봄

반대로 표준 방식의 양끝 구간은 노이즈 분포를 일관되게 유지하려면 주의 깊은 처리가 필요함

양자화 관점

두 방식은 균일 스칼라 양자화기(uniform scalar quantizer)로 볼 수 있음

Wikipedia의 quantization 설명)은 signed input data의 균일 양자화기를 주로 mid-riser와 mid-tread로 나눔

mid-tread는 0값 재구성 레벨을 가지며, mid-riser는 0값 분류 임계값을 가짐

공식은 다음처럼 대응됨

방식 인코딩 디코딩
mid-tread k = trunc(x L + 0.5) y_k = k / L
mid-riser k = trunc(x L) y_k = (k + 0.5) / L

표준 방식은 L=255를 쓰는 mid-tread 형태이고, 대안 방식은 L=256을 쓰는 mid-riser 형태임

표준 방식은 0.0과 1.0에 양끝을 맞추는 프로그래밍 편의를 얻는 대신, 8비트 입력에 최적인 구간 배치와는 다름

재구성 오차와 실제 이미지 처리

균일 분포의 실수 x ∈ [0, 1]를 8비트 정수로 인코딩하고 다시 실수로 재구성하는 시스템을 직접 설계한다면 256 방식이 이론상 더 정밀함

표준 방식의 표현 가능 범위는 [-0.5 / 255, 255.5 / 255]가 되어 [0, 1]에 꼭 필요한 것보다 구간 간격이 넓어짐

StackOverflow 사용자 Peter Mudrievskij의 계산에 따르면 평균 절대 오차는 255 나누기에서 1 / 1020, 256 나누기에서 1 / 1024임

하지만 이미 저장된 8비트 RGB 이미지를 읽어 처리하는 상황에서는 저장 당시 잃어버린 정보가 복원되지 않음

이미지가 255를 곱하고 반올림하는 방식으로 양자화됐다면, 로드할 때 256으로 나누어도 정밀도가 돌아오지 않음

다른 사람이 만든 이미지는 대부분 표준 방식으로 양자화됐을 가능성이 높으므로, 대안 공식으로 읽으면 이론적으로 잘못된 스케일 팩터를 쓰게 됨

실제로는 색상이 절대 측정값처럼 동작하지 않아, 약간 더 작은 범위와 작은 오프셋에서 처리하는 결과가 됨

두 양자화기의 인코딩 단계와 디코딩 단계를 섞으면 깨진 코드가 됨

결론

낯선 사람이 제공한 이미지를 처리한다면 RGB 값은 255로 정규화해야 함

부동소수점 값이 정확하지 않다는 이유나 추상적인 재구성 오차가 더 크다는 느낌만으로 256 방식을 선택할 근거는 약함

이미지 저장과 로딩을 모두 제어하고, 0이 0에 매핑될 필요가 없으며, 처리 코드가 8비트 동적 범위에 묶여도 괜찮다면 256으로 나누어 약간 더 높은 이론적 정밀도를 노릴 수 있음

Read Entire Article