RGB 값을 255로 정규화해야 할까, 256으로 정규화해야 할까?
(30fps.net)- 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으로 나누어 약간 더 높은 이론적 정밀도를 노릴 수 있음
댓글과 토론
Lobste.rs 의견들
- 지저분해 보이지만 맞음, 값은 255가 맞음
직관적이지 않다면 2비트 퇴화 사례로 보면 됨. 가능한 정수 값이 0, 1, 2, 3뿐일 때 정수→부동소수점 변환을 전부 계산해 보면, 검정/흰색이 검정/흰색이 아니거나 간격이 명백히 고르지 않은 이상한 동작을 피하려면 0.0, 0.33..., 0.66..., 1.0이 됨
따라서 역변환은 4(2^2)가 아니라 3을 곱하는 방식이 됨- 앞부분은 맞지만, 거기서 “역변환은 3을 곱해야지 4가 아니다”가 따라오지는 않음
역변환에는 양자화(반올림) 가 필요하고, 바로 그 지점이 대칭성을 깨는 핵심임
0..=1 범위의 균등한 실수 그라디언트를 만들어 0, 1, 2, 3으로 양자화해 보면, 3을 곱하면 결과가 고르지 않다는 걸 알 수 있음. ×3 후round()는 1과 2가 과대표현되고, ×3 후floor나ceil은 0이나 3을 특이점처럼 접어 넣어서 그라디언트가 4색 중 3색만 쓰는 것처럼 보이게 만듦
/3과×3논리는 정확한 숫자를 왕복 변환할 때는 괜찮아 보이지만, 중간값은 반올림 선택에 크게 영향을 받고 데이터 처리를 시작하는 순간 중요해짐
정수 비율이 균등해지는 건 (4-ε)를 곱하고 내림할 때뿐이며, 이는 ×4,floor(),clamp()와 같음. 이상한 1 차이 또는 ε 차이 오류처럼 느껴지지만, 직관적으로는 가장 보기 좋은 해법임
- 앞부분은 맞지만, 거기서 “역변환은 3을 곱해야지 4가 아니다”가 따라오지는 않음
- 제목 때문에 많이 헷갈렸음. 일부러 그런 건지 모르겠지만, 결국 “0..1이 [0..255.0] 에 대응하나, [0.5..255.5] 에 대응하나?”처럼 보임
내게 답은 늘 “당연히” [0.0..255.0]이었지만, 아마 모두에게 당연한 건 아닌 듯함
글에서는 “극단” 구간이 다른 구간의 절반 용량만 가진다고 하는데, 이 프레이밍도 맞지 않다고 봄
[0..1] 밖의 값이 존재하지 않는다면 좁은 구간처럼 보이는 건 렌더링 산물임. 범위를 벗어난 값이 없다는 지식을 가지고 버킷을 잘라냈기 때문에 더 좁게 렌더링된 것뿐임
반대로 [0..1] 밖의 값이 존재한다면 그 범위는 무한함. 글은 후자는 인정하지만 전자는 인정하지 않음
첫 번째를 인정하는 순간 올바른 동작은 분명해 보이지만, 이런 글이 나왔다는 사실 자체가 객관적으로 “분명한” 문제는 아니라는 뜻이기도 함 :D- 정말로 0…255.0이 당연하다면, 어떤 부동소수점 값 범위가 정수 0으로 돌아가고 어떤 값이 정수 255로 돌아가야 함?
0..<1이 정수 0으로 가고, 254>..255.0이 정수 255로 간다고 하면 128이 잡아먹힘. 아마 127.5..128.5가 128로 가길 원할 텐데, 그러면 이 절반들은 어디로 가야 함?
128을 맞추려고 전체를 조금 이동시키면 0..0.99609375가 정수 0으로 매핑됨
- 정말로 0…255.0이 당연하다면, 어떤 부동소수점 값 범위가 정수 0으로 돌아가고 어떤 값이 정수 255로 돌아가야 함?
- 표준 접근도 사람들이 자연스럽게
round()를 호출하면서 생긴 것처럼 보임
사람들에게 그 방식이 꽤 자연스럽게 느껴지니, 단순함 때문에 표준이 된 것 같음 - 256으로 달성하려던 것의 반대 방식도 쓸모가 있을지 궁금함. 즉 0.0은 0, 1.0은 255로 보내고, 나머지 부동소수점 값은 1부터 254로 매핑하는 방식임
처리 중에도 검정은 검정으로, 흰색은 흰색으로 유지되면 좋겠음uint8_t output = 0.0f >= result ? 0 : 1.0f <= result ? 255 : 1 + 253*result;- 이렇게 하면 0과 255가 단위 구간에서 다른 숫자보다 더 큰 몫을 가져감. 대략 0.8%, 즉 255/253 정도임
- 첫 번째 이미지가 내 환경에서는 깨져 보임
- 글 작성자임. 이미지 파일이 손상됐다는 뜻인가요?
pngcrush로 압축하긴 했음. 아니면 이미지 내용이 뭔가 잘못됐다는 뜻인가요?
- 글 작성자임. 이미지 파일이 손상됐다는 뜻인가요?