[CS300 #282] 색 공간 — RGB 숫자는 빛의 양이 아니다
컴퓨터공학 300 주제 시리즈의 282번째 글이다. 전체 지도는 여기.
한 줄 요약
색 공간은 “숫자 세 개가 어떤 색을 뜻하는지”에 대한 약속이다. 같은 (128, 128, 128) 도 어느 색 공간이냐에 따라 다른 색이고, sRGB 숫자는 빛의 양과 비례하지 않는다.
왜 필요한가
색을 다루는 코드에서 흔한 버그가 있다.
- 두 색을 섞으려고 RGB 값을 평균냈더니 중간이 너무 어둡다.
- 이미지를 축소했더니 밝은 무늬가 칙칙해졌다.
- 디자이너 화면과 내 화면에서 같은 색 코드가 다르게 보인다.
- 명도 대비를 “눈대중”으로 정했더니 접근성 검사에서 떨어졌다.
모두 “색 숫자”와 “실제 빛” 사이의 관계를 모르고 계산해서 생긴다. 색 공간을 이해하면 이 관계를 정확히 다룰 수 있다.
핵심 개념
색은 사람 눈의 반응이다
사람 망막에는 서로 다른 파장대에 반응하는 세 종류의 원뿔세포가 있다. 그래서 대부분의 색 지각은 세 숫자로 근사할 수 있다. 이것이 RGB 처럼 3채널 표현이 통하는 이유다. 국제조명위원회(CIE)는 1931년에 사람의 평균 색 반응을 수치화한 CIE XYZ 공간을 정했다. 다른 색 공간은 대개 XYZ 와의 변환식으로 정의된다.
색 공간을 정하는 세 가지
RGB 계열 색 공간은 세 요소로 정해진다.
| 요소 | 뜻 | sRGB 의 값 |
|---|---|---|
| 원색(primaries) | R, G, B 각각이 어떤 색인가 | ITU-R BT.709 와 같은 원색 |
| 백색점(white point) | (1,1,1) 이 어떤 흰색인가 |
D65 |
| 전달 함수(transfer function) | 숫자와 빛의 세기의 관계 | 감마 약 2.2 에 가까운 구간 함수 |
같은 숫자라도 원색이 다르면 다른 색이다. Display P3 의 (1, 0, 0) 은 sRGB 의 (1, 0, 0) 보다 채도가 높은 빨강이다. 이렇게 표현 가능한 색의 범위를 색역(gamut)이라 한다.
감마: sRGB 숫자는 비선형이다
sRGB 는 숫자를 빛의 세기에 그대로 비례시키지 않는다. 사람 눈은 어두운 영역의 차이에 더 민감하므로, 8비트라는 한정된 단계를 어두운 쪽에 더 많이 배분한다. CSS Color 4 명세에 실린 sRGB 의 변환은 다음과 같다(값은 0~1 범위).
선형화 (sRGB -> 빛의 세기)
c <= 0.04045 이면 c / 12.92
그렇지 않으면 ((c + 0.055) / 1.055) ^ 2.4
부호화 (빛의 세기 -> sRGB)
v <= 0.0031308 이면 v * 12.92
그렇지 않으면 1.055 * v ^ (1/2.4) - 0.055
결과적으로 sRGB 값 0.5(8비트로 약 128)는 빛의 세기로는 약 21% 다. “반만큼 밝은 회색”이 아니다.
선형 공간에서 계산하라
빛은 더해진다. 두 빛을 섞거나, 픽셀을 평균내 축소하거나, 블러를 걸거나, 조명을 계산하는 일은 물리적으로 빛의 세기에 대한 연산이다. 그러니 다음 순서를 지켜야 한다.
sRGB 값 --선형화--> 선형 RGB --(섞기·평균·조명)--> 선형 RGB --부호화--> sRGB 값
이 순서를 건너뛰면 그라디언트 중간이 탁해지고, 축소한 이미지가 어두워진다. 현대 GPU 는 sRGB 텍스처를 읽을 때 자동으로 선형화하고, sRGB 프레임버퍼에 쓸 때 자동으로 부호화하는 기능을 제공한다.
상대 휘도
선형 RGB 에서 각 채널이 밝기에 기여하는 정도는 다르다. sRGB(BT.709 원색) 기준의 상대 휘도는 다음과 같다.
Y = 0.2126 R + 0.7152 G + 0.0722 B (R, G, B 는 선형값)
초록이 밝기의 대부분을 차지한다. WCAG 의 명도 대비 계산도 이 식을 쓴다. 다만 WCAG 2.x 문서는 선형화 임계값을 0.03928 로 적어 두었는데, 8비트 입력에서는 결과 차이가 없다.
자주 만나는 다른 색 공간
| 공간 | 성격 | 쓰임 |
|---|---|---|
| HSV / HSL | RGB 를 색상·채도·명도로 재배치한 것 | 색 선택기 UI |
| CIE Lab, OKLab | 지각적으로 균일하도록 설계 | 색 차이 계산, 그라디언트 |
| YCbCr | 밝기(Y)와 색차(Cb, Cr) 분리 | JPEG, 동영상 압축 |
| CMYK | 잉크의 감산 혼합 | 인쇄 |
HSL 의 “L” 은 지각 밝기가 아니다. 같은 L 값의 노랑과 파랑은 눈에 보이는 밝기가 크게 다르다. 지각 밝기가 필요하면 Lab 계열의 L 을 써야 한다. CSS Color 4 는 lab(), oklch() 같은 함수를 정의한다.
직접 해 보기
검정과 흰색의 “중간”을 두 가지 방법으로 구하고, 채널별 밝기 기여를 확인한다.
import colorsys
def srgb_to_linear(c8):
c = c8 / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def linear_to_srgb(v):
c = v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
return round(c * 255)
def luminance(rgb):
r, g, b = (srgb_to_linear(x) for x in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
black, white = (0, 0, 0), (255, 255, 255)
naive = tuple((a + b) // 2 for a, b in zip(black, white))
mid_lin = linear_to_srgb(0.5)
print("sRGB 값 평균:", naive, "-> 상대 휘도", round(luminance(naive), 3))
print("빛의 양 50%:", (mid_lin,) * 3, "-> 상대 휘도", round(luminance((mid_lin,) * 3), 3))
for name, c in [("red", (255, 0, 0)), ("green", (0, 255, 0)), ("blue", (0, 0, 255))]:
print(f"{name:5s} 휘도 = {luminance(c):.4f}")
h, s, v = colorsys.rgb_to_hsv(70 / 255, 130 / 255, 180 / 255) # steelblue
print("steelblue HSV =", round(h * 360), "deg,", round(s, 2), round(v, 2))
실행 결과다.
sRGB 값 평균: (127, 127, 127) -> 상대 휘도 0.212
빛의 양 50%: (188, 188, 188) -> 상대 휘도 0.503
red 휘도 = 0.2126
green 휘도 = 0.7152
blue 휘도 = 0.0722
steelblue HSV = 207 deg, 0.61 0.71
숫자 평균인 127 은 빛으로 21% 정도다. 빛의 절반은 sRGB 값 188 이다. 순수 초록은 순수 파랑보다 약 10배 밝다. 두 사실 모두 “RGB 숫자를 그대로 더하고 나누면 안 된다”는 것을 보여 준다.
현업에서는
- 이미지 리사이즈 서비스에서 축소 후 사진이 어둡다는 민원이 나오면, 감마를 무시하고 sRGB 값을 그대로 평균냈는지부터 의심한다.
- 디자인 시스템에서 색 토큰을 정할 때 HSL 로 명도 단계를 만들면 색상마다 실제 밝기가 들쭉날쭉하다. OKLCH 같은 지각 균일 공간으로 단계를 만드는 팀이 늘고 있다.
- 웹 접근성 점검 도구는 위의 상대 휘도 식으로 글자와 배경의 대비율을 계산한다. 대비 기준은 접근성 글에서 다시 다룬다.
- 모니터링 대시보드의 상태색(정상·경고·장애)은 색만으로 구분하지 말고 밝기 차이도 충분히 둔다. 색각 이상 사용자는 빨강과 초록의 색상 차이를 구분하기 어려울 수 있지만 밝기 차이는 볼 수 있다.
확인 문제
- sRGB 8비트 값 128 은 빛의 세기로 대략 몇 %인가? 50% 보다 큰가 작은가?
- 두 이미지를 50:50 으로 섞는 크로스페이드를 sRGB 값에 직접 적용하면 중간 프레임이 어떻게 보이는가?
- 상대 휘도 식에서 초록의 계수가 가장 큰 이유는?
- HSL 의 L 값이 같은 두 색이 서로 다른 밝기로 보이는 이유는?
풀이
- 약 21% 다. 50% 보다 작다.
- 실제 빛의 평균보다 어둡게 보인다. 비선형 값을 평균냈기 때문이다. 선형화 후 섞고 다시 부호화해야 한다.
- 사람 눈의 밝기 감도가 녹색 파장대에서 가장 높기 때문이다. 계수는 원색과 백색점에서 XYZ 의 Y 로 가는 변환에서 나온다.
- HSL 은 RGB 를 기하학적으로 재배치했을 뿐 지각 밝기를 모델링하지 않는다. 채널별 밝기 기여 차이가 L 에 반영되지 않는다.
더 읽을거리 (References)
- W3C, CSS Color Module Level 4 — sRGB·Display P3·Lab·OKLab 정의와 변환식
- W3C, A Standard Default Color Space for the Internet - sRGB
- W3C WAI, Understanding SC 1.4.3: Contrast (Minimum) — 상대 휘도와 대비율
- Python 문서, colorsys