這是網際網路時代用數學解決的另一個大問題。
迴圈播放式的 GIF 動圖是網路上一種流行的藝術形式,在Reddit有兩個專屬的論壇(r/perfectloops 和r/cinemagraphs),還有數不盡的Tumblr 頁面。
找到並提取電影中的迴圈片段,需要極大的注意力和耐心,電腦前的你,很可能就像下面這樣:
為了使事情變得簡單點,我寫了個Python指令碼來自動完成這個任務。這篇博文解釋了演算法背後的數學並提供了一些使用範例。
何時視訊片段迴圈是無縫銜接?
如果一段視訊的第一幀和最後一幀很相似,我們就說它迴圈起來是無縫銜接的。視訊的一幀\(F\)可以表示為一個整數序列,數值表明了圖片畫素的顏色。例如,\(F[1]\), \(F[2]\)和\(F[3]\)給出了第一個畫素的紅、綠、藍的數值。\(F[4]\), \(F[5]\), \(F[6]\)給出了第二個畫素的值,等等。
給定同一視訊的兩幀\(F_1\), \(F_2\), 我們定義它們的差別為顏色差別的和:
如果\(d(F_1,F_2)\)小於某個任意閾值\(T\),我們就認為這兩幀相似。
要理解接下來的內容,很重要的一點是注意到\(d(F_1, F_2)\)定義了兩幀的距離,可以看作是平面上兩點幾何距離的一般化。
因此\(d(F_1, F_2)\)有很好的數學性質,在下一節我們利用它來加快計算。
找到無縫銜接迴圈的片段
在這一節,我們想要找到給定視訊中所有無縫銜接迴圈、3秒鐘內的片段的時間(起始時間和終止時間)。有一種簡單的做法,比較每一幀和之前3秒的所有幀。當我們發現相似的兩幀時(即距離小於某個預設的閾值\(T\)),就把對應的時間加到列表中。
問題是這種方法需要大量的比較(對於一部標準視訊大約一千萬),耗時以小時計。因此我們來看看使計算更快的一些技巧。
技巧一:使用縮減版的幀。HD高清視訊幀有數百萬的畫素,因此計算它們之間的距離要數百萬次操作。當把幀縮小成縮圖(150畫素寬)時,對於我們的目的它們仍然足夠精細,距離計算卻可以快得多(記憶體佔用也更少)。
技巧二:利用三角不等式。有這個非常高效的技巧,在不計算兩幀距離的情況下,我們就能推斷出它們是否匹配。因為\(d(F_1, F_2)\)定義了兩幀之間的數學距離,很多經典幾何的結論都能用得上,特別是下面的關於三角形邊的長度的不等式:
第一個不等式告訴我們如果A接近於B,且B接近於C,那麼A接近於C。對於視訊幀來說就是:
在實際中我們這麼來利用這個不等式:如果我們已知幀\(F1\)和幀\(F2\)很相似,\(F2\)和另一幀\(F3\)很相似,那麼我們不用計算\(d(F_1, F_3)\)就知道\(F1\)和\(F3\)很相似。
第二個不等式告訴我們如果點A接近於B,而B遠離C,那麼A也遠離C。對於幀來說就是:
如果\(F1\)和\(F2\)很相似,而\(F2\)和\(F3\)大不相同,那麼我們無需計算\(d(F_1, F_3)\)就知道\(F1\)和\(F3\)也有很大差別。
下面就開始有點複雜了:我們應用這些三角不等式來獲得幀距離上界和下界的資訊, 在每次計算兩幀距離時這些都會得到更新。比如,計算\(d(F_1, F_2)\)後,\(d(F_1, F_3)\)的上下界(分別用和表示)可以如下更新:
如果更新後,,我們可以下結論:\(F_1\)和\(F_3\)匹配得很好。如果在某個時刻,,我們就知道\(F_1\)和\(F_3\)不匹配。如果用這個技術不能決定\(F_1\)和\(F_3\)是否匹配,那我們最終需要計算\(d(F_1, F_3)\),但知道\(d(F_1, F_3)\)後反過來可以讓我們更新另一個距離(\(d(F_1, F_4)\))的界,以此類推。
作為說明,假設一部視訊依次有如下幀:
當演算法計算到\(F_4\)時,它首先計算這幀和\(F_3\)的距離,發現它們不匹配。在這個時候,演算法已經發現\(F_3\)和\(F_2\)及\(F_1\)相似,因此推斷出\(F_1\)和\(F_2\)都不和\(F_4\)匹配(當然,之前的十幾幀也是)。在實際中,這種方法能避免80%到90%的幀距離計算。
技巧三:用高效的公式計算距離。當我們用上一節的公式來計算兩幀之間的距離時,需要大約3N次操作:N次減法,N次乘法,(N-1)次加法來得到最終的和。但是\(d(F_1,F_2)\)的公式也可以重寫為如下形式,也就是餘弦定理:
其中我們用瞭如下記號:
\(d(F_1,F_2)\)的這個表示式的有趣之處在於,我們先對每幀計算一次範數\(||F||\),那麼對於每對\(F_1\), \(F_2\)只需計算就可以得到它們之間的距離。而這隻需2N次操作,比之前快了50%。
對每幀計算\(|F|\)的另一個好處是,對於兩幀\(F_1\)和\(F_2\),我們有:
這提供了技巧二里兩幀之間距離上下界的初始值。
用虛擬碼表示的最終演算法。總結起來,我們得到了如下演算法:
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 |
for each frame F1 in the movie: F1 <- downsized( F1 ) previous_frames <- list of frames in the 3 seconds before F1 list_of_matching_couples = [] compute and store |F1| for each frame F2 in previous_frames: compute upper_F1_F2 and lower_F1_F2 using Eq.2 if upper_F1_F2 < T: mark (F1, F2) as matching add (F1, F2) to the list_of_matching_couples if lower_F1_F2 > T: mark (F1, F2) as non-matching for each frame F2 in previous_frames: if couple (F1,F2) isn't already marked matching or non-matching: compute d(F1, F2) for each frame F3 after F2 in previous_frames: update upper_F1_F3 and lower_F1_F3 using Eq.1 if upper_F1_F3 < T: mark (F1, F3) as matching add (F1, F3) to the list_of_matching_couples if lower_F1_F3 > T: mark (F1, F3) as non-matching |
這裡是Python的實現。計算時間可能依賴於視訊檔案的質量,不過我嘗試的大多數電影能在大約20分鐘處理完。令人印象深刻,是吧?Eugene。
挑選有趣的片段
前一節描述的演算法找到所有的幀對,包括連續的幀(通常看起來非常像)和來自靜止片段的幀(典型的是黑屏)。因此我們會得到幾十萬個視訊片段,而只有一些是真正有趣的。在提取GIF之前我們必須找到一種方式來過濾掉不想要的片段。過濾操作只需幾秒鐘,但它的成功極大地依賴於你用的過濾標準。這裡是一些有效的例子:
- 第一幀和最後一幀必須至少相隔0.5秒。
- 序列中必須至少有一幀和第一幀不匹配。這個標準能夠排除靜止片段。
- 第一幀的起始時間必須在上一個提取的片段的起始時間的0.5秒之後。這是為了避免重複的片段(即那些起始和終止時間幾乎一樣的片段)。
我儘量不做太多限制(避免偶然過濾掉好的片段),因此通常得到大約200個GIF,其中很多隻是中等有趣(眨眼之類)。最後一步是人工過濾,就像下面這樣:
使用範例
I我實現了這個演算法,作為我的Python視訊庫MoviePy的外掛。這裡是一個包含很多細節的例子指令碼:
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 |
from moviepy.editor import VideoFileClip from moviepy.video.tools.cuts import FramesMatches # Open a video file (any format should work) clip = VideoFileClip("myvideo.avi") # Downsize the clip to a width of 150px to speed up things clip_small = clip.resize(width=150) # Find all the pairs of matching frames an return their # corresponding start and end times. Takes 15-60 minutes. matches = FramesMatches.from_clip(clip_small, 5, 3) # (Optional) Save the matches for later use. # matches.save("myvideo_matches.txt") # matches = FramesMatches.load("myvideo_matches.txt") # Filter the scenes: keep only segments with duration >1.5 seconds, # where the first and last frame have a per-pixel distance < 1, # with at least one frame at a distance 2 of the first frame, # and with >0.5 seconds between the starts of the selected segments. selected_scenes = matches.select_scenes(match_thr=1, min_time_span=1.5, nomatch_thr=2, time_distance=0.5) # The final GIFs will be 450 pixels wide clip_medium = clip.resize(width=450) # Extract all the selected scenes as GIFs in folder "myfolder" selected_scenes.write_gifs(clip_medium, "myfolder") |
這裡是我用迪士尼的《白雪公主》來試驗時得到的:
1 2 3 4 5 6 |
import moviepy.editor as mp from moviepy.video.tools.cuts import FramesMatches clip = mp.VideoFileClip("snowwhite.mp4") scenes = FramesMatches.from_clip(clip.resize(width=120), 5, 2) selected_scenes = scenes.select_scenes(2, 0.5, 4, 0.5) selected_scenes.write_gifs(clip.resize(width=270), "snow_white") |
(更多圖片,請檢視http://imgur.com/a/nVcqQ)
這裡有些GIF可以切割得更好,有些也不太有趣(太短),還有些迴圈片段被錯過了。我認為罪魁禍首是最後一步過濾的引數,這些本可以被調整得更好。
另一個例子:最近有人在r/perfectloops發了個Youtube視訊,要求把它轉成迴圈播放式的 GIF。下面的指令碼就做了這件事:從Youtube下載視訊,找到切割成迴圈播放序列的最好的時間(t1, t2),然後生成GIF:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import moviepy.editor as mpy from moviepy.video.tools.cuts import FramesMatches # Get the video from youtube, save it as "hamac.mp4" mpy.download_webfile("NpxD9TZIlv8", "hamac.mp4") clip = mpy.VideoFileClip("hamac.mp4").resize(width=200) matches = FramesMatches.from_clip(clip, 40, 3) # loose matching # find the best matching pair of frames > 1.5s away best = matches.filter(lambda x: x.time_span >1.5).best() # Write the sequence to a GIF (with speed=30% of the original) final = clip.subclip(best.t1, best.t2).speedx(0.3) final.write_gif("hamac.gif", fps=10) |
利用MoviePy,你也可以對GIF做後期處理,加上文字:
既然你讀到了這,這有個為你準備的更高階的技巧:
輪到你了!
我在這裡呈現的演算法並不完美。對於低亮度的影片片段,它表現得很糟,有時輕微的攝像機移動或者背景中的運動物體都能阻止片段迴圈播放。雖然這些片段能被人類輕易地修正,卻很難被演算法認出、處理。
因此我的指令碼並未完全解決問題,製作迴圈播放式的 GIF 仍然是門藝術。如果你對這個演算法有任何點子或評論,或者你嘗試後發現了電影中的有趣的迴圈播放,我很樂意聽到!在那之前,快樂地製作GIF吧!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式