import zipfile import tempfile from pathlib import Path from PIL import Image import gradio as gr def _unique_name(base_name, existing): """ Return a unique filename (base_name already includes extension). If base_name exists in `existing`, append _1, _2, ... before the extension. """ if base_name not in existing: existing.add(base_name) return base_name stem = Path(base_name).stem suffix = Path(base_name).suffix i = 1 while True: candidate = f"{stem}_{i}{suffix}" if candidate not in existing: existing.add(candidate) return candidate i += 1 def center_crop_or_resize_to_target(images, out_names, target_w=None, target_h=None): """ If target_w/target_h are None: center-crop all images to the smallest width/height. If target_w/target_h are provided: - If an image is larger than the target in both dimensions: center-crop to the target. - If an image is smaller in either dimension: scale up (no more than necessary) so the image covers the target in both dimensions, then center-crop to the exact target. Returns path to a ZIP file containing JPEGs named according to out_names. """ # Determine target dimensions if not provided if target_w is None or target_h is None: widths = [img.width for img in images] heights = [img.height for img in images] target_w = min(widths) target_h = min(heights) else: # ensure ints target_w = int(target_w) target_h = int(target_h) out_dir = Path(tempfile.mkdtemp(prefix="cropped_")) saved_files = [] for img, out_name in zip(images, out_names): # Work on a copy to avoid mutating original PIL Image objects held by caller working = img if working.mode != "RGB": working = working.convert("RGB") # If image is smaller than target in either dimension, scale up minimally if working.width < target_w or working.height < target_h: scale = max(target_w / working.width, target_h / working.height) new_w = max(1, int(round(working.width * scale))) new_h = max(1, int(round(working.height * scale))) working = working.resize((new_w, new_h), resample=Image.LANCZOS) # Now center-crop to target (if image is larger or equal) left = max(0, (working.width - target_w) // 2) top = max(0, (working.height - target_h) // 2) right = left + target_w bottom = top + target_h cropped = working.crop((left, top, right, bottom)) out_path = out_dir / out_name cropped.save(out_path, format="JPEG", quality=95) saved_files.append(out_path) # If we created a converted/resized copy (working), and it's a different object, close it try: if working is not img: working.close() except: pass zip_path = out_dir / "cropped_images.zip" with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: for p in saved_files: zf.write(p, arcname=p.name) return str(zip_path) def process_upload(filepaths, mode="smallest", width=None, height=None): """ filepaths: list of local file paths (gr.File with type='filepath' provides these) mode: "smallest" or "specified" width/height: integers (used only when mode == "specified") Returns (zip_path or None, message) """ if not filepaths: return None, "No files uploaded." images = [] try: # Load images in the same order as filepaths for fp in filepaths: img = Image.open(fp) images.append(img.copy()) img.close() except Exception as e: for im in images: try: im.close() except: pass return None, f"Error loading images: {e}" # Build output names that match input stems, with .jpg extension, and ensure uniqueness existing = set() out_names = [] for fp in filepaths: stem = Path(fp).stem # sanitize stem: remove path separators and problematic characters safe_stem = "".join(c for c in stem if c.isalnum() or c in (" ", "_", "-")).rstrip() if not safe_stem: safe_stem = "image" base_name = f"{safe_stem}.jpg" unique = _unique_name(base_name, existing) out_names.append(unique) # Validate and decide target dims if mode == "specified": try: if width is None or height is None: raise ValueError("Width and height must be provided for specified mode.") target_w = int(width) target_h = int(height) if target_w <= 0 or target_h <= 0: raise ValueError("Width and height must be positive integers.") except Exception as e: for im in images: try: im.close() except: pass return None, f"Invalid dimensions: {e}" zip_path = center_crop_or_resize_to_target(images, out_names, target_w=target_w, target_h=target_h) else: # smallest mode: compute min dims and crop zip_path = center_crop_or_resize_to_target(images, out_names, target_w=None, target_h=None) for im in images: try: im.close() except: pass return zip_path, f"Done — {Path(zip_path).name}" with gr.Blocks() as demo: gr.Markdown("## Center-crop images to smallest dimensions or to specified dimensions and download as ZIP (preserve input filenames)") with gr.Row(): inp = gr.File(label="Upload images", file_count="multiple", type="filepath") with gr.Row(): mode = gr.Radio(choices=["Crop to smallest", "Specify dimensions"], value="Crop to smallest", label="Mode") with gr.Row(): width = gr.Number(value=512, label="Target width (px)", precision=0) height = gr.Number(value=512, label="Target height (px)", precision=0) with gr.Row(): out_file = gr.File(label="Download ZIP") btn = gr.Button("Crop and Package") status = gr.Textbox(label="Status", interactive=False) # Hide width/height when not needed (client-side) def toggle_dims(selected): show = selected == "Specify dimensions" return gr.update(visible=show), gr.update(visible=show) mode.change(fn=toggle_dims, inputs=[mode], outputs=[width, height]) def run(filepaths, selected_mode, w, h): chosen = "specified" if selected_mode == "Specify dimensions" else "smallest" zip_path, msg = process_upload(filepaths, mode=chosen, width=w, height=h) if zip_path is None: return None, msg return zip_path, msg btn.click(run, inputs=[inp, mode, width, height], outputs=[out_file, status]) if __name__ == "__main__": demo.launch()