I’m having trouble to implement correctely a manual mesh/grid warp feature in Python, similar to “transform warp” tool in Photoshop.
I already have a perspective warp that works correctly, however I can’t manage to understand how to achieve this sort of mesh deformation warp, haven’t had any luck.
Current look: the grid right now collapses onto itself, won’t stay fixed in place and won’t deform the shape and the image as you can see here:
Wanted outcome:
Here is the current code:
import tkinter as tk
from tkinter import ttk, filedialog
import cv2
import numpy as np
from PIL import Image, ImageTk
import os
from scipy.interpolate import griddata
from matplotlib.path import Path
class AdvancedSmartObjectGUI:
def __init__(self, master):
self.master = master
self.master.title("Advanced Smart Object GUI")
self.canvas = tk.Canvas(self.master, width=800, height=600, bg='white')
self.canvas.pack(fill=tk.BOTH, expand=True)
self.background_image = None
self.overlay_image = None
self.warped_overlay = None
self.shape_points = []
self.grid_points = []
self.original_grid_points = []
self.original_shape_points = []
self.dragging = None
self.mode = 'create'
self.opacity = 1.0
self.create_buttons()
self.canvas.bind("<Button-1>", self.on_click)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_release)
self.master.bind("<Control-z>", self.undo)
self.history = []
self.load_background_from_folder()
def create_buttons(self):
button_frame = ttk.Frame(self.master)
button_frame.pack(fill=tk.X)
ttk.Button(button_frame, text="Load Overlay", command=self.load_overlay).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Create Shape", command=lambda: self.set_mode('create')).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Move Shape", command=lambda: self.set_mode('move')).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Edit Shape", command=lambda: self.set_mode('edit')).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Grid", command=lambda: self.set_mode('grid')).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Reset", command=self.reset).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Undo", command=self.undo).pack(side=tk.LEFT)
self.opacity_scale = ttk.Scale(button_frame, from_=0, to=1, orient=tk.HORIZONTAL, command=self.update_opacity)
self.opacity_scale.set(1.0)
self.opacity_scale.pack(side=tk.LEFT)
def set_mode(self, mode):
self.mode = mode
if mode == 'grid' and len(self.shape_points) == 4:
self.create_grid_points()
self.original_shape_points = self.shape_points.copy()
self.original_grid_points = self.grid_points.copy()
self.display_image()
def create_grid_points(self):
rows, cols = 4, 4
self.grid_points = []
for i in range(rows + 1):
for j in range(cols + 1):
t = i / rows
s = j / cols
x = (1-t)*(1-s)*self.shape_points[0][0] + (1-t)*s*self.shape_points[1][0] + t*(1-s)*self.shape_points[3][0] + t*s*self.shape_points[2][0]
y = (1-t)*(1-s)*self.shape_points[0][1] + (1-t)*s*self.shape_points[1][1] + t*(1-s)*self.shape_points[3][1] + t*s*self.shape_points[2][1]
self.grid_points.append((x, y))
def load_background_from_folder(self):
folder_path = r'C:UsersUtenteDesktopa2'
image_files = [f for f in os.listdir(folder_path) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
if image_files:
file_path = os.path.join(folder_path, image_files[0])
self.background_image = cv2.imread(file_path)
self.background_image = cv2.cvtColor(self.background_image, cv2.COLOR_BGR2RGB)
self.background_image = cv2.resize(self.background_image, (800, 600))
self.display_image()
else:
print("No image files found in the specified folder.")
def load_overlay(self):
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.png *.jpg *.jpeg")])
if file_path:
self.overlay_image = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
if self.overlay_image.shape[2] == 3:
self.overlay_image = cv2.cvtColor(self.overlay_image, cv2.COLOR_BGR2RGBA)
else:
self.overlay_image = cv2.cvtColor(self.overlay_image, cv2.COLOR_BGRA2RGBA)
if len(self.shape_points) < 4:
self.shape_points = [(0, 0), (800, 0), (800, 600), (0, 600)]
self.create_grid_points()
self.warp_overlay()
self.display_image()
def warp_overlay(self):
if self.overlay_image is None or len(self.grid_points) < 25:
return
min_x = min(p[0] for p in self.shape_points)
max_x = max(p[0] for p in self.shape_points)
min_y = min(p[1] for p in self.shape_points)
max_y = max(p[1] for p in self.shape_points)
shape_height = max_y - min_y
overlay_aspect = self.overlay_image.shape[1] / self.overlay_image.shape[0]
new_width = int(shape_height * overlay_aspect)
if new_width > 0 and shape_height > 0:
resized_overlay = cv2.resize(self.overlay_image, (new_width, int(shape_height)))
src_points = np.array([(0, 0), (new_width, 0), (new_width, shape_height), (0, shape_height)], dtype=np.float32)
dst_points = np.array(self.shape_points, dtype=np.float32)
matrix = cv2.getPerspectiveTransform(src_points, dst_points)
self.warped_overlay = cv2.warpPerspective(resized_overlay, matrix, (800, 600), borderMode=cv2.BORDER_TRANSPARENT)
else:
print("Invalid dimensions for resized overlay. Skipping warp operation.")
def display_image(self):
if self.background_image is None:
self.canvas.delete("all")
return
img = self.background_image.copy()
if self.warped_overlay is not None:
img = self.overlay_images(img, self.warped_overlay)
self.photo = ImageTk.PhotoImage(image=Image.fromarray(img))
self.canvas.delete("all")
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo)
if self.mode == 'grid':
self.draw_grid()
else:
self.draw_shape()
def overlay_images(self, background, overlay):
mask = overlay[:, :, 3] / 255.0 * self.opacity
for c in range(3):
background[:, :, c] = background[:, :, c] * (1 - mask) + overlay[:, :, c] * mask
return background.astype(np.uint8)
def draw_shape(self):
if len(self.shape_points) > 1:
self.canvas.create_polygon(self.shape_points, outline='red', fill='', tags='shape')
for i, point in enumerate(self.shape_points):
self.canvas.create_oval(point[0]-5, point[1]-5, point[0]+5, point[1]+5, fill='red', tags=f'handle{i}')
def draw_grid(self):
rows, cols = 4, 4
for i in range(rows + 1):
points = [self.grid_points[j] for j in range(i*(cols+1), (i+1)*(cols+1))]
self.canvas.create_line(points, fill='blue')
for j in range(cols + 1):
points = [self.grid_points[i*(cols+1)+j] for i in range(rows+1)]
self.canvas.create_line(points, fill='blue')
for point in self.grid_points:
self.canvas.create_oval(point[0]-3, point[1]-3, point[0]+3, point[1]+3, fill='blue')
def on_click(self, event):
if self.mode == 'create':
self.shape_points.append((event.x, event.y))
elif self.mode == 'move':
if self.point_inside_shape(event.x, event.y):
self.dragging = 'all'
self.last_x, self.last_y = event.x, event.y
elif self.mode == 'edit':
for i, point in enumerate(self.shape_points):
if abs(event.x - point[0]) < 5 and abs(event.y - point[1]) < 5:
self.dragging = i
self.last_x, self.last_y = event.x, event.y
return
elif self.mode == 'grid':
for i, point in enumerate(self.grid_points):
if abs(event.x - point[0]) < 5 and abs(event.y - point[1]) < 5:
self.dragging = i
return
self.display_image()
def on_drag(self, event):
if self.dragging is not None:
if self.mode == 'move':
dx, dy = event.x - self.last_x, event.y - self.last_y
self.shape_points = [(x+dx, y+dy) for x, y in self.shape_points]
self.grid_points = [(x+dx, y+dy) for x, y in self.grid_points]
self.last_x, self.last_y = event.x, event.y
elif self.mode == 'edit':
self.shape_points[self.dragging] = (event.x, event.y)
self.create_grid_points()
elif self.mode == 'grid':
self.update_grid(self.dragging, event.x, event.y)
self.warp_overlay()
self.display_image()
def update_grid(self, index, new_x, new_y):
old_x, old_y = self.grid_points[index]
dx, dy = new_x - old_x, new_y - old_y
self.grid_points[index] = (new_x, new_y)
rows, cols = 4, 4
for i, point in enumerate(self.grid_points):
if i != index: # Don't move the dragged point
dist = np.sqrt((point[0] - old_x)**2 + (point[1] - old_y)**2)
max_dist = 200 # Influence radius
if dist < max_dist:
influence = (max_dist - dist) / max_dist
new_point_x = point[0] + dx * influence
new_point_y = point[1] + dy * influence
# Ensure the point stays within the shape boundaries
row = i // (cols + 1)
col = i % (cols + 1)
t = row / rows
s = col / cols
min_x = min(self.shape_points[0][0], self.shape_points[3][0])
max_x = max(self.shape_points[1][0], self.shape_points[2][0])
min_y = min(self.shape_points[0][1], self.shape_points[1][1])
max_y = max(self.shape_points[2][1], self.shape_points[3][1])
new_point_x = max(min_x, min(new_point_x, max_x))
new_point_y = max(min_y, min(new_point_y, max_y))
self.grid_points[i] = (new_point_x, new_point_y)
# Update shape points based on the new grid
self.update_shape_points()
# Apply cv2.remap to deform the overlay image according to the new grid
self.deform_overlay()
def update_shape_points(self):
rows, cols = 4, 4
for i, point in enumerate(self.shape_points):
if i == 0:
self.shape_points[i] = self.grid_points[0]
elif i == 1:
self.shape_points[i] = self.grid_points[cols]
elif i == 2:
self.shape_points[i] = self.grid_points[-1]
elif i == 3:
self.shape_points[i] = self.grid_points[-(cols+1)]
def deform_overlay(self):
if self.overlay_image is None:
return
h, w = self.overlay_image.shape[:2]
src_points = np.array(self.original_grid_points, dtype=np.float32)
dst_points = np.array(self.grid_points, dtype=np.float32)
M = cv2.findHomography(src_points, dst_points)[0]
self.warped_overlay = cv2.warpPerspective(self.overlay_image, M, (w, h), borderMode=cv2.BORDER_TRANSPARENT)
def on_release(self, event):
if self.dragging is not None:
self.add_to_history()
self.dragging = None
def point_inside_shape(self, x, y):
path = Path(self.shape_points)
return path.contains_point((x, y))
def reset(self):
self.shape_points = []
self.grid_points = []
self.original_shape_points = []
self.overlay_image = None
self.warped_overlay = None
self.display_image()
def update_opacity(self, value):
self.opacity = float(value)
self.display_image()
def add_to_history(self):
state = {
'shape_points': self.shape_points.copy(),
'grid_points': self.grid_points.copy(),
'original_grid_points': self.original_grid_points.copy(),
'warped_overlay': self.warped_overlay.copy() if self.warped_overlay is not None else None,
'opacity': self.opacity
}
self.history.append(state)
if len(self.history) > 10:
self.history.pop(0)
def undo(self, event=None):
if len(self.history) > 1:
self.history.pop() # Remove current state
previous_state = self.history[-1]
self.shape_points = previous_state['shape_points']
self.grid_points = previous_state['grid_points']
self.original_grid_points = previous_state['original_grid_points']
self.warped_overlay = previous_state['warped_overlay']
self.opacity = previous_state['opacity']
self.opacity_scale.set(self.opacity)
self.display_image()
if __name__ == "__main__":
root = tk.Tk()
app = AdvancedSmartObjectGUI(root)
root.mainloop()
Pollo is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.