# OpenGL NCKU HW2 ## 作業環境 vscode / glfw version 3.3.6 / cmake version 3.31.4 / GNU Make 4.3 / g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3. / Linux 6.11.0-19-generic #19~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Feb 17 11:51:52 UTC 2 x86_64 x86_64 x86_64 GNU/Linux ## 方法說明 ### morph_images 先將輸入影像的大小對齊,並根據對齊調整特徵線,之後進行 wrap 與 blend。 ```python def morph_images(img1, img2, lines1, lines2, t, progress_callback=None): """ Perform image morphing between two images. Args: img1, img2: Source images as numpy arrays lines1, lines2: Lists of line pairs for both images t: Morphing parameter (0 to 1) progress_callback: Optional callback for progress reporting Returns: Morphed image as numpy array """ if progress_callback: progress_callback(0) # Ensure images have same dimensions h1, w1 = img1.shape[:2] h2, w2 = img2.shape[:2] # Resize images if needed if h1 != h2 or w1 != w2: # Resize to the larger dimensions new_h, new_w = max(h1, h2), max(w1, w2) img1 = cv2.resize(img1, (new_w, new_h), interpolation=cv2.INTER_CUBIC) img2 = cv2.resize(img2, (new_w, new_h), interpolation=cv2.INTER_CUBIC) # Adjust line coordinates if w1 != new_w or h1 != new_h: x_scale, y_scale = new_w / w1, new_h / h1 lines1 = [((p1[0] * x_scale, p1[1] * y_scale), (p2[0] * x_scale, p2[1] * y_scale)) for p1, p2 in lines1] if w2 != new_w or h2 != new_h: x_scale, y_scale = new_w / w2, new_h / h2 lines2 = [((p1[0] * x_scale, p1[1] * y_scale), (p2[0] * x_scale, p2[1] * y_scale)) for p1, p2 in lines2] # Warp both images if progress_callback: warp1 = MorphingAlgorithm.warp_image( img1, lines1, lines2, t, lambda p: progress_callback(p * 0.4)) warp2 = MorphingAlgorithm.warp_image( img2, lines2, lines1, 1 - t, lambda p: progress_callback(40 + p * 0.4)) else: warp1 = MorphingAlgorithm.warp_image(img1, lines1, lines2, t) warp2 = MorphingAlgorithm.warp_image(img2, lines2, lines1, 1 - t) # Blend the warped images blended = MorphingAlgorithm.blend_images(warp1, warp2, t) if progress_callback: progress_callback(100) return blended ``` ### warp_image 將圖片分成一個個區塊進行平行化運算,根據 `Feature-based image metamorphosis` ,計算其 $u$ 、 $v$ 與其對應點。 $u = \frac{(X - P) \cdot (Q - P)}{\|Q - P\|^2}$ $v = \frac{(X - P) \cdot \text{Perpendicular}(Q - P)}{\|Q - P\|}$ $X' = P' + u \cdot (Q' - P') + \frac{v \cdot \text{Perpendicular}(Q' - P')}{\|Q' - P'\|}$ 與權重 $weight = \left( \frac{length^p}{a + dist} \right)^b$ 其常數數值為 ```python a, b, p = 0.5, 1.0, 0.5 ``` ```python def warp_image(img, src_lines, dst_lines, t, progress_callback=None): """ Warp an image based on line correspondences. Args: img: Source image as numpy array src_lines: List of source line pairs [(p1, p2), ...] dst_lines: List of destination line pairs [(p1, p2), ...] t: Morphing parameter (0 to 1) progress_callback: Optional callback for progress reporting Returns: Warped image as numpy array """ if not src_lines or not dst_lines: return img.copy() height, width = img.shape[:2] warp_img = np.zeros_like(img) # Process image in chunks for better performance chunk_size = 100 # Process in chunks of 100 rows total_chunks = (height + chunk_size - 1) // chunk_size def process_chunk(start_y, end_y): chunk_result = np.zeros_like(img[start_y:end_y, :]) # Generate coordinate grids for vectorized operation y_coords, x_coords = np.mgrid[start_y:end_y, 0:width] points = np.column_stack((x_coords.flatten(), y_coords.flatten())) # Calculate displacement for each point displacements = np.zeros((points.shape[0], 2)) weights_sum = np.zeros(points.shape[0]) for i in range(len(src_lines)): src_start, src_end = np.array(src_lines[i]) dst_start, dst_end = np.array(dst_lines[i]) # Interpolate line endpoints inter_start = (1 - t) * src_start + t * dst_start inter_end = (1 - t) * src_end + t * dst_end # Calculate line vector Q = inter_end - inter_start Q_length_squared = np.dot(Q, Q) if Q_length_squared < 1e-6: # Avoid division by zero continue src_Q = src_end - src_start if np.dot(src_Q, src_Q) < 1e-6: continue # Calculate perpendicular vector src_perp = np.array([-src_Q[1], src_Q[0] ]) / np.linalg.norm(src_Q) # Calculate u and v for each point P_minus_start = points - inter_start u = np.einsum('ij,j->i', P_minus_start, Q) / Q_length_squared v = np.einsum('ij,j->i', P_minus_start, np.array( [-Q[1], Q[0]])) / np.sqrt(Q_length_squared) # Calculate mapped source coordinates mapped = src_start + np.outer(u, src_Q) + np.outer(v, src_perp) # Calculate weights dist = np.abs(v) length = np.sqrt(Q_length_squared) a, b, p = 0.5, 1.0, 0.5 # Adjusted parameters for smoother warping weight = (length**p / (a + dist))**b # Accumulate weighted displacements displacements += weight[:, np.newaxis] * (mapped - points) weights_sum += weight # Apply displacements where weights are significant valid_indices = weights_sum > 1e-6 if np.any(valid_indices): src_points = points[valid_indices] + displacements[ valid_indices] / weights_sum[valid_indices, np.newaxis] # Clip to image boundaries src_points = np.clip( np.round(src_points).astype(int), [0, 0], [width - 1, height - 1]) # Remap to flat indices flat_indices = valid_indices.nonzero()[0] dst_y = y_coords.flatten()[flat_indices] - start_y dst_x = x_coords.flatten()[flat_indices] # Copy pixel values chunk_result[dst_y, dst_x] = img[src_points[:, 1], src_points[:, 0]] return chunk_result # Process chunks in parallel with concurrent.futures.ThreadPoolExecutor() as executor: futures = [] for i in range(0, height, chunk_size): end_y = min(i + chunk_size, height) futures.append(executor.submit(process_chunk, i, end_y)) # Collect results for future in concurrent.futures.as_completed(futures): i = futures.index(future) chunk_start = i * chunk_size chunk_end = min(chunk_start + chunk_size, height) warp_img[chunk_start:chunk_end, :] = future.result() if progress_callback: progress_callback((i + 1) / total_chunks * 100) return warp_img ``` ### blend 使用 color blending $\text{blend}(i,j) = (1 - t) \, \text{src}(i,j) + t \, \text{dst}(i,j)$ ```python def blend_images(img1, img2, t): """Blend two images using weighted average.""" return ((1 - t) * img1 + t * img2).astype(np.uint8) ``` ## Bonus ### Gui Controll All Things ```python class MorphApp(QMainWindow): """Main application window for image morphing.""" def __init__(self): super().__init__() self.setup_ui() def setup_ui(self): """Set up the user interface.""" self.setWindowTitle("Advanced Image Morphing") self.resize(1200, 800) # Central widget and main layout central_widget = QWidget() main_layout = QVBoxLayout(central_widget) # Create scroll areas for image canvases self.image1_scroll = QScrollArea() self.image1_scroll.setWidgetResizable(True) self.image1_canvas = ImageCanvas(self) self.image1_scroll.setWidget(self.image1_canvas) self.image2_scroll = QScrollArea() self.image2_scroll.setWidgetResizable(True) self.image2_canvas = ImageCanvas(self) self.image2_scroll.setWidget(self.image2_canvas) # Create scroll area for result self.result_scroll = QScrollArea() self.result_scroll.setWidgetResizable(True) self.result_label = QLabel("Morph result will be shown here.") self.result_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.result_label.setMinimumSize(400, 300) self.result_label.setStyleSheet("border: 1px solid gray;") self.result_scroll.setWidget(self.result_label) # Create toolbar toolbar = QToolBar("Main Toolbar") self.addToolBar(toolbar) # Add actions to toolbar self.load_action1 = QAction("Load Image 1", self) self.load_action1.triggered.connect( lambda: self.load_image(self.image1_canvas)) toolbar.addAction(self.load_action1) self.load_action2 = QAction("Load Image 2", self) self.load_action2.triggered.connect( lambda: self.load_image(self.image2_canvas)) toolbar.addAction(self.load_action2) self.reset_action = QAction("Reset Lines", self) self.reset_action.triggered.connect(self.reset_lines) toolbar.addAction(self.reset_action) self.save_action = QAction("Save Morph", self) self.save_action.triggered.connect(self.save_morph) toolbar.addAction(self.save_action) toolbar.addSeparator() self.match_action = QAction("Suggest Feature Matches", self) self.match_action.triggered.connect(self.suggest_matches) toolbar.addAction(self.match_action) # Create canvases layout canvases_layout = QHBoxLayout() canvases_layout.addWidget(self.image1_scroll) canvases_layout.addWidget(self.image2_scroll) main_layout.addLayout(canvases_layout) # Create slider layout slider_layout = QVBoxLayout() slider_label = QLabel("Morph Parameter (t):") slider_layout.addWidget(slider_label) self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 100) self.slider.setValue(50) self.slider.valueChanged.connect(self.update_morph) slider_layout.addWidget(self.slider) # Create progress bar self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) self.progress_bar.setTextVisible(True) slider_layout.addWidget(self.progress_bar) main_layout.addLayout(slider_layout) main_layout.addWidget(self.result_scroll) # Set central widget self.setCentralWidget(central_widget) # Create status bar self.statusBar = QStatusBar() self.setStatusBar(self.statusBar) # Initialize morph worker self.morph_worker = None # Connect signals with debouncing self.morph_timer = QTimer() self.morph_timer.setSingleShot(True) self.morph_timer.timeout.connect(self.trigger_morph_update) # Use valueChanged for slider preview, but only trigger morph on release self.slider.sliderReleased.connect(self.queue_morph_update) self.image1_canvas.line_updated.connect(self.queue_morph_update) self.image2_canvas.line_updated.connect(self.queue_morph_update) def queue_morph_update(self): """Queue a morph update after a short delay to prevent rapid updates.""" self.morph_timer.start(300) # 300ms delay def trigger_morph_update(self): """Trigger the actual morph update.""" self.update_morph() ``` ### Load images 利用 Qt 載入影像,並自訂物件渲染。 ```python class MorphApp(QMainWindow): ... def load_image(self, canvas): """Load an image into the specified canvas.""" path, _ = QFileDialog.getOpenFileName( self, "Open Image", "", "Images (*.png *.jpg *.jpeg *.bmp)") if path: success = canvas.load_image(path) if success: self.statusBar.showMessage(f"Loaded {path}", 3000) # Check if we need to update correspondence lines if (canvas == self.image1_canvas and self.image2_canvas.image is not None) or \ (canvas == self.image2_canvas and self.image1_canvas.image is not None): # Reset lines when loading a new image self.reset_lines() ``` ```python class ImageCanvas(QLabel): ... def load_image(self, path): """Load an image from file path.""" try: self.original_image = cv2.cvtColor(cv2.imread(path), cv2.COLOR_BGR2RGB) self.image = self.original_image.copy() self.redraw() self.setMinimumSize(400, 300) # Set minimum size for canvas return True except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load image: {str(e)}") return False ``` ### Draw Line by youself 利用滑鼠在圖片上點擊,即可編輯、移除與新增特徵線 (三個 bonus) ```python class ImageCanvas(QLabel): ... def mousePressEvent(self, event): """Handle mouse press events for line creation and editing.""" if self.image is None: return # Convert to image coordinates img_x, img_y = self.get_image_coords(event.position().x(), event.position().y()) pos = QPoint(img_x, img_y) if event.button() == Qt.MouseButton.LeftButton: # Check if we're clicking on an existing line for i, line_item in enumerate(self.lines): if line_item.contains_point(pos): self.selected_line_idx = i self.dragging = True self.last_mouse_pos = pos self.redraw() return # If not on an existing line, create a new line if self.temp_start is None: self.temp_start = pos else: end = pos # Create a LineItem new_line = LineItem(self.temp_start, end) self.lines.append(new_line) self.temp_start = None self.line_updated.emit() self.redraw() elif event.button() == Qt.MouseButton.RightButton: # Check if we're right-clicking an existing line for i, line_item in enumerate(self.lines): if line_item.contains_point(pos): # Create a context menu menu = QMenu(self) delete_action = menu.addAction("Delete Line") action = menu.exec(event.globalPosition().toPoint()) if action == delete_action: del self.lines[i] if self.selected_line_idx == i: self.selected_line_idx = -1 elif self.selected_line_idx > i: self.selected_line_idx -= 1 self.line_updated.emit() self.redraw() return def mouseMoveEvent(self, event): """Handle mouse move events for line dragging.""" if self.image is None: return # Convert to image coordinates img_x, img_y = self.get_image_coords(event.position().x(), event.position().y()) pos = QPoint(img_x, img_y) if self.dragging and self.selected_line_idx >= 0: line_item = self.lines[self.selected_line_idx] line_item.move_point(pos) self.line_updated.emit() self.redraw() # Update temp line drawing if self.temp_start is not None: self.redraw() def mouseReleaseEvent(self, event): """Handle mouse release events.""" if event.button() == Qt.MouseButton.LeftButton: self.dragging = False ``` ### Morphing image 運算平行化 利用 thread 將影像拆散給多個運算單元進行運算,加速處理速度。 ```python def process_chunk(start_y, end_y): chunk_result = np.zeros_like(img[start_y:end_y, :]) # Generate coordinate grids for vectorized operation y_coords, x_coords = np.mgrid[start_y:end_y, 0:width] points = np.column_stack((x_coords.flatten(), y_coords.flatten())) # Calculate displacement for each point displacements = np.zeros((points.shape[0], 2)) weights_sum = np.zeros(points.shape[0]) for i in range(len(src_lines)): src_start, src_end = np.array(src_lines[i]) dst_start, dst_end = np.array(dst_lines[i]) # Interpolate line endpoints inter_start = (1 - t) * src_start + t * dst_start inter_end = (1 - t) * src_end + t * dst_end # Calculate line vector Q = inter_end - inter_start Q_length_squared = np.dot(Q, Q) if Q_length_squared < 1e-6: # Avoid division by zero continue src_Q = src_end - src_start if np.dot(src_Q, src_Q) < 1e-6: continue # Calculate perpendicular vector src_perp = np.array([-src_Q[1], src_Q[0] ]) / np.linalg.norm(src_Q) # Calculate u and v for each point P_minus_start = points - inter_start u = np.einsum('ij,j->i', P_minus_start, Q) / Q_length_squared v = np.einsum('ij,j->i', P_minus_start, np.array( [-Q[1], Q[0]])) / np.sqrt(Q_length_squared) # Calculate mapped source coordinates mapped = src_start + np.outer(u, src_Q) + np.outer(v, src_perp) # Calculate weights dist = np.abs(v) length = np.sqrt(Q_length_squared) a, b, p = 0.5, 1.0, 0.5 # Adjusted parameters for smoother warping weight = (length**p / (a + dist))**b # Accumulate weighted displacements displacements += weight[:, np.newaxis] * (mapped - points) weights_sum += weight # Apply displacements where weights are significant valid_indices = weights_sum > 1e-6 if np.any(valid_indices): src_points = points[valid_indices] + displacements[ valid_indices] / weights_sum[valid_indices, np.newaxis] # Clip to image boundaries src_points = np.clip( np.round(src_points).astype(int), [0, 0], [width - 1, height - 1]) # Remap to flat indices flat_indices = valid_indices.nonzero()[0] dst_y = y_coords.flatten()[flat_indices] - start_y dst_x = x_coords.flatten()[flat_indices] # Copy pixel values chunk_result[dst_y, dst_x] = img[src_points[:, 1], src_points[:, 0]] return chunk_result ``` ### Scroll bar for t/alpha t 為從 0 到 1 以 0.01遞增的數值。 ```python def setup_ui(): slider_layout = QVBoxLayout() slider_label = QLabel("Morph Parameter (t):") slider_layout.addWidget(slider_label) self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 100) self.slider.setValue(50) self.slider.valueChanged.connect(self.update_morph) slider_layout.addWidget(self.slider) ``` ## 程式如何執行 直接執行程式,透過 load image 按鈕選擇輸入的兩張圖片,輸入完成後,系統會自動運算 morph 的結果,使用者可以自行加入成對的特徵線以利圖片映射。 ```shell $ python main.py ``` ![image](https://hackmd.io/_uploads/r1ry7moygl.png)