Created
August 27, 2025 16:15
-
-
Save mattppal/f9d59de82560e25db1d54572c7ea0516 to your computer and use it in GitHub Desktop.
Nano banana variations script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /// script | |
# dependencies = [ | |
# "google-genai", | |
# "pillow", | |
# "pathlib", | |
# "python-dotenv", | |
# ] | |
# /// | |
# Description: Generate variations of a person image using Google Gemini AI and create a GIF | |
# Variations can be a file path (txt newline separated) or a comma-separated list of variations. | |
# Usage: uv run nano-banana.py <input_image> <variations> [output_dir] [--duration <duration>] [--model <model>] [--gif-quality <quality>] [--gif-only] | |
# Requires: GEMINI_API_KEY environment variable | |
# Author: matt | |
# Created: 2025-01-27 | |
# Purpose: AI-powered image variation generator with GIF output | |
import base64 | |
import mimetypes | |
import os | |
import sys | |
import argparse | |
from pathlib import Path | |
from typing import List | |
from concurrent.futures import ThreadPoolExecutor, as_completed | |
from google import genai | |
from google.genai import types | |
from PIL import Image | |
import json | |
import dotenv | |
import glob | |
dotenv.load_dotenv() | |
def save_binary_file(file_name: str, data: bytes) -> None: | |
"""Save binary data to file.""" | |
with open(file_name, "wb") as f: | |
f.write(data) | |
print(f"File saved to: {file_name}") | |
def load_image_as_base64(image_path: str) -> tuple[str, str]: | |
"""Load image and return as base64 string with mime type.""" | |
if not os.path.exists(image_path): | |
raise FileNotFoundError(f"Image file not found: {image_path}") | |
mime_type, _ = mimetypes.guess_type(image_path) | |
if not mime_type or not mime_type.startswith('image/'): | |
raise ValueError(f"File is not a valid image: {image_path}") | |
with open(image_path, "rb") as f: | |
image_data = f.read() | |
base64_data = base64.b64encode(image_data).decode('utf-8') | |
return base64_data, mime_type | |
def load_variations(variations_input: str) -> List[str]: | |
"""Load variations from file or parse from string.""" | |
variations = [] | |
# Check if it's a file path | |
if os.path.exists(variations_input): | |
with open(variations_input, 'r') as f: | |
content = f.read().strip() | |
# Try to parse as JSON first | |
try: | |
data = json.loads(content) | |
if isinstance(data, list): | |
variations = data | |
elif isinstance(data, dict) and 'variations' in data: | |
variations = data['variations'] | |
else: | |
raise ValueError("JSON must be a list or contain 'variations' key") | |
except json.JSONDecodeError: | |
# Parse as plain text (one variation per line) | |
variations = [line.strip() for line in content.split('\n') if line.strip()] | |
else: | |
# Treat as comma-separated string | |
variations = [v.strip() for v in variations_input.split(',') if v.strip()] | |
if not variations: | |
raise ValueError("No variations provided") | |
return variations | |
def generate_variation(client, model: str, base_image_b64: str, base_mime_type: str, | |
variation_prompt: str, output_dir: Path, variation_index: int) -> str: | |
"""Generate a single variation of the image.""" | |
# Create the prompt that includes the base image and variation request | |
prompt = f"""I have provided an image of a person. Please create a variation of this person with the following modification: {variation_prompt} | |
Keep the position and orientation of the person the same as the original image. The image should have the same aspect ratio and the person should be the same size as the original image. This is extremely important. THE PERSON SHOULD BE THE SAME SIZE AS THE ORIGINAL IMAGE AND THE IMAGE SHOULD HAVE THE SAME ASPECT RATIO AS THE ORIGINAL IMAGE.""" | |
contents = [ | |
types.Content( | |
role="user", | |
parts=[ | |
types.Part.from_text(text=prompt), | |
types.Part.from_bytes( | |
data=base64.b64decode(base_image_b64), | |
mime_type=base_mime_type | |
) | |
], | |
), | |
] | |
generate_content_config = types.GenerateContentConfig( | |
response_modalities=[ | |
"IMAGE", | |
"TEXT", | |
], | |
) | |
print(f"Generating variation {variation_index + 1}: {variation_prompt}") | |
file_index = 0 | |
output_file = None | |
try: | |
for chunk in client.models.generate_content_stream( | |
model=model, | |
contents=contents, | |
config=generate_content_config, | |
): | |
if ( | |
chunk.candidates is None | |
or chunk.candidates[0].content is None | |
or chunk.candidates[0].content.parts is None | |
): | |
continue | |
if chunk.candidates[0].content.parts[0].inline_data and chunk.candidates[0].content.parts[0].inline_data.data: | |
file_name = f"variation_{variation_index:03d}_{file_index}" | |
file_index += 1 | |
inline_data = chunk.candidates[0].content.parts[0].inline_data | |
data_buffer = inline_data.data | |
file_extension = mimetypes.guess_extension(inline_data.mime_type) | |
output_file = output_dir / f"{file_name}{file_extension}" | |
save_binary_file(str(output_file), data_buffer) | |
else: | |
if chunk.text: | |
print(f"Response: {chunk.text}") | |
except Exception as e: | |
print(f"Error generating variation {variation_index + 1}: {e}") | |
return None | |
return str(output_file) if output_file else None | |
def collect_existing_images(output_dir: Path) -> List[str]: | |
"""Collect all existing images in the output directory.""" | |
image_paths = [] | |
# Add original image if it exists | |
for ext in ['.jpeg', '.jpg', '.png']: | |
original_path = output_dir / f"original{ext}" | |
if original_path.exists(): | |
image_paths.append(str(original_path)) | |
break | |
# Add variation images, sorted by number | |
variation_pattern = str(output_dir / "variation_*_0.png") | |
variation_files = sorted(glob.glob(variation_pattern)) | |
image_paths.extend(variation_files) | |
return image_paths | |
def create_gif_from_images(image_paths: List[str], output_path: str, duration: int = 1000, quality: int = 256) -> None: | |
"""Create an optimized GIF from a list of image paths with consistent sizing.""" | |
if not image_paths: | |
print("No images to create GIF from") | |
return | |
# Filter out None values and non-existent files | |
valid_paths = [path for path in image_paths if path and os.path.exists(path)] | |
if not valid_paths: | |
print("No valid images found for GIF creation") | |
return | |
try: | |
print(f"Processing {len(valid_paths)} images for GIF...") | |
# Load first image to get target dimensions | |
first_img = Image.open(valid_paths[0]) | |
target_size = first_img.size | |
print(f"Target size: {target_size[0]}x{target_size[1]}") | |
images = [] | |
for i, path in enumerate(valid_paths): | |
img = Image.open(path) | |
# Resize to match first image dimensions if different | |
if img.size != target_size: | |
print(f"Resizing image {i+1} from {img.size} to {target_size}") | |
# Use LANCZOS for high quality resizing | |
img = img.resize(target_size, Image.Resampling.LANCZOS) | |
# Convert to RGB if necessary (for GIF compatibility) | |
if img.mode != 'RGB': | |
img = img.convert('RGB') | |
# Optimize for GIF: reduce colors using adaptive palette | |
img = img.quantize(colors=quality, method=Image.Quantize.MEDIANCUT) | |
images.append(img) | |
if images: | |
print("Creating optimized GIF...") | |
# Save as GIF with optimization | |
images[0].save( | |
output_path, | |
save_all=True, | |
append_images=images[1:], | |
duration=duration, | |
loop=0, | |
optimize=True, # Enable optimization for smaller file size | |
disposal=2 # Clear frame before next for better compression | |
) | |
print(f"GIF created: {output_path}") | |
else: | |
print("No valid images to create GIF") | |
except Exception as e: | |
print(f"Error creating GIF: {e}") | |
def main(): | |
parser = argparse.ArgumentParser( | |
description="Generate variations of a person image and create a GIF", | |
formatter_class=argparse.RawDescriptionHelpFormatter, | |
epilog=""" | |
Examples: | |
python nano-banana-mix.py photo.jpg variations.txt | |
python nano-banana-mix.py photo.jpg "happy, sad, surprised, angry" | |
python nano-banana-mix.py photo.jpg variations.json custom_output/ | |
""" | |
) | |
parser.add_argument("input_image", nargs='?', help="Path to input image of person") | |
parser.add_argument("variations", nargs='?', help="File path or comma-separated list of variations") | |
parser.add_argument("output_dir", nargs='?', default="output", help="Output directory (default: output)") | |
parser.add_argument("--duration", type=int, default=750, help="GIF frame duration in ms (default: 750)") | |
parser.add_argument("--model", default="gemini-2.5-flash-image-preview", help="Gemini model to use") | |
parser.add_argument("--gif-quality", type=int, default=256, choices=[64, 128, 256], help="GIF color palette size for quality vs speed (default: 256)") | |
parser.add_argument("--gif-only", action="store_true", help="Only regenerate GIF from existing images in output directory") | |
args = parser.parse_args() | |
# Handle GIF-only mode | |
if args.gif_only: | |
if not args.output_dir or args.output_dir == "output": | |
args.output_dir = input("Enter directory with existing images: ").strip() or "output" | |
output_dir = Path(args.output_dir) | |
if not output_dir.exists(): | |
print(f"Error: Directory {output_dir} does not exist") | |
sys.exit(1) | |
# Collect existing images | |
existing_images = collect_existing_images(output_dir) | |
if not existing_images: | |
print(f"No images found in {output_dir}") | |
sys.exit(1) | |
print(f"Found {len(existing_images)} existing images") | |
# Create GIF from existing images | |
gif_path = output_dir / "variations.gif" | |
print(f"Creating GIF with {len(existing_images)} images at {args.duration}ms per frame...") | |
create_gif_from_images(existing_images, str(gif_path), args.duration, args.gif_quality) | |
print(f"\n=== GIF Regenerated ===") | |
print(f"Images used: {len(existing_images)}") | |
print(f"Frame duration: {args.duration}ms") | |
print(f"GIF created: {gif_path}") | |
return | |
# Interactive mode if no arguments provided | |
if not args.input_image: | |
print("=== Nano Banana Mix - Interactive Mode ===") | |
args.input_image = input("Enter path to input image: ").strip() | |
args.variations = input("Enter variations file path or comma-separated prompts: ").strip() | |
args.output_dir = input("Enter output directory (default: output): ").strip() or "output" | |
# Validate API key | |
api_key = os.environ.get("GEMINI_API_KEY") | |
if not api_key: | |
print("Error: GEMINI_API_KEY environment variable is required") | |
print("Set it with: export GEMINI_API_KEY='your-api-key'") | |
sys.exit(1) | |
try: | |
# Load base image | |
print(f"Loading base image: {args.input_image}") | |
base_image_b64, base_mime_type = load_image_as_base64(args.input_image) | |
# Load variations | |
print(f"Loading variations from: {args.variations}") | |
variations = load_variations(args.variations) | |
print(f"Found {len(variations)} variations: {variations}") | |
# Setup output directory | |
output_dir = Path(args.output_dir) | |
output_dir.mkdir(exist_ok=True) | |
# Copy original image to output directory | |
original_output = output_dir / f"original{Path(args.input_image).suffix}" | |
with open(args.input_image, 'rb') as src, open(original_output, 'wb') as dst: | |
dst.write(src.read()) | |
print(f"Original image copied to: {original_output}") | |
# Initialize Gemini client | |
client = genai.Client(api_key=api_key) | |
# Generate variations in parallel | |
generated_images = [str(original_output)] # Start with original | |
print(f"Generating {len(variations)} variations in parallel...") | |
with ThreadPoolExecutor(max_workers=min(len(variations), 5)) as executor: | |
# Submit all tasks | |
future_to_variation = { | |
executor.submit( | |
generate_variation, | |
client, args.model, base_image_b64, base_mime_type, | |
variation, output_dir, i | |
): (i, variation) for i, variation in enumerate(variations) | |
} | |
# Collect results as they complete | |
completed_count = 0 | |
for future in as_completed(future_to_variation): | |
completed_count += 1 | |
i, variation = future_to_variation[future] | |
try: | |
result = future.result() | |
if result: | |
generated_images.append(result) | |
print(f"Progress: {completed_count}/{len(variations)} variations completed") | |
except Exception as e: | |
print(f"Error generating variation {i + 1} ('{variation}'): {e}") | |
# Create GIF | |
gif_path = output_dir / "variations.gif" | |
print(f"Creating GIF with {len(generated_images)} images...") | |
create_gif_from_images(generated_images, str(gif_path), args.duration, args.gif_quality) | |
print(f"\n=== Results ===") | |
print(f"Generated images: {len(generated_images)}") | |
print(f"Output directory: {output_dir}") | |
print(f"GIF created: {gif_path}") | |
except Exception as e: | |
print(f"Error: {e}") | |
sys.exit(1) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment