import gradio as gr from PIL import Image import io import zipfile import random def random_black_or_white(): """返回 (r,g,b,a) = 黑 or 白,50% 概率。""" return (0, 0, 0, 255) if random.random() < 0.5 else (255, 255, 255, 255) def random_non_black_white(): """ 返回 (r,g,b,a),其中 (r,g,b) != (0,0,0) 且 != (255,255,255)。 用于填充最后拼图时剩余空格,使之是“非黑非白”的纯色。 """ while True: r = random.randint(0, 255) g = random.randint(0, 255) b = random.randint(0, 255) if not (r == g == b == 0 or r == g == b == 255): return (r, g, b, 255) def resize_to_64_multiple(img: Image.Image): """ 将单张 RGBA 图片就近缩放到 (w', h'),其中 w'、h' 均为 64 的倍数(至少64)。 背景随机填充黑/白。原图保持居中(若有空余,四周即背景色;透明度保留)。 """ w, h = img.size # 找到最接近的 64 倍数(至少 64) w64 = max(64, round(w / 64) * 64) h64 = max(64, round(h / 64) * 64) # 算缩放比:保证原图能放入 w64*h64 scale = min(w64 / w, h64 / h) new_w = int(w * scale) new_h = int(h * scale) # 随机黑或白做背景 bg_color = random_black_or_white() background = Image.new("RGBA", (w64, h64), bg_color) # 缩放 scaled = img.resize((new_w, new_h), Image.Resampling.LANCZOS) # 居中贴到背景 offset_x = (w64 - new_w) // 2 offset_y = (h64 - new_h) // 2 # 注意第三个参数 scaled:保持其透明度 background.paste(scaled, (offset_x, offset_y), scaled) return background def limit_2048(img: Image.Image): """若图片宽或高 > 2048,则等比例缩小到不超过 2048。""" w, h = img.size if w > 2048 or h > 2048: scale = min(2048 / w, 2048 / h) new_w = int(w * scale) new_h = int(h * scale) img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) return img def make_collage_2x2(images_4): """传入 4 张同尺寸 RGBA,做 2×2 拼接,再限制不超过 2048。""" w, h = images_4[0].size collage = Image.new("RGBA", (2 * w, 2 * h), (0,0,0,255)) collage.paste(images_4[0], (0, 0), images_4[0]) collage.paste(images_4[1], (w, 0), images_4[1]) collage.paste(images_4[2], (0, h), images_4[2]) collage.paste(images_4[3], (w, h), images_4[3]) return limit_2048(collage) def make_collage_leftover(images_leftover): """ 对剩余的 1~3 张图做“兼容性拼接”。 1) 首先统一尺寸(同宽高); 2) 随机选择 (rows, cols) 布局(如 1x1/1x2/2x1/2x2 等)能容纳所有图; 3) 将图随机放到网格单元,剩余格子用“非黑非白”的纯色填充; 4) 最后若超出 2048,则缩小。 """ n = len(images_leftover) if n < 1 or n > 3: return None # 保险 # 统一每张图片的尺寸:按64倍数策略后,找出最大 w,h resized_list = [] max_w = 0 max_h = 0 for img in images_leftover: rimg = resize_to_64_multiple(img) resized_list.append(rimg) if rimg.size[0] > max_w: max_w = rimg.size[0] if rimg.size[1] > max_h: max_h = rimg.size[1] # 再次处理,使它们都成为 (max_w, max_h) # (若小于max_w或max_h,就在背景再“居中贴图”) uniformed = [] for rimg in resized_list: w, h = rimg.size if w == max_w and h == max_h: uniformed.append(rimg) else: bg = Image.new("RGBA", (max_w, max_h), rimg.getpixel((0,0))) # 取自身背景色(黑或白)进行填充,这样保持一致 offx = (max_w - w)//2 offy = (max_h - h)//2 bg.paste(rimg, (offx, offy), rimg) uniformed.append(bg) # 现在 uniformed 每张都是 (max_w, max_h),按 n ∈ [1,2,3] # 决定随机布局 possible_layouts = [] if n == 1: # 可以放在 (1x1), (1x2), (2x1), (2x2) possible_layouts = [(1,1), (1,2), (2,1), (2,2)] elif n == 2: # (1x2), (2x1), (2x2) possible_layouts = [(1,2), (2,1), (2,2)] else: # n == 3 # (2x2) possible_layouts = [(2,2)] rows, cols = random.choice(possible_layouts) # 拼接画布 big_w = cols * max_w big_h = rows * max_h collage = Image.new("RGBA", (big_w, big_h), (0,0,0,255)) # 网格坐标 cells = [] for r in range(rows): for c in range(cols): cells.append((r,c)) random.shuffle(cells) # 打乱单元格顺序 # 将 n 张图放前 n 个格子 for i, img_ in enumerate(uniformed): r, c = cells[i] offset_x = c * max_w offset_y = r * max_h collage.paste(img_, (offset_x, offset_y), img_) # 剩余单元用“非黑非白”随机色填充 leftover_cells = cells[n:] for (r, c) in leftover_cells: fill_col = random_non_black_white() rect = Image.new("RGBA", (max_w, max_h), fill_col) offset_x = c * max_w offset_y = r * max_h collage.paste(rect, (offset_x, offset_y), rect) return limit_2048(collage) def process_images(uploaded_files): """ 1) 把文件读成 RGBA; 2) 分成若干 4 张组 => each 2×2 拼接; 3) 若最后有 1~3 张剩余,则调用 make_collage_leftover(); 4) 返回多张结果图(列表) """ pil_images = [] for f in uploaded_files: if f is not None: img = Image.open(f.name).convert("RGBA") pil_images.append(img) results = [] # 每 4 张一组 full_groups = len(pil_images) // 4 leftover_count = len(pil_images) % 4 # 处理完整的 4 张组 idx = 0 for _ in range(full_groups): group_4 = pil_images[idx : idx+4] idx += 4 # 先统一尺寸 => 找 max_w, max_h temp = [resize_to_64_multiple(im) for im in group_4] # 再次统一(可能有不同64倍数) max_w = max([im.size[0] for im in temp]) max_h = max([im.size[1] for im in temp]) uniformed = [] for rimg in temp: w, h = rimg.size if w == max_w and h == max_h: uniformed.append(rimg) else: bg = Image.new("RGBA", (max_w, max_h), rimg.getpixel((0,0))) offx = (max_w - w)//2 offy = (max_h - h)//2 bg.paste(rimg, (offx, offy), rimg) uniformed.append(bg) # 2x2 拼接 collage_4 = make_collage_2x2(uniformed) results.append(collage_4) # 处理剩余 1~3 张 if leftover_count > 0: leftover_images = pil_images[idx:] collage_left = make_collage_leftover(leftover_images) if collage_left is not None: results.append(collage_left) return results def make_zip(uploaded_files): """打包所有拼接结果为 ZIP (PNG 格式),若无结果则返回 None。""" collages = process_images(uploaded_files) if not collages: return None buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for i, img in enumerate(collages, start=1): img_bytes = io.BytesIO() img.save(img_bytes, format="PNG") img_bytes.seek(0) zf.writestr(f"collage_{i}.png", img_bytes.read()) buf.seek(0) return buf with gr.Blocks() as demo: gr.Markdown("## 图片 2×2 拼接小工具(含随机填充、兼容不足4张)") gr.Markdown( "1. 一次可上传多张图片,每 4 张为一组严格 2×2 拼接;\n" "2. 若最后不足 4 张 (1~3),会随机选择网格大小 (1x1,1x2,2x1,2x2),并随机分配位置;\n" " 剩余空格用“非黑非白”的随机颜色填充;\n" "3. 每张图先按 64 的倍数就近缩放,空余处随机黑/白 (50% 概率);\n" "4. 拼出的图若任一边超 2048,则等比例缩小到不超 2048;\n" "5. 保留 PNG 透明度,背景填充只在超出区域;\n" "6. 生成结果可预览,也可打包下载成 ZIP。" ) with gr.Row(): with gr.Column(): input_files = gr.Files(label="上传图片(可多选)", file_types=["image"]) btn_preview = gr.Button("生成预览") btn_zip = gr.Button("打包下载 ZIP") with gr.Column(): gallery = gr.Gallery(label="拼接结果预览", columns=2) zipfile_output = gr.File(label="下载拼接结果 ZIP", visible=False, interactive=False) btn_preview.click(fn=process_images, inputs=[input_files], outputs=[gallery]) btn_zip.click(fn=make_zip, inputs=[input_files], outputs=[zipfile_output]) demo.launch()