from pathlib import Path import numpy as np from PIL import Image, ImageDraw import matplotlib.pyplot as plt from matplotlib.tri import Triangulation from image_preparing import resize_image_to_resolution import time transformation_resolution = (1600, 1200) def create_grid_correspondences(image, nx=8, ny=6): w, h = image.size xs = np.linspace(0, w - 1, nx) ys = np.linspace(0, h - 1, ny) pts = [] for y in ys: for x in xs: pts.append([float(x), float(y)]) return np.array(pts, dtype=float).copy() def calculate_delaunay_triangles(points): tri = Triangulation(points[:, 0], points[:, 1]) return tri.triangles def calculate_affine_coefficients(from_triangle, to_triangle): A = [] B = [] for (x_dst, y_dst), (x_src, y_src) in zip(from_triangle, to_triangle): A.append([x_dst, y_dst, 1, 0, 0, 0]) A.append([0, 0, 0, x_dst, y_dst, 1]) B.append(x_src) B.append(y_src) sol, *_ = np.linalg.lstsq(np.array(A), np.array(B), rcond=None) return tuple(sol.tolist()) def warp_triangle(source_image, source_triangle, destination_triangle, output_size, offset): dx, dy = offset destination_local = np.array(destination_triangle) - np.array([dx, dy]) coefficients = calculate_affine_coefficients(destination_local, source_triangle) return source_image.transform(output_size, Image.AFFINE, coefficients, resample=Image.BILINEAR) def reconstruct_goal_from_source(source_image, source_points, destination_points): w, h = source_image.size triangles = calculate_delaunay_triangles(destination_points) out = Image.new("RGB", (w, h)) for tri in triangles: tri = np.asarray(tri, dtype=int) source_triangle = source_points[tri] destination_triangle = destination_points[tri] min_x = max(int(np.floor(destination_triangle[:, 0].min())), 0) max_x = min(int(np.ceil(destination_triangle[:, 0].max())), w) min_y = max(int(np.floor(destination_triangle[:, 1].min())), 0) max_y = min(int(np.ceil(destination_triangle[:, 1].max())), h) if max_x <= min_x or max_y <= min_y: continue out_w = max_x - min_x out_h = max_y - min_y warped = warp_triangle(source_image, source_triangle, destination_triangle, (out_w, out_h), (min_x, min_y)) mask = Image.new("L", (out_w, out_h), 0) draw = ImageDraw.Draw(mask) tri_local = [(x - min_x, y - min_y) for x, y in destination_triangle.tolist()] draw.polygon(tri_local, fill=255) out.paste(warped, (min_x, min_y), mask) return out def show_image_transition(source_path, goal_path): src = resize_image_to_resolution(source_path, size=transformation_resolution) goal = resize_image_to_resolution(goal_path, size=transformation_resolution) start = time.time() image = reposition_image_tiles(src, goal, grid=transformation_resolution) end = time.time() print(f"Transition took {end - start:.2f} seconds") fig, ax = plt.subplots(figsize=(8, 6), facecolor="black") fig.patch.set_facecolor("black") ax.set_facecolor("black") ax.imshow(image, cmap=None) ax.axis("off") def close_transition_on_key(event): if event.key in ("q", "escape"): plt.close(fig) fig.canvas.mpl_connect("key_press_event", close_transition_on_key) plt.show() def calculate_grid_edges(size, divisions): return np.rint(np.linspace(0, size, divisions + 1)).astype(np.int32) def build_tile_boxes_and_centers(width, height, columns, rows): x_edges = calculate_grid_edges(width, columns) y_edges = calculate_grid_edges(height, rows) x0 = np.repeat(x_edges[:-1], rows) x1 = np.repeat(x_edges[1:], rows) y0 = np.tile(y_edges[:-1], columns) y1 = np.tile(y_edges[1:], columns) boxes = np.stack([x0, y0, x1, y1], axis=1) centers = np.stack([(x0 + x1) * 0.5, (y0 + y1) * 0.5], axis=1).astype(np.float32) areas = np.maximum((x1 - x0) * (y1 - y0), 1).astype(np.float32) return boxes, centers, areas def calculate_integral_image_rgb(array): integral = array.cumsum(axis=0).cumsum(axis=1) return np.pad(integral, ((1, 0), (1, 0), (0, 0)), mode="constant", constant_values=0) def calculate_tile_means(integral, boxes, areas): x0 = boxes[:, 0] y0 = boxes[:, 1] x1 = boxes[:, 2] y1 = boxes[:, 3] sums = ( integral[y1, x1] - integral[y0, x1] - integral[y1, x0] + integral[y0, x0] ) return sums / areas[:, None] def match_tiles_by_feature_rank(source_averages, source_centers, goal_averages, goal_centers, width, height): color_scale = 255.0 * np.sqrt(3.0) spatial_scale = max(np.hypot(width, height), 1e-6) src_feat = np.hstack([ source_averages / color_scale, (source_centers / spatial_scale) * 0.35, ]) goal_feat = np.hstack([ goal_averages / color_scale, (goal_centers / spatial_scale) * 0.35, ]) # Fixed projection keeps pairing deterministic and avoids O(n^2) matching. projection = np.array([0.50, 0.30, 0.20, 0.60, 0.40], dtype=np.float32) src_order = np.argsort(src_feat @ projection) goal_order = np.argsort(goal_feat @ projection) return src_order, goal_order def reposition_image_tiles(source_image, goal_image, grid=transformation_resolution): src = source_image.convert("RGB") goal = goal_image.convert("RGB") w, h = src.size nx, ny = grid src_boxes, src_centers, src_areas = build_tile_boxes_and_centers(w, h, nx, ny) goal_boxes, goal_centers, goal_areas = build_tile_boxes_and_centers(w, h, nx, ny) src_arr = np.asarray(src, dtype=np.float32) goal_arr = np.asarray(goal, dtype=np.float32) src_integral = calculate_integral_image_rgb(src_arr) goal_integral = calculate_integral_image_rgb(goal_arr) src_avgs = calculate_tile_means(src_integral, src_boxes, src_areas) goal_avgs = calculate_tile_means(goal_integral, goal_boxes, goal_areas) src_order, goal_order = match_tiles_by_feature_rank( src_avgs, src_centers, goal_avgs, goal_centers, w, h, ) final = Image.new("RGB", (w, h)) for s_idx, g_idx in zip(src_order.tolist(), goal_order.tolist()): sx0, sy0, sx1, sy1 = src_boxes[s_idx].tolist() gx0, gy0, gx1, gy1 = goal_boxes[g_idx].tolist() tile = src.crop((sx0, sy0, sx1, sy1)) goal_w = max(gx1 - gx0, 1) goal_h = max(gy1 - gy0, 1) if tile.size != (goal_w, goal_h): tile = tile.resize((goal_w, goal_h), Image.Resampling.BILINEAR) final.paste(tile, (gx0, gy0)) return final