摘要:本文介紹了一種使用OpenCVSharp對攝像頭中的綠幕影片進行實時“摳人像、替換背景”的方式,對於專案中的演算法進行了分析。本文中給出了簡化OpenCVSharp中Mat、MatExpr等託管資源釋放的方法。本文還介紹了“高效攝像頭播放控制元件”以及和OpenCVSharp的效能最佳化技術,包括高效讀寫Mat資料、如何避免效率低的程式碼等。
一、為什麼自己開發實時摳圖軟體
由於工作的需要,我需要一個能夠對於攝像頭中的人像進行實時地“扣除背景、替換背景,並且把替換背景後的圖片顯示到視窗中”的功能。很多會議直播軟體都有類似的功能,比如Zoom、微軟Teams等都有人像摳圖功能,但是他們的這些功能都只侷限於在它們的軟體內使用。我又試用了幾個軟體,包括XSplit Vcam、抖音直播伴侶、OBS,他們的功能都做的很優秀,包括很多都還有不需要綠幕的智慧摳圖的功能,非常強大,但是他們都無法滿足我的特殊要求。所以我需要自己開發這樣一款軟體。
典型的人像摳圖需要在被摳圖的物體之後放上綠幕,然後再透過程式把綠幕扣除掉,這樣人像就被保留下來了,再把摳出來的人像繪製到新的背景圖上即可。很多影視製作都是用類似這樣的原理製作出來的。如圖 1 所示 [1]。
圖 1
只要環境光線調整好了,透過綠幕進行摳圖是非常準確的,不過這種方式的缺點就是對於場地的佈置要求非常高。所以現在流行“無綠幕摳圖”的功能,也就是用人工智慧的方法智慧識別前景人像和背景,然後智慧的把前景人像識別出來。XSplit Vcam有這個功能,而且可以把摳圖的結果再模擬成一個虛擬攝像頭進行輸出,屬於民用領域中比較強悍的一款軟體,但是如果背景比較複雜的話,XSplit Vcam移除背景的效果仍然不理想。我個人在計算機視覺方面,特別是結合人工智慧進行影象的智慧處理方面,研究很淺,我不認為在時間有限的情況下,能寫出來一個比Vcam還要強大的軟體,因此我決定仍然用傳統的綠幕形式來實現我想要的功能,畢竟只要花幾十塊錢買一塊綠幕即可。
在開始講解實現程式碼之前,先展示一下軟體的執行效果。圖 2 是相機採集的原始影象,可以看到背後是一張綠幕,而 圖 3 則是軟體執行後的效果,而且是實時摳圖的,目前可以做到大約20FPS(一秒鐘約20幀)。
圖 2 沒有摳綠幕
圖 3摳人像、替換背景
二、軟體架構
軟體使用了OpenCV,它是一個非常成熟、功能豐富的計算機視覺庫。OpenCV支援C/C++、Python、.NET、Java等主流的程式語言。在網際網路上,使用Python進行OpenCV開發的資料最多。由於個人不是很喜歡Python的語法,所以這個軟體我使用C#語言在.NET 5平臺上進行開發。由於OpenCV在各個程式語言上用法大同小異,因此這裡用C#實現的程式碼改用其他程式語言也非常容易。
.NET平臺下,有兩個OpenCV的繫結庫:OpenCVSharp和Emgu CV。由於OpenCVSharp沒有商業使用限制,因此我這裡使用OpenCVSharp。不過,即使您使用的是Emgu CV,這篇文章裡的程式碼也是簡單修改後就可以應用到Emgu CV中。
三、如何獲得原始碼
由於摳綠幕替換背景的功能只是我的軟體的一個模組,整個軟體暫時不方便開源,所以我把摳綠幕替換背景這部分核心程式碼功能剝離到一個單獨的開源專案中。
專案開源地址:https://github.com/yangzhongke/Zack.OpenCVSharp.Ext
程式碼中的“GreenScreenRemovalDemo.cs”就是最核心的程式碼,也可以在專案頁面底部的【GreenScreenRemovalDemo】中下載各個作業系統下的可執行檔案,其中的GreenScreenRemovalDemo就是主程式。
以Windows為例,執行GreenScreenRemovalDemo.exe,就會出現如圖 4 所示的控制檯
圖 4 選擇用演示影片還是攝像頭
如果輸入v,就會自動播放一個內建的monster.mp4綠幕影片檔案 [2],供沒有綠幕環境的朋友進行體驗,程式會從影片檔案中將綠幕剔除掉替換為自定義背景檔案bg.png。如果在圖 4這一步輸入數字,則會從指定編號的網路攝像頭中讀取畫面進行摳圖,如果您的計算機中只有一個攝像頭,那麼輸入0即可。體驗完畢,在圖形視窗內按任意鍵就會退出程式。
如下的圖 5、圖 6和圖 7分辨就是綠幕影片、背景圖以及合成圖。
圖 5 綠幕影片monster.mp4
圖 6 背景圖bg.png(紐西蘭的伊甸山)
圖 7 替換背景後的合成圖
四、核心原理
圖 8 原始幀圖片
圖 8 是從攝像頭獲取的一幀原始圖片。首先,呼叫我編寫的RenderGreenScreenMask(src, matMask)方法,把原始幀src轉換為一張黑白圖matMask做為遮罩。matMast中,綠色部分渲染為黑色,其他部分渲染為白色,如圖 9。
RenderGreenScreenMask方法的主要程式碼如下 [3]:
private unsafe void RenderGreenScreenMask(Mat src, Mat matMask)
{
int rows = src.Rows;
int cols = src.Cols;
for (int x = 0; x < rows; x++)
{
Vec3b* srcRow = (Vec3b*)src.Ptr(x);
byte* maskRow = (byte*)matMask.Ptr(x);
for (int y = 0; y < cols; y++)
{
var pData = srcRow + y;
byte blue = pData->Item0;
byte green = pData->Item1;
byte red = pData->Item2;
byte max = Math.Max(red, Math.Max(blue, green));
//if this pixel is some green, render the pixel with the same position on matMask as black
if (green == max && green > 30)
{
*(maskRow + y) = 0;
}
else
{
*(maskRow + y) = 255;//render as white
}
}
}
}
為了加速圖片的畫素點訪問,這裡使用指標來操作。C#中可以使用指標操作記憶體,這樣可以大大加速程式的執行效率。因為環境光照的影響,背景綠幕中的各個點顏色並不完全相同,所以這裡使用畫素點的green == max (blue,green,red)&& green > 30是否為true來判斷一個點是否是綠色,30是一個閾值,可以根據情況來調節識別效果,這個閾值選的越大,被認為是綠色的範圍越窄。
圖 9 去掉綠色
var contoursExternalForeground = Cv2.FindContoursAsArray(matMask, RetrievalModes.External, ContourApproximationModes.ApproxNone)
.Select(c => new { contour = c, Area = (int)Cv2.ContourArea(c) })
.Where(c => c.Area >= minBlockArea)
.OrderByDescending(c => c.Area).Take(5).Select(c => c.contour);
這裡的minBlockArea代表設定的一個“最小允許輪廓區域的面積”。
接下來新建一個空的黑色Mat,名字為matMaskForeground,然後把上面得到的大輪廓區域繪製到這個matMaskForeground中,並且內部填充為白色,程式碼如下:
matMaskForeground.DrawContours(contoursExternalForeground, -1, new Scalar(255),
thickness: -1);
matMaskForeground對應的圖片內容如圖 10。這樣matMaskForeground中就只包含若干大面積輪廓了,其他小面積的干擾都被排除了。
圖 10 找到最大幾個閉合區域,然後填充為白色
接下來,要把圖 9 中的手臂、手、肩膀和脖子形成的那些大的鏤空區域摳出來。因此把圖 9 和 圖 10 做“異或”操作,得到 圖 11 這樣的鏤空區域。
圖 11 前兩張圖片做異或操作,得到身體內部的鏤空區域
因為眼鏡中反射的螢幕中的綠光、或者衣服上的小的綠色可能會被識別為小的鏤空區域,,可以看到 圖 11 的右下角就有一些小白色區域,因此再次使用FindContoursAsArray、DrawContours把 圖 11 中的小面積的區域排除掉。然後再把排除掉小面積輪廓的圖 11 和圖 10 做合併操作,就得到圖 12,就是一個白色部分為身體區域,而黑色部分為綠幕背景的的圖片。
圖 12 把小鏤空區域去掉,並和身體遮罩做合併
接下來使用圖 12 做為遮罩對原始幀影象 圖 8 進行背景透明處理,得到圖 13, 這樣的圖片就是背景透明的圖片了。主要程式碼如下:
public static void AddAlphaChannel(Mat src, Mat dst, Mat alpha)
{
using (ResourceTracker t = new ResourceTracker())
{
//split is used for splitting the channels separately
var bgr = t.T(Cv2.Split(src));
var bgra = new[] { bgr[0], bgr[1], bgr[2], alpha };
Cv2.Merge(bgra, dst);
}
}
其中src是原始幀影象,dst是合併結果,而alpha則是圖 12 這個透明遮罩。
最後把背景透明的圖 13 繪製到我們自定義的背景圖上,就得到替換為背景圖的圖 14了。核心程式碼如下:
public unsafe static void DrawOverlay(Mat bg, Mat overlay)
{
int colsOverlay = overlay.Cols;
int rowsOverlay = overlay.Rows;
for (int i = 0; i < rowsOverlay; i++)
{
Vec3b* pBg = (Vec3b*)bg.Ptr(i);
Vec4b* pOverlay = (Vec4b*)overlay.Ptr(i);
for (int j = 0; j < colsOverlay; j++)
{
Vec3b* pointBg = pBg + j;
Vec4b* pointOverlay = pOverlay + j;
if (pointOverlay->Item3 != 0)
{
pointBg->Item0 = pointOverlay->Item0;
pointBg->Item1 = pointOverlay->Item1;
pointBg->Item2 = pointOverlay->Item2;
}
}
}
}
其中引數bg就是原始幀影象圖 8,而overlay則是背景透明的圖 13,經過DrawOverlay方法繪製後,bg的內容就變成了圖 14,然後就可以輸出到介面上了。
圖 13 背景透明圖
圖 14 最終結果
上面講述的核心程式碼就位於GreenScreenRemovalDemo專案的ReplaceGreenScreenFilter類中。下面列出ReplaceGreenScreenFilter最主幹的程式碼:
class ReplaceGreenScreenFilter
{
private byte _greenScale = 30;
private double _minBlockPercent = 0.01;
private Mat _backgroundImage;
public void SetBackgroundImage(Mat backgroundImage)
{
this._backgroundImage = backgroundImage;
}
private unsafe void RenderGreenScreenMask(Mat src, Mat matMask)
{
int rows = src.Rows;
int cols = src.Cols;
for (int x = 0; x < rows; x++)
{
Vec3b* srcRow = (Vec3b*)src.Ptr(x);
byte* maskRow = (byte*)matMask.Ptr(x);
for (int y = 0; y < cols; y++)
{
var pData = srcRow + y;
byte blue = pData->Item0;
byte green = pData->Item1;
byte red = pData->Item2;
byte max = Math.Max(red, Math.Max(blue, green));
if (green == max && green > this._greenScale)
{
*(maskRow + y) = 0;
}
else
{
*(maskRow + y) = 255;//render as white
}
}
}
}
public void Apply(Mat src)
{
using (ResourceTracker t = new ResourceTracker())
{
Size srcSize = src.Size();
Mat matMask = t.NewMat(srcSize, MatType.CV_8UC1, new Scalar(0));
RenderGreenScreenMask(src, matMask);
//the area is by integer instead of double, so that it can improve the performance of comparision of areas
int minBlockArea = (int)(srcSize.Width * srcSize.Height * this.MinBlockPercent);
var contoursExternalForeground = Cv2.FindContoursAsArray(matMask, RetrievalModes.External, ContourApproximationModes.ApproxNone)
.Select(c => new { contour = c, Area = (int)Cv2.ContourArea(c) })
.Where(c => c.Area >= minBlockArea)
.OrderByDescending(c => c.Area).Take(5).Select(c => c.contour);
//a new Mat used for rendering the selected Contours
var matMaskForeground = t.NewMat(srcSize, MatType.CV_8UC1, new Scalar(0));
//thickness: -1 means filling the inner space
matMaskForeground.DrawContours(contoursExternalForeground, -1, new Scalar(255),
thickness: -1);
//matInternalHollow is the inner Hollow parts of body part.
var matInternalHollow = t.NewMat(srcSize, MatType.CV_8UC1, new Scalar(0));
Cv2.BitwiseXor(matMaskForeground, matMask, matInternalHollow);
int minHollowArea = (int)(minBlockArea * 0.01);//the lower size limitation of InternalHollow is less than minBlockArea, because InternalHollows are smaller
//find the Contours of Internal Hollow
var contoursInternalHollow = Cv2.FindContoursAsArray(matInternalHollow, RetrievalModes.External, ContourApproximationModes.ApproxNone)
.Select(c => new { contour = c, Area = Cv2.ContourArea(c) })
.Where(c => c.Area >= minHollowArea)
.OrderByDescending(c => c.Area).Take(10).Select(c => c.contour);
//draw hollows
foreach (var c in contoursInternalHollow)
{
matMaskForeground.FillConvexPoly(c, new Scalar(0));
}
var element = t.T(Cv2.GetStructuringElement(MorphShapes.Cross, new Size(3, 3)));
//smooth the edge of matMaskForeground
Cv2.MorphologyEx(matMaskForeground, matMaskForeground, MorphTypes.Close,
element, iterations: 6);
var foreground = t.NewMat(src.Size(), MatType.CV_8UC4, new Scalar(0));
ZackCVHelper.AddAlphaChannel(src, foreground, matMaskForeground);
//resize the _backgroundImage to the same size of src
Cv2.Resize(_backgroundImage, src, src.Size());
//draw foreground(people) on the backgroundimage
ZackCVHelper.DrawOverlay(src, foreground);
}
}
}
五、重要技術
受限於篇幅,這裡不講解OpenCV的基礎知識,這裡只講解專案中的一些重點技術以及OpenCVSharp使用過程中的一些需要注意的事項。由於我也是剛接觸OpenCVSharp幾天時間,所以如果存在有問題的地方,請各位指正。
1) 簡化OpenCVSharp物件的釋放
在OpenCVSharp中,Mat 和 MatExpr等類的物件擁有非託管資源,因此需要呼叫Dispose()方法手動釋放。更糟糕的是,+、-、*等運算子每次都會建立一個新的物件,這些物件都需要釋放,否則就會有記憶體洩露。但是這些物件釋放的程式碼看起來非常囉嗦。
假設有如下Python中訪問opencv的程式碼:
mat1 = np.empty([100,100])
mat3 = 255-mat1*0.8
mats1 = cv2.split(mat3)
mat4=cv2.merge(mats1[0],mats1[2],mats1[2])
而在C#中同樣的程式碼則像下面這樣囉嗦:
using (Mat mat1 = new Mat(new Size(100, 100), MatType.CV_8UC3))
using (Mat mat2 = mat1 * 0.8)
using (Mat mat3 = 255-mat2)
{
Mat[] mats1 = mat3.Split();
using (Mat mat4 = new Mat())
{
Cv2.Merge(new Mat[] { mats1[0], mats1[1], mats1[2] }, mat4);
}
foreach(var m in mats1)
{
m.Dispose();
}
}
因此我建立了一個ResourceTracker類用來管理OpenCV的資源。ResourceTracker類的T()方法用於把OpenCV物件加入跟蹤記錄。T()方法的實現很簡單,就是把被包裹的物件加入跟蹤記錄,然後再把物件返回。T()方法的核心程式碼如下:
public Mat T(Mat obj)
{
if (obj == null)
{
return obj;
}
trackedObjects.Add(obj);
return obj;
}
public Mat[] T(Mat[] objs)
{
foreach (var obj in objs)
{
T(obj);
}
return objs;
}
ResourceTracker實現了IDisposable介面,當ResourceTracker類的 Dispose()方法被呼叫後,ResourceTracker跟蹤的所有資源都會被釋放。T()方法可以跟蹤一個物件或者一個物件陣列。而NewMat() 這個方法是T(new Mat(...)) 的一個簡化。因為+、-、*等運算子每次都會建立一個新的物件,所以每步運算得到的物件都需要釋放,他們可以使用T()進行包裹。例如:t.T(255 - t.T(picMat * 0.8))
因此,上面的囉嗦的C#程式碼可以簡化成如下的樣子:
using (ResourceTracker t = new ResourceTracker())
{
Mat mat1 = t.NewMat(new Size(100, 100), MatType.CV_8UC3,new Scalar(0));
Mat mat3 = t.T(255-t.T(mat1*0.8));
Mat[] mats1 = t.T(mat3.Split());
Mat mat4 = t.NewMat();
Cv2.Merge(new Mat[] { mats1[0], mats1[1], mats1[2] }, mat4);
}
在離開ResourceTracker的using程式碼塊之後,所有ResourceTracker物件管理的Mat、MatExpr等物件的資源都會被釋放。
這個ResourceTracker類我放到了Zack.OpenCVSharp.Ext這個NuGet包中,可以透過如下NuGet命令安裝:
Install-Package Zack.OpenCVSharp.Ext
專案的原始碼地址:https://github.com/yangzhongke/Zack.OpenCVSharp.Ext
2) 訪問Mat中資料的高效方式
這種指標方式的參考程式碼請參考上面的RenderGreenScreenMask()、DrawOverlay()兩個方法,Zack.OpenCVSharp.Ext這個開源專案中np類的where方法還演示了C#泛型、指標操作以及lambda的結合使用。
OpenCVSharp中,Vec4b、Vec3b、byte等代表不同位元組長度的記憶體單元,一定要根據使用的Mat物件的通道數等來選擇使用Vec4b、Vec3b、byte等,使用不當不僅會影響效能,而且還可能會造成資料混亂,資料混亂的最直接的表現就是圖片顯示錯亂、破圖。
3) CameraPlayer
我的軟體需要從攝像頭採集影象,並且顯示到介面上,而且在顯示到介面上之前,還要對影象進行“摳人像、替換背景”的操作。在最開始的時候,我使用AForge.NET完成攝像頭的影象採集和顯示,不過效能非常低。因為需要先把AForge.NET採集到的Bitmap轉換為OpenCVSharp的Mat,摳圖處理完成後再把Mat轉換回Bitmap,顯示到介面上。所以我就直接使用OpenCVSharp的VideoCapture類來完成攝像頭影象的採集,由於它採集到的幀影象直接用Mat表示,省去了轉換環節,速度得到了很大的提升。
我把從攝像頭取資料以及顯示到介面上的操作封裝了一個CameraPlayer控制元件中,同時提供了.NET Core和.NET Framework版的WinForm控制元件,可以直接拿來用,而且提供了SetFrameFilter(Action<Mat> frameFilterFunc)方法來允許設定一個委託,從而在把幀影象的Mat繪製到介面前使用OpenCVSharp進行處理。
CameraPlayer控制元件中影象採集、影象的處理和影象的顯示是由不同執行緒負責,各自並行處理,所以效能非常高。
我把這個CameraPlayer控制元件開源了,具體用法請參考專案的文件。
專案地址:https://github.com/yangzhongke/Zack.CameraLib
在開發CameraPlayer的時候,我發現如果不設定VideoCapture的FourCC屬性(也就是影片的編碼),取一幀需要100ms,而把FourCC屬性設定為"MJPG"之後,取一幀只要50ms。我不知道這是否和攝像頭相關。因此,如果你因為FourCC屬性設定為"MJPG"之後,讀取影象的速度反而變慢了,可以嘗試修改一個不同的FourCC值。
4) 謹慎使用可能造成效能問題的玩意兒
在實現RenderGreenScreenMask()這個方法的時候,其中有一步是用來“取blue、green、red三個值中的最大值”,最開始的時候,我使用.NET中的LINQ擴充套件方法實現new byte[]{blue,green,red}.Max(); 但是發現改成byte max1 = blue > green ? blue : green; byte max = max1>red?max1:red;這種簡單的方法計算之後,每一幀的處理時間減少了50%。
由於LINQ操作涉及到“建立集合物件、把資料放入集合物件、獲取資料”這樣的過程,速度會比常規演算法慢一些,在普通的資料處理中這點效能差距可以忽略不計,特別是在使用LINQ對資料庫等進行操作的時候,相對於耗時的IO操作來講,這點效能差別更是可以忽略不計。但是由於這裡是在雙層迴圈中使用,而且執行的操作的速度非常快的記憶體讀寫,所以就把效能差距放大了。
因此,在使用OpenCVSharp對影象進行處理的時候,要謹慎使用這些可能會造成效能問題的高階玩意兒。
5) Mat記憶體的初始化
在建立空的Mat物件的時候,最好初始化Mat物件的記憶體資料,就像在C語言中對於malloc拿到的記憶體空間最好用memset重置一樣,以免造成記憶體中舊的殘留資料干擾我們的操作。比如new Mat(srcSize,MatType.CV_8UC1)這樣建立的空白Mat中的記憶體可能是複用之前被釋放的其他物件的記憶體,資料是髒的,除非你的下一步操作是把Mat的每一位都重新填充,否則請使用Mat 建構函式的Scalar型別的引數來初始化記憶體,參考程式碼如下:new Mat(srcSize,MatType.CV_8UC1,new Scalar(0))
六、未來工作
在以後有時間的時候,我可能會做如下這些工作。
1) 提升從攝像頭取一幀的速度。因為我目前用的攝像頭“羅技C920”標稱的是FPS=30,所以理論上來講,取一幀的速度是33ms,而目前我取一幀的速度是50ms,我要研究一下是否能進一步提升取一幀影象的速度。
2) 考慮增加美顏、瘦臉、亮膚等功能,目前的人像摳圖演算法處理一幀需要大約20ms,而從攝像頭取一幀的速度是50ms,因此還有30ms的額外時間可以用來做這些美化工作。
3) 用人工智慧演算法實現“無綠幕摳人像、去除背景”。完全自己實現這個無疑是比較難的。我發現一個很強大的開源專案MODNet,它是一個python+torch實現的使用神經網路做智慧人像識別的庫,包含已經訓練完成模型。而torch也有對應的.NET移植版,所以理論上這是可以做到的。
七、結論
使用OpenCVSharp的時候,只要注意使用本文中介紹的高效訪問記憶體的方式,並且合理呼叫相關的函式,可以非常高效能的進行影象的處理,因此我開發的軟體可以做到每一幀影象處理僅需大約20ms。藉助於我開發的Zack.OpenCVSharp.Ext這個包中的ResourceTracker類,可以讓OpenCVSharp中的資源釋放變得非常簡單,在幾乎不用修改表示式、程式碼的基礎上,讓資源能夠及時得到釋放,避免記憶體洩漏。