与实时视频相关参数包含:帧率、码率、时延、抖动等。帧率体现了视频的流畅性,要想达到较好的流畅性体验要求——网络视频帧率不低于24帧,视频会议帧率不低于15帧。在实际开发中,我们遇到了不少问题 ,主要包括:
本文主要研究WebRTC中的帧率调整策略,解决上述实际开发中帧率较低的问题,以期达到较好的流畅性体验。
帧率计算方法
帧率并非恒定值,帧率大小反映的是每秒多少视频帧的统计值。在视频会议中,同一路视频流发送端的帧率和接收端的帧率并不相同。对于发送端帧率,我们需要明确:发送端输出帧率不等于摄像头采集帧率,编码器实际输入帧率不等于摄像头采集帧率,发送端帧率为编码器输出帧率 。
发送端帧率
摄像头采集帧率决定了发送端输入帧率的最大值。当采集的视频数据传送到编码器时,受制于编码器性能和系统硬件性能,编码器的实际输入帧率并不等于摄像头的采集帧率。摄像头采集帧率和编码器输入帧率共同决定了发送端的帧率。在WebRTC中的统计信息展现的是摄像头的采集帧率(作为输入帧率)和编码器的输出帧率(作为输出帧率)。
1、发送端输入帧率计算
WebRTC在*“webrtc/video/vie_encoder.cc”*文件EncodeTask 类中统计了摄像头的采集帧率——发送端输入帧率:
1 2 3 4 bool Run () override { vie_encoder_->stats_proxy_->OnIncomingFrame (frame_.width (),frame_.height ()); return true ; }
2、编码器输入帧率计算
为了计算编码器的实际输入帧率,WebRTC维持了一个大小为k F r a m e C o u n t H i s t o r y S i z e kFrameCountHistorySize k F r am e C o u n t H i s t ory S i ze 的数组T f T_f T f ,该数组用于保存最新的k F r a m e C o u n t H i s t o r y S i z e kFrameCountHistorySize k F r am e C o u n t H i s t ory S i ze 个帧放入数组的时间戳信息。帧率计算f p s fps f p s 公式如下:
f p s = N f × 1000.0 f T f [ 0 ] − T f [ N f ] N f : m a x ( T n o w − T f [ N f ] ) < k F r a m e H i s t o r y W i n M s
fps=\frac{N_f\times 1000.0f}{T_f[0]-T_f[N_f]} \qquad {N_f:\ max(T_{now}-T_f[N_f])<kFrameHistoryWinMs}
f p s = T f [ 0 ] − T f [ N f ] N f × 1000.0 f N f : ma x ( T n o w − T f [ N f ]) < k F r am eH i s t ory Win M s
其中,
k F r a m e C o u n t H i s t o r y S i z e kFrameCountHistorySize k F r am e C o u n t H i s t ory S i ze 一般取值为90,k F r a m e C o u n t H i s t o r y S i z e kFrameCountHistorySize k F r am e C o u n t H i s t ory S i ze 一般取值为2000;
N f N_f N f 是使N f : m a x ( T n o w − T f [ N f ] ) < k F r a m e H i s t o r y W i n M s N_f:\ max(T_{now}-T_f[N_f])<kFrameHistoryWinMs N f : ma x ( T n o w − T f [ N f ]) < k F r am eH i s t ory Win M s 成立的最大序列号;
T n o w T_{now} T n o w 为当前时间,T f [ 0 ] = T n o w T_f[0]=T_{now} T f [ 0 ] = T n o w 是数组内newest帧的时间戳,T f [ k F r a m e C o u n t H i s t o r y S i z e ] T_f[kFrameCountHistorySize] T f [ k F r am e C o u n t H i s t ory S i ze ] 为数组内现存oldest帧的时间戳;
当N f = = 0 N_f==0 N f == 0 时,不执行该公式,帧率保持上一次计算的结果。
在WebRTC中,上述公式在*“webrtc/modules/video_coding/media_optimization.cc”*文件MediaOptimization 类中实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void ProcessIncomingFrameRate (int64_t now) { int32_t num = 0 , nr_of_frames = 0 ; for (num = 1 ; num < (kFrameCountHistorySize - 1 ); ++num) { if (incoming_frame_times_[num] <= 0 || now - incoming_frame_times_[num] > kFrameHistoryWinMs) { break ; } else { nr_of_frames++; } } if (num > 1 ) { const int64_t diff = incoming_frame_times_[0 ] - incoming_frame_times_[num - 1 ]; incoming_frame_rate_ = 0.0 ; if (diff > 0 ) { incoming_frame_rate_ = nr_of_frames * 1000.0f / static_cast <float >(diff); } } }
3、编码器输出帧率计算
为了计算编码器的实际输出帧率,WebRTC维护了一个k B i t r a t e A v e r a g e W i n M s kBitrateAverageWinMs k B i t r a t e A v er a g e Win M s 时间段内的已编码帧的数组T f T_f T f ,依据下列公式来计算实际码率:
f p s = 90000 × ( s i z e o f ( T f ) − 1 ) + T d i f f 2 T d i f f
fps=\frac{90000 \times (sizeof(T_f)-1) + \frac{T_{diff}}{2}}{T_{diff}}
f p s = T d i ff 90000 × ( s i zeo f ( T f ) − 1 ) + 2 T d i ff
其中,
T d i f f = T f [ b a c k ] − T f [ f r o n t ] T_{diff}=T_f[back]-T_f[front] T d i ff = T f [ ba c k ] − T f [ f ro n t ] ,表示数组T f T_f T f 最大的时间间隔,当T d i f f < 0 T_{diff}<0 T d i ff < 0 时,f p s = s i z e o f ( T f ) fps=sizeof(T_f) f p s = s i zeo f ( T f ) ;
s i z e o f ( T f ) sizeof(T_f) s i zeo f ( T f ) 表示数组T f T_f T f 的大小,当s i z e o f ( T f ) < = 1 sizeof(T_f)<=1 s i zeo f ( T f ) <= 1 时,f p s = s i z e o f ( T f ) fps=sizeof(T_f) f p s = s i zeo f ( T f ) 。
在WebRTC中,上述公式在*“webrtc/modules/video_coding/media_optimization.cc”*文件MediaOptimization 类中实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void MediaOptimization::PurgeOldFrameSamples (int64_t now_ms) { while (!encoded_frame_samples_.empty ()) { if (now_ms - encoded_frame_samples_.front ().time_complete_ms > kBitrateAverageWinMs) { encoded_frame_samples_.pop_front (); } else { break ; } } } void MediaOptimization::UpdateSentFramerate () { if (encoded_frame_samples_.size () <= 1 ) { avg_sent_framerate_ = encoded_frame_samples_.size (); return ; } int denom = encoded_frame_samples_.back ().timestamp - encoded_frame_samples_.front ().timestamp; if (denom > 0 ) { avg_sent_framerate_ = (90000 * (encoded_frame_samples_.size () - 1 ) + denom / 2 ) / denom; } else { avg_sent_framerate_ = encoded_frame_samples_.size (); } }
接收端帧率
在WebRTC中,将接收端帧率分为了三种:网络接收帧率——接收端输入帧率、解码器输出帧率、视频渲染帧率。
1、网络接收帧率
网络接收帧率统计的是接收端接收到网络发送过来的视频帧帧率。在完整接收到一帧数据后,由FrameBuffer 类调用*ReceiveStatisticsProxy::OnCompleteFrame()*来统计。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void OnCompleteFrame (bool is_keyframe, size_t size_bytes) { int64_t now_ms = clock_->TimeInMilliseconds (); frame_window_.insert (std::make_pair (now_ms, size_bytes)); int64_t old_frames_ms = now_ms - kRateStatisticsWindowSizeMs; while (!frame_window_.empty () && frame_window_.begin ()->first < old_frames_ms) { frame_window_accumulated_bytes_ -= frame_window_.begin ()->second; frame_window_.erase (frame_window_.begin ()); } size_t framerate = (frame_window_.size () * 1000 + 500 ) / kRateStatisticsWindowSizeMs; stats_.network_frame_rate = static_cast <int >(framerate); }
2、解码器输出帧率
WebRTC实现了RateStatistics 来统计解码器输出帧率,在编码结束后由VideoReceiveStream 调用*ReceiveStatisticsProxy::OnDecodedFrame()*来统计。具体代码如下:
1 2 3 4 5 6 7 void OnDecodedFrame () { uint64_t now = clock_->TimeInMilliseconds (); rtc::CritScope lock (&crit_) ; ++stats_.frames_decoded; decode_fps_estimator_.Update (1 , now); stats_.decode_frame_rate = decode_fps_estimator_.Rate (now).value_or (0 ); }
3、视频渲染帧率
WebRTC实现了RateStatistics 来统计视频渲染帧率,在视频渲染结束后由VideoReceiveStream 调用*ReceiveStatisticsProxy::OnRenderedFrame()*来统计。具体代码如下:
1 2 3 4 5 6 7 void OnRenderedFrame (const VideoFrame& frame) { uint64_t now = clock_->TimeInMilliseconds (); rtc::CritScope lock (&crit_) ; renders_fps_estimator_.Update (1 , now); stats_.render_frame_rate = renders_fps_estimator_.Rate (now).value_or (0 ); ++stats_.frames_rendered; }
发送端帧率策略
影响发送端帧率的主要因素包含:视频采集(摄像头/桌面)帧率、编码器性能。
视频采集帧率策略
摄像头是视频采集的来源,其帧率决定了视频会议帧率的上限。与摄像头采集相关的参数包含:像素格式、帧率和分辨率。下表列出了ThinkPad T440P自带摄像头支持的部分视频格式:
格式
分辨率
帧率
MJPG
1280x720
30
MJPG
640x360
30
YUY2
1280x720
10
YUY2
640x360
30
可以看出对于YUY2格式,1280x720的帧率仅为10帧,要想达到30帧必须要采用MJPG格式。这是因为,同样是1280x720分辨率,30帧YUY2和MJPG格式需要传输的数据量分别为:
YUY2:1280x720x30x2x8=421Mbps
MJPG:1280x720x30x3x8/20=32Mbps
YUY2需要的传输带宽过大,所以很多摄像头对于RGB、YUV等格式1280x720仅支持10帧。然而10帧是远远不能够满足视频会议的帧率需求的,因此在选择视频采集规格时,需要注意像素格式、帧率和分辨率的权衡。在实际应用中,我们可以采集MJPG格式1280x720x30视频规格,然后在应用层转换为YUV格式。WebRTC在*“webrtc/modules/video_capture/video_capture_impl.cc”*的VideoCaptureImpl 类中实现了转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 if (frameInfo.codecType == kVideoCodecUnknown) { const VideoType commonVideoType = RawVideoTypeToCommonVideoVideoType (frameInfo.rawType); if (frameInfo.rawType != kVideoMJPEG && CalcBufferSize (commonVideoType, width, abs (height)) != videoFrameLength) { return -1 ; } int stride_y = width, stride_uv = (width + 1 ) / 2 ; int target_width = width, target_height = height; rtc::scoped_refptr<I420Buffer> buffer = I420Buffer::Create ( target_width, abs (target_height), stride_y, stride_uv, stride_uv); const int conversionResult = ConvertToI420 ( commonVideoType, videoFrame, 0 , 0 , width, height, videoFrameLength, apply_rotation ? _rotateFrame : kVideoRotation_0, buffer.get ()); }
最终得到的YUV格式的视频数据会被送到编码器中被编码,需要注意:不是所有的视频数据都会被编码器编码,详细内容将在下一节介绍。
采集编码丢帧策略
受限于系统硬件性能和编码器性能,视频采集图片的速度有可能比编码器编码速度快,这将导致多余的图片帧在编码器任务队列中累积。由于视频会议需要较低的时延,编码器必须要及时处理最新的帧,此时WebRTC采取丢帧策略——当有多个帧在编码器任务队列时,只编码最新的一帧 。WebRTC在*“webrtc/video/vie_encoder.cc”*文件EncodeTask 类中实现了该策略:
1 2 3 4 5 6 7 8 9 10 11 bool Run () override { ++vie_encoder_->captured_frame_count_; if (--vie_encoder_->posted_frames_waiting_for_encode_ == 0 ) { vie_encoder_->EncodeVideoFrame (frame_, time_when_posted_us_); } else { LOG (LS_VERBOSE) << "Incoming frame dropped due to that the encoder is blocked." ; ++vie_encoder_->dropped_frame_count_; } return true ; }
可以看出,由于编码器是阻塞的,如果编码器性能或系统硬件性能较差,编码器会丢掉因阻塞而累积的帧,进而导致发送端帧率降低。在具体使用场景中,这往往会导致两种现象:
接收端黑屏:如果发送端一开始就卡死在编码器中,接收端会一直黑屏,直到第一个帧编码完成;
接收端卡顿:如果发送端运行后经常阻塞在编码器中,接收端会卡顿,严重影响视频质量。
因此,摄像头采集帧率并不等于编码器的实际输入帧率,MediaOptimization 类中得到的编码器实际输入帧率,需要在下次编码前设置为编码器的输入帧率。
恒定码率丢帧策略
除了上文所述的采集编码丢帧策略,WebRTC还实现了一种漏桶算法的变体,用于跟踪何时应该主动丢帧,以避免编码器无法保持其比特率时,产生过高的比特率。漏桶算法 的示意图如下:
漏桶算法 的实现位于*“webrtc/modules/video_coding/frame_dropper.cc”*中的FrameDropper 类,其实现了三个关键方法:
Fill()
Leak()
DropFrame()
从字面上可以看出,这三个方法对应于上图所示漏桶算法的三个操作。这三个方法都在MediaOptimization 类被调用。
首先,来看看FrameDropper 类的核心参数:
漏桶容积:accumulator_max_ ,其值为target-bps×kLeakyBucketSizeSeconds ,随目标码率改变而改变;
漏桶累积:accumulator_ ,其表示漏桶累积的字节数,每次Fill()时增加,每次 Leak()时减少,其最大值为 target-bps×kAccumulatorCapBufferSizeSecs ;
丢帧率:drop_ratio_ ,其为一个指数滤波器,使丢帧率保持一个平滑的变化过程,每次*Leak()*后更新丢帧率;
关键帧率:key_frame_ratio_ ,其为一个指数滤波器,使关键帧率保持一个平滑的变化过程,每次*Fill()*后更新;
差分帧码率:delta_frame_size_avg_kbits_ ,其为一个指数滤波器,使关键帧率保持一个平滑的变化过程,每次*Fill()*后更新。
其次,为了防止关键帧和较大的差分帧立即溢出,进而导致后续较小的帧出现较高丢帧,关键帧和较大的差分帧是不会被立即在桶中累计。相反,这些较大的帧会在漏桶中累计前,会分成若干小块,进而在*Leak()*操作中逐次累计这些小块,来防止较关键帧和较大的差分帧立即溢出。FrameDropper 类增加了额外的几个参数来实现该策略:
large_frame_accumulation_spread_ :大帧最大拆分块数,四舍五入取整;
large_frame_accumulation_count_ :大帧剩余拆分块数,四舍五入取整;
large_frame_accumulation_chunk_size_ :单个块尺寸,其值为framesize/large_frame_accumulation_count_ 。
最后,来看看FrameDropper 类的核心操作:
1、Fill()
当视频帧被编码后,MediaOptimization 类会调用Fill()方法来填充漏桶。调用顺序很简单,主要关注 Fill()方法的实现——将大帧拆分为 large_frame_accumulation_count_个小块,并不累加 accumulator_ ;将小帧直接累计accumulator_ 。Fill()方法同时需要更新 key_frame_ratio_和 delta_frame_size_avg_kbits_ ,用以计算大帧拆分块数和大帧判断。具体实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 void Fill (size_t framesize_bytes, bool delta_frame) { float framesize_kbits = 8.0f * static_cast <float >(framesize_bytes) / 1000.0f ; if (!delta_frame) { if (large_frame_accumulation_count_ == 0 ) { if (key_frame_ratio_.filtered () > 1e-5 && 1 / key_frame_ratio_.filtered () < large_frame_accumulation_spread_) { large_frame_accumulation_count_ = static_cast <int32_t >(1 / key_frame_ratio_.filtered () + 0.5 ); } else { large_frame_accumulation_count_ = static_cast <int32_t >(large_frame_accumulation_spread_ + 0.5 ); } large_frame_accumulation_chunk_size_ = framesize_kbits / large_frame_accumulation_count_; framesize_kbits = 0 ; } } else { if (delta_frame_size_avg_kbits_.filtered () != -1 && (framesize_kbits > kLargeDeltaFactor * delta_frame_size_avg_kbits_.filtered ()) && large_frame_accumulation_count_ == 0 ) { large_frame_accumulation_count_ = static_cast <int32_t >(large_frame_accumulation_spread_ + 0.5 ); large_frame_accumulation_chunk_size_ = framesize_kbits / large_frame_accumulation_count_; framesize_kbits = 0 ; } else { delta_frame_size_avg_kbits_.Apply (1 , framesize_kbits); } key_frame_ratio_.Apply (1.0 , 0.0 ); } accumulator_ += framesize_kbits; CapAccumulator (); }
2、Leak()
Leak()操作按照编码器输入帧率的频率来执行,每次Leak 的大小为 target_bps/input_fps ,每次Leak 时需要判断是否需要累计Fill()方法拆分的块,进而更新 drop_ratio_ 。*drop_ratio_*的更新遵循下列原则:
当accumulator_ > 1.3f * accumulator_max_ ,drop_ratio_基数调整为 0.8f ,提高丢帧率调整加速度;
当accumulator_ < 1.3f * accumulator_max_ ,drop_ratio_基数调整为 0.9f ,降低丢帧率调整加速度。
实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 void Leak (uint32_t input_framerate) { float expected_bits_per_frame = target_bitrate_ / input_framerate; if (large_frame_accumulation_count_ > 0 ) { expected_bits_per_frame -= large_frame_accumulation_chunk_size_; --large_frame_accumulation_count_; } accumulator_ -= expected_bits_per_frame; if (accumulator_ < 0.0f ) { accumulator_ = 0.0f ; } if (accumulator_ > 1.3f * accumulator_max_) { drop_ratio_.UpdateBase (0.8f ); } else { drop_ratio_.UpdateBase (0.9f ); } if (accumulator_ > accumulator_max_) { if (was_below_max_) { drop_next_ = true ; } drop_ratio_.Apply (1.0f , 1.0f ); drop_ratio_.UpdateBase (0.9f ); } else { drop_ratio_.Apply (1.0f , 0.0f ); } was_below_max_ = accumulator_ < accumulator_max_; }
3、DropFrame()
DropFrame()操作用来判断是否需要将输入到编码器的这一帧丢弃,其利用 drop_ratio_来使丢帧率保持一个平滑的变化过程。当 drop_ratio_.filtered() >= 0.5f 时,表明连续丢弃多个帧(至少一个帧);当0.0f < drop_ratio_.filtered() < 0.5f 时,表明多个帧才会丢弃一个帧。具体的丢帧策略见实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 bool FrameDropper::DropFrame () { if (drop_ratio_.filtered () >= 0.5f ) { float denom = 1.0f - drop_ratio_.filtered (); if (denom < 1e-5 ) { denom = 1e-5f ; } int32_t limit = static_cast <int32_t >(1.0f / denom - 1.0f + 0.5f ); int max_limit = static_cast <int >(incoming_frame_rate_ * max_drop_duration_secs_); if (limit > max_limit) { limit = max_limit; } if (drop_count_ < 0 ) { drop_count_ = -drop_count_; } if (drop_count_ < limit) { drop_count_++; return true ; } else { drop_count_ = 0 ; return false ; } } else if (drop_ratio_.filtered () > 0.0f && drop_ratio_.filtered () < 0.5f ) { float denom = drop_ratio_.filtered (); if (denom < 1e-5 ) { denom = 1e-5f ; } int32_t limit = -static_cast <int32_t >(1.0f / denom - 1.0f + 0.5f ); if (drop_count_ > 0 ) { drop_count_ = -drop_count_; } if (drop_count_ > limit) { if (drop_count_ == 0 ) { drop_count_--; return true ; } else { drop_count_--; return false ; } } else { drop_count_ = 0 ; return false ; } } drop_count_ = 0 ; return false ; }
接收端帧率策略
影响接收端帧率的主要因素包含:网络状况、解码器性能、渲染速度。
网络状况导致丢帧
网络因素对实时视频流的影响十分严重,当网络出现拥塞,导致较高的丢包率,明显的现象就是视频接收端帧率降到很低。比较严重时,接收端接收帧率可能只有几帧,导致无法进行正常的视频通话。WebRTC在*“webrtc/modules/video_coding/packet_buffer.cc"的PacketBuffer 中,将接收到的RTP包组合成一个完整的视频帧。之后,该完整的帧会被送到 "webrtc/modules/video_coding/rtp_frame_reference_finder.cc”*的RtpFrameReferenceFinder 中。一个完整的帧可能是关键帧,也可能是参考帧,RtpFrameReferenceFinder 类中关键帧直接送到解码器中处理。而对于参考帧,会判断其是否连续,若不连续会一直暂存在队列中,直到连续——送到解码器,或者下一个关键帧来了——从队列中删除。两个类相应的操作见下面两个函数:
1 2 3 4 5 bool PacketBuffer::InsertPacket (VCMPacket* packet) {} void RtpFrameReferenceFinder::ManageFrameGeneric (std::unique_ptr<RtpFrameObject> frame, int picture_id) {}
视频会议软件通常会采用NACK和FEC等手段来降低丢包对视频通话质量的影响。同时,解码器一定时间内,没有收到可解码数据,会向发送端请求I帧,这也就在一定程度上保证帧率不会过于低。这部分代码实现与*“webrtc/video/video_receive_stream.cc”*的VideoReceiveStream 类中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void VideoReceiveStream::Decode () { static const int kMaxWaitForFrameMs = 3000 ; std::unique_ptr<video_coding::FrameObject> frame; video_coding::FrameBuffer::ReturnReason res = frame_buffer_->NextFrame (kMaxWaitForFrameMs, &frame); if (res == video_coding::FrameBuffer::ReturnReason::kStopped) return ; if (frame) { if (video_receiver_.Decode (frame.get ()) == VCM_OK) rtp_stream_receiver_.FrameDecoded (frame->picture_id); } else { RequestKeyFrame (); } }
解码导致丢帧
看一下WebRTC内调用解码模块的代码,就可以看出WebRTC解码导致失败的可能原因。这部分代码位于*“webrtc/modules/video_coding/video_receiver.cc”*,实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 int32_t Decode (const VCMEncodedFrame& frame) { int32_t ret = _decoder->Decode (frame, clock_->TimeInMilliseconds ()); bool request_key_frame = false ; if (ret < 0 ) { if (ret == VCM_ERROR_REQUEST_SLI) { return RequestSliceLossIndication ( _decodedFrameCallback.LastReceivedPictureID () + 1 ); } else { request_key_frame = true ; } } else if (ret == VCM_REQUEST_SLI) { ret = RequestSliceLossIndication ( _decodedFrameCallback.LastReceivedPictureID () + 1 ); } if (!frame.Complete () || frame.MissingFrame ()) { request_key_frame = true ; ret = VCM_OK; } if (request_key_frame) { rtc::CritScope cs (&process_crit_) ; _scheduleKeyRequest = true ; } return ret; }
通过上面代码可以看出,如果解码器无法将接收到的数据解码,要么发送SLI要么发送PLI,请求重新发送关键帧。从SLI/PLI发出到收到可解码的关键帧这个时间间隔内,接收端的帧率会比正常情况低。
渲染导致丢帧
在实际应用中,经过WebRTC处理后显示的帧率较大,但最终的显示效果却比较差,能够感觉到明显的卡顿。这就和应用软件的渲染有关。研究不深,暂不撰写。