# 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
```
