因工作的需要,開發了一款視頻播放程序。期間也經歷許多曲折,查閱了大量資料,經過了反復測試,終於圓滿完成了任務。
我把開發過程中的一些思路、想法寫下來,以期對后來者有所幫助。
視頻播放的本質
就是連續的圖片。當每秒播放的圖片超過一定數量,人眼就很難覺察到每幀圖像播放間隔,看到的就是連續的視頻流。
視頻播放的過程
必須有數據源,數據源一般是攝像頭采集后,再經過壓縮傳送到程序。攝像頭采集的視頻信號一般轉換為YUV格式、這個格式再經過h264壓縮,傳送出去。(視頻信號不經過壓縮,數據量非常大,h264是當今最流行的壓縮格式)
程序處理的過程要經過相反的過程。先對h264解壓縮獲取YUV格式數據,再將YUV格式數據轉換為RGB格式。視頻控件的功能就是高效的把RGB數據顯示出來。后續主要介紹這個處理流程。
h264解壓縮采用的ffmpeg庫,如何處理解壓縮見我另一篇文章:使用ffmpeg實現對h264視頻解碼。YUV格式轉換為RGB格式的原理並不復雜,關鍵是轉換效率,一般的算法占用CPU非常高,我這里也是采用ffmpeg庫提供的轉換算法。
視頻播放代碼解析
1)播放視頻的本質就是rgb數據的高效顯示。播放控件輸入數據如下:
public class BitmapDecodeInfo { public VideoClientTag ClientTag; //視頻源唯一標識 public Size OrginSize; //視頻原始大小 public Size CurSize; //視頻當前大小 public int Framerate; //每秒播放幀數 public byte[] RgbData { get; internal set; } //視頻數據 }
RgbData數據就是ffmpeg解壓縮后的數據,該數據根據播放控件的大小,對視頻做了縮放。
2)RGB數據轉換
視頻播放使用WPF Image控件,對此控件做了進一步封裝。設置Image.Source屬性,就可以顯示圖片。Source屬性的類型是ImageSource。RGB數據必須轉換為ImageSource類型,才能對Image.Source賦值。這里,我是把RGB數據使用WriteableBitmap封裝,能很好的實現這個功能。
WriteableBitmap _drawBitmap; public WriteableBitmap GetWriteableBitmap(BitmapDecodeInfo bitmapInfo, out bool newBitmap) { if (_drawBitmap == null || _drawBitmap.Width != bitmapInfo.CurSize.Width || _drawBitmap.Height != bitmapInfo.CurSize.Height) { newBitmap = true; _drawBitmap = new WriteableBitmap(bitmapInfo.CurSize.Width, bitmapInfo.CurSize.Height, 96, 96, PixelFormats.Bgr24, null); _drawBitmap.WritePixels(new Int32Rect(0, 0, bitmapInfo.CurSize.Width, bitmapInfo.CurSize.Height), bitmapInfo.RgbData, _drawBitmap.BackBufferStride, 0); return _drawBitmap; } else { newBitmap = false; _drawBitmap.WritePixels(new Int32Rect(0, 0, bitmapInfo.CurSize.Width, bitmapInfo.CurSize.Height), bitmapInfo.RgbData, _drawBitmap.BackBufferStride, 0); return _drawBitmap; } }
將WriteableBitmap賦值給Image.Source,就能顯示圖片了。還有幾點需要注意:對界面控件的賦值必須在界面線程處理:
Dispatcher.Invoke(new Action(() => { if (AppValue.WpfShowImage) { BitmapSource source = GetWriteableBitmap(bitmapInfo, out bool newBitmap); if (newBitmap) Source = source; } }));
3)數據緩沖和精確定時
視頻數據的來源不可能是均勻連續的,需要對數據做緩沖,再均勻連續的播放出來。需要將數據放到緩沖類中,每隔固定的時間去取。
現在假定播放幀數為25幀每秒。該緩沖類有自適應功能,就是緩沖數據幀數小於一定值時,播放變慢;否則,播放變快。
//圖像緩沖,播放速度控制 public class ImageVideoPool { public long _spanPerFrame = 40; //時間間隔 毫秒。每秒25幀 public long _spanPerFrameCur = 40; public void SetFramerate(int framerate) { _spanPerFrame = 1000 / framerate; _spanPerFrameCur = _spanPerFrame; } ObjectPool<BitmapDecodeInfo> _listVideoStream = new ObjectPool<BitmapDecodeInfo>(); public void PutBitmap(BitmapDecodeInfo image) { _listVideoStream.PutObj(image); if (_listVideoStream.CurPoolCount > _framePoolCountMax * 2) { _listVideoStream.RemoveFirst(); } SetCutPoolStage(); } public int ImagePoolCount => _listVideoStream.CurPoolCount; long _playImageCount = 0; public long PlayImageCount => _playImageCount; void SetCutPoolStage() { Debug.Assert(_framePoolCount > _framePoolCountMin && _framePoolCount < _framePoolCountMax); //設置當前的狀態 if (_listVideoStream.CurPoolCount < _framePoolCountMin) { SetPoolStage(EN_PoolStage.up_to_normal); } else if (_listVideoStream.CurPoolCount > _framePoolCountMax) { SetPoolStage(EN_PoolStage.down_to_normal); } else if (_listVideoStream.CurPoolCount == _framePoolCount) { SetPoolStage(EN_PoolStage.normal); } } long _lastPlayerTime = 0; long _curPlayerTime = 0; internal void OnTimeout(int spanMs) { _curPlayerTime += spanMs; } int _framePoolCount = 30; int _framePoolCountMax = 50; //緩沖數據大於此值,播放變快 int _framePoolCountMin = 10; //緩沖數據小於此值,播放變慢 EN_PoolStage _poolStage = EN_PoolStage.normal; public BitmapDecodeInfo GetBitmapInfo() { if (_listVideoStream.CurPoolCount == 0) return null; int timeSpan = (int)(_curPlayerTime - _lastPlayerTime); if (timeSpan < _spanPerFrameCur) return null; BitmapDecodeInfo result = _listVideoStream.GetObj(); if (result != null) { SetCutPoolStage(); _lastPlayerTime = _curPlayerTime; _playImageCount++; } return result; } void SetPoolStage(EN_PoolStage stag) { bool change = (_poolStage == stag); _poolStage = stag; if (change) { switch (_poolStage) { case EN_PoolStage.normal: { _spanPerFrameCur = _spanPerFrame;//恢復正常播放頻率 break; } case EN_PoolStage.up_to_normal: { //播放慢一些 _spanPerFrameCur = (int)(_spanPerFrame * 1.2); break; } case EN_PoolStage.down_to_normal: { //播放快一些 _spanPerFrameCur = (int)(_spanPerFrame * 0.8); break; } } } } enum EN_PoolStage { normal, up_to_normal,//從FramePoolCountMin--》FramePoolCount down_to_normal,//FramePoolCountMax--》FramePoolCount } }
需要一個精確時鍾,每隔一段時間從緩沖區取數據,再將數據顯示出來。Windows下多媒體時鍾精度較高,定時器代碼如下:

class MMTimer { //Lib API declarations [DllImport("Winmm.dll", CharSet = CharSet.Auto)] static extern uint timeSetEvent(uint uDelay, uint uResolution, TimerCallback lpTimeProc, UIntPtr dwUser, uint fuEvent); [DllImport("Winmm.dll", CharSet = CharSet.Auto)] static extern uint timeKillEvent(uint uTimerID); [DllImport("Winmm.dll", CharSet = CharSet.Auto)] static extern uint timeGetTime(); [DllImport("Winmm.dll", CharSet = CharSet.Auto)] static extern uint timeBeginPeriod(uint uPeriod); [DllImport("Winmm.dll", CharSet = CharSet.Auto)] static extern uint timeEndPeriod(uint uPeriod); //Timer type definitions [Flags] public enum fuEvent : uint { TIME_ONESHOT = 0, //Event occurs once, after uDelay milliseconds. TIME_PERIODIC = 1, TIME_CALLBACK_FUNCTION = 0x0000, /* callback is function */ //TIME_CALLBACK_EVENT_SET = 0x0010, /* callback is event - use SetEvent */ //TIME_CALLBACK_EVENT_PULSE = 0x0020 /* callback is event - use PulseEvent */ } //Delegate definition for the API callback delegate void TimerCallback(uint uTimerID, uint uMsg, UIntPtr dwUser, UIntPtr dw1, UIntPtr dw2); //IDisposable code private bool disposed = false; public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } private void Dispose(bool disposing) { if (!this.disposed) { Stop(); } disposed = true; } ~MMTimer() { Dispose(false); } /// <summary> /// The current timer instance ID /// </summary> uint id = 0; /// <summary> /// The callback used by the the API /// </summary> TimerCallback thisCB; /// <summary> /// The timer elapsed event /// </summary> public event EventHandler Timer; protected virtual void OnTimer(EventArgs e) { if (Timer != null) { Timer(this, e); } } public MMTimer() { //Initialize the API callback thisCB = CBFunc; } /// <summary> /// Stop the current timer instance (if any) /// </summary> public void Stop() { lock (this) { if (id != 0) { timeKillEvent(id); Debug.WriteLine("MMTimer " + id.ToString() + " stopped"); id = 0; } } } /// <summary> /// Start a timer instance /// </summary> /// <param name="ms">Timer interval in milliseconds</param> /// <param name="repeat">If true sets a repetitive event, otherwise sets a one-shot</param> public void Start(uint ms, bool repeat) { //Kill any existing timer Stop(); //Set the timer type flags fuEvent f = fuEvent.TIME_CALLBACK_FUNCTION | (repeat ? fuEvent.TIME_PERIODIC : fuEvent.TIME_ONESHOT); lock (this) { id = timeSetEvent(ms, 0, thisCB, UIntPtr.Zero, (uint)f); if (id == 0) { throw new Exception("timeSetEvent error"); } Debug.WriteLine("MMTimer " + id.ToString() + " started"); } } void CBFunc(uint uTimerID, uint uMsg, UIntPtr dwUser, UIntPtr dw1, UIntPtr dw2) { //Callback from the MMTimer API that fires the Timer event. Note we are in a different thread here OnTimer(null); } }
總結:
把視頻控件處理流程梳理一下:1視頻數據放入緩沖-->定時器每隔一端時間取出數據-->將數據顯示到image控件上。
后記:
交通部2016年發布一個規范《JT/T1076-2016道路運輸車輛衛星定位系統車載視頻終端技術要求》,規范的目的是一個平台可以接入多個硬件廠家的視頻數據。本人就是依據這個規范開發的系統。視頻解碼采用ffmpeg開源庫。整個系統包括視頻數據采集、流媒體服務器、視頻播放器。所有程序采用c#編寫。視頻數據的數據量一般都很大;所以,在開發過程中,十分注重性能。
有些人對c#的性能有些擔憂的,畢竟市面上的流媒體服務器、播放器大部分都是c語言編寫的。我從事c#開發10多年,認為c#性能上是沒有問題,關鍵還是個人要對算法有所了解,對所處理的邏輯有所了解。一切都是拿來主義,性能肯定不會高。開發本系統中,好多處理算法都是自己從頭編寫。事實證明,c#也可以開發出高效的系統。
我大概用了3個月,把整個系統設計完成。