image-cutter / app.py
airabbitX's picture
Upload app.py
0534163 verified
"""
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"<style>{custom_css}</style>")
gr.HTML(f'''<div class="header">
<h1>✂️ Image Grid Cutter</h1>
<p>Cut images into grids, crop tiles, create animations</p>
<div class="demo-image">
<img src="{DEMO_IMAGE_URL}" alt="Demo image preview" loading="lazy" />
</div>
<p><a href="https://www.airabbit.blog/how-to-create-insane-animation-with-nano-banaan-and-gpt-image-geneato-with-a-single-pormtp-and" target="_blank" style="color:#dc2626; text-decoration:none; font-weight:600;">📖 Documentation & Tutorial →</a></p>
</div>''')
with gr.Tabs():
# ===== TAB 1: CUT =====
with gr.Tab("1. Cut"):
with gr.Row():
with gr.Column(scale=1):
gr.HTML('<p style="font-weight:600; color:#374151;">Upload Image</p>')
input_image = gr.Image(type="pil", label=None, height=250, sources=["upload", "clipboard"])
gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Grid Size</p>')
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('<p style="text-align:center; color:#64748b;">2×2 = 4 pieces</p>')
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('<p style="font-weight:600; color:#374151;">Preview</p>')
preview_image = gr.Image(type="pil", label=None, height=450, interactive=False)
gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Cut Tiles</p>')
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('<p style="color:#64748b; margin-bottom:1rem;">Navigate tiles, crop individually or apply to all.</p>')
with gr.Row():
with gr.Column(scale=1):
# Background color option
gr.HTML('<p style="font-weight:600; color:#374151;">Background Fill:</p>')
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('<p style="font-weight:600; color:#374151; margin-top:0.5rem;">Current Tile:</p>')
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('<p style="text-align:center; color:#374151; font-weight:600;">Tile 1 of 1</p>')
with gr.Row():
first_btn = gr.Button("⏮ First", size="sm")
last_btn = gr.Button("Last ⏭", size="sm")
gr.HTML('<p style="font-weight:600; color:#374151; margin-top:1rem;">Crop Tool</p>')
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('<p style="font-weight:600; color:#374151;">All Tiles (after edits)</p>')
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('<p style="color:#64748b; margin-bottom:1rem;">Generate animated GIF/MP4 or export tiles as PDF.</p>')
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('<hr style="margin:1.5rem 0; border:none; border-top:1px solid #e2e8f0;">')
pdf_button = gr.Button("📄 Export PDF", variant="primary", size="lg")
pdf_file = gr.File(label="Download PDF", height=60)
with gr.Column(scale=2):
gr.HTML('<p style="font-weight:600; color:#374151;">Preview will use current tiles</p>')
# ===== 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'<p style="text-align:center; color:#64748b;">{rows}×{cols} = {total} pieces</p>'
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'<p style="text-align:center; color:#374151; font-weight:600;">Tile 1 of {total} (R1-C1)</p>'
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, '<p style="text-align:center; color:#64748b;">No tiles</p>', 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'<p style="text-align:center; color:#374151; font-weight:600;">Tile {new_idx + 1} of {total} (R{row_num}-C{col_num})</p>'
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, '<p style="text-align:center; color:#64748b;">No tiles</p>', 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'<p style="text-align:center; color:#374151; font-weight:600;">Tile {idx + 1} of {total} (R{row_num}-C{col_num})</p>'
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,
)