220 lines
6.4 KiB
Python
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()
|