要把一個人從影片裡摳出來換到別的背景,五年前的標準流程是租一間有綠幕的棚,把光打勻,然後在後製軟體裡下色鍵。

綠幕之後出現的第一代替代方案叫 Background Matting,到 BGMv2 已經相當堪用。它不要棚了,但要你先拍一張「沒有人的純背景照」當參考。這一步聽起來很小,實際上它把用途鎖死了:攝影機不能動,光線不能變,你也不能在人已經站在畫面裡的時候才想到要去背。

同一時期的另一條路是 trimap。你逐幀標出「這裡一定是人、這裡一定是背景、這一圈我不確定」,模型負責把不確定的那一圈算清楚。品質很好,代價是有人要坐在那裡標。

再往後,一般的 neural matting 終於不需要任何額外輸入了。丟一張圖進去就吐一張 alpha。問題換了個地方冒出來:它把影片當成一疊照片,每一幀各算各的。人一動,邊緣就逐幀跳動,看起來在閃;被東西擋住再出現,前後也接不起來。

RobustVideoMatting(下稱 RVM)是 ByteDance 在 WACV 2022 發表的模型,論文題目直接把答案寫在裡面:Robust High-Resolution Video Matting with Temporal Guidance。它只吃影片本身,不要棚、不要背景照、不要 trimap,而且處理第 n+1 幀的時候,手上還有第 n 幀留下來的東西。

底下這幾段就是同一件事的「以前怎麼做」跟「現在怎麼做」對照,一項一項來。

模型怎麼看影片:從重新認識,到一路盯著

以前每幀重新算一次,現在把上一幀的狀態接回來。

RVM 的 decoder 裡放了 4 層 ConvGRU,也就是把 RNN 那套 hidden state 換成卷積版。第 n 幀算完會吐出 4 個 recurrent state,第 n+1 幀把它們接回去當輸入,模型因此記得上一幀的人在哪、邊緣長什麼樣子。

用個比方:一般模型每一幀都在重新認識這個人,RVM 是一路盯著這個人在動。

這句不只是說法。論文用 dtSSD 衡量時間連貫性,RVM 是 1.01,MODNet 是 1.57,數字越低越穩。這一項差距最直接對應到肉眼看到的「不閃」。

效能上還有個小聰明,我第一次讀到的時候愣了一下:ConvGRU 只吃一半的 channel。

1
2
3
4
5
6
7
self.gru = ConvGRU(channels // 2)

def forward(self, x, r):
a, b = x.split(self.channels // 2, dim=-3) # 特徵沿 channel 切一半
b, r = self.gru(b, r) # 只有 b 進 GRU
x = torch.cat([a, b], dim=-3) # 算完再接回去
return x, r

一半的特徵直接穿過去,另一半才付 recurrent 的計算成本。四層 GRU 分別掛在 1/16、1/8、1/4、1/2 四個尺度上,這就是 recurrent state 剛好是 4 個的原因。

解析度怎麼扛:從按解析度付錢,到 4K 跟 HD 同價

以前想跑 4K 就得付 4K 的算力。現在不用。

論文 Table 4 那組數字是整個設計最漂亮的地方:

解析度 downsample_ratio FPS(GTX 1080 Ti) GMACs
512×288 1 131.9 4.57
1920×1080 0.25 104.2 4.15
3840×2160 0.125 76.5 4.15

HD 跟 4K 的計算量一模一樣。原因是 downsample_ratio 把送進主網路的畫面壓到差不多大:HD 乘 0.25 是 480×270,4K 乘 0.125 也是 480×270。主網路根本不知道原圖多大。

那細節怎麼補回來?靠 Deep Guided Filter。整條資料流長這樣:

1
2
3
4
5
6
7
8
src(原始高解析度影格)
├─ 縮小成 src_sm(480×270 左右)
├─ backbone(src_sm) → 四個尺度的特徵 f1~f4
├─ LR-ASPP 壓縮最深層特徵
├─ decoder(4 層 ConvGRU) → hid + 新的 r1~r4
├─ 投影成 4 channel:fgr_residual(3) + pha(1) ← 此時仍是低解析度
├─ DGF refiner(src, src_sm, fgr_residual, pha, hid) ← 只有這步在原解析度跑
└─ fgr = fgr_residual + src,回傳 [fgr, pha, r1, r2, r3, r4]

DGF 學的是「怎麼把低解析度的粗略答案,照著高解析度原圖的紋理線索重新插值回去」。所以髮絲邊緣會沿著原圖的真實邊界走,而不是把糊掉的 alpha 硬放大。整個管線裡只有這一步在原解析度上跑。

前景怎麼算:從畫出來,到只說差多少

一般做法是讓網路直接預測前景的 RGB。RVM 預測的是殘差:

1
fgr = fgr_residual + src

網路只要學「這一格的前景跟原圖差多少」。絕大多數像素上前景就等於原圖,輸出接近 0 就好,學習負擔小很多,也比較不會把人臉顏色畫歪。

這種「只學差值」的招式在 ML 裡到處都是,但放在 matting 上特別合理:畫面裡真正需要動的像素只有人的邊緣那一圈。

模型該多大:從越大越好,到官方叫你用小的

RVM 只有兩個版本,model.py 裡就一行 assert,合法值只有這兩個:

variant Backbone 參數量 權重大小 HD FPS
mobilenetv3 MobileNetV3-Large 3.749 M 14.5 MB 104.2
resnet50 ResNet50 26.890 M 102.9 MB 71.1

README 對 ResNet50 的原話是只有「small performance improvements」。大了 7 倍,品質只小贏,所以官方自己叫你用 MobileNetV3,除非你在做離線的高品質輸出。實際下載量也是這樣:MobileNetV3 權重 14 萬次,ResNet50 只有 3.6 萬次。

一個 14.5 MB、375 萬參數的模型,塞得進消費級筆電的 GPU,這才是它到現在還被一堆下游專案當模組用的理由。

有幾件事,五年來一點都沒變

前面講的都是進步。接下來這幾件是這個 repo 到今天還在絆人的地方,而且大多不寫在 README 開頭。

先講最容易誤判的:README 那張測速表不是你跑 inference.py 會看到的速度。 README 自己的 Note 3 就寫了,那些數字量的是 tensor throughput,附的轉檔腳本「預期會慢很多」,因為沒用硬體影片編解碼,也沒把 tensor 傳輸放到平行執行緒。

issue #206 是活生生的案例:使用者跑 inference.py 只有 4.2 it/s、RTX 2060 使用率在 0 到 13% 之間跳;同一台機器跑 inference_speed_test.py 有 33.2 it/s。差了約 8 倍,瓶頸在編解碼和資料搬運,不在模型。要做真正的即時管線,官方建議自己接 PyNvCodec。

環境相依也是個坑。requirements_inference.txt 全文五行,版本全部釘死在 2021 年:

1
2
3
4
5
av==8.0.3
torch==1.9.0
torchvision==0.10.0
tqdm==4.61.1
pims==0.5

torch 1.9.0 的預編譯版不含 40 系列顯卡需要的 compute capability,所以 issue #221 和 #232 都是 RTX 4090 報錯。實務上多數人直接忽略釘死的版本裝新版 torch。

授權那條更麻煩。程式碼是 GPL-3.0,強 copyleft,包進閉源商業 App 是明確衝突。而權重的授權呢?issue #191 在 2022 年 8 月開出來直接問這件事,多位開發者附議並指出這對商用和上架 App Store 影響重大,作者一次都沒回覆,issue 至今仍是 open。保守做法是假設權重也受 GPL-3.0 約束,要商用請找法務評估。

還有一件該知道的事:這個 repo 的核心模型自 2021 年 9 月釋出 v1.0.0 之後就沒再動過,master 最後一筆 commit 停在 2023 年 3 月 13 日。9,463 顆星,但它現在的角色是一個穩定堪用的現成元件,不是還在演進的專案。

最容易寫錯的那三行

如果你只從這篇帶走一段程式碼,帶這段。RVM 的精髓在 recurrent state 怎麼傳,而它也是最容易寫錯的地方:

1
2
3
4
5
6
7
8
9
10
11
12
model = MattingNetwork('mobilenetv3').eval().cuda()
model.load_state_dict(torch.load('rvm_mobilenetv3.pth'))

bgr = torch.tensor([.47, 1, .6]).view(3, 1, 1).cuda() # 綠色背景
rec = [None] * 4 # 4 個 recurrent state 的初始值
downsample_ratio = 0.25

with torch.no_grad():
for src in DataLoader(reader): # RGB tensor,已正規化到 0~1
fgr, pha, *rec = model(src.cuda(), *rec, downsample_ratio)
com = fgr * pha + bgr * (1 - pha) # 標準 matting 合成公式
writer.write(com)

關鍵在那個星號:

1
2
3
fgr, pha, *rec = model(src, *rec, downsample_ratio)
# ^^^^^ 傳入時把 list 攤成 r1, r2, r3, r4 四個參數
# ^^^^^^^^^^^^^^ 回傳時把後四個值重新收集成 list,覆寫回 rec

下一圈迴圈就帶著上一幀的記憶。官方文件特地用 Correct / Wrong Way 對照,因為漏掉這件事的後果很陰險:

1
2
3
# 錯誤:沒有循環傳遞,等於把影片當一疊獨立照片處理
for frame in YOUR_VIDEO:
fgr, pha = model(frame, downsample_ratio)[:2]

這樣寫不會噴錯,它會跑,只是整個模型退化成逐幀影像去背,這篇前面講的所有好處全部消失。你要靠肉眼看邊緣有沒有在閃才會發現。

順便講幾個同一類的地雷。downsample_ratio 的判準只有一句:讓縮小後的解析度落在 256 到 512 之間,而且官方明寫 higher is not always better。全身入鏡的影片要用比特寫更高的值(1080p 特寫 0.25,全身 0.4),傳 None 走自動模式算出來是 0.267,偏向特寫那一欄,所以拍全身不要只靠自動值。另外 output_type='video' 給你的不是透明背景,是綠幕合成的 mp4,因為一般影片格式沒有 alpha 通道;要真透明得用 png_sequence。轉場的時候記得把 rec 重設回 [None] * 4,不然 GRU 會帶著轉場前的記憶進到完全不同的畫面。

這五年真正改變的東西

回頭看綠幕、背景照、trimap 這三種舊做法,它們有個共同點:都在想辦法從外部補一個資訊給模型。棚補的是「背景是純色的」,背景照補的是「背景長這樣」,trimap 補的是「這一圈才要算」。

RVM 沒有補任何外部資訊。它做的是把一個一直都在、卻被丟掉的東西撿回來:影片的相鄰兩幀之間本來就有大量重複,上一幀算出來的東西,這一幀有九成還能用。舊做法的模型每一幀都把它扔了,然後回頭要你用綠幕來補。

認對這件事之後,剩下的設計決策幾乎都是同一句話的變形:能不算的就不要算,能沿用的就沿用。ConvGRU 只吃一半 channel、只有 DGF 在原解析度跑、只預測前景殘差不預測整張前景,三個決策一個形狀。

它也還有沒解掉的部分,而且是設計上的邊界不是 bug:pha 只有 1 個 channel,畫面有多人時是「所有人 vs 背景」,沒辦法分出第一個人和第二個人;影子去不掉,issue #22 有人問,作者沒給解法;髮絲級的細節後來被 SGHM、SAM2Matting 這類方法拿來當對照組並宣稱勝出。iPhone 12 跑 1080p 每幀要 180 到 200 ms,換算約 5 FPS,離手機上即時還很遠。

補一句誠實的:這篇是照論文、README、官方推論文件和一輪 issue 讀出來的,我沒有在自己的機器上跑過完整的轉檔管線,上面所有速度數字都來自官方或 issue 回報者。如果你要拿它上線,inference_speed_test.pyinference.py 兩邊都跑一次,差距會告訴你瓶頸在哪。


來源