agentic-ai-engineering/skills/individual/planf3/scripts/edit_gpt_image.py

220 lines
6.4 KiB
Python

#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "openai>=1.50.0",
# "python-dotenv>=1.0.0",
# ]
# ///
"""
Edit existing images using OpenAI's gpt-image-2 (ChatGPT Images 2.0).
Pass one or more input images. With multiple inputs, gpt-image-2 composes them.
Usage:
python edit_gpt_image.py input.png "edit instruction" output.png [options]
python edit_gpt_image.py "Put cat on couch" result.png cat.png couch.png [options]
Examples:
python edit_gpt_image.py photo.png "Add a rainbow in the sky" edited.png
python edit_gpt_image.py "Make a group photo" group.png p1.png p2.png p3.png
Environment:
OPENAI_API_KEY - Required API key
"""
import argparse
import base64
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv(Path.cwd() / ".env")
VALID_QUALITY = ["auto", "low", "medium", "high"]
VALID_FORMATS = ["png", "jpeg", "webp"]
# gpt-image-2 does NOT support "transparent" — only opaque/auto.
VALID_BACKGROUND = ["auto", "opaque"]
def backup_if_exists(output_path: str) -> None:
"""Copy an existing output file into ./backup/ before it gets overwritten.
Edits often target a path that already holds an image (sometimes the input
itself), so back the original up first — losing it to an edit is silent and
unrecoverable. backup/ self-ignores via a backup/.gitignore of "*".
"""
out = Path(output_path)
if not out.exists():
return
backup_dir = Path.cwd() / "backup"
backup_dir.mkdir(exist_ok=True)
gitignore = backup_dir / ".gitignore"
if not gitignore.exists():
gitignore.write_text("*\n")
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = backup_dir / f"{out.stem}_{ts}{out.suffix}"
counter = 1
while dest.exists():
dest = backup_dir / f"{out.stem}_{ts}_{counter}{out.suffix}"
counter += 1
shutil.copy2(out, dest)
print(f"Backed up existing {output_path} -> {dest}")
def edit_gpt_image(
input_paths: list[str],
instruction: str,
output_path: str,
model: str = "gpt-image-2",
size: str = "auto",
quality: str = "auto",
output_format: str = "png",
output_compression: int | None = None,
mask_path: str | None = None,
background: str = "auto",
) -> None:
"""Edit/compose images using gpt-image-2."""
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise EnvironmentError("OPENAI_API_KEY environment variable not set")
for p in input_paths:
if not os.path.exists(p):
raise FileNotFoundError(f"Input image not found: {p}")
client = OpenAI(api_key=api_key)
image_files = [open(p, "rb") for p in input_paths]
try:
kwargs = {
"model": model,
"image": image_files if len(image_files) > 1 else image_files[0],
"prompt": instruction,
"size": size,
"quality": quality,
"output_format": output_format,
"background": background,
}
if output_compression is not None and output_format in {"jpeg", "webp"}:
kwargs["output_compression"] = output_compression
if mask_path:
if not os.path.exists(mask_path):
raise FileNotFoundError(f"Mask not found: {mask_path}")
kwargs["mask"] = open(mask_path, "rb")
print(f"Model: {model}")
print(f"Inputs: {', '.join(input_paths)}")
print(f"Size: {size}")
print(f"Quality: {quality}")
print(f"Format: {output_format}")
print(f"Background: {background}")
print(f"Prompt: {instruction[:120]}{'...' if len(instruction) > 120 else ''}")
print()
print("Editing image...")
result = client.images.edit(**kwargs)
finally:
for f in image_files:
f.close()
if mask_path and "mask" in kwargs:
kwargs["mask"].close()
item = result.data[0]
backup_if_exists(output_path)
Path(output_path).write_bytes(base64.b64decode(item.b64_json))
print(f"Saved: {output_path}")
if getattr(result, "usage", None):
print(f"Usage: {result.usage}")
def main():
parser = argparse.ArgumentParser(
description="Edit/compose images using OpenAI gpt-image-2",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("instruction", help="Edit/compose instruction")
parser.add_argument("output", help="Output file path")
parser.add_argument(
"inputs",
nargs="+",
help="One or more input image paths (multiple = composition)",
)
parser.add_argument(
"--model",
"-m",
default="gpt-image-2",
help="Model ID (default: gpt-image-2)",
)
parser.add_argument(
"--size",
"-s",
default="auto",
help="Image size WxH (default: auto). E.g. 1024x1024, 1536x1024, 2048x2048.",
)
parser.add_argument(
"--quality",
"-q",
default="auto",
choices=VALID_QUALITY,
help="Quality tier (default: auto)",
)
parser.add_argument(
"--format",
"-f",
default="png",
choices=VALID_FORMATS,
help="Output format (default: png)",
)
parser.add_argument(
"--compression",
type=int,
default=None,
help="Output compression 0-100 (jpeg/webp only)",
)
parser.add_argument(
"--mask",
default=None,
help="Optional mask PNG (transparent areas = regions to edit)",
)
parser.add_argument(
"--background",
default="auto",
choices=VALID_BACKGROUND,
help=(
"Background mode (default: auto). gpt-image-2 supports only "
"'auto' or 'opaque''transparent' is NOT supported by this model."
),
)
args = parser.parse_args()
try:
edit_gpt_image(
input_paths=args.inputs,
instruction=args.instruction,
output_path=args.output,
model=args.model,
size=args.size,
quality=args.quality,
output_format=args.format,
output_compression=args.compression,
mask_path=args.mask,
background=args.background,
)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()