編程去除背景綠幕摳圖,基於.NET+OpenCVSharp

摘要:本文介紹了一種使用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 (intx = 0; x < rows; x++)

       {

              Vec3b*srcRow = (Vec3b*)src.Ptr(x);

              byte*maskRow = (byte*)matMask.Ptr(x);

              for(int y = 0; y < cols; y++)

              {

                     varpData = srcRow + y;

                     byteblue = pData->Item0;

                     bytegreen = pData->Item1;

                     bytered = pData->Item2;

                     bytemax = Math.Max(red, Math.Max(blue, green));

                     //ifthis pixel is some green, render the pixel with the same position on matMask asblack

                     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去掉綠色

 

接下來,調用OpenCV的FindContoursAsArray()方法找到 圖 9中的若干個輪廓信息。爲了去掉一些綠幕中的褶皺或者光線問題造成的小面積干擾,對於找到的輪廓信息,需要刪除掉面積較小的輪廓,只保留面積較大的輪廓。使用C#中的LINQ操作可以輕鬆的完成這個篩選,代碼如下:

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())

       {

              //splitis used for splitting the channels separately

              varbgr = t.T(Cv2.Split(src));

              varbgra = new[] { bgr[0], bgr[1], bgr[2], alpha };

              Cv2.Merge(bgra,dst);

       }

}

 

其中src是原始幀圖像,dst是合併結果,而alpha則是圖 12這個透明遮罩。

最後把背景透明的圖 13繪製到我們自定義的背景圖上,就得到替換爲背景圖的圖 14了。核心代碼如下:

publicunsafe 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, MatmatMask)

       {

              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 aswhite

                            }

                     }

              }

       }

 

       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, sothat 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 selectedContours

                     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 ofbody 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, newScalar(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 sizeof src

                     Cv2.Resize(_backgroundImage, src, src.Size());

                     //draw foreground(people) on the backgroundimage

                     ZackCVHelper.DrawOverlay(src, foreground);

              }

       }

}

 

五、重要技術

受限於篇幅,這裏不講解OpenCV的基礎知識,這裏只講解項目中的一些重點技術以及OpenCVSharp使用過程中的一些需要注意的事項。由於我也是剛接觸OpenCVSharp幾天時間,所以如果存在有問題的地方,請各位指正。

  • 簡化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 = newMat(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 (ResourceTrackert = new ResourceTracker())

{

       Mat mat1 = t.NewMat(new Size(100, 100), MatType.CV_8UC3,newScalar(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-PackageZack.OpenCVSharp.Ext

項目的源代碼地址:https://github.com/yangzhongke/Zack.OpenCVSharp.Ext

 

  • 訪問Mat中數據的高效方式

OpenCVSharp中提供了很多訪問Mat中數據的方法,經過測試,我發現,At()方式最慢,GetGenericIndexer也很慢,因爲他們都是完全通過託管代碼的方式進行的,性能必然打折扣。而直接訪問內存的GetUnsafeGenericIndexer方式快了很多,但是最快的方式還是使用mat.Ptr(x)並使用指針這種方式速度最快,因爲這種方式直接通過指針讀寫Mat的內存。使用這種方式的方法需要標記爲unsafe,並且項目要啓用“允許不安全代碼”。由於這種方式是直接讀寫內存,所以一定要注意你的代碼,以免造成不正確的內存訪問或者AccessViolation,對指針操作不熟悉的讀者,可以閱讀我出版的圖書《零基礎趣學C語言》(作者:楊中科,人民郵電出版社),因爲C#中指針操作和C語言幾乎一模一樣。

這種指針方式的參考代碼請參考上面的RenderGreenScreenMask()、DrawOverlay()兩個方法,Zack.OpenCVSharp.Ext這個開源項目中np類的where方法還演示了C#泛型、指針操作以及lambda的結合使用。

OpenCVSharp中,Vec4b、Vec3b、byte等代表不同字節長度的內存單元,一定要根據使用的Mat對象的通道數等來選擇使用Vec4b、Vec3b、byte等,使用不當不僅會影響性能,而且還可能會造成數據混亂,數據混亂的最直接的表現就是圖片顯示錯亂、花屏。

 

  • 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值。

 

  • 謹慎使用可能造成性能問題的玩意兒

在實現RenderGreenScreenMask()這個方法的時候,其中有一步是用來“取blue、green、red三個值中的最大值”,最開始的時候,我使用.NET中的LINQ擴展方法實現newbyte[]{blue,green,red}.Max();  但是發現改成byte max1 = blue > green ? blue : green; byte max =max1>red?max1:red;這種簡單的方法計算之後,每一幀的處理時間減少了50%。

由於LINQ操作涉及到“創建集合對象、把數據放入集合對象、獲取數據”這樣的過程,速度會比常規算法慢一些,在普通的數據處理中這點性能差距可以忽略不計,特別是在使用LINQ對數據庫等進行操作的時候,相對於耗時的IO操作來講,這點性能差別更是可以忽略不計。但是由於這裏是在雙層循環中使用,而且執行的操作的速度非常快的內存讀寫,所以就把性能差距放大了。

因此,在使用OpenCVSharp對圖像進行處理的時候,要謹慎使用這些可能會造成性能問題的高級玩意兒。

 

  • 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))

 

六、未來工作

在以後有時間的時候,我可能會做如下這些工作。

  • 提升從攝像頭取一幀的速度。因爲我目前用的攝像頭“羅技C920”標稱的是FPS=30,所以理論上來講,取一幀的速度是33ms,而目前我取一幀的速度是50ms,我要研究一下是否能進一步提升取一幀圖像的速度。

  • 除了我長得不好看這個不可控因素之外,摳出來的圖也是原圖,亮度以及邊緣都還有待優化,所以考慮增加美顏、瘦臉、亮膚、邊緣優化等功能,目前的人像摳圖算法處理一幀需要大約20ms,而從攝像頭取一幀的速度是50ms,因此還有30ms的額外時間可以用來做這些美化工作。

  • 用人工智能算法實現“無綠幕摳人像、去除背景”。完全自己實現這個無疑是比較難的。我發現一個很強大的開源項目MODNet,它是一個python+torch實現的使用神經網絡做智能人像識別的庫,包含已經訓練完成模型。而torch也有對應的.NET移植版,所以理論上這是可以做到的。

 

七、結論

使用OpenCVSharp的時候,只要注意使用本文中介紹的高效訪問內存的方式,並且合理調用相關的函數,可以非常高性能的進行圖像的處理,因此我開發的軟件可以做到每一幀圖像處理僅需大約20ms。藉助於我開發的Zack.OpenCVSharp.Ext這個包中的ResourceTracker類,可以讓OpenCVSharp中的資源釋放變得非常簡單,在幾乎不用修改表達式、代碼的基礎上,讓資源能夠及時得到釋放,避免內存泄漏。

點擊【閱讀原文】查看項目的Github頁面。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章