<style> p { text-align: justify; } .f-box { display: flex; justify-content: center; } .sm { width: 60%; margin-left: 2rem; } .ss { width: 47%; margin-left: 2rem; } .img-caption { font-size: 1.5rem; font-weight: 500; letter-spacing: -1px; padding: 10px 0 30px 0; } </style> <h1 style="text-align:center">VFX Project II: Image Stitching</h1> <p style="text-align:right">VFX (NTU, Spring 2022)</p> Hao-Cheng Lo 駱皓正 (D08227104) Li-Wei Fu 傅立威 (R10942078) 有鑒於數學式顯示問題,請參照[線上版完整報告書](https://hackmd.io/@VFX2022/H1mDIvlB5)。 ## 緒論 在本作業中,縫合全景圖的實作流程如下:首先,在特徵偵測(feature detection)階段,我們以Harris Coner Dectection對圖片進行特徵的偵測。其次,在特徵描述(feature description)階段,我們以SIFT Descriptor之算法對於上述特徵點以向量對其進行表徵。接著,在特徵配對(feature matching)階段,我們比對連續兩兩影像之特徵相似度,擇優選出2D對2D的對應點,並應用該對應點資料,搭配RANSAC解出較具有穩健性的平面投影轉換(homography)。最後,在全景影像縫合(image blending)階段,我們根據前揭平面投影轉換與後向形變(backward warping),據此逐一對圖片做縫合,便可得一全景圖。以下是我們各小節的細節與實驗結果,並以Parrington資料集為例,本作業所親自攝影照片置於最後部分: 1. 特徵偵測(feature detection) 2. 特徵描述(feature description) 3. 特徵配對(feature matching) 4. 全景影像縫合(image blending) 5. 結果展示 ## 特徵偵測(feature detection) 針對特徵的偵測,本作業採取課堂中所提及之Harris Corner Dectection算法作為特徵偵測。具體而言,其主要步驟如下,代碼符號與講義一致: 1. 針對原圖轉成灰階並通過Gaussian Filter進行降噪處理。 2. 計算其一次微分$I_x,\ I_y$與二次微分$I^2_x,\ I^2_y$,並使其再次通過Gaussian Filter。 3. 建構矩陣$M$,並依照其行列值與跡數求出Harris Corner Response $R$. 4. 設定一閾值$=\mu_R +\alpha\sigma_R$,其中$\alpha$為使用者自定,本作業設定為$0.5$,如果一像素之Harris Corner Response低於閾值則被過濾掉;反之,高於閾值則保留。 5. 除了設定全局的閾值,我們亦用non-maximal suppression之方法,檢視每個候選特徵是否是局部最大值,也就是相較於其鄰居為最大者。最大者之對應特徵點,則為Harris Corner之特徵。 ```python= def harris_corner(I_x, I_y, kernel_size=9, sigma=3, k_value=0.04, alpha=0.5): ''' Input: I_x: x gradient I_y: y gradient Output: points: index=1 is x coordinate, index=0 is y coordinate ''' # https://en.wikipedia.org/wiki/Harris_corner_detector # Step 1: Gradient # done # Step 2: Structure tensor setup # In original algo, it is mean filter. S_x_2 = cv2.GaussianBlur(np.square(I_x), (kernel_size, kernel_size), sigma) S_y_2 = cv2.GaussianBlur(np.square(I_y), (kernel_size, kernel_size), sigma) S_xy = cv2.GaussianBlur(I_x * I_y, (kernel_size, kernel_size), sigma) # Step 3: Harris response calculation det_M = (S_x_2 * S_y_2) - np.square(S_xy) trace_M = S_x_2 + S_y_2 harris_response = det_M - k_value * (trace_M ** 2) # Step 4: Non-maximum suppression threshold = np.mean(harris_response) + alpha * np.std(harris_response) local_max = np.where(harris_response <= threshold, 0, 1).astype(np.uint8) for i in np.arange(9): if i == 4: continue kernel = np.zeros(9) kernel[4] = 1 kernel[i] = -1 kernel = kernel.reshape(3, 3) tmp_result = np.where(cv2.filter2D( harris_response, -1, kernel) < 0, 0, 1).astype(np.uint8) local_max &= tmp_result # Decide feature points points = np.where(local_max == 1) print(f"[Harris Corner] Found {len(points[0])} local features!") return points, harris_response # x, y coordinates ``` ### 實驗結果 以下為Parrington資料集之兩連續圖之Harris Coner Detection之表現。我們可以發現無論從Response的分布以及最後特徵點的結果來看,反應較大的區域(深紅色)或是最後篩選出來的特徵點,皆偏向位於影像中corner處,應屬合理。 <div class="f-box"> <img class="ss" src="https://i.imgur.com/5vYbEqS.jpg"> <img class="ss" src="https://i.imgur.com/VAkwgpf.jpg"> </div> <center class="img-caption"> 原影像 </center> <div class="f-box"> <img class="sm" src="https://i.imgur.com/AQDi6zx.png"> <img class="sm" src="https://i.imgur.com/AhRtQjU.png"> </div> <center class="img-caption"> Harris Coner Response </center> <div class="f-box"> <img class="ss" src="https://i.imgur.com/0IMEugb.png"> <img class="ss" src="https://i.imgur.com/LWyQte4.png"> </div> <center class="img-caption"> 特徵點 </center> ## 特徵描述(feature description) 基於前揭確定之特徵點後,我們進一步需要表徵這些特徵點,以便區別不同特徵點,進而匹配不同影像上相似的特徵點。具體而言,我們運用SIFT的特徵描述算法,該算法著重局部描述(local description)。步驟如下: 1. 找出每個特徵點梯度的角度$\theta$和長度大小$m$。 2. 把360度分為八類角度(bin),並將每個特徵點的角度設為其對應的角度分類(bin)。 3. 對於「所有點」來說,考慮其鄰居在八類角度的分佈情況(並考慮其實際角度值),據此對該點進行以八維向量描述。 4. 對於「特徵點」來說,使用一個16x16的window並根據前一步求得的主要旋轉方向,並進行旋轉校正,以便不受到旋轉影響。 5. 接續上步驟,對於「特徵點」來說,再考慮其鄰居16×16大小的像素,並平均區分為4×4的區域共16區,接著檢視每一區在八維向量的分佈情形。 6. 據此,可合併成16×8=128維度的資料。最後針對這些資料進行正規化,即可得到代表該特徵點的128維特徵向量。 ```python= def sift_descriptor(points, I_x, I_y, num_bin=8, kernel_size=11, patch_size=12): # length and theta m = np.hypot(I_x, I_y) theta = get_theta(I_x, I_y) # histogram of theta hist_bin_width = 360.0 / num_bin theta_which_hist = (theta) // int(hist_bin_width) % num_bin orientation_shape = (num_bin, I_x.shape[0], I_x.shape[1]) orientation_stack = np.zeros(orientation_shape) for i in range(num_bin): build_orientation_layer( orientation_stack[i], theta_which_hist == i, m, kernel_size) descriptors = [] keypoints = [] for x, y in zip(points[1], points[0]): # for each point description = [] try: rotated_orientation_stack = build_rotated_orientation_stack( x, y, orientation_stack, patch_size, theta[y, x]) for yy in [4, 8, 12, 16]: for xx in [4, 8, 12, 16]: orientation_vec = np.array( [np.sum(rotated_orientation_stack[i][yy:yy+4, xx:xx+4]) for i in range(num_bin)]) orientation_vec = orientation_vec / \ (orientation_vec.sum() + 1e-7) orientation_vec = np.clip(orientation_vec, 0, 0.2) orientation_vec = orientation_vec / \ (orientation_vec.sum() + 1e-7) description += orientation_vec.tolist() except: description = -1 if np.sum(description) > 0: keypoints.append([x, y]) descriptors.append(description) print( f"[SIFT Descriptor] Complete {len(keypoints)} features' descriptors!") return np.array(keypoints).astype(np.float32), np.array(descriptors).astype(np.float32) ``` ### 實驗結果 由以下結果可以發現在邊緣強烈處,其梯度值較大。除此之外,從各個像素之角度的分類也可以發現,類似的邊緣其分類較為一致。 <div class="f-box"> <img class="sm" src="https://i.imgur.com/kJpo5r7.png"> <img class="sm" src="https://i.imgur.com/wi0z63n.png"> </div> <center class="img-caption"> Gradient Value </center> <div class="f-box"> <img class="sm" src="https://i.imgur.com/NdYhzTP.png"> <img class="sm" src="https://i.imgur.com/xRylWj9.png"> </div> <center class="img-caption"> Theta in 8 Bins </center> ## 特徵配對(feature matching) ### 找好對應點 前面我們已經把找到的特徵點都用一個128維度的向量來表示,接下來我們要把不同張圖片的對應點找出來。我們使用 Euclidean Distance 及 Manhattan Distance 來計算兩向量的距離,配合上課教授教的 heuristic method 做一個簡單的篩選。首先我們在要拼接的圖片裡,先找出和前一張圖片的特徵點最接近的點以及第二接近的點,如果最接近的點和第二接近的點距離比小於 0.8 我們便把它當作對應點。 ```python= def match_keypoints(des1, des2, norm='L1'): ''' Input: des1: N-by-128 matrices, representing N points' descriptor des2: M-by-128 matrices, representing M points' descriptor norm: distance definition, there are two options L1-norm - Manhattan distance L2-norm - Euclidean distance Output: matches: list of match index ''' # We use heuristic method: # If the distance ratio of d(x, x1) and d(x, x2) is smaller than 0.8, # then it is accepted as a match. RATIO = 0.8 N = des1.shape[0] if norm == 'L1': dist = cdist(des1, des2, metric='minkowski', p=1) elif norm == 'L2': dist = cdist(des1, des2, metric='euclidean') matches = [] sorted_index = np.argsort(dist, axis=1) for i in range(N): idx1, idx2 = sorted_index[i, 0], sorted_index[i, 1] dist1, dist2 = dist[i, idx1], dist[i, idx2] if dist1 < dist2 * RATIO: matches.append([i, idx1]) return matches ``` ### Homography 找到對應點之後,我們透過 Homography 得到兩張連續圖片間的關係。計算 Homography 最少需要四個點,如果只使用四個點可以得到唯一解;超過不一定有解,但我們可以計算誤差最小的。如果 input 對應點是四個點,我們會使用第一種方法,用反矩陣的方式解 Ah = v,在這裡我們假設 Homography 最後一個參數是1 (h33 = 1);如果 input 對應點超過四個點,我們會使用第二種方法,解 Ah = 0 的 least square solution。 ```python= def solve_homography(u, v): ''' Input: u: N-by-2 source pixel location matrices v: N-by-2 destination pixel location matrices Output: H: 3-by-3 homography matrix ''' N = u.shape[0] if v.shape[0] is not N: print('u and v should have the same size') return None if N < 4: print('At least 4 points should be given') return None # Method 1: A is 8 x 8 matrix, slove Ah = v with unique solution (Faster) if N == 4: A = np.array([ [u[0, 0], u[0, 1], 1, 0, 0, 0, -u[0, 0] * v[0, 0], -u[0, 1] * v[0, 0]], [0, 0, 0, u[0, 0], u[0, 1], 1, -u[0, 0] * v[0, 1], -u[0, 1] * v[0, 1]], [u[1, 0], u[1, 1], 1, 0, 0, 0, -u[1, 0] * v[1, 0], -u[1, 1] * v[1, 0]], [0, 0, 0, u[1, 0], u[1, 1], 1, -u[1, 0] * v[1, 1], -u[1, 1] * v[1, 1]], [u[2, 0], u[2, 1], 1, 0, 0, 0, -u[2, 0] * v[2, 0], -u[2, 1] * v[2, 0]], [0, 0, 0, u[2, 0], u[2, 1], 1, -u[2, 0] * v[2, 1], -u[2, 1] * v[2, 1]], [u[3, 0], u[3, 1], 1, 0, 0, 0, -u[3, 0] * v[3, 0], -u[3, 1] * v[3, 0]], [0, 0, 0, u[3, 0], u[3, 1], 1, -u[3, 0] * v[3, 1], -u[3, 1] * v[3, 1]], ]) # In some cases, Homography cannot be calculated. # 1) Duplicate points # 2) More than 2 collinear points if np.linalg.det(A) == 0: return None H = np.append(np.matmul(np.linalg.inv(A), v.flatten()), 1).reshape(3, 3) return H # Method 2: A is 2n x 9 matrix, slove Ah = 0 with least square solution A = [] for ui, vi in zip(u, v): A.append([ui[0], ui[1], 1, 0, 0, 0, -ui[0] * vi[0], -ui[1] * vi[0], -vi[0]]) A.append([0, 0, 0, ui[0], ui[1], 1, -ui[0] * vi[1], -ui[1] * vi[1], -vi[1]]) U, Sigma, Vt = np.linalg.svd(A) H = Vt[-1].reshape(3, 3) return H ``` ### 用 Ransac 篩選 outlier Matching 時難免會有一些 Match 錯誤的點,我們必須要把這些點篩選掉,以免對結果產生影響。 使用的方法為 Ransac,因為 Homography 最少是由四個點決定,所以我們每次 random sample 四個點,我們會先判斷這四個點是否能圍成一個四邊形,確保可以用這四個點計算 Homography。接著把所有的特徵點帶入 Homography 算出對應點的座標(計算得出),和真正的對應點(ground truth)計算誤差,誤差太大的就當作是 outlier。我們希望 inlier 越多越好,如果 inlier 數量相同我們會再比較總誤差,總誤差越小越好,每次進步就會更新 Homography 及 inlier。前面的步驟會重複執行 10000 次,最後就可以找到不錯的結果。 ```python= def find_homography(kp1, des1, kp2, des2, norm='L1', num_iters=10000, threshold=5): matches = match_keypoints(des1, des2, norm) print(f"[Match] Found {len(matches)} matches!") im1_pts = np.array([kp1[m[0]] for m in matches]) im2_pts = np.array([kp2[m[1]] for m in matches]) u = im2_pts.T u = np.append(u, [np.ones_like(u[0])], axis=0) v_ans = im1_pts.T v_ans = np.append(v_ans, [np.ones_like(v_ans[0])], axis=0) # apply RANSAC to choose best H most_inliers = 0 best_H = np.eye(3) least_error = np.inf best_mask = np.full(len(matches), True) population = range(len(matches)) for i in range(num_iters): # randomly choose 4 corners to compute the homography idx = random.sample(population, 4) H = solve_homography(im2_pts[idx], im1_pts[idx]) if H is None: continue v = np.matmul(H, u) v /= v[2] dist = np.linalg.norm(v[:2] - v_ans[:2], axis=0) inliers = (dist < threshold).sum() if inliers > most_inliers or (inliers == most_inliers and least_error > dist[best_mask].sum()): best_H = H best_mask = dist < threshold most_inliers = inliers least_error = dist[best_mask].sum() # # (Optional) Remove outliers and recalculate H # best_H = solve_homography(im2_pts[best_mask], im1_pts[best_mask]) return best_H ``` ### 實驗結果 以下結果展示最接近的前50個對應點,可以看到這些對應關係畫出來後長度都很接近、互相平行,表示兩張照片基本上只有 translation 而已。 ![](https://i.imgur.com/NF27vM7.png) ## 全景影像縫合(image blending) 找完圖片的之間對應關係後就可以 warping 了,我們實現了 forward warp 以及 backward warp。首先用 meshgrid 建立座標,u 是 source image 座標、v 是 destination image 座標。 Forward warp 就是把計算好的 Homography 和 u 相乘得到 v,normalized 後四捨五入,然後用 mask 把超過 destination image 邊界的地方找出並刪除,最後直接把每個點 assign 過去,這種方法因為不能保證 destination image 所有的 pixel 都能夠被對應到,所以可能會產生洞。 Backward warp 需要計算 Homography 的 inverse,與 v 相乘得到 u,也要做normalized,然後用 mask 把超過 source image 邊界的地方找出並刪除,接著在source image 做 bilinear interpolation 得到 destination image 每個pixel 的值再 assign 過去,這種方法不會產生洞。因此我們最後會選用 backward warp 來拼接圖片。 ```python= def warping(src, dst, H, ymin, ymax, xmin, xmax, direction='b'): ''' Input: src: source image dst: destination output image H: homography from source to destination ymin: lower vertical bound of the destination (source, if forward warp) pixel coordinate ymax: upper vertical bound of the destination (source, if forward warp) pixel coordinate xmin: lower horizontal bound of the destination (source, if forward warp) pixel coordinate xmax: upper horizontal bound of the destination (source, if forward warp) pixel coordinate direction: indicates backward warping or forward warping Output: dst: destination output image Note: Image shape: H, W, C Image coordinate: X, Y but H -> Y W -> X So we should swap H and W to generate the correct coordinates src and dst shape: H W C corresponding coordinate: v[1] v[0] ''' h_src, w_src, ch = src.shape h_dst, w_dst, ch = dst.shape H_inv = np.linalg.inv(H) # Prevent changing to the original dst dst = dst.copy() # meshgrid the (x, y) coordinate pairs x, y = np.meshgrid(np.arange(xmin, xmax), np.arange(ymin, ymax)) x, y = x.flatten().astype(int), y.flatten().astype(int) if direction == 'b': # reshape the destination pixels as N x 3 homogeneous coordinate v = np.stack([x, y, np.ones_like(x)]) u = np.matmul(H_inv, v) u /= u[2] # calculate the mask of the transformed coordinate (should not exceed the boundaries of source image) # upper bound use ">=" to prevent index out of bounds in interpolation mask = np.logical_or(np.logical_or(u[0] < 0, u[0] >= w_src - 1), np.logical_or(u[1] < 0, u[1] >= h_src - 1)) v_x, v_y, u_x, u_y = np.delete(v[0], mask), np.delete(v[1], mask), np.delete(u[0], mask), np.delete(u[1], mask) # We use bilinear interpolation to deal with the problem of coordinates that are not integers int_x, int_y = u_x.astype(int), u_y.astype(int) frac_x, frac_y = u_x - int_x, u_y - int_y a = (1 - frac_x) * (1 - frac_y) b = (1 - frac_x) * frac_y c = frac_x * (1 - frac_y) d = frac_x * frac_y p = np.zeros((int_x.size, 3)) p += a[..., None] * src[int_y, int_x] p += b[..., None] * src[int_y + 1, int_x] p += c[..., None] * src[int_y, int_x + 1] p += d[..., None] * src[int_y + 1, int_x + 1] # assign to destination image dst[v_y, v_x] = p elif direction == 'f': # reshape the destination pixels as N x 3 homogeneous coordinate u = np.stack([x, y, np.ones_like(x)]) v = np.matmul(H, u) v /= v[2] # use the rounding method to assign the destination image coordinates v = np.rint(v).astype(int) # calculate the mask of the transformed coordinate (should not exceed the boundaries of destination image) mask = np.logical_or(np.logical_or(v[0] < 0, v[0] > w_dst - 1), np.logical_or(v[1] < 0, v[1] > h_dst - 1)) v_x, v_y, u_x, u_y = np.delete(v[0], mask), np.delete(v[1], mask), np.delete(u[0], mask), np.delete(u[1], mask) # assign to destination image dst[v_y, v_x] = src[u_y, u_x] return dst ``` ### 實驗結果 拼接完圖片後會有一些黑色沒有 warp 到的地方,我們最後會手動裁切掉讓結果更好看。 ![](https://i.imgur.com/m6NA6ZQ.png) ## Demo code ```python= if __name__ == "__main__": img_paths = glob.glob("ours/*.jpg") img_paths = sorted(img_paths, key=lambda x: int(re.findall(r"\d+", x)[0])) imgs = [cv2.imread(path) for path in img_paths] h_max = max([x.shape[0] for x in imgs]) w_max = sum([x.shape[1] for x in imgs]) # create the final stitched canvas dst = np.zeros((h_max, w_max, imgs[0].shape[2]), dtype=np.uint8) dst[:imgs[0].shape[0], :imgs[0].shape[1]] = imgs[0] # for all images to be stitched H_total = np.eye(3) acc_width = 0 for idx in range(len(imgs) - 1): im1 = imgs[idx] im2 = imgs[idx + 1] # feature detection (detection + description) kp1, des1 = myDetectAndCompute(im1.copy()) kp2, des2 = myDetectAndCompute(im2.copy()) # matching (match keypoint + use RANSAC method to find homography) H = find_homography(kp1, des1, kp2, des2) # chain the homographies H_total = H_total @ H # apply warping acc_width += im1.shape[1] dst = warping(im2, dst, H_total, 0, h_max, 0, w_max, direction='b') cv2.imwrite("./panorama.png", dst) ``` ## 結果展示 Ours: ![](https://i.imgur.com/xg8bUBI.jpg) ![](https://i.imgur.com/sqSuFag.jpg) ![](https://i.imgur.com/1JIXVHf.jpg)