常常用Photoshop的人應該熟悉磁力套索(Magnetic Lasso)這個功能,就是人爲引導下的摳圖輔助工具。在研發領域通常不這麼叫,一般管這種邊緣提取的辦法叫Intelligent Scissors或者Livewire。git
原本是給一個圖像分割項目算法評估時的Python框架,以爲有點意思,就稍稍拓展了一下,用PyQt加了個殼,在很是簡陋的程度上模擬了一下的磁力套索功能。爲何簡陋:1) 只實現了最基本的邊緣查找。路徑冷卻,動態訓練,鼠標位置修正都沒有,更別提曲線閉合,摳圖,Alpha Matting等等;2) 沒考慮性能規範,只爲了書寫方便;3) 我對Qt瞭解很淺,至今不會寫Signal-Slot,不知道GUI寫得是否合理;4) 沒調試。github
基本算法算法
相關算法我並無作很深刻的調研,不過相信這類應用中影響力最大的算法是來源於[1],也是本文的主要參考,基本思想是把圖片當作是一個無向圖,相鄰像素之間就能夠計算出一個局部cost,因而就轉化成了最短路徑問題了,接下來就是基於Dijkstra算法產生路徑,就是須要提取的邊緣。主要涉及的算法有兩部分:1) 相鄰像素的cost計算;2) 最短路徑算法。canvas
邊緣檢測數據結構
計算相鄰像素cost的最終目的仍是爲了尋找邊緣,因此本質仍是邊緣檢測。基本思想是,經過各類不一樣手段檢測邊緣,而且根據檢測到的強度來求加權值,做爲cost。從最短路徑的角度來講,就是邊緣越明顯的地方,cost的值越小。[1]中的建議是用三種指標求加權:1) 邊緣檢測算子;2) 梯度強度(Gradient Magnitude);3) 梯度方向(Gradient Direction)。本文的方法和[1]有那麼一些不同,由於懶,用了OpenCV中的Canny算子檢測邊緣而不是Laplacian Zero-Crossing Operator。表達式以下:app
\[l\left( p,q \right)={{w}_{E}}{{f}_{E}}\left( q \right)+{{w}_{G}}{{f}_{G}}\left( q \right)+{{w}_{D}}{{f}_{D}}\left( p,q \right)\]框架
Canny算子ide
基本思想是根據梯度信息,先檢測出許多連通的像素,而後對於每一坨連通的像素只取其中最大值且連通的部分,將周圍置零,獲得初始的邊緣(Edges),這個過程叫作Non-Maximum Suppression。而後用二閾值的辦法將這些檢測到的初始邊緣分爲Strong, Weak, and None三個等級,顧名思義,Strong就是很肯定必定是邊緣了,None就被捨棄,而後從Weak中挑選和Strong連通的做爲保留的邊緣,獲得最後的結果,這個過程叫作Hysteresis Thresholding。這個算法太經典,更多細節一Google出來一大堆,我就不贅述了。公式以下:模塊化
\[{{f}_{E}}\left( q \right)=\left\{ \begin{matrix}
0;\text{ if }q\text{ is on a edge} \\
1;\text{ if }q\text{ is not on a edge} \\
\end{matrix} \right.\]函數
其實從權值的計算上和最大梯度有些重複,由於若是沿着最大梯度方向找出來的路徑基本上就是邊緣,這一項的做用個人理解主要應該是1) 避免梯度都很大的區域出現離明顯邊緣的偏離;2) 保證提取邊緣的連續性,必定程度上來說也是保證平滑。
梯度強度
就是梯度求模而已,x和y兩個方向的梯度值平方相加在開方,公式以下:
\[{{I}_{G}}\left( q \right)=\sqrt{{{I}_{x}}\left( q \right)+{{I}_{y}}\left( q \right)}\]
由於要求cost,因此反向並歸一化:
\[{{f}_{G}}\left( q \right)=1-\frac{{{I}_{G}}\left( q \right)}{\max \left( {{I}_{G}} \right)}\]
梯度方向
這一項實際上是個平滑項,會給變化劇烈的邊緣賦一個比較高的cost,讓提取的邊緣避免噪聲的影響。具體公式以下:
\[{{f}_{D}}\left( p,q \right)=\frac{2}{3\pi }\left( \arccos \left( {{d}_{p}}\left( p,q \right) \right)+\arccos \left( {{d}_{q}}\left( p,q \right) \right) \right)\]
其中,
\[{{d}_{p}}\left( p,q \right)=\left\langle {{d}_{\bot }}\left( p \right),{{l}_{D}}\left( p,q \right) \right\rangle \]
\[{{d}_{q}}\left( p,q \right)=\left\langle {{l}_{D}}\left( p,q \right),{{d}_{\bot }}\left( q \right) \right\rangle \]
\[{{l}_{D}}\left( p,q \right)=\left\{ \begin{matrix}
q-p;\text{ if }\left\langle {{d}_{\bot }}\left( p \right),q-p \right\rangle \ge 0 \\
p-q;\text{ if }\left\langle {{d}_{\bot }}\left( p \right),q-p \right\rangle <0 \\
\end{matrix} \right.\]
\({{d}_{\bot }}\left( p \right)\)是取p的垂直方向,另外注意上式中符號的判斷會將\({{d}_{\bot }}\left( p \right)\)和\({{l}_{D}}\left( p,q \right)\)的取值限制在π/2之內。
\[{{d}_{\bot }}\left( p \right)=\left( {{p}_{y}},-{{p}_{x}} \right)\]
斜對角方向的cost修正
在二維圖像中,相鄰的像素一般按照間隔歐式距離分爲兩種:1) 上下左右相鄰,間隔爲像素邊長;2) 斜對角相鄰,間隔爲像素邊長的\(\sqrt{2}\)倍。在計算局部cost的時候一般要把這種距離差別的影響考慮進去,好比下面這幅圖:
2 | 3 | 4 |
5 | 6 | 6 |
7 | 8 | 9 |
若是不考慮像素位置的影響,那麼查找最小cost的時候會認爲左上角的cost=2最小。然而若是考慮到像素間距的影響,咱們來看左上角方向,和中心的差別是6-2,作個線性插值的話,則左上角距中心單位距離上的值應該爲\(6-\left( 6-2 \right)\times 1/\sqrt{2}\ =3.17>3\),因此正上方的纔是最小cost的正確方向。
最短路徑查找
在磁力套索中,通常的用法是先單擊一個點,而後移動鼠標,在鼠標和一開始單擊的點之間就會出現自動貼近邊緣的線,這裏咱們定義一開始單擊的像素點爲種子點(seed),而磁力套索其實在考慮上部分提到的邊緣相關cost的狀況下查找種子點到當前鼠標的最短路徑。以下圖,紅色的就是種子點,而移動鼠標時,最貼近邊緣的種子點和鼠標座標的連線就會實時顯示,這也是爲何磁力套索也叫Livewire。
實現最短路徑的辦法不少,通常而言就是動態規劃了,這裏介紹的是基於Dijkstra算法的一種實現,基本思想是,給定種子點後,執行Dijkstra算法將圖像的全部像素遍歷,獲得每一個像素到種子點的最短路徑。如下面這幅圖爲例,在一個cost矩陣中,利用Dijkstra算法遍歷每個元素後,每一個元素都會指向一個相鄰的元素,這樣任意一個像素都能找到一條到seed的路徑,好比右上角的42和39對應的像素,沿着箭頭到了0。
算法以下:
輸入: |
遍歷的過程會優先通過cost最低的區域,以下圖:
全部像素對應的到種子像素的最短路徑都找到後,移動鼠標時就直接畫出到seed的最短路徑就能夠了。
Python實現
算法部分直接調用了OpenCV的Canny函數和Sobel函數(求梯度),對於RGB的處理也很簡陋,直接用梯度最大的值來近似。另外由於懶,cost map和path map都直接用了字典(dict),而記錄展開過的像素則直接採用了集合(set)。GUI部分由於不會用QThread因此用了Python的threading,只有圖像顯示交互區域和狀態欄提示,左鍵點擊設置種子點,右鍵結束,已經提取的邊緣爲綠色線,正在提取的爲藍色線。
代碼
算法部分
1 from __future__ import division 2 import cv2 3 import numpy as np 4 5 SQRT_0_5 = 0.70710678118654757 6 7 class Livewire(): 8 """ 9 A simple livewire implementation for verification using 10 1. Canny edge detector + gradient magnitude + gradient direction 11 2. Dijkstra algorithm 12 """ 13 14 def __init__(self, image): 15 self.image = image 16 self.x_lim = image.shape[0] 17 self.y_lim = image.shape[1] 18 # The values in cost matrix ranges from 0~1 19 self.cost_edges = 1 - cv2.Canny(image, 85, 170)/255.0 20 self.grad_x, self.grad_y, self.grad_mag = self._get_grad(image) 21 self.cost_grad_mag = 1 - self.grad_mag/np.max(self.grad_mag) 22 # Weight for (Canny edges, gradient magnitude, gradient direction) 23 self.weight = (0.425, 0.425, 0.15) 24 25 self.n_pixs = self.x_lim * self.y_lim 26 self.n_processed = 0 27 28 @classmethod 29 def _get_grad(cls, image): 30 """ 31 Return the gradient magnitude of the image using Sobel operator 32 """ 33 rgb = True if len(image.shape) > 2 else False 34 grad_x = cv2.Sobel(image, cv2.CV_64F, 1, 0, ksize=3) 35 grad_y = cv2.Sobel(image, cv2.CV_64F, 0, 1, ksize=3) 36 if rgb: 37 # A very rough approximation for quick verification... 38 grad_x = np.max(grad_x, axis=2) 39 grad_y = np.max(grad_y, axis=2) 40 41 grad_mag = np.sqrt(grad_x**2+grad_y**2) 42 grad_x /= grad_mag 43 grad_y /= grad_mag 44 45 return grad_x, grad_y, grad_mag 46 47 def _get_neighbors(self, p): 48 """ 49 Return 8 neighbors around the pixel p 50 """ 51 x, y = p 52 x0 = 0 if x == 0 else x - 1 53 x1 = self.x_lim if x == self.x_lim - 1 else x + 2 54 y0 = 0 if y == 0 else y - 1 55 y1 = self.y_lim if y == self.y_lim - 1 else y + 2 56 57 return [(x, y) for x in xrange(x0, x1) for y in xrange(y0, y1) if (x, y) != p] 58 59 def _get_grad_direction_cost(self, p, q): 60 """ 61 Calculate the gradient changes refer to the link direction 62 """ 63 dp = (self.grad_y[p[0]][p[1]], -self.grad_x[p[0]][p[1]]) 64 dq = (self.grad_y[q[0]][q[1]], -self.grad_x[q[0]][q[1]]) 65 66 l = np.array([q[0]-p[0], q[1]-p[1]], np.float) 67 if 0 not in l: 68 l *= SQRT_0_5 69 70 dp_l = np.dot(dp, l) 71 l_dq = np.dot(l, dq) 72 if dp_l < 0: 73 dp_l = -dp_l 74 l_dq = -l_dq 75 76 # 2/3pi * ... 77 return 0.212206590789 * (np.arccos(dp_l)+np.arccos(l_dq)) 78 79 def _local_cost(self, p, q): 80 """ 81 1. Calculate the Canny edges & gradient magnitude cost taking into account Euclidean distance 82 2. Combine with gradient direction 83 Assumption: p & q are neighbors 84 """ 85 diagnol = q[0] == p[0] or q[1] == p[1] 86 87 # c0, c1 and c2 are costs from Canny operator, gradient magnitude and gradient direction respectively 88 if diagnol: 89 c0 = self.cost_edges[p[0]][p[1]]-SQRT_0_5*(self.cost_edges[p[0]][p[1]]-self.cost_edges[q[0]][q[1]]) 90 c1 = self.cost_grad_mag[p[0]][p[1]]-SQRT_0_5*(self.cost_grad_mag[p[0]][p[1]]-self.cost_grad_mag[q[0]][q[1]]) 91 c2 = SQRT_0_5 * self._get_grad_direction_cost(p, q) 92 else: 93 c0 = self.cost_edges[q[0]][q[1]] 94 c1 = self.cost_grad_mag[q[0]][q[1]] 95 c2 = self._get_grad_direction_cost(p, q) 96 97 if np.isnan(c2): 98 c2 = 0.0 99 100 w0, w1, w2 = self.weight 101 cost_pq = w0*c0 + w1*c1 + w2*c2 102 103 return cost_pq * cost_pq 104 105 def get_path_matrix(self, seed): 106 """ 107 Get the back tracking matrix of the whole image from the cost matrix 108 """ 109 neighbors = [] # 8 neighbors of the pixel being processed 110 processed = set() # Processed point 111 cost = {seed: 0.0} # Accumulated cost, initialized with seed to itself 112 paths = {} 113 114 self.n_processed = 0 115 116 while cost: 117 # Expand the minimum cost point 118 p = min(cost, key=cost.get) 119 neighbors = self._get_neighbors(p) 120 processed.add(p) 121 122 # Record accumulated costs and back tracking point for newly expanded points 123 for q in [x for x in neighbors if x not in processed]: 124 temp_cost = cost[p] + self._local_cost(p, q) 125 if q in cost: 126 if temp_cost < cost[q]: 127 cost.pop(q) 128 else: 129 cost[q] = temp_cost 130 processed.add(q) 131 paths[q] = p 132 133 # Pop traversed points 134 cost.pop(p) 135 136 self.n_processed += 1 137 138 return paths
GUI部分
1 from __future__ import division 2 import time 3 import cv2 4 from PyQt4 import QtGui, QtCore 5 from threading import Thread 6 from livewire import Livewire 7 8 class ImageWin(QtGui.QWidget): 9 def __init__(self): 10 super(ImageWin, self).__init__() 11 self.setupUi() 12 self.active = False 13 self.seed_enabled = True 14 self.seed = None 15 self.path_map = {} 16 self.path = [] 17 18 def setupUi(self): 19 self.hbox = QtGui.QVBoxLayout(self) 20 21 # Load and initialize image 22 self.image_path = '' 23 while self.image_path == '': 24 self.image_path = QtGui.QFileDialog.getOpenFileName(self, '', '', '(*.bmp *.jpg *.png)') 25 self.image = QtGui.QPixmap(self.image_path) 26 self.cv2_image = cv2.imread(str(self.image_path)) 27 self.lw = Livewire(self.cv2_image) 28 self.w, self.h = self.image.width(), self.image.height() 29 30 self.canvas = QtGui.QLabel(self) 31 self.canvas.setMouseTracking(True) 32 self.canvas.setPixmap(self.image) 33 34 self.status_bar = QtGui.QStatusBar(self) 35 self.status_bar.showMessage('Left click to set a seed') 36 37 self.hbox.addWidget(self.canvas) 38 self.hbox.addWidget(self.status_bar) 39 self.setLayout(self.hbox) 40 41 def mousePressEvent(self, event): 42 if self.seed_enabled: 43 pos = event.pos() 44 x, y = pos.x()-self.canvas.x(), pos.y()-self.canvas.y() 45 46 if x < 0: 47 x = 0 48 if x >= self.w: 49 x = self.w - 1 50 if y < 0: 51 y = 0 52 if y >= self.h: 53 y = self.h - 1 54 55 # Get the mouse cursor position 56 p = y, x 57 seed = self.seed 58 59 # Export bitmap 60 if event.buttons() == QtCore.Qt.MidButton: 61 filepath = QtGui.QFileDialog.getSaveFileName(self, 'Save image audio to', '', '*.bmp\n*.jpg\n*.png') 62 image = self.image.copy() 63 64 draw = QtGui.QPainter() 65 draw.begin(image) 66 draw.setPen(QtCore.Qt.blue) 67 if self.path_map: 68 while p != seed: 69 draw.drawPoint(p[1], p[0]) 70 for q in self.lw._get_neighbors(p): 71 draw.drawPoint(q[1], q[0]) 72 p = self.path_map[p] 73 if self.path: 74 draw.setPen(QtCore.Qt.green) 75 for p in self.path: 76 draw.drawPoint(p[1], p[0]) 77 for q in self.lw._get_neighbors(p): 78 draw.drawPoint(q[1], q[0]) 79 draw.end() 80 81 image.save(filepath, quality=100) 82 83 else: 84 self.seed = p 85 86 if self.path_map: 87 while p != seed: 88 p = self.path_map[p] 89 self.path.append(p) 90 91 # Calculate path map 92 if event.buttons() == QtCore.Qt.LeftButton: 93 Thread(target=self._cal_path_matrix).start() 94 Thread(target=self._update_path_map_progress).start() 95 96 # Finish current task and reset 97 elif event.buttons() == QtCore.Qt.RightButton: 98 self.path_map = {} 99 self.status_bar.showMessage('Left click to set a seed') 100 self.active = False 101 102 def mouseMoveEvent(self, event): 103 if self.active and event.buttons() == QtCore.Qt.NoButton: 104 pos = event.pos() 105 x, y = pos.x()-self.canvas.x(), pos.y()-self.canvas.y() 106 107 if x < 0 or x >= self.w or y < 0 or y >= self.h: 108 pass 109 else: 110 # Draw livewire 111 p = y, x 112 path = [] 113 while p != self.seed: 114 p = self.path_map[p] 115 path.append(p) 116 117 image = self.image.copy() 118 draw = QtGui.QPainter() 119 draw.begin(image) 120 draw.setPen(QtCore.Qt.blue) 121 for p in path: 122 draw.drawPoint(p[1], p[0]) 123 if self.path: 124 draw.setPen(QtCore.Qt.green) 125 for p in self.path: 126 draw.drawPoint(p[1], p[0]) 127 draw.end() 128 self.canvas.setPixmap(image) 129 130 def _cal_path_matrix(self): 131 self.seed_enabled = False 132 self.active = False 133 self.status_bar.showMessage('Calculating path map...') 134 path_matrix = self.lw.get_path_matrix(self.seed) 135 self.status_bar.showMessage(r'Left: new seed / Right: finish') 136 self.seed_enabled = True 137 self.active = True 138 139 self.path_map = path_matrix 140 141 def _update_path_map_progress(self): 142 while not self.seed_enabled: 143 time.sleep(0.1) 144 message = 'Calculating path map... {:.1f}%'.format(self.lw.n_processed/self.lw.n_pixs*100.0) 145 self.status_bar.showMessage(message) 146 self.status_bar.showMessage(r'Left: new seed / Right: finish')
主函數
1 import sys 2 from PyQt4 import QtGui 3 from gui import ImageWin 4 5 def main(): 6 app = QtGui.QApplication(sys.argv) 7 window = ImageWin() 8 window.setMouseTracking(True) 9 window.setWindowTitle('Livewire Demo') 10 window.show() 11 window.setFixedSize(window.size()) 12 sys.exit(app.exec_()) 13 14 if __name__ == '__main__': 15 main()
蛋疼地上傳到了Github(傳送門),歡迎fork。
效率的改進
由於這個代碼的原型只是爲了用C++開發以前的Python評估和驗證,因此徹底沒考慮效率,執行速度是徹底不行的,基本上400x400的圖片就不能忍了……至於基於Python版本的效率提高我沒有仔細想過,只是大概看來有這麼幾個比較明顯的地方:
1) 取出當前最小cost像素操做
p = min(cost, key=cost.get)
這個雖然寫起來很爽但顯然是不行的,至少得用個min heap什麼的。由於我是用dict同時表示待處理像素和cost了,也懶得想一下怎麼和Python的heapq結合起來,因此直接用了粗暴省事的min()。
2) 梯度方向的計算
三角函數的計算應該是儘可能避免的,另外原文多是爲了將值域擴展到>π因此把q-p也用上了,其實這一項原本權重就小,那怕直接用兩個像素各自的梯度方向向量作點積而後歸一化一下結果也是還行的。即便要用arccos,也能夠考慮寫個look-up table近似。固然我最後想說的是我的以爲其實這項真沒那麼必要,直接自適應spilne或者那怕三點均值平滑去噪效果就不錯了。
3) 計算相鄰像素的位置
若是兩個像素相鄰,則他們各自周圍的8個相鄰像素也會重合。的個人辦法比較原始,能夠考率不用模塊化直接計算。
4) 替換部分數據結構
好比path map其實本質是給出每一個像素在最短路徑上的上一個像素,是個矩陣。其實能夠考慮用線性的數據結構代替,不過若是真這樣作通常來講都是在C/C++代碼裏了。
5) numpy
我印象中對numpy的調用順序也會影響到效率,連續調用numpy的內置方法彷佛會帶來效率的總體提高,不過話仍是說回來,實際應用中若是到了這一步,應該也屬於C/C++代碼範疇了。
6) 算法層面的改進
這塊沒有深刻研究,第一感受是實際應用中不必一上來就計算整幅圖像,能夠根據seed位置作一些區塊劃分,鼠標自己也會留下軌跡,也或許能夠考慮只在鼠標軌跡方向進行啓發式搜索。另外計算路徑的時候也許能夠考慮借鑑有點相似於Image Pyramid的思想,不必一上來就對全分辨率下的路徑進行查找。因爲後來作的項目沒有采用這個算法,因此我也沒有繼續研究,雖然挺好奇的,其實有好多現成的代碼,好比GIMP,不過沒有精力去看了。
更多的改進
雖然都沒作,大概介紹一下,都是考慮了實用性的改進。
路徑冷卻(Path Cooling)
用過Photoshop和GIMP磁力套索的人都知道,即便鼠標不點擊圖片,在移動過程當中也會自動生成一些將摳圖軌跡固定住的點,這些點其實就是新的種子點,而這種使用過程當中自動生成新的種子點的方法叫Path cooling。這個方法的基本思路以下:隨着鼠標移動過程當中若是必定時間內一段路徑都保持固定不變,那麼就把這段路徑中離種子最遠的點設置爲新的種子,其實背後隱藏的仍是動態規劃的思想,貝爾曼最優。這個名字也是比較形象的,路徑冷卻。
動態訓練(Interactive Dynamic Training)
單純的最短路徑查找在使用的時候經常出現找到的邊緣不是想要的邊緣的問題,好比上圖,綠色的線是上一段提取的邊緣,藍色的是當前正在提取的邊緣。左圖中,鏡子外面Lena的帽子邊緣是咱們想要提取的,然而因爲鏡子裏的Lena的帽子邊緣的cost更低,因此實際提取出的藍色線段如右圖中貼到右邊了。因此Interactive Dynamic Training的思想是,認爲綠色的線段是正確提取的邊緣,而後利用綠色線段做爲訓練數據來給當前提取邊緣的cost函數附加一個修正值。
[1]中採用的方法是統計前一段邊緣上點的梯度強度的直方圖,而後按照梯度出現頻率給當前圖中的像素加權。舉例來講若是綠色線段中的全部像素對應的梯度強度都是在50到100之間的話,那麼能夠將50到100以10爲單位分爲5個bin,統計每一個bin裏的出現頻率,也就是直方圖,而後對當前檢測到的梯度強度,作個線性加權。比方說50~60區間內對應的像素最多有10個,那麼把10做爲最大值,而且對當前檢測到的梯度強度處於50~60之間的像素均乘上係數1.0;若是訓練數據中70~80之間有5個,那麼cost加權係數爲5/10=0.5,則對全部當前檢測到的梯度強度處於70~80之間的像素均乘上係數0.5;若是訓練數據中100以上沒有,因此cost附加爲0/10=0,則加權係數爲0,這樣即便檢測到更強的邊緣也不會偏離前一段邊緣了。這是基本思想,固然實際的實現沒有這麼簡單,除了邊緣上的像素還要考慮垂直邊緣上左邊和右邊的兩個像素點,這樣保證了邊緣的pattern。另外隨着鼠標愈來愈遠離訓練邊緣,檢測到的邊緣的pattern可能會出現不同,因此Training可能會起副作用,因此這種Training的做用範圍也須要考慮到鼠標離種子點的距離,最後還要有一些平滑去噪的處理,具體都在[1]裏有講到,挺繁瑣的(那會好像尚未SIFT),不詳述了。
種子點位置的修正(Cursor Snap)
雖然這個算法能夠自動找出種子點和鼠標之間最貼近邊緣的路徑,不過,人的手,經常抖,因此種子點未必能很好地設置到邊緣上。因此能夠在用戶設置完種子點位置以後,自動在其座標周圍小範圍內,好比7x7的區域內搜索cost最低的像素,做爲真正的種子點位置,這個過程叫作Cursor snap。
參考文獻:[1] Mortensen, Eric N., and William A. Barrett. "Intelligent scissors for image composition." Proceedings of the 22nd annual conference on Computer graphics and interactive techniques. ACM, 1995.