- 原文地址:Real-world dynamic programming: seam carving
- 原文做者:Avik Das
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:nettee
- 校對者:JalanJiang,TokenJan
咱們一直認爲動態規劃(dynamic programming)是一個在學校裏學習的技術,而且只是用來經過軟件公司的面試。實際上,這是由於大多數的開發者不會常常處理須要用到動態規劃的問題。本質上,動態規劃能夠高效求解那些能夠分解爲高度重複子問題的問題,所以在不少場景下是頗有用的。html
在這篇文章中,我將會仔細分析動態規劃的一個有趣的實際應用:接縫裁剪(seam carving)。Avidan 和 Shamir 的這篇文章 Seam Carving for Content-Aware Image Resizing 中詳細討論了這個問題以及提出的技術(搜索文章的標題能夠免費獲取)。前端
這篇文章是動態規劃的系列文章中的一篇。若是你還不瞭解動態規劃技術,請參閱我寫的動態規劃的圖形化介紹。android
(因爲 Medium 不支持數學公式渲染,我是用圖片來顯示覆雜的公式的。若是訪問圖片有困難,能夠看我我的網站上的文章。)ios
爲了用動態規劃解決實際問題,咱們須要將問題建模爲能夠應用動態規劃的形式。本節介紹了這個問題的必要的準備工做。git
論文的原做者介紹了一種在智能考慮圖片內容的狀況下改變圖片的寬度或高度的方法,叫作環境敏感的圖片大小調整(content-aware image resizing)。後面會介紹論文的細節,但這裏先作一個概述。假設你想調整下面這個衝浪者圖片的大小。github
論文中詳細討論了,有多種方法能夠減小圖片的寬度。咱們最早想到的是裁剪和縮放,以及它們相關的缺點。刪除圖片中部的幾列像素也是一種方法,但你也能夠想象獲得,這樣會在圖片中留下一條可見的分割線,左右的內容沒法對齊。並且即便是這些方法全用上了,也只能刪掉這麼點圖片:面試
Avidan 和 Shamir 在他們的論文中展現的是一個叫作接縫裁剪的技術。它首先會識別出圖片中不太有意義的「低能量」區域,而後找到穿過圖片的能量最低的「接縫」。對於減小圖片寬度的狀況,接縫裁剪會找到一個豎向的、從圖片頂部延伸到底部、下一行最多向左或向右移動一個像素的接縫。算法
在衝浪者的圖片中,能量最低的接縫穿過圖片中部水面最平靜的位置。這和咱們的直覺相符。後端
經過識別出能量最低的接縫並刪除它,咱們能夠把圖片的寬度減小一個像素。不斷重複這個過程能夠充分減小圖片的寬度。數組
這個算法刪除了圖片中間的靜止水面,以及圖片左側的水面,這仍然符合咱們的直覺。和直接剪裁圖片不一樣的是,左側水面的質地得以保留,也沒有突兀的過渡。圖片的中間確實有一些不是很完美的過渡,但大部分的結果看起來很天然。
這個算法的關鍵在於找到能量最低的接縫。要作到這一點,咱們首先定義圖片中每一個像素的能量,而後應用動態規劃算法來尋找穿過圖片的能量最低的路徑。下一節中會詳細討論這個算法。讓咱們先看看如何爲圖片中的像素定義能量。
論文中討論了一些不一樣的能量函數,以及它們在調整圖片大小時的效果。簡單起見,咱們使用一個簡單的能量函數,表達圖片中的顏色在每一個像素周圍的變化強烈程度。爲了完整起見,我會將能量函數介紹得詳細一點,以備你想本身實現它,但這部分的計算僅僅是爲後續動態規劃做準備。
爲了計算單個像素的能量,咱們檢查這個像素左右的像素。咱們計算逐個份量之間的平方距離,也就是分別計算紅色、綠色、藍色份量之間的平方距離,而後相加。咱們對中心像素上下的像素進行一樣的計算。最終,咱們將水平和垂直距離相加。
惟一的特殊狀況是當像素位於邊緣,例如左側邊緣時,它的左邊沒有像素。對於這種狀況,咱們只需比較將其和右邊的像素比較。對於上邊緣、右邊緣、下邊緣的像素,會進行相似的調整。
當週圍像素的顏色很是不一樣時,能量函數較大;而當顏色類似時,能量函數較小。
這個能量函數在衝浪者圖片上效果很好。然而,能量函數的值域很廣,當對能量進行可視化時,圖片中的大部分像素看起來能量爲零。實際上,這些區域的能量只是相對於能量最高的區域比較低,但並非零。爲了讓能量函數更容易可視化,我放大了衝浪者,並調亮了該區域。
爲每一個像素計算出了能量以後,咱們如今能夠搜索從圖片頂部延伸到底部的低能量接縫了。一樣的分析方法也適用於從左側延伸至右側的水平接縫,可讓咱們減小原始圖片的高度。不過,咱們如今只關注垂直的接縫。
咱們先定義最低能量接縫的概念:
注意,最低能量接縫不必定會通過圖片中的最低能量像素。是讓接縫的能量總和最小,而不是讓單個像素的能量最小。
從上圖中能夠看到,「從最頂行開始,依次選擇下一行中的最低能量像素」的貪心方法是行不通的。在選擇了能量爲 2 的像素以後,咱們被迫走入了圖片中的一個高能量區域。而若是咱們在中間一行選擇一個能量相對高一點的像素,咱們還有可能進入左下的低能量區域。
上述的貪心方法的問題在於,當決定如何延伸接縫時,咱們沒有考慮到將來的接縫剩餘部分。咱們沒法預知將來,但咱們能夠記錄下目前全部已知的信息,從而能夠觀察過去。
讓咱們反過來進行選擇。咱們再也不從多個像素中選擇一個來延伸單個接縫,而是從多個接縫中選擇一個來鏈接單個像素。 咱們要作的是,對於每一個像素,在上一行能夠鏈接的像素中進行選擇。若是上一行中的每一個像素都編碼了到那個像素爲止的路徑,咱們本質上就觀察了那個像素以前的全部歷史。
這代表了能夠對圖片中的每一個像素劃分子問題。由於子問題須要記錄到那個像素的最優路徑,比較好的方法是將每一個像素的子問題定義爲以那個像素結尾的最低能量接縫的能量。
和貪心的方法不一樣,上述方法本質上嘗試了圖片中的全部路徑。只不過,當嘗試全部可能的路徑時,在一遍又一遍地解決相同的子問題,讓動態規劃成爲這個方法的一個完美的選擇。
與往常同樣,咱們如今須要將上述的思路形式化爲一個遞歸關係。子問題是關於原圖片中的每個像素的,所以遞歸關係的輸入能夠簡單的是那個像素的 x 和 y 座標。這可使輸入是簡單的整數、使子問題的排序變得容易,也使咱們能夠用一個二維數組存儲計算過的值。
咱們定義函數 M(x,y) 表示從圖片頂部開始、到像素 (x,y) 結束的最低能量的垂直接縫。使用字母 M 是由於論文裏就是這麼定義的。
首先,咱們定義基本狀況(base case)。在圖片的最頂行,全部以這些像素結尾的接縫都只有一個像素長,由於再往上沒有其餘像素了。所以,以這些像素結尾的最低能量接縫就是這些像素的能量:
對於其餘的全部像素,咱們須要查看上一行的像素。因爲接縫須要是相連的,咱們的候選只有左上方、上方、右上方三個最近的像素。咱們要選取以這些像素結尾的接縫中能量最低的那個,而後加上當前像素的能量:
咱們須要考慮所查看的像素位於圖片的左邊緣或右邊緣時的邊界狀況。對於左、右邊緣處的像素,咱們分別忽略 M(x−1,y−1) 或者 M(x+1,y−1)。
最終,咱們須要取得豎向延伸了整個圖片的最低能量接縫的能量。這意味着查看圖片的最底行,選擇以這些像素中的一個結尾的最低能量接縫。設圖片寬 W 個像素,高 H 個像素,咱們要的是:
有了這個定義,咱們就獲得了一個遞歸關係,包括咱們所需的全部性質:
因爲每一個子問題 M(x,y) 對應於原圖片中的單個像素,子問題的依賴圖很是容易可視化,只需將子問題放在二維網格中,就像在原圖片中的排列同樣!
如遞歸關係的基本狀況(base case)所示,最頂行的子問題對應於圖片的最頂行,能夠簡單地用單個像素的能量值初始化。
從第二行開始,依賴關係開始出現。首先,在第二行的最左單元,咱們遇到了一個邊界狀況。因爲左側沒有其餘單元,標記爲 (0,1) 的單元只依賴於上方和右上方最近的單元。對於第三行最左側的單元來講也是一樣的狀況。
再看第二行的第二個單元,標記爲 (1,1) 的單元。這是遞歸關係的一個最典型的展現。這個單元依賴於左上、上方、右上最近的三個單元。這種依賴結構適用於第二行及之後的全部「中間」的單元。
第二行的最後,右邊緣處表示了第二個邊界狀況。由於右側沒有其餘單元,這個單元只依賴於上方和左上最近的單元。
最後,對全部後續行重複這個過程。
因爲完整的依賴圖箭頭數量極多,使人生畏,逐個地觀察每一個子問題能讓咱們創建直觀的依賴模式。
從上述分析中,咱們能夠獲得子問題的順序:
由於每一行只依賴於前一行,因此咱們只須要維護兩行的數據:前一行和當前行。實際上,若是從左至右計算,咱們實際上能夠丟棄前一行使用過的一些元素。不過,這會讓算法更復雜,由於咱們須要弄清楚前一行的哪部分能夠丟棄,以及如何丟棄。
在下面的 Python 代碼中,輸入是行的列表,其中每行是數字的列表,表示這一行中每一個像素的能量。輸入命名爲 pixel_energies
,而 pixel_energies[y][x]
表示位於座標 (x,y) 處像素的能量。
首先計算最頂行的接縫的能量,只需拷貝最頂行的單個像素的能量:
previous_seam_energies_row = list(pixel_energies[0])
複製代碼
接着,循環遍歷輸入的其他行,計算每行的接縫能量。最棘手的部分是肯定引用前一行中的哪些元素,由於左邊緣像素的左側和右邊緣像素的右側是沒有像素的。
在每次循環中,會爲當前行建立一個新的接縫能量的列表。每次循環結束時,將前一行的數據替換爲當前行的數據,供下一輪循環使用。這樣咱們就丟棄了前一行。
# 在循環中跳過第一行
for y in range(1, len(pixel_energies)):
pixel_energies_row = pixel_energies[y]
seam_energies_row = []
for x, pixel_energy in enumerate(pixel_energies_row):
# 判斷要在前一行中遍歷的 x 值的範圍。這個範圍取決於當前像素是在圖片
# 的中間仍是邊緣。
x_left = max(x - 1, 0)
x_right = min(x + 1, len(pixel_energies_row) - 1)
x_range = range(x_left, x_right + 1)
min_seam_energy = pixel_energy + \
min(previous_seam_energies_row[x_i] for x_i in x_range)
seam_energies_row.append(min_seam_energy)
previous_seam_energies_row = seam_energies_row
複製代碼
最終, previous_seam_energies_row
包含了最底行的接縫能量。取出這個列表中的最小值,這就是答案!
min(seam_energy for seam_energy in previous_seam_energies_row)
複製代碼
你能夠測試這個實現:把它包裝在一個函數中,而後建立一個二維數組做爲輸入調用這個函數。下面的輸入數據會讓貪心算法失敗,但同時也有明顯可見的最低能量接縫:
ENERGIES = [
[9, 9, 0, 9, 9],
[9, 1, 9, 8, 9],
[9, 9, 9, 9, 0],
[9, 9, 9, 0, 9],
]
print(min_seam_energy(ENERGIES))
複製代碼
對於原圖片中的每個像素,都有一個對應的子問題。每一個子問題最多有 3 個依賴,因此解決每一個子問題的工做量是常數。最後,咱們須要再遍歷最後一行一遍。那麼,若是圖片寬 W 像素,高 H 像素,時間複雜度是 O(W×H+W)。
在任意時刻,咱們持有兩個列表,分別存儲前一行和當前行。前一行的列表共有 W 個元素,而當前行的列表不斷增加,最多有 W 個元素。那麼,空間複雜度是 O(2W),也就是 O(W)。
注意到,若是咱們真的從前一行的數據中丟棄一部分元素,咱們能夠在當前行的列表增加的同時縮減前一行的列表。不過,空間複雜度仍舊是 O(W)。取決於圖片的寬度,常量係數可能會有一點影響,但一般不會有什麼大的影響。
如今咱們找到了最低能量垂直接縫的能量,那麼如何利用這個信息呢?事實上咱們並不關心接縫的能量,而是接縫自己!問題是,從接縫的最後一個像素,咱們沒法回溯到接縫的其他部分。
這是我在文章前面的內容中跳過的部分,但不少動態規劃的問題也有類似的考慮。例如,若是你還記得盜賊問題,咱們能夠知道盜竊的數值並提取出最大值,但咱們不知道哪些房子產出了那個總和的值。
解決方法是通用的:存儲後向指針。在接縫裁剪的問題中,咱們不只須要每一個像素處的接縫能量值,還想要知道前一行的哪一個像素獲得了這個能量。經過存儲這個信息,咱們能夠沿着這些指針一路到達圖片的頂部,獲得組成了最低能量接縫的像素。
首先,咱們建立一個類來存儲一個像素的能量和後向指針。能量值會用來計算子問題。由於後向指針只是記錄了前一行的哪一個像素產生了當前的能量,咱們能夠只用 x 座標來表示這個指針。
class SeamEnergyWithBackPointer():
def __init__(self, energy, x_coordinate_in_previous_row=None):
self.energy = energy
self.x_coordinate_in_previous_row = \
x_coordinate_in_previous_row
複製代碼
每一個子問題將會是這個類的一個實例,而再也不只是一個數字。
在最後,咱們須要回溯整個圖片的高度,沿着後向指針重建最低能量的接縫。不幸的是,這意味着咱們須要存儲圖片中全部的像素,而不只是前一行。
爲了實現這一點,咱們將保留全部子問題的所有結果,即便能夠丟棄前面行的接縫能量數值。咱們能夠用像輸入的數組同樣的二維數組來存儲這些結果。
讓咱們從第一行開始,這一行只包含單個像素的能量。因爲沒有前一行,全部的後向指針都是 None
。可是爲了一致性,咱們仍是會存儲 SeamEnergyWithBackPointer
的實例:
seam_energies = []
# 拷貝最頂行的像素能量來初始化最頂行的接縫能量。最頂行沒有後向指針。
seam_energies.append([
SeamEnergyWithBackPointer(pixel_energy)
for pixel_energy in pixel_energies[0]
])
複製代碼
主循環的工做方式幾乎和先前的實現相同,除了如下幾點區別:
SeamEnergyWithBackPointer
的實例,因此當計算遞歸關係的值時,咱們須要在這些對象內部查找接縫能量。SeamEnergyWithBackPointer
實例。在這個實例中咱們既存儲當前像素的接縫能量,又存儲用於計算當前接縫能量的前一行的 x 座標。seam_energies
中。# 在循環中跳過第一行
for y in range(1, len(pixel_energies)):
pixel_energies_row = pixel_energies[y]
seam_energies_row = []
for x, pixel_energy in enumerate(pixel_energies_row):
# 判斷要在前一行中遍歷的 x 值的範圍。這個範圍取決於當前像素是在圖片
# 的中間仍是邊緣。
x_left = max(x - 1, 0)
x_right = min(x + 1, len(pixel_energies_row) - 1)
x_range = range(x_left, x_right + 1)
min_parent_x = min(
x_range,
key=lambda x_i: seam_energies[y - 1][x_i].energy
)
min_seam_energy = SeamEnergyWithBackPointer(
pixel_energy + seam_energies[y - 1][min_parent_x].energy,
min_parent_x
)
seam_energies_row.append(min_seam_energy)
seam_energies.append(seam_energies_row)
複製代碼
當所有的子問題表格都填滿後,咱們就能夠重建最低能量的接縫。首先找到最底行對應於最低能量接縫的 x 座標:
# 找到最底行接縫能量最低的 x 座標
min_seam_end_x = min(
range(len(seam_energies[-1])),
key=lambda x: seam_energies[-1][x].energy
)
複製代碼
而後,從圖片的底部走向頂部,y 座標從 len(seam_energies) - 1
降到 0
。 在每輪循環中,將當前的 (x,y) 座標對添加到表示接縫的列表中,而後將 x 的值設爲當前行的 SeamEnergyWithBackPointer
對象所指向的位置。
# 沿着後向指針前進,獲得一個構成最低能量接縫的座標列表
seam = []
seam_point_x = min_seam_end_x
for y in range(len(seam_energies) - 1, -1, -1):
seam.append((seam_point_x, y))
seam_point_x = \
seam_energies[y][seam_point_x].x_coordinate_in_previous_row
seam.reverse()
複製代碼
這樣就自底向上地構建出了接縫,將列表反轉就獲得了自頂向下的接縫座標。
時間複雜度和以前類似,由於咱們仍然須要將每一個像素處理一次。在最後還須要從最後一行中找出最低的接縫能量,而後向上走一個圖片的高度來重建接縫。那麼,對於 W×H 的圖片,時間複雜度是 O(W×H+W+H)。
至於空間複雜度,咱們仍然爲每一個子問題存儲常量級的數據,可是如今咱們再也不丟棄任何數據。那麼,咱們使用了 O(W×H) 的空間。
找到了最低能量的垂直接縫後,咱們能夠簡單地將原圖片中的像素複製到新圖片中。新圖片中的每一行都是原圖片中對應行除去最低能量接縫的像素後的剩餘像素。由於咱們在每一行都刪去了一個像素,那麼咱們能夠從一個 W×H 的圖片獲得 (W−1)×H 的圖片。
咱們能夠重複這個過程,在新圖片上從新計算能量函數,而後找到新圖片上的最低能量接縫。你可能很想在原圖片上找到不止一個低能量的接縫,而後一次性把它們都刪除。但問題是兩個接縫可能相關交叉,在中間共享同一個像素。在第一個接縫刪掉以後,第二個接縫就會因爲缺乏了一個像素而再也不有效。
上述視頻展現了應用於衝浪者圖片上的接縫刪除過程(視頻連接在此——譯者注)。我是經過獲取每次迭代的圖片,而後在上面添加最低能量接縫的可視化線條來製做的這個視頻。
已經有不少深刻的講解了,那讓咱們以一些漂亮的照片結束吧!請看下面的在拱門國家公園的岩層的照片:
這個圖片的能量函數:
這產生了下面的最低能量接縫。注意到這個接縫穿過了右側的岩石,正好從岩石頂部被照亮與天空顏色一致的部分進入。或許咱們須要選擇一個更好的能量函數!
最終,調整拱門圖片的大小以後:
這個結果確定不太完美,原圖片中的不少邊緣在調整大小後的圖片中都有些變形。一種可能的改進是實現另外一個論文中討論的能量函數。
動態規劃雖然經常只在教學中遇到,但它仍是解決實際的複雜問題的有用技術。在本文中,咱們討論了動態規劃的一個應用:使用接縫裁剪實現環境敏感的圖片大小調整。
咱們應用了相同的原理,將問題分解爲子問題,分析子問題之間的依賴關係,而後以時間、空間複雜度最小的順序求解。另外,咱們還探索了經過後向指針,除了計算最小的數值,還能找到產生這個數值的特定選擇。而後將這部份內容應用到實際的問題上,對問題進行預處理和後處理,讓動態規劃算法真正有用。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。