""" Image Grid Cutter - A Gradio app to split images into a customizable grid """ import gradio as gr from PIL import Image, ImageDraw import io import zipfile import tempfile import os import math # Ensure local URLs bypass any proxy checks and avoid Gradio API schema issues. def _ensure_no_proxy(): for key in ("NO_PROXY", "no_proxy"): val = os.environ.get(key, "") parts = [p.strip() for p in val.split(",") if p.strip()] for host in ("127.0.0.1", "localhost"): if host not in parts: parts.append(host) os.environ[key] = ",".join(parts) def _patch_gradio_schema(): try: import gradio_client.utils as grc_utils _orig = grc_utils._json_schema_to_python_type _orig_get_type = grc_utils.get_type def _json_schema_to_python_type(schema, defs=None): if isinstance(schema, bool): return "Any" return _orig(schema, defs) def _get_type(schema): if isinstance(schema, bool): return "any" return _orig_get_type(schema) grc_utils._json_schema_to_python_type = _json_schema_to_python_type grc_utils.get_type = _get_type except Exception: pass _ensure_no_proxy() _patch_gradio_schema() def split_image_into_grid(image, rows, cols): """Splits an image into a grid of smaller images, preserving full resolution.""" if image is None: return [] img_width, img_height = image.size rows = int(rows) cols = int(cols) # Calculate tile dimensions with remainder handling tile_width = img_width // cols tile_height = img_height // rows width_remainder = img_width % cols height_remainder = img_height % rows tiles = [] for row in range(rows): for col in range(cols): # Distribute remainder pixels across tiles left = col * tile_width + min(col, width_remainder) upper = row * tile_height + min(row, height_remainder) # Add extra pixel for tiles that get remainder right = left + tile_width + (1 if col < width_remainder else 0) lower = upper + tile_height + (1 if row < height_remainder else 0) # Ensure we don't exceed image bounds right = min(right, img_width) lower = min(lower, img_height) tile = image.crop((left, upper, right, lower)) tiles.append(tile) return tiles def create_grid_preview(image, rows, cols): """Creates a preview image showing the grid lines overlaid on the image.""" if image is None: return None rows = int(rows) cols = int(cols) preview = image.copy().convert("RGBA") overlay = Image.new("RGBA", preview.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) img_width, img_height = image.size tile_width = img_width // cols tile_height = img_height // rows width_remainder = img_width % cols height_remainder = img_height % rows line_color = (220, 38, 38, 255) # Red line_width = max(2, min(img_width, img_height) // 200) # Draw vertical lines (matching actual tile boundaries) for col in range(1, cols): x = col * tile_width + min(col, width_remainder) draw.line([(x, 0), (x, img_height)], fill=line_color, width=line_width) # Draw horizontal lines (matching actual tile boundaries) for row in range(1, rows): y = row * tile_height + min(row, height_remainder) draw.line([(0, y), (img_width, y)], fill=line_color, width=line_width) result = Image.alpha_composite(preview, overlay) return result.convert("RGB") def process_image(image, rows, cols): """Main processing function that splits the image and returns tiles.""" if image is None: return [None] * 256, None, None rows = int(rows) cols = int(cols) tiles = split_image_into_grid(image, rows, cols) # Pad with None padded_tiles = tiles[:256] + [None] * (256 - len(tiles[:256])) # Create zip temp_dir = tempfile.gettempdir() zip_path = os.path.join(temp_dir, "grid_tiles.zip") with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: for i, tile in enumerate(tiles): img_buffer = io.BytesIO() tile.save(img_buffer, format='PNG') img_buffer.seek(0) row_num = i // cols + 1 col_num = i % cols + 1 zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) # Return first tile for crop reference first_tile = tiles[0] if tiles else None return padded_tiles, zip_path, first_tile def get_cropped_image(editor_value): """Extract the cropped image from ImageEditor.""" if editor_value is None: return None if isinstance(editor_value, Image.Image): return editor_value if isinstance(editor_value, dict): if 'composite' in editor_value and editor_value['composite'] is not None: return editor_value['composite'] if 'background' in editor_value and editor_value['background'] is not None: return editor_value['background'] return None def apply_crop_to_all(original_image, rows, cols, cropped_editor, original_tile): """Apply the crop from one tile to all tiles.""" if original_image is None: return [None] * 256, None rows = int(rows) cols = int(cols) # Get the cropped image from editor cropped_img = get_cropped_image(cropped_editor) # If no crop was done or no original tile, just return original tiles if cropped_img is None or original_tile is None: tiles = split_image_into_grid(original_image, rows, cols) padded = tiles[:256] + [None] * (256 - len(tiles[:256])) return padded, None orig_w, orig_h = original_tile.size crop_w, crop_h = cropped_img.size # If same size, no crop was applied if orig_w == crop_w and orig_h == crop_h: tiles = split_image_into_grid(original_image, rows, cols) padded = tiles[:256] + [None] * (256 - len(tiles[:256])) return padded, None # Calculate how much was cropped from each side # Assume centered crop for now left_crop = (orig_w - crop_w) // 2 top_crop = (orig_h - crop_h) // 2 right_crop = orig_w - crop_w - left_crop bottom_crop = orig_h - crop_h - top_crop # Split and apply same crop to each tile tiles = split_image_into_grid(original_image, rows, cols) cropped_tiles = [] for tile in tiles: t_w, t_h = tile.size # Apply proportional crop new_left = min(left_crop, t_w - 1) new_top = min(top_crop, t_h - 1) new_right = max(new_left + 1, t_w - right_crop) new_bottom = max(new_top + 1, t_h - bottom_crop) if new_right > new_left and new_bottom > new_top: cropped = tile.crop((new_left, new_top, new_right, new_bottom)) else: cropped = tile cropped_tiles.append(cropped) # Create zip temp_dir = tempfile.gettempdir() zip_path = os.path.join(temp_dir, "cropped_tiles.zip") with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: for i, tile in enumerate(cropped_tiles): img_buffer = io.BytesIO() tile.save(img_buffer, format='PNG') img_buffer.seek(0) row_num = i // cols + 1 col_num = i % cols + 1 zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) padded = cropped_tiles[:256] + [None] * (256 - len(cropped_tiles[:256])) return padded, zip_path def create_tiles_zip(tiles, cols): """Create a ZIP file from tiles list.""" if not tiles: return None cols = int(cols) temp_dir = tempfile.gettempdir() zip_path = os.path.join(temp_dir, "edited_tiles.zip") with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zip_file: for i, tile in enumerate(tiles): if tile is not None: img_buffer = io.BytesIO() tile.save(img_buffer, format='PNG') img_buffer.seek(0) row_num = i // cols + 1 col_num = i % cols + 1 zip_file.writestr(f'tile_r{row_num}_c{col_num}.png', img_buffer.getvalue()) return zip_path def generate_gif_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0"): """Generate an animated GIF from a list of tiles.""" if not tiles: return None frame_duration = int(frame_duration) # Determine background color if bg_type == "Transparent": bg_rgba = (0, 0, 0, 0) else: hex_color = bg_color.lstrip('#') r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) bg_rgba = (r, g, b, 255) # Get MAX dimensions from all tiles (handles individually cropped tiles) tile_width = max(t.width for t in tiles) tile_height = max(t.height for t in tiles) img_width = tile_width * cols img_height = tile_height * rows frames = [] def parse_bg_rgb(bg_type_val, bg_color_val): if bg_type_val == "Transparent": return (255, 255, 255) hex_color = bg_color_val.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def build_gif_palette(sample_frames, bg_type_val, bg_color_val): if not sample_frames: return None sample_count = min(10, len(sample_frames)) if sample_count == 1: indices = [0] else: indices = [round(i * (len(sample_frames) - 1) / (sample_count - 1)) for i in range(sample_count)] samples = [] bg_rgb_local = parse_bg_rgb(bg_type_val, bg_color_val) sample_size = 200 for i in indices: frame = sample_frames[i].convert("RGBA") base = Image.new("RGB", frame.size, bg_rgb_local) base.paste(frame, mask=frame.split()[3]) samples.append(base.resize((sample_size, sample_size), Image.LANCZOS)) cols_local = min(5, len(samples)) rows_local = int(math.ceil(len(samples) / cols_local)) mosaic = Image.new("RGB", (sample_size * cols_local, sample_size * rows_local), bg_rgb_local) for idx, s in enumerate(samples): x = (idx % cols_local) * sample_size y = (idx // cols_local) * sample_size mosaic.paste(s, (x, y)) palette_img = mosaic.convert("P", palette=Image.ADAPTIVE, colors=255) palette = palette_img.getpalette() palette[255 * 3:255 * 3 + 3] = [255, 0, 255] palette_img.putpalette(palette) return palette_img def save_gif(frames_list, path, duration_ms, bg_type_val, bg_color_val, tail_pause=False): if not frames_list: return None palette_img = build_gif_palette(frames_list, bg_type_val, bg_color_val) bg_rgb_local = parse_bg_rgb(bg_type_val, bg_color_val) frames_p = [] for f in frames_list: f_rgba = f.convert("RGBA") if bg_type_val == "Transparent": mask = f_rgba.split()[3] base = Image.new("RGB", f_rgba.size, (255, 0, 255)) base.paste(f_rgba, mask=mask) f_p = base.quantize(palette=palette_img, dither=Image.NONE) else: base = Image.new("RGB", f_rgba.size, bg_rgb_local) base.paste(f_rgba, mask=f_rgba.split()[3]) f_p = base.quantize(palette=palette_img, dither=Image.NONE) frames_p.append(f_p) durations = [duration_ms] * len(frames_p) if tail_pause and len(durations) > 0: durations[-1] = duration_ms * 3 save_kwargs = dict(save_all=True, append_images=frames_p[1:], duration=durations, loop=0, disposal=2) if bg_type_val == "Transparent": save_kwargs["transparency"] = 255 frames_p[0].save(path, **save_kwargs) return path if single_tile_mode: # Normalize all frames to same size (max dimensions), center smaller tiles for tile in tiles: frame = Image.new("RGBA", (tile_width, tile_height), bg_rgba) x = (tile_width - tile.width) // 2 y = (tile_height - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) if frames: temp_dir = tempfile.gettempdir() gif_path = os.path.join(temp_dir, "grid_animation.gif") return save_gif(frames, gif_path, frame_duration, bg_type, bg_color) return None if animation_type == "Reveal (build up)": for i in range(len(tiles) + 1): frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for j in range(i): row = j // cols col = j % cols x = col * tile_width + (tile_width - tiles[j].width) // 2 y = row * tile_height + (tile_height - tiles[j].height) // 2 tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Cycle (one at a time)": for i, tile in enumerate(tiles): frame = Image.new("RGBA", (img_width, img_height), bg_rgba) row = i // cols col = i % cols x = col * tile_width + (tile_width - tile.width) // 2 y = row * tile_height + (tile_height - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Flash (all tiles)": max_dim = max(tile_width, tile_height) for tile in tiles: frame = Image.new("RGBA", (max_dim, max_dim), bg_rgba) x = (max_dim - tile.width) // 2 y = (max_dim - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Shuffle (random order)": import random indices = list(range(len(tiles))) random.shuffle(indices) revealed = set() for idx in indices: revealed.add(idx) frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for j in revealed: row = j // cols col = j % cols x = col * tile_width + (tile_width - tiles[j].width) // 2 y = row * tile_height + (tile_height - tiles[j].height) // 2 tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] frame.paste(tile_rgba_img, (x, y)) frames.append(frame) if not frames: return None # Add final complete frame final_frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for i, tile in enumerate(tiles): row = i // cols col = i % cols x = col * tile_width + (tile_width - tile.width) // 2 y = row * tile_height + (tile_height - tile.height) // 2 tile_rgba = tile.convert("RGBA") if tile.mode != "RGBA" else tile final_frame.paste(tile_rgba, (x, y)) frames.append(final_frame) temp_dir = tempfile.gettempdir() gif_path = os.path.join(temp_dir, "grid_animation.gif") return save_gif(frames, gif_path, frame_duration, bg_type, bg_color, tail_pause=True) def generate_video_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0", transition="None"): """Generate an MP4 video from a list of tiles.""" if not tiles: return None try: import imageio.v2 as imageio import numpy as np except Exception as exc: raise gr.Error("MP4 export requires imageio and imageio-ffmpeg. Install them and restart the app.") from exc rows = int(rows) cols = int(cols) frame_duration = int(frame_duration) # Determine background color (video can't be transparent) if bg_type == "Transparent": bg_rgba = (255, 255, 255, 255) else: hex_color = bg_color.lstrip('#') r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) bg_rgba = (r, g, b, 255) tile_width = max(t.width for t in tiles) tile_height = max(t.height for t in tiles) img_width = tile_width * cols img_height = tile_height * rows frames = [] if single_tile_mode: for tile in tiles: frame = Image.new("RGBA", (tile_width, tile_height), bg_rgba) x = (tile_width - tile.width) // 2 y = (tile_height - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Reveal (build up)": for i in range(len(tiles) + 1): frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for j in range(i): row = j // cols col = j % cols x = col * tile_width + (tile_width - tiles[j].width) // 2 y = row * tile_height + (tile_height - tiles[j].height) // 2 tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Cycle (one at a time)": for i, tile in enumerate(tiles): frame = Image.new("RGBA", (img_width, img_height), bg_rgba) row = i // cols col = i % cols x = col * tile_width + (tile_width - tile.width) // 2 y = row * tile_height + (tile_height - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Flash (all tiles)": max_dim = max(tile_width, tile_height) for tile in tiles: frame = Image.new("RGBA", (max_dim, max_dim), bg_rgba) x = (max_dim - tile.width) // 2 y = (max_dim - tile.height) // 2 tile_rgba_img = tile.convert("RGBA") if tile.mode != "RGBA" else tile frame.paste(tile_rgba_img, (x, y)) frames.append(frame) elif animation_type == "Shuffle (random order)": import random indices = list(range(len(tiles))) random.shuffle(indices) revealed = set() for idx in indices: revealed.add(idx) frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for j in revealed: row = j // cols col = j % cols x = col * tile_width + (tile_width - tiles[j].width) // 2 y = row * tile_height + (tile_height - tiles[j].height) // 2 tile_rgba_img = tiles[j].convert("RGBA") if tiles[j].mode != "RGBA" else tiles[j] frame.paste(tile_rgba_img, (x, y)) frames.append(frame) if not frames: return None if not single_tile_mode: final_frame = Image.new("RGBA", (img_width, img_height), bg_rgba) for i, tile in enumerate(tiles): row = i // cols col = i % cols x = col * tile_width + (tile_width - tile.width) // 2 y = row * tile_height + (tile_height - tile.height) // 2 tile_rgba = tile.convert("RGBA") if tile.mode != "RGBA" else tile final_frame.paste(tile_rgba, (x, y)) frames.append(final_frame) # Normalize frames to a consistent, even size for MP4 max_w = max(f.width for f in frames) max_h = max(f.height for f in frames) if max_w % 2 == 1: max_w += 1 if max_h % 2 == 1: max_h += 1 normalized = [] for f in frames: if f.width != max_w or f.height != max_h: base = Image.new("RGBA", (max_w, max_h), bg_rgba) x = (max_w - f.width) // 2 y = (max_h - f.height) // 2 if f.mode == "RGBA": base.paste(f, (x, y), f.split()[3]) else: base.paste(f, (x, y)) f = base normalized.append(f) # Expand frames for timing and optional transitions fps = 30 base_step_frames = max(1, round((frame_duration / 1000.0) * fps)) extra_pause_frames = base_step_frames * 2 # matches the previous 3x final pause def to_rgb_array(img): if img.mode == "RGBA": flat = Image.new("RGB", img.size, bg_rgba[:3]) flat.paste(img, mask=img.split()[3]) return np.array(flat) return np.array(img.convert("RGB")) rgb_frames = [] if transition == "Fade" and len(normalized) > 1: transition_frames = min(base_step_frames, max(1, round(base_step_frames * 0.4))) hold_frames = base_step_frames - transition_frames for i in range(len(normalized) - 1): a = normalized[i] b = normalized[i + 1] a_rgb = to_rgb_array(a).astype(np.float32) b_rgb = to_rgb_array(b).astype(np.float32) for _ in range(hold_frames): rgb_frames.append(a_rgb.astype(np.uint8)) for t in range(1, transition_frames + 1): alpha = t / transition_frames blended = (a_rgb * (1 - alpha) + b_rgb * alpha).astype(np.uint8) rgb_frames.append(blended) last_rgb = to_rgb_array(normalized[-1]) for _ in range(hold_frames + extra_pause_frames): rgb_frames.append(last_rgb) else: for i, f in enumerate(normalized): repeat = base_step_frames if i == len(normalized) - 1: repeat += extra_pause_frames frame_rgb = to_rgb_array(f) for _ in range(repeat): rgb_frames.append(frame_rgb) temp_dir = tempfile.gettempdir() video_path = os.path.join(temp_dir, "grid_animation.mp4") try: writer = imageio.get_writer(video_path, format="ffmpeg", fps=fps, codec="libx264") except Exception: # Retry without codec in case ffmpeg is present but codec is unsupported writer = imageio.get_writer(video_path, format="ffmpeg", fps=fps) try: for frame in rgb_frames: writer.append_data(frame) finally: writer.close() return video_path def generate_pdf_from_tiles(tiles, rows, cols): """Generate a PDF from tiles in the same order as cut.""" if not tiles: return None rows = int(rows) cols = int(cols) # Convert all tiles to RGB for PDF compatibility rgb_tiles = [] for tile in tiles: if tile.mode == "RGBA": # Convert RGBA to RGB with white background rgb_tile = Image.new("RGB", tile.size, (255, 255, 255)) rgb_tile.paste(tile, mask=tile.split()[3]) rgb_tiles.append(rgb_tile) elif tile.mode != "RGB": rgb_tile = tile.convert("RGB") rgb_tiles.append(rgb_tile) else: rgb_tiles.append(tile) # Create PDF temp_dir = tempfile.gettempdir() pdf_path = os.path.join(temp_dir, "grid_tiles.pdf") # Save first image as PDF, then append others (high resolution for sharpness) if rgb_tiles: rgb_tiles[0].save(pdf_path, "PDF", resolution=300.0, save_all=True, append_images=rgb_tiles[1:]) return pdf_path return None def generate_gif(image, rows, cols, animation_type, single_tile_mode, frame_duration): """Generate an animated GIF from the grid tiles.""" if image is None: return None rows = int(rows) cols = int(cols) frame_duration = int(frame_duration) tiles = split_image_into_grid(image, rows, cols) img_width, img_height = image.size frames = [] if single_tile_mode: for tile in tiles: frames.append(tile.copy()) if frames: temp_dir = tempfile.gettempdir() gif_path = os.path.join(temp_dir, "grid_animation.gif") durations = [frame_duration] * len(frames) frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=durations, loop=0) return gif_path return None tile_width = img_width // cols tile_height = img_height // rows if animation_type == "Reveal (build up)": for i in range(len(tiles) + 1): frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) for j in range(i): row = j // cols col = j % cols x = col * tile_width y = row * tile_height frame.paste(tiles[j], (x, y)) frames.append(frame) elif animation_type == "Cycle (one at a time)": for i, tile in enumerate(tiles): frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) row = i // cols col = i % cols x = col * tile_width y = row * tile_height frame.paste(tile, (x, y)) frames.append(frame) elif animation_type == "Flash (all tiles)": max_dim = max(tile_width, tile_height) for tile in tiles: frame = Image.new("RGB", (max_dim, max_dim), (240, 240, 240)) x = (max_dim - tile.width) // 2 y = (max_dim - tile.height) // 2 frame.paste(tile, (x, y)) frames.append(frame) elif animation_type == "Shuffle (random order)": import random indices = list(range(len(tiles))) random.shuffle(indices) revealed = set() for idx in indices: revealed.add(idx) frame = Image.new("RGB", (img_width, img_height), (240, 240, 240)) for j in revealed: row = j // cols col = j % cols x = col * tile_width y = row * tile_height frame.paste(tiles[j], (x, y)) frames.append(frame) if not frames: return None frames.append(image.copy()) temp_dir = tempfile.gettempdir() gif_path = os.path.join(temp_dir, "grid_animation.gif") durations = [frame_duration] * (len(frames) - 1) + [frame_duration * 3] frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=durations, loop=0) return gif_path def generate_video(image, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type="Transparent", bg_color="#f0f0f0", transition="None"): """Generate an MP4 video from the grid tiles.""" if image is None: return None tiles = split_image_into_grid(image, rows, cols) return generate_video_from_tile_list(tiles, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type, bg_color, transition) # CSS DEMO_IMAGE_URL = "https://cas-bridge.xethub.hf.co/xet-bridge-us/69496c121579b695d50d788c/6f3470987bf30eb4a8c0816a9d848f6c2cd4ea7a56a6fb4f147658f32539e49c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20260126%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260126T214749Z&X-Amz-Expires=3600&X-Amz-Signature=33c621ac642048510c067354a8d6bbfbea7249f8eb4d4e6a4db2c2cb5d673f5b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27demo.jpeg%3B+filename%3D%22demo.jpeg%22%3B&response-content-type=image%2Fjpeg&x-id=GetObject&Expires=1769467669&Policy=eyJTdGF0ZW1lbnQiOlt7IkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTc2OTQ2NzY2OX19LCJSZXNvdXJjZSI6Imh0dHBzOi8vY2FzLWJyaWRnZS54ZXRodWIuaGYuY28veGV0LWJyaWRnZS11cy82OTQ5NmMxMjE1NzliNjk1ZDUwZDc4OGMvNmYzNDcwOTg3YmYzMGViNGE4YzA4MTZhOWQ4NDhmNmMyY2Q0ZWE3YTU2YTZmYjRmMTQ3NjU4ZjMyNTM5ZTQ5YyoifV19&Signature=VjKr0Lep9hhybTjPuu-T2tSKleCU5kEDy2X5Ljlusun4mUGqktwniL3u3S-XxelJNlNN2zRvc0tms6hxcnabzxuL7-pTsk956hC8Tg0TZRaqD5w0rqyyXqI6tXUrZ9OjXs5ltuwojFHrEkiYe0pv0Q36qyHitChVssb5mtB8N4p-kcsBl7SMTLwrxg8hULyntBc6ZNkxXEVN1k-UOBiR7D8JolxVgjiUbtfVzMDXTwUkO6nN8PapNlABj4dIry21lVrB8yPA10GYAKt2tkVjmt0aIxiux7MaM8TstBsRqX74Yjnf4a5VWCAlmO%7EwZhFW7rl2Q-M%7ET8WtM5U9vb2Rlw__&Key-Pair-Id=K2L8F4GPSG1IFC" custom_css = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); * { font-family: 'Inter', sans-serif !important; } .gradio-container { background: #f8fafc !important; max-width: 1400px !important; margin: 0 auto !important; } .header { text-align: center; padding: 1.5rem 0 1rem; } .header h1 { font-size: 1.8rem; font-weight: 700; color: #1e293b; margin: 0; } .header p { color: #64748b; margin-top: 0.25rem; } .header .demo-image { margin: 0.75rem auto 0; max-width: 720px; width: 100%; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); } .header .demo-image img { display: block; width: 100%; height: auto; border-radius: 12px; } .gr-button-primary { background: #dc2626 !important; border: none !important; font-weight: 600 !important; } .gr-button-primary:hover { background: #b91c1c !important; } footer { display: none !important; } .tile-output img { border-radius: 4px !important; border: 1px solid #e2e8f0 !important; } """ # Build UI with gr.Blocks(title="Image Grid Cutter") as demo: # State to store data between tabs stored_image = gr.State(None) stored_rows = gr.State(2) stored_cols = gr.State(2) stored_tiles = gr.State([]) # List of all tiles (can be individually edited) stored_original_tile = gr.State(None) # Original tile before crop (for reference) selected_tile_idx = gr.State(0) # Currently selected tile index stored_bg_type = gr.State("Transparent") # Background type stored_bg_color = gr.State("#f0f0f0") # Background color gr.HTML(f"") gr.HTML(f'''
Cut images into grids, crop tiles, create animations
Upload Image
') input_image = gr.Image(type="pil", label=None, height=250, sources=["upload", "clipboard"]) gr.HTML('Grid Size
') with gr.Row(): rows_slider = gr.Slider(minimum=1, maximum=16, value=2, step=1, label="Rows") cols_slider = gr.Slider(minimum=1, maximum=16, value=2, step=1, label="Columns") grid_info = gr.HTML('2×2 = 4 pieces
') cut_button = gr.Button("✂️ Cut Image", variant="primary", size="lg") download_zip = gr.File(label="Download ZIP", height=60) with gr.Column(scale=2): gr.HTML('Preview
') preview_image = gr.Image(type="pil", label=None, height=450, interactive=False) gr.HTML('Cut Tiles
') tile_outputs = [] for row_idx in range(16): with gr.Row(): for col_idx in range(16): i = row_idx * 16 + col_idx tile = gr.Image(type="pil", label=None, height=50, show_label=False, visible=(i < 4), interactive=False, elem_classes=["tile-output"]) tile_outputs.append(tile) # ===== TAB 2: CROP (Optional) ===== with gr.Tab("2. Crop (Optional)"): gr.HTML('Navigate tiles, crop individually or apply to all.
') with gr.Row(): with gr.Column(scale=1): # Background color option gr.HTML('Background Fill:
') with gr.Row(): bg_type = gr.Dropdown(choices=["Transparent", "Color"], value="Transparent", label=None, scale=1, show_label=False) bg_color = gr.ColorPicker(value="#f0f0f0", label=None, show_label=False, scale=1) # Current tile preview with navigation arrows gr.HTML('Current Tile:
') with gr.Row(): prev_btn = gr.Button("◀", size="sm", scale=0, min_width=50) current_tile_preview = gr.Image(type="pil", label=None, height=150, interactive=False, show_label=False) next_btn = gr.Button("▶", size="sm", scale=0, min_width=50) # Navigation info + first/last selected_info = gr.HTML('Tile 1 of 1
') with gr.Row(): first_btn = gr.Button("⏮ First", size="sm") last_btn = gr.Button("Last ⏭", size="sm") gr.HTML('Crop Tool
') crop_editor = gr.ImageEditor( type="pil", label=None, height=350, sources=["upload"], transforms=["crop"], brush=False, eraser=False, interactive=True ) with gr.Row(): save_tile_btn = gr.Button("💾 Save This Tile", variant="secondary", size="lg") apply_crop_btn = gr.Button("✅ Apply to All", variant="primary", size="lg") cropped_zip = gr.File(label="Download All Tiles (ZIP)", height=60) with gr.Column(scale=2): gr.HTML('All Tiles (after edits)
') cropped_outputs = [] for row_idx in range(16): with gr.Row(): for col_idx in range(16): i = row_idx * 16 + col_idx tile = gr.Image(type="pil", label=None, height=50, show_label=False, visible=(i < 4), interactive=False) cropped_outputs.append(tile) # ===== TAB 3: ANIMATION ===== with gr.Tab("3. Export"): gr.HTML('Generate animated GIF/MP4 or export tiles as PDF.
') with gr.Row(): with gr.Column(scale=1): animation_type = gr.Dropdown( choices=["Reveal (build up)", "Cycle (one at a time)", "Flash (all tiles)", "Shuffle (random order)"], value="Reveal (build up)", label="Animation Style" ) single_tile_mode = gr.Checkbox(label="Single tile per frame (slideshow)", value=True) frame_duration = gr.Slider(minimum=50, maximum=5000, value=200, step=50, label="Frame Duration (ms)") gif_button = gr.Button("🎬 Generate GIF", variant="primary", size="lg") gif_file = gr.File(label="Download GIF", height=60) video_transition = gr.Dropdown( choices=["None", "Fade"], value="None", label="Video Transition" ) video_button = gr.Button("🎥 Generate MP4", variant="primary", size="lg") video_file = gr.File(label="Download MP4", height=60) gr.HTML('Preview will use current tiles
') # ===== EVENT HANDLERS ===== def update_preview(image, rows, cols): rows = int(rows) cols = int(cols) total = rows * cols preview = create_grid_preview(image, rows, cols) if image else None visibility = [gr.update(visible=(i < total)) for i in range(256)] info = f'{rows}×{cols} = {total} pieces
' return [preview, info] + visibility input_image.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) rows_slider.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) cols_slider.change(fn=update_preview, inputs=[input_image, rows_slider, cols_slider], outputs=[preview_image, grid_info] + tile_outputs) def do_cut(image, rows, cols): tiles_list, zip_path, first_tile = process_image(image, rows, cols) rows = int(rows) cols = int(cols) total = rows * cols # Create tile list (non-None tiles only) actual_tiles = [t for t in tiles_list[:total] if t is not None] # Convert first_tile to dict format for ImageEditor editor_value = {"background": first_tile, "layers": [], "composite": None} if first_tile else None # Create updates with visibility for Cut tab tile_updates = [gr.update(value=tiles_list[i] if i < len(tiles_list) else None, visible=(i < total)) for i in range(256)] # Create updates for Crop tab tiles crop_updates = [gr.update(value=tiles_list[i] if i < len(tiles_list) else None, visible=(i < total)) for i in range(256)] # Info text info_text = f'Tile 1 of {total} (R1-C1)
' return tile_updates + crop_updates + [zip_path, editor_value, first_tile, first_tile, image, rows, cols, actual_tiles, info_text, 0] cut_button.click( fn=do_cut, inputs=[input_image, rows_slider, cols_slider], outputs=tile_outputs + cropped_outputs + [download_zip, crop_editor, stored_original_tile, current_tile_preview, stored_image, stored_rows, stored_cols, stored_tiles, selected_info, selected_tile_idx] ) def navigate_tile(tiles, current_idx, rows, cols, direction): """Navigate to prev/next tile.""" if not tiles: return None, None, 'No tiles
', 0 total = len(tiles) new_idx = current_idx + direction # Wrap around if new_idx < 0: new_idx = total - 1 elif new_idx >= total: new_idx = 0 tile = tiles[new_idx] editor_value = {"background": tile, "layers": [], "composite": None} row_num = new_idx // int(cols) + 1 col_num = new_idx % int(cols) + 1 info = f'Tile {new_idx + 1} of {total} (R{row_num}-C{col_num})
' return tile, editor_value, info, new_idx prev_btn.click( fn=lambda tiles, idx, rows, cols: navigate_tile(tiles, idx, rows, cols, -1), inputs=[stored_tiles, selected_tile_idx, stored_rows, stored_cols], outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] ) next_btn.click( fn=lambda tiles, idx, rows, cols: navigate_tile(tiles, idx, rows, cols, 1), inputs=[stored_tiles, selected_tile_idx, stored_rows, stored_cols], outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] ) def jump_to_tile(tiles, rows, cols, target_idx): """Jump to first or last tile.""" if not tiles: return None, None, 'No tiles
', 0 total = len(tiles) idx = max(0, min(target_idx, total - 1)) tile = tiles[idx] editor_value = {"background": tile, "layers": [], "composite": None} row_num = idx // int(cols) + 1 col_num = idx % int(cols) + 1 info = f'Tile {idx + 1} of {total} (R{row_num}-C{col_num})
' return tile, editor_value, info, idx first_btn.click( fn=lambda tiles, rows, cols: jump_to_tile(tiles, rows, cols, 0), inputs=[stored_tiles, stored_rows, stored_cols], outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] ) last_btn.click( fn=lambda tiles, rows, cols: jump_to_tile(tiles, rows, cols, len(tiles) - 1 if tiles else 0), inputs=[stored_tiles, stored_rows, stored_cols], outputs=[current_tile_preview, crop_editor, selected_info, selected_tile_idx] ) def save_single_tile(tiles, idx, cropped_editor, rows, cols, bg_type_val, bg_color_val): """Save the cropped tile back to the tiles list.""" if not tiles or cropped_editor is None: print(f"save_single_tile: No tiles or editor") return [gr.update()] * 256 + [tiles, None, None] idx = int(idx) rows = int(rows) cols = int(cols) total = rows * cols # Get cropped image cropped_img = get_cropped_image(cropped_editor) if cropped_img is None: print(f"save_single_tile: Could not get cropped image from editor") return [gr.update()] * 256 + [tiles, None, None] # Get max dimensions from all tiles max_w = max(t.width for t in tiles) max_h = max(t.height for t in tiles) print(f"save_single_tile: Saving tile {idx}, original size={tiles[idx].size}, cropped size={cropped_img.size}, bg={bg_type_val}") # Determine background color if bg_type_val == "Transparent": bg_rgba = (0, 0, 0, 0) mode = "RGBA" else: # Parse hex color hex_color = bg_color_val.lstrip('#') r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) bg_rgba = (r, g, b, 255) mode = "RGBA" # Create normalized version (padded with background to match max size) normalized = Image.new(mode, (max_w, max_h), bg_rgba) x = (max_w - cropped_img.width) // 2 y = (max_h - cropped_img.height) // 2 # Convert cropped to RGBA if needed if cropped_img.mode != "RGBA": cropped_img = cropped_img.convert("RGBA") normalized.paste(cropped_img, (x, y)) # Update tile in list with normalized version new_tiles = list(tiles) if 0 <= idx < len(new_tiles): new_tiles[idx] = normalized # Debug: print all sizes after update sizes = [f"{t.size}" for t in new_tiles] print(f"save_single_tile: After update (normalized), sizes={sizes}") # Create updates for display updates = [gr.update(value=new_tiles[i] if i < len(new_tiles) else None, visible=(i < total)) for i in range(256)] # Create zip zip_path = create_tiles_zip(new_tiles, cols) # Update preview to show normalized tile return updates + [new_tiles, normalized, zip_path, bg_type_val, bg_color_val] save_tile_btn.click( fn=save_single_tile, inputs=[stored_tiles, selected_tile_idx, crop_editor, stored_rows, stored_cols, bg_type, bg_color], outputs=cropped_outputs + [stored_tiles, current_tile_preview, cropped_zip, stored_bg_type, stored_bg_color] ) def apply_crop_to_all_tiles(tiles, cropped_editor, orig_tile, rows, cols, current_idx): """Apply the same crop to all tiles.""" if not tiles or cropped_editor is None or orig_tile is None: return [gr.update()] * 256 + [tiles, None, None] rows = int(rows) cols = int(cols) total = rows * cols current_idx = int(current_idx) cropped_img = get_cropped_image(cropped_editor) if cropped_img is None: return [gr.update()] * 256 + [tiles, None, None] orig_w, orig_h = orig_tile.size crop_w, crop_h = cropped_img.size # If same size, no crop if orig_w == crop_w and orig_h == crop_h: updates = [gr.update(value=tiles[i] if i < len(tiles) else None, visible=(i < total)) for i in range(256)] zip_path = create_tiles_zip(tiles, cols) preview = tiles[current_idx] if current_idx < len(tiles) else None return updates + [tiles, preview, zip_path] # Calculate crop margins left_crop = (orig_w - crop_w) // 2 top_crop = (orig_h - crop_h) // 2 right_crop = orig_w - crop_w - left_crop bottom_crop = orig_h - crop_h - top_crop # Apply crop to all tiles new_tiles = [] for tile in tiles: t_w, t_h = tile.size new_left = min(left_crop, t_w - 1) new_top = min(top_crop, t_h - 1) new_right = max(new_left + 1, t_w - right_crop) new_bottom = max(new_top + 1, t_h - bottom_crop) if new_right > new_left and new_bottom > new_top: cropped = tile.crop((new_left, new_top, new_right, new_bottom)) else: cropped = tile new_tiles.append(cropped) updates = [gr.update(value=new_tiles[i] if i < len(new_tiles) else None, visible=(i < total)) for i in range(256)] zip_path = create_tiles_zip(new_tiles, cols) # Update preview with current tile preview = new_tiles[current_idx] if current_idx < len(new_tiles) else None return updates + [new_tiles, preview, zip_path] apply_crop_btn.click( fn=apply_crop_to_all_tiles, inputs=[stored_tiles, crop_editor, stored_original_tile, stored_rows, stored_cols, selected_tile_idx], outputs=cropped_outputs + [stored_tiles, current_tile_preview, cropped_zip] ) def generate_gif_from_tiles(image, rows, cols, tiles, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val): """Generate GIF using edited tiles if available, otherwise from image.""" if tiles and len(tiles) > 0: # Debug: print tile sizes sizes = [f"{t.size}" for t in tiles] print(f"GIF tiles sizes: {sizes}, bg={bg_type_val}") # Use edited tiles return generate_gif_from_tile_list(tiles, int(rows), int(cols), animation_type, single_tile_mode, int(frame_duration), bg_type_val, bg_color_val) else: # Fall back to original image return generate_gif(image, rows, cols, animation_type, single_tile_mode, frame_duration) gif_button.click( fn=generate_gif_from_tiles, inputs=[stored_image, stored_rows, stored_cols, stored_tiles, animation_type, single_tile_mode, frame_duration, stored_bg_type, stored_bg_color], outputs=[gif_file] ) def generate_video_from_tiles(image, rows, cols, tiles, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val, transition_val): """Generate MP4 using edited tiles if available, otherwise from image.""" if tiles and len(tiles) > 0: return generate_video_from_tile_list(tiles, int(rows), int(cols), animation_type, single_tile_mode, int(frame_duration), bg_type_val, bg_color_val, transition_val) return generate_video(image, rows, cols, animation_type, single_tile_mode, frame_duration, bg_type_val, bg_color_val, transition_val) video_button.click( fn=generate_video_from_tiles, inputs=[stored_image, stored_rows, stored_cols, stored_tiles, animation_type, single_tile_mode, frame_duration, stored_bg_type, stored_bg_color, video_transition], outputs=[video_file] ) def generate_pdf(tiles, rows, cols): """Generate PDF from stored tiles.""" if tiles and len(tiles) > 0: return generate_pdf_from_tiles(tiles, rows, cols) return None pdf_button.click( fn=generate_pdf, inputs=[stored_tiles, stored_rows, stored_cols], outputs=[pdf_file] ) if __name__ == "__main__": demo.launch( show_error=True, server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"), ssr_mode=False, )