4:2:0부터 4:4:4까지, 크로마 서브샘플링과 비트 뎁스 실측기
개요
영상 인코딩 설정값 중에 픽셀 포맷(pixel format)이라는 항목이 있습니다. yuv420p, yuv422p, yuv444p 같은 이름으로 등장하는데, 여기 붙은 420/422/444라는 숫자가 크로마 서브샘플링 비율이고, 뒤에 붙는 8bit/10bit이 비트 뎁스입니다.
대부분의 스트리밍 서비스가 별 고민 없이 4:2:0 8bit를 쓰는 이유가 있고, 그럼에도 방송이나 색보정이 중요한 파이프라인에서 4:2:2나 4:4:4, 10bit를 요구하는 이유도 있습니다.
문제는 이 옵션들이 인코더마다, 그리고 서비스마다 실제로 지원하는 범위가 다르다는 점입니다. FFmpeg 문서를 읽는 것과 실제로 인코딩을 돌려서 ffprobe로 확인하는 것 사이에는 늘 차이가 있습니다.
이 글에서는 로컬 FFmpeg로 4:2:0/4:2:2/4:4:4와 8bit/10bit 조합을 실제로 인코딩해보고, 이어서 Tencent Cloud의 미디어 트랜스코딩 서비스 MPS에 같은 조합을 요청했을 때 실제로 무슨 일이 일어나는지 API 호출로 직접 확인한 결과를 정리합니다.
색상 밴딩 클레임을 받아서 확인해봤다
부드러운 색상 그라디언트가 많은 애니메이션 장면을 트랜스코딩해서 넘겼는데, "하늘 배경에 색상 밴딩(banding)이 보인다"는 클레임이 들어왔다고 가정해보겠습니다. 밴딩은 부드럽게 이어져야 할 색상이 계단처럼 끊겨 보이는 현상인데, 원인 후보는 크게 두 가지입니다.
하나는 비트 뎁스입니다. 8bit는 채널당 256단계 밖에 표현하지 못해서, 넓은 면적에 걸쳐 색이 서서히 변하는 장면에서는 계단 현상이 눈에 띌 수 있습니다. 10bit는 채널당 1024단계라 같은 그라디언트도 훨씬 매끄럽게 표현됩니다.
다른 하나는 크로마 서브샘플링입니다. 4:2:0은 색차(chroma) 정보를 가로세로 모두 절반으로 줄여서 저장합니다. 즉 밝기(luma) 해상도는 그대로 유지하면서 색 정보만 4분의 1로 줄이는 방식입니다. 색상 경계가 뚜렷한 그래픽이나 자막에서는 이게 색번짐으로 보이고, 그라디언트에서는 색상 단계가 거칠어지는 방식으로 나타날 수 있습니다.
이 클레임을 해결하려면 "인코딩을 10bit로 바꾸면 되는지, 크로마 서브샘플링도 같이 손봐야 하는지, 그리고 지금 쓰는 트랜스코딩 파이프라인이 애초에 그 설정을 받아주기는 하는지"를 순서대로 확인해야 합니다. 아래는 그 확인 과정을 실제로 밟아본 기록입니다.
테스트 환경
- FFmpeg 8.0 (libx264, libx265 포함 빌드)
- 소스: 1280x720, H.264 yuv420p, 24fps, 약 10초 분량의 애니메이션 클립(부드러운 색 그라디언트가 많은 장면 위주)
- 소스 자체가 이미 4:2:0 8bit로 인코딩되어 있다는 점이 뒤에서 중요한 변수가 됩니다.
$ ffprobe -v error -show_entries stream=codec_name,width,height,pix_fmt \
-show_entries format=duration,size -of default 2b5a5a261d494544.mp4
codec_name=h264
width=1280
height=720
pix_fmt=yuv420p
duration=10.041667
size=220687171. FFmpeg로 4:2:0 / 4:2:2 / 4:4:4를 실제로 인코딩해보기
먼저 오디오를 빼고 같은 CRF 값(20)으로 픽셀 포맷만 바꿔가며 세 번 인코딩했습니다.
# 4:2:0 8bit (기본)
ffmpeg -y -i src.mp4 -an -c:v libx264 -pix_fmt yuv420p -profile:v high -crf 20 h264_420_8bit.mp4
# 4:2:2 8bit
ffmpeg -y -i src.mp4 -an -c:v libx264 -pix_fmt yuv422p -profile:v high422 -crf 20 h264_422_8bit.mp4
# 4:4:4 8bit
ffmpeg -y -i src.mp4 -an -c:v libx264 -pix_fmt yuv444p -profile:v high444 -crf 20 h264_444_8bit.mp4세 번 다 에러 없이 끝났습니다. libx264가 4:2:2와 4:4:4를 지원하지 않는다는 이야기를 종종 듣는데, 실제로는 지원합니다.
다만 프로파일을 각각 High 4:2:2, High 4:4:4 Predictive로 맞춰줘야 하고, 이 프로파일들은 하드웨어 디코더나 구형 플레이어에서 재생이 안 될 수 있다는 점이 별개 문제로 따라옵니다. ffprobe로 실제 결과를 확인하면 이렇습니다.
$ ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,pix_fmt,bits_per_raw_sample \
-of default=noprint_wrappers=1 <파일>| 인코딩 | codec | profile | pix_fmt | bits_per_raw_sample | 파일 크기 |
|---|---|---|---|---|---|
| 4:2:0 8bit | h264 | High | yuv420p | 8 | 6,153,274 bytes (5.87MB) |
| 4:2:2 8bit | h264 | High 4:2:2 | yuv422p | 8 | 6,889,235 bytes (6.57MB) |
| 4:4:4 8bit | h264 | High 4:4:4 Predictive | yuv444p | 8 | 6,145,316 bytes (5.86MB) |
ffmpeg 인코딩 로그에 찍힌 비디오 트랙 평균 비트레이트(오디오 제외)는 4:2:0이 4898.76kb/s, 4:2:2가 5485.08kb/s, 4:4:4가 4892.42kb/s였습니다.
여기서 눈에 띄는 부분은 4:4:4가 4:2:0과 파일 크기, 비트레이트가 거의 같다는 점입니다. 이론적으로는 4:4:4가 4:2:0보다 크로마 데이터를 4배 더 담으니 훨씬 커져야 할 것 같은데 그렇지 않았습니다.
이유는 소스 자체에 있습니다. 소스가 이미 4:2:0으로 한 번 서브샘플링되어 색 정보가 절반씩 줄어든 상태이기 때문에, 이걸 다시 4:4:4로 풀어봐야 실제로 새로 생기는 색 디테일이 없습니다. 그냥 이미 뭉개진 색 정보를 넓은 평면에 복사해서 채운 것에 가깝고, 그래서 인코더 입장에서는 압축하기 쉬운(변화가 적은) 데이터가 됩니다.
반면 4:2:2는 가로 방향만 서브샘플링하는데, 이 중간 단계에서 인코더가 처리해야 하는 경계 패턴이 오히려 늘어나면서 비트레이트가 더 커진 것으로 보입니다.
이 결과가 주는 실무적 교훈은 분명합니다. 이미 4:2:0으로 손실이 발생한 소스를 4:4:4로 재인코딩해도 잃어버린 색 정보가 되살아나지 않습니다. 4:2:2나 4:4:4의 이득을 실제로 보려면 카메라 원본이나 마스터 파일처럼 처음부터 크로마 서브샘플링을 거치지 않은 소스에서 시작해야 합니다.
이 결론을 눈으로도 확인할 수 있습니다. 같은 CRF 20으로 인코딩한 4:2:0과 4:4:4 결과물입니다.
4:2:0 8bit (yuv420p)
4:4:4 8bit (yuv444p)
두 영상을 나란히 재생해보면 육안으로는 사실상 구분이 안 됩니다. 파일 크기와 비트레이트가 거의 같았던 이유가 그대로 화질에도 반영된 셈입니다. 이미 4:2:0으로 서브샘플링된 소스에는 4:4:4로 풀어줄 색 정보 자체가 남아 있지 않다는 걸 수치뿐 아니라 실제 영상으로도 확인할 수 있습니다.
잘못된 프로파일 조합을 강제하면 실제로 이런 에러가 난다
-pix_fmt yuv444p인데 프로파일을 high로 잘못 지정하면 어떻게 되는지도 실측했습니다.
$ ffmpeg -y -i src.mp4 -an -c:v libx264 -pix_fmt yuv444p -profile:v high -crf 20 -t 1 bad.mp4
...
x264 [error]: high profile doesn't support 4:4:4
[libx264 @ 0x...] Error setting profile high.
[libx264 @ 0x...] Possible profiles: baseline main high high10 high422 high444
[vost#0:0/libx264 @ 0x...] Error while opening encoder - maybe incorrect parameters...
Conversion failed!libx264가 친절하게도 가능한 프로파일 목록(baseline main high high10 high422 high444)까지 에러 메시지에 출력해줍니다. 여기서 high10이라는 이름이 보이는데, 이건 크로마 서브샘플링이 아니라 비트 뎁스에 대응하는 프로파일입니다.
크로마 서브샘플링(422, 444)과 비트 뎁스(10)는 각각 별도 축이고, 프로파일 이름에 이 두 축이 섞여서 노출된다는 점을 실제 에러 메시지로 확인할 수 있습니다.
2. 순수하게 크로마 서브샘플링만으로 생기는 손실 측정하기
방금 본 것처럼 이미 4:2:0인 소스로는 서브샘플링의 실제 손실을 보여주기 어렵습니다. 그래서 크로마 서브샘플링 손실만 따로 떼어서 측정하기 위해, 코덱 압축을 아예 배제한 실험을 별도로 했습니다.
FFmpeg의 gradients 소스 필터로 합성 그라디언트 프레임을 4:4:4 무손실로 만든 다음, 이걸 4:2:0과 4:2:2로 다운샘플했다가 다시 4:4:4로 업샘플해서 원본과 픽셀 단위로 비교하는 방식입니다. 코덱의 양자화 손실이 전혀 섞이지 않으므로 순수하게 리샘플링 자체의 손실만 잡아낼 수 있습니다.
# 1) 합성 4:4:4 기준 프레임 생성 (무손실 rawvideo)
ffmpeg -y -f lavfi -i "gradients=size=640x360:rate=1" -frames:v 1 \
-pix_fmt yuv444p -f rawvideo ref444.yuv
# 2) 4:2:0 / 4:2:2로 다운샘플
ffmpeg -y -f rawvideo -pix_fmt yuv444p -s 640x360 -i ref444.yuv \
-pix_fmt yuv420p -f rawvideo down420.yuv
ffmpeg -y -f rawvideo -pix_fmt yuv444p -s 640x360 -i ref444.yuv \
-pix_fmt yuv422p -f rawvideo down422.yuv
# 3) 다시 4:4:4로 업샘플(복원 시도)
ffmpeg -y -f rawvideo -pix_fmt yuv420p -s 640x360 -i down420.yuv \
-pix_fmt yuv444p -f rawvideo up444_from420.yuv
ffmpeg -y -f rawvideo -pix_fmt yuv422p -s 640x360 -i down422.yuv \
-pix_fmt yuv444p -f rawvideo up444_from422.yuv
# 4) 원본과 PSNR 비교
ffmpeg -f rawvideo -pix_fmt yuv444p -s 640x360 -i ref444.yuv \
-f rawvideo -pix_fmt yuv444p -s 640x360 -i up444_from420.yuv \
-lavfi psnr -f null -측정 결과는 다음과 같았습니다.
| 왕복 경로 | Y PSNR | U PSNR | V PSNR |
|---|---|---|---|
| 4:4:4 → 4:2:0 → 4:4:4 | inf (무손실) | 62.94dB | 61.24dB |
| 4:4:4 → 4:2:2 → 4:4:4 | inf (무손실) | 64.05dB | 62.79dB |
밝기(Y) 채널은 서브샘플링 대상이 아니므로 두 경로 모두 완전히 무손실(PSNR 무한대)로 나왔습니다. 이는 실험 설계가 의도대로 크로마 채널만의 손실을 측정하고 있다는 걸 보여줍니다.
색차(U, V) 채널에서는 4:2:2가 4:2:0보다 U에서 약 1.1dB, V에서 약 1.5dB 더 높은 PSNR을 유지했습니다. 즉 4:2:2가 4:2:0보다 색 정보를 더 온전히 보존한다는 게 코덱 압축과 무관하게 순수 리샘플링 단계에서부터 수치로 확인됩니다.
3. 10bit는 실제로 어떻게 다른가
H.265(libx265)로 10bit 인코딩도 실제로 돌려봤습니다.
ffmpeg -y -i src.mp4 -an -c:v libx265 -pix_fmt yuv420p10le -profile:v main10 -crf 20 h265_420_10bit.mp4$ ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,pix_fmt -of default=noprint_wrappers=1 h265_420_10bit.mp4
codec_name=hevc
profile=Main 10
pix_fmt=yuv420p10le정상적으로 Main 10 프로파일, yuv420p10le 픽셀 포맷으로 인코딩됐습니다. 파일 크기는 5,563,542 bytes(5.31MB)로, 같은 CRF의 4:2:0 8bit H.264(5.87MB)보다 오히려 작았습니다.
다만 이건 10bit 자체의 효과가 아니라 H.265 코덱이 H.264보다 압축 효율이 높기 때문일 가능성이 큽니다. 8bit H.264와 10bit H.265를 같은 실험에서 직접 비교한 건 아니라서, 이 크기 차이를 비트 뎁스만의 효과로 해석하면 안 됩니다.
실측 중에 예상 밖의 결과도 하나 나왔습니다. libx264는 흔히 "8bit 전용"으로 알려져 있어서, 10bit 픽셀 포맷을 강제로 넣으면 에러가 나거나 자동으로 8bit로 떨어질 거라 예상하고 테스트했습니다.
ffmpeg -y -i src.mp4 -an -c:v libx264 -pix_fmt yuv420p10le -crf 20 h264_420_10bit_test.mp4$ ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,pix_fmt,bits_per_raw_sample -of default=noprint_wrappers=1 h264_420_10bit_test.mp4
codec_name=h264
profile=High 10
pix_fmt=yuv420p10le
bits_per_raw_sample=10에러 없이 진짜 10bit(High 10 프로파일, bits_per_raw_sample=10)로 인코딩됐습니다. libx264는 원래 8bit 빌드와 10bit 빌드가 분리되어 배포되는 경우가 많아서(정적 빌드마다 다름), 이 결과는 지금 쓰고 있는 Homebrew FFmpeg 8.0 빌드의 libx264가 8bit와 10bit를 모두 지원하도록 빌드되어 있다는 뜻입니다.
다른 환경, 특히 배포판 패키지나 정적 빌드를 쓴다면 결과가 다를 수 있으므로, "libx264가 10bit를 지원하는지"는 문서를 믿기보다 지금처럼 직접 짧게 인코딩해서 ffprobe로 확인하는 편이 안전합니다.
참고로 libx265의 4:4:4 지원도 짧게 확인했는데, -pix_fmt yuv444p만 지정해도 프로파일이 자동으로 Rext(Range Extensions)로 잡히면서 정상적으로 인코딩됐습니다.
로컬 인코딩 결과를 종합하면 다음과 같습니다.
4. Tencent Cloud MPS는 이 조합들을 실제로 받아주는가
로컬 FFmpeg에서 되는 걸 확인했으니, 이번에는 클라우드 매니지드 트랜스코딩 서비스인 Tencent Cloud MPS에 같은 조합을 요청했을 때 무슨 일이 일어나는지 실제 API 호출로 확인했습니다.
소스 영상을 COS 버킷에 올리고, MPS의 ProcessMedia API로 트랜스코딩 작업을 만든 뒤, DescribeTaskDetail로 결과를 폴링하고, 완성된 파일을 다시 받아서 ffprobe로 검증하는 순서입니다.
4-1. 기본 제공 프리셋부터 확인
DescribeTranscodeTemplates API로 페이지네이션을 돌며 계정에 등록된 트랜스코딩 템플릿을 전부 훑었습니다.
templates =
offset = 0
while True:
resp = call_api("DescribeTranscodeTemplates", {"Offset": offset, "Limit": 100})
batch = resp["TranscodeTemplateSet"]
templates += batch
if len(batch) < 100:
break
offset += 100총 391개 템플릿을 확인했고, 각 템플릿의 VideoTemplate.BitDepth와 VideoTemplate.VideoProfile 값을 집계했습니다.
| 항목 | 분포 |
|---|---|
| Codec | h264 311 / libx264 47 / h265 14 / libx265 13 / av1 6 |
| BitDepth | 8 (366개) / 미지정 (25개) |
| VideoProfile | 미지정 (247개) / high (144개) |
391개 프리셋 중 BitDepth가 10인 템플릿은 0개였고, VideoProfile이 high422나 high444인 템플릿도 0개였습니다. 기본 제공 프리셋만 놓고 보면 사실상 전부 4:2:0 8bit입니다.
4-2. OverrideParameter로 커스텀 트랜스코딩을 시도하면서 발견한 함정
프리셋에 없다고 API 자체가 안 된다는 뜻은 아니므로, ProcessMedia의 OverrideParameter로 직접 값을 넣어봤습니다. 처음에는 문서에서 흔히 보이는 방식대로 Definition: 0에 커스텀 파라미터를 실어 보냈습니다.
{
"Definition": 0,
"OverrideParameter": {
"Container": "mp4",
"VideoTemplate": { "Codec": "libx264", "VideoProfile": "high444", "BitDepth": 8 }
}
}결과는 실패였습니다.
ErrCode: 70000
Message: InvalidParameterValue.Container is nullDescribeTaskDetail로 실제 처리된 파라미터를 확인해보면 OverrideParameter 필드 자체가 null로 찍혀 있었습니다. 즉 Definition: 0으로 보낸 OverrideParameter가 서버에 아예 반영되지 않은 채 무시된 것으로 보입니다.
이건 API 문서만 보고는 알기 어려운 함정이라, 실제로 태스크 상세를 찍어보고 나서야 원인을 특정할 수 있었습니다.
기존에 존재하는 프리셋 Definition ID(720p H.264 프리셋인 100030) 위에 OverrideParameter를 얹는 방식으로 바꾸자 정상적으로 반영됐습니다.
{
"Definition": 100030,
"OverrideParameter": {
"VideoTemplate": { "VideoProfile": "high444", "BitDepth": 8 }
}
}4-3. 크로마 서브샘플링은 현재 표준 옵션 목록에는 없다
위 요청은 결과가 이렇게 나왔습니다.
ErrCode: 70000
Message: InvalidParameterValue.VideoProfile(high444)high444는 VideoTemplate의 VideoProfile 필드가 기본으로 지원하는 값이 아닙니다. API가 실제로 돌려준 에러 메시지로 확인한 결과입니다.
MPS는 HEVC 10bit HDR10 MOV나 ProRes+MOV처럼 커스텀 설정이 필요한 규격도 별도로 지원하는 서비스라, 4:2:2/4:4:4처럼 기본 옵션에 없는 조합이 필요하다면 담당 계정팀이나 기술지원에 문의해 커스텀 지원 여부를 논의하는 걸 권합니다.
4-4. 그런데 비트 뎁스 10은 실제로 받아준다
같은 방식으로 이번엔 BitDepth: 10만 넣어봤습니다.
{
"Definition": 100030,
"OverrideParameter": { "VideoTemplate": { "BitDepth": 10 } }
}이번엔 성공했습니다. 결과물을 COS에서 내려받아 ffprobe로 확인한 결과는 이렇습니다.
| 요청 | Codec | VideoProfile 요청값 | 결과 | pix_fmt | profile |
|---|---|---|---|---|---|
| A | libx264 | high444 | 실패 (InvalidParameterValue) | - | - |
| B | libx264 (기본) | 미지정, BitDepth: 10 | 성공 | yuv420p10le | High 10 |
| C | libx265 | main10, BitDepth: 10 | 성공 | yuv420p10le | Main 10 |
| D | libx265 | 미지정, BitDepth: 10 | 성공 | yuv420p10le | Main 10 (자동) |
B의 결과를 ffprobe로 직접 확인하면 다음과 같이 실제 10bit로 나옵니다.
codec_name=h264
profile=High 10
pix_fmt=yuv420p10le
bits_per_raw_sample=10가짜로 태그만 바꿔치기한 게 아니라 bits_per_raw_sample=10까지 정직하게 찍히는 진짜 10bit 산출물입니다.
4-5. 정리
실측 결과를 종합하면, MPS는 애초에 예상했던 "클라우드 서비스는 배포 호환성 때문에 무조건 4:2:0으로 강제한다"는 단순한 그림과는 조금 다릅니다. 정확히는 이렇습니다.
- 비트 뎁스(8bit → 10bit)는
OverrideParameter로 실제로 조정 가능하고, 요청하면 진짜 10bit 산출물이 나옵니다. - 크로마 서브샘플링(4:2:0 → 4:2:2/4:4:4)은 이번에 테스트한 기본
VideoTemplate경로에서는 현재 지원하지 않는 값이었습니다. - 391개 기본 프리셋 중에도 4:2:2/4:4:4 옵션은 없었습니다.
즉 MPS에서는 색상 밴딩 문제를 10bit 인코딩으로 완화하는 건 바로 가능하고, 크로마 서브샘플링 쪽은 기본 옵션 범위 밖이라 필요하다면 별도로 지원 여부를 문의하거나 로컬 인코딩 파이프라인을 함께 쓰는 게 현실적입니다.
실무에 적용할 때
앞서의 가상 시나리오로 돌아가보면, 색상 밴딩 클레임을 받았을 때 확인 순서는 이렇게 정리됩니다.
- 먼저 소스 자체가 몇 bit, 몇 크로마 포맷인지 ffprobe로 확인합니다. 이미 4:2:0 8bit 소스라면 출력 포맷을 아무리 올려도 사라진 정보가 되살아나지 않습니다.
- 밴딩이 색상 경계가 아니라 넓은 그라디언트 면적에서 계단처럼 보인다면 비트 뎁스 문제일 가능성이 높습니다. 이 경우 10bit 인코딩으로 전환하는 게 효과적이고, 매니지드 트랜스코딩 서비스에서도 대체로 옵션으로 열려 있습니다.
- 색상 경계 자체가 번지거나 뭉개진다면 크로마 서브샘플링 문제일 가능성이 높습니다. 이 경우는 원본 소스가 4:2:2/4:4:4를 갖추고 있어야 의미가 있고, 매니지드 서비스가 이 옵션을 지원하는지부터 API로 직접 확인해야 합니다. 문서에 없다고 끝난 게 아니라, 이 글에서 한 것처럼 실제 요청을 보내보고 에러 메시지로 확인하는 편이 확실합니다.
마치며
크로마 서브샘플링과 비트 뎁스는 이름만 보면 비슷한 급의 옵션처럼 보이지만, 실제로 인코더와 클라우드 서비스가 다루는 방식은 상당히 다릅니다. libx264와 libx265는 둘 다 4:2:2, 4:4:4, 10bit를 지원하지만 프로파일을 정확히 맞춰야 하고, 소스가 이미 4:2:0이라면 상위 포맷으로 재인코딩해도 실질적인 이득이 없습니다.
Tencent Cloud MPS 같은 매니지드 트랜스코딩 서비스에서는 비트 뎁스는 API로 바로 조정할 수 있는 값으로 열려 있는 반면, 크로마 서브샘플링은 이번에 테스트한 기본 옵션 범위에는 없었습니다. 두 옵션을 뭉뚱그려서 "화질을 더 높이는 설정" 정도로 생각하기 쉬운데, 실제로는 서로 다른 축이고 지원 여부도 따로 확인해야 한다는 게 이번 실측의 결론입니다.