-
[비디오 코덱] FFmpeg Error Resilience 코드 뜯어보기IT 2022. 8. 28. 13:00
지난 글에서 비디오 코덱 (특히 H.264)의 error resilience에 대해 알아보았다. 이번엔 가장 널리 쓰이는 상용 코덱 소프트웨어인 FFmpeg에서 error resilience를 어떻게 구현해놨는지 뜯어보았다. FFmpeg은 소스코드들에 주석도 별로 안 달려있고 변수명들도 매우 불친절해서 무슨 뜻인지 파악하기가 매우 힘들다... 그래서 이것저것 바꿔보고 돌려보면서 파악해야 했다.
소스코드는 여기에서 확인할 수 있다.
https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/error_resilience.c
Error concealment 적용하기
일단 앞선 글에서 언급했듯이 FFmpeg은 여러 error resilience 기법들 중 FMO, Data partitioning 등은 제대로 구현해놓지 않았다. Slice coding은 당연히 구현되어있고, 위의 소스코드에서는 주로 디코딩 단계에서 수행하는 error concealment에 집중되어있다.
먼저 error concealment 기능을 키고 끄는 방법은 다음과 같다. 여타 인코딩/디코딩 옵션들 설정할 때처럼 AVCodecContext의 멤버 변수를 수정해주면 된다. 원하는 옵션값들을 OR해서 넣어주면 된다.
AVCodecContext *dec_ctx = avcodec_alloc_context3(dec); dec_ctx->error_concealment = ...; // List of options // #define FF_EC_GUESS_MVS 1 // #define FF_EC_DEBLOCK 2 // #define FF_EC_FAVOR_INTER 256
코드 동작 분석
설정을 해주고 나면 디코딩 시 일부 정보가 손실되었을 때 error resilience를 담당하는 코드로 진입하게 된다.
void ff_er_frame_end(ERContext *s) 함수가 error concealment 진행을 담당하는 top level 함수이다.
시작하기 전, 알아두면 좋은 변수 의미들
- mb_width, mb_height: 이 프레임에 가로로, 세로로 MB가 몇개 들어가는지. 총 MB 개수는 mb_widh * mb_height가 되겠죠? 다만 주의할 점은 mb_stride != mb_width라는 점. (몇몇 비디오들에 대해 확인해본 결과 mb_stride=mb_width+1로 설정되어있었다.) 즉, MB의 x 방향 인덱스 mb_x와 y 방향 인덱스 mb_y가 주어졌을때, 해당 MB의 인덱스는 mb_x + mb_y * mb_width가 아닌, mb_x + mb_y * mb_stride로 계산해야 한다.
- s->cur_pic, s->last_pic: 이름 그대로 현재 프레임과 과거 프레임에 대한 정보를 담고 있다.
- s->cur_pic.motion_val, s->cur_pic.ref_index : 모션벡터와 레퍼런스 프레임
- s->cur_pic.mb_type : MB type (Inter, Intra, ...)
- s->cur_pic.f->data[0], [1], [2] : 실제 프레임 데이터이며, [0], [1], [2]는 각각 Y, Cb, Cr 채널.
에러난 MB들 체크하기
앞부분은 주로 각 MacroBlock (MB)에 어떤 에러가 발생했는지를 체크하는 부분이다. 에러 종류에는 ER_AC_ERROR, ER_DC_ERROR, ER_MV_ERROR가 있는데, 이름 그대로 AC값, DC값, 모션벡터에 에러가 난 케이스를 의미한다. ER_MB_ERROR는 이들을 통칭하는 변수이며, ER_AC_ERROR | ER_DC_ERROR | ER_MV_ERROR로 되어있다. 일단은 partitioning이 없고 특정 슬라이스가 통째로 날라간 경우만을 생각한다면(e.g., 패킷 유실), 에러가 발생한 MB는 무조건 ER_AC_ERROR & ER_DC_ERROR & ER_MV_ERROR를 모두 가지게 된다. 이런 내용이 아래 코드에 나와있다. 일단 에러가 발생했다면 무조건 ER_MB_ERROR로 만들어주는 것을 확인할 수 있다.
if (!s->partitioned_frame) { for (i = 0; i < s->mb_num; i++) { const int mb_xy = s->mb_index2xy[i]; int error = s->error_status_table[mb_xy]; if (error & ER_MB_ERROR) error |= ER_MB_ERROR; s->error_status_table[mb_xy] = error; } }
Concealment 방식 결정
그 다음 에러가 발생한 MB들을 intra-predicted로 보고 error conceal을 할지, inter-predicted로 보고 error conceal할지를 결정하는 부분이 있다. intra/inter 여부에 따라 error conceal하는 방법이 달라지기 때문에 이를 결정해줘야 한다. intra일 경우 같은 프레임 내의 주변 픽셀들을 이용해 conceal하는 spatial prediction, inter의 경우 이전 프레임의 정보를 이용해 conceal하는 temporal prediction을 한다.
FFmpeg에서는 손상된 각 MB에 대해 이를 결정하지 않고, 아래와 같이 프레임 전체에 대해 intra/inter 하나를 결정한 후 그 프레임의 모든 손상된 MB를 그거로 퉁친다.
is_intra_likely = is_intra_more_likely(s); /* set unknown mb-type to most likely */ for (i = 0; i < s->mb_num; i++) { const int mb_xy = s->mb_index2xy[i]; int error = s->error_status_table[mb_xy]; if (!((error & ER_DC_ERROR) && (error & ER_MV_ERROR))) continue; if (is_intra_likely) s->cur_pic.mb_type[mb_xy] = MB_TYPE_INTRA4x4; else s->cur_pic.mb_type[mb_xy] = MB_TYPE_16x16 | MB_TYPE_L0; }
is_intra_more_likely 함수를 까보면 핵심 로직은 다음과 같다.
- 만약 conceal에 이용할 이전 프레임이 없다면 spatial prediction해야 하므로 intra로 결정.
- 만약 현재 프레임에서 멀쩡한 MB가 5개 미만이라면 spatial prediction은 어려울 것으로 판단하여 inter로 결정
- 그것도 아니라면 멀쩡한 MB들을 몇 개 쭉 스캔해서 살펴보면서 예측해본다.
- I 프레임: 각 MB와 이전 프레임에서 같은 포지션의 MB의 Sum of Absolute Difference (SAD) 들이, 이전 프레임에서 같은 포지션의 MB와 이전 프레임에서 한 줄 아래 MB와의 SAD들보다 클 경우 intra로 결정. 아니면 inter로 결정.
이 부분은 왜 이렇게 했는지 아직 이해가 안된다. I프레임이면 그냥 intra로 해야 되는거 아닌가? 실제로 비디오들로 돌려보면 결국은 다 intra로 결정하긴 한다. - P 프레임: 멀쩡한 MB들의 type이 intra가 많은지 inter가 많은지 살펴봐서 다수인 쪽으로 결정. 일반적으로 P 프레임은 inter-predicted MB가 많기 때문에 웬만하면 inter쪽으로 결정된다.
- I 프레임: 각 MB와 이전 프레임에서 같은 포지션의 MB의 Sum of Absolute Difference (SAD) 들이, 이전 프레임에서 같은 포지션의 MB와 이전 프레임에서 한 줄 아래 MB와의 SAD들보다 클 경우 intra로 결정. 아니면 inter로 결정.
static int is_intra_more_likely(ERContext *s) { ... // 이전 프레임 없음 -> spatial prediction if (!s->last_pic.f || !s->last_pic.f->data[0]) return 1; ... undamaged_count = 0; for (i = 0; i < s->mb_num; i++) { const int mb_xy = s->mb_index2xy[i]; const int error = s->error_status_table[mb_xy]; if (!((error & ER_DC_ERROR) && (error & ER_MV_ERROR))) undamaged_count++; } // 현재 프레임에 멀쩡한 MB가 너무 적음 -> temporal prediction if (undamaged_count < 5) return 0; ... for (mb_y = 0; mb_y < s->mb_height - 1; mb_y++) { for (mb_x = 0; mb_x < s->mb_width; mb_x++) { error = s->error_status_table[mb_xy]; if ((error & ER_DC_ERROR) && (error & ER_MV_ERROR)) continue; // skip damaged if (s->cur_pic.f->pict_type == AV_PICTURE_TYPE_I) { // 현재 MB uint8_t *mb_ptr = s->cur_pic.f->data[0] + mb_x * 16 + mb_y * 16 * linesize[0]; // 이전 프레임의 같은 위치의 MB uint8_t *last_mb_ptr = s->last_pic.f->data[0] + mb_x * 16 + mb_y * 16 * linesize[0]; // + SAD(이전 프레임 같은 위치 MB, 현재 MB) is_intra_likely += s->mecc.sad[0](NULL, last_mb_ptr, mb_ptr, linesize[0], 16); // + SAD(이전 프레임 같은 위치 MB, 이전 프레임 한 줄 아래 MB) is_intra_likely -= s->mecc.sad[0](NULL, last_mb_ptr, last_mb_ptr + linesize[0] * 16, linesize[0], 16); } else { // P or B frame: majority voting if (IS_INTRA(s->cur_pic.mb_type[mb_xy])) is_intra_likely++; else is_intra_likely--; } } } }
Error conceal 하기
이제 본격적으로 error conceal을 하는 부분이다.
Inter-predicted MB
가장 먼저 /* handle inter blocks with damaged AC */ 라는 주석이 나오는 루프가 나오는데, 모션벡터는 멀쩡하면서 AC 정보가 손실된 inter-predicted MB들을 대상으로 error conceal하는 부분이다. 근데 우리는 앞서 언급했듯 partitioning 없이 슬라이스 통째로 손실된 경우만 고려하므로 이런 경우는 없다. 손실되는 경우 MV, AC, DC 모두 손실된다.
그 다음 /* guess MVs */ 주석이 나온다. 여기가 모션 벡터 정보가 손상된 inter-predicted MB에 대해 모션 벡터를 예측하는 부분이다. 예측된 모션벡터를 이용하여 error conceal을 진행할 것이다. 여기선 B 프레임 말고 P 프레임만 보도록 하자. P 프레임은 void guess_mv(ERContext *s) 함수를 호출한다. 수행 시간을 측정해본 결과 이 함수가 error concealment 시간의 상당 부분을 차지하는 병목이었다.
이 함수가 하는 일을 하이레벨로 설명하면 다음과 같다. 손상된 MB의 모션벡터를 예측하는 방법은 여러 가지 있다. 바로 왼쪽/오른쪽/위쪽/아래쪽 MB의 모션벡터를 가져다 쓸 수도 있고, 그들의 모션벡터의 평균/중앙값을 쓸 수도 있고, 그냥 모션벡터를 0으로 설정할 수도 있고, 이전 프레임의 동일 위치의 MB의 모션벡터를 가져다 쓸 수도 있다. mv_predictor가 이 8가지 방법들로 예측된 모션벡터를 담는 변수다. 이들을 모두 시도해본 후 가장 점수가 높은 걸 가져다 쓴다. 점수는 '예측한 모션벡터와 과거 프레임으로 손상된 MB의 내용물을 채웠을때 주변과 스무스하게 이어지는지'를 통해 매긴다. 이런 여러 방법들을 다 시도해보려다 보니 시간이 오래 걸리는 것이다.
코드를 조금 살펴봐보자. 먼저 다음과 같이 손상된 inter-predicted MB만을 골라낸다. 이를 위해 Intra-predicted MB 또는 멀쩡한 Inter-predicted MB들을 골라서 MV_FROZEN이라고 표시해놓는다. 그 후 나머지 손상된 inter-predicted macroblock들은, 이전 프레임의 같은 위치의 모션 벡터를 그대로 복사해넣는다. 이건 그냥 예측 방법 중 하나를 써서 예측한 임시값이라고 보면 된다.
// 매크로블럭들을 순회 for (i = 0; i < mb_width * mb_height; i++) { const int mb_xy = s->mb_index2xy[i]; int f = 0; int error = s->error_status_table[mb_xy]; if (IS_INTRA(s->cur_pic.mb_type[mb_xy])) f = MV_FROZEN; // intra if (!(error & ER_MV_ERROR)) f = MV_FROZEN; // inter with undamaged MV fixed[mb_xy] = f; if (f == MV_FROZEN) num_avail++; else if(s->last_pic.f->data[0] && s->last_pic.motion_val[0]){ // 이전 프레임의 같은 위치의 모션벡터를 그대로 복사해옴 const int mb_y= mb_xy / s->mb_stride; const int mb_x= mb_xy % s->mb_stride; const int mot_index= (mb_x + mb_y*mot_stride) * mot_step; s->cur_pic.motion_val[0][mot_index][0]= s->last_pic.motion_val[0][mot_index][0]; s->cur_pic.motion_val[0][mot_index][1]= s->last_pic.motion_val[0][mot_index][1]; s->cur_pic.ref_index[0][4*mb_xy] = s->last_pic.ref_index[0][4*mb_xy]; } }
그 후, error conceal할 MB들을 모은다. 바로 모든 MB들을 error conceal할 수는 없고, 주변에 멀쩡한 MB (MV_FROZEN)가 존재해야만 그걸 기반으로 interpolation/extrapolation해서 conceal할 수 있다. 따라서 아래와 같이 MV_FROZEN이라고 표시된 MB 주변의 MB들만 모은다.
for (mb_y = 0; mb_y < mb_height; mb_y++) { for (mb_x = 0; mb_x < mb_width; mb_x++) { const int mb_xy = mb_x + mb_y * mb_stride; if (fixed[mb_xy] == MV_FROZEN) { if (mb_x) add_blocklist(blocklist, &blocklist_length, fixed, mb_x - 1, mb_y, mb_xy - 1); if (mb_y) add_blocklist(blocklist, &blocklist_length, fixed, mb_x, mb_y - 1, mb_xy - mb_stride); if (mb_x+1 < mb_width) add_blocklist(blocklist, &blocklist_length, fixed, mb_x + 1, mb_y, mb_xy + 1); if (mb_y+1 < mb_height) add_blocklist(blocklist, &blocklist_length, fixed, mb_x, mb_y + 1, mb_xy + mb_stride); } } }
이제 error conceal할 MB들에 대해 8가지 예측 방법들을 다 시도해보고 점수를 매겨보고 베스트를 고른다. 이 부분 코드는 보면 딱 알 수 있으므로 생략!
Intra-predicted MB
Intra MB들은 DC 값만 채워넣는다.
이를 위해 먼저 아래와 같이 s->cur_pic.f->data 를 이용하여 s->dc_val 에 각 MB의 DC 값들을 채워넣는다. 픽셀값으로부터 DC값을 추출하는듯한데 정확한 변환 공식의 유도 과정은 잘 모르겠다만 현재로선 그리 중요하진 않다. 확인해보니 손상된 MB들의 DC값은 0으로 채워지더라.
참고1) s->dc_val[0], s->dc_val[1], s->dc_val[2] 는 각각 Y, U, V의 DC 값을 의미한다.
참고2) 이 코드에 /* fill DC for inter blocks */ 라고 주석이 달려있는데, non-partitioned frame의 경우 inter뿐만 아니라 intra도 채워지게 된다. 주석이 잘못된듯...
for (mb_y = 0; mb_y < s->mb_height; mb_y++) { for (mb_x = 0; mb_x < s->mb_width; mb_x++) { ... dc_ptr = &s->dc_val[0][mb_x * 2 + mb_y * 2 * s->b8_stride]; ... dc_ptr[(n & 1) + (n >> 1) * s->b8_stride] = (dc + 4) >> 3; ... s->dc_val[1][mb_x + mb_y * s->mb_stride] = (dcu + 4) >> 3; s->dc_val[2][mb_x + mb_y * s->mb_stride] = (dcv + 4) >> 3; } }
그 다음 손상된 MB들의 DC 값을 예측해넣을 차례다. (이전 단계에선 그냥 0으로 채워넣었었다.) 예측은 '가까운 멀쩡한 MB들'의 DC값들의 distance 기반 weighted sum이다. guess_dc 함수에서 이걸 한다. 코드를 보면 4 방향에서 가까운 undamaged MB의 DC값들을 기록해나가는 걸 볼 수 있다.
for(b_y=0; b_y<h; b_y++){ for(b_x=0; b_x<w; b_x++){ // 왼->오 ... } for(b_x=w-1; b_x>=0; b_x--){ // 오->왼 ... } } for(b_x=0; b_x<w; b_x++){ for(b_y=0; b_y<h; b_y++){ // 위->아래 ... } for(b_y=h-1; b_y>=0; b_y--){ // 아래->위 ... } }
그리고 마지막 for 루프에서는 4 방향의 weighted sum을 통해 DC를 예측한다.
'IT' 카테고리의 다른 글
[비디오 코덱] Error resilience (0) 2022.07.31 General optimization의 끝? (0) 2022.01.30 [웹] 홈페이지 SSL 인증서 오류 고치기 대소동 (0) 2021.11.23 HCI design study에 대한 단상 (0) 2021.10.10 시스템 연구에 대한 단상 (0) 2021.09.16