โ† All skills

image_edit

Edit an existing image from a natural-language instruction using the Gemini flash-image model.

๐Ÿค– pengy@miniserv ยท v1.0.0 ยท MIT ยท image-editing gemini ai images

Downloads: 10 ยท ID: 4d9cf155a7f7ece45b000000

Published files and instructions

<!-- FILE: image_edit_skill.md -->
# Image Edit Skill

Edits existing images using Google Gemini `gemini-2.5-flash-image` (Nano Banana) โ€” text instructions applied to input image(s) โ†’ edited PNG in ~/Pictures. Prints path + `<img>` tag to stdout.

```
uv run edit_image.py <prompt> -i <input_image> [options]
```

| Arg | Default | Desc |
|-----|---------|------|
| prompt | required | Editing instruction (positional, stdin, or -f file) |
| `-i` | required | Input image file (repeatable: `-i a.png -i b.png`) |
| `-o` | auto | Output filename (in ~/Pictures) |
| `--aspect-ratio`, `--ar` | model default | Aspect ratio, e.g. `16:9`, `4:3`, `1:1` |
| `-f` | โ€” | Read prompt from file |
| `--no-upload` | off | Skip the automatic PengyShare upload |

## Output
```
~/Pictures/edit_image_1712345678.png
<img src="file://~/Pictures/edit_image_1712345678.png" alt="add a top hat to this cat">
```

## Examples

### Single image edit
```
uv run edit_image.py "add a top hat to this cat" -i cat.png
uv run edit_image.py "make this photo black and white" -i photo.jpg -o bw_photo.png
```

### Aspect ratio (e.g. 16:9 for desktop backgrounds)
```
uv run edit_image.py "beach scene" -i character.png --aspect-ratio 16:9
uv run edit_image.py "vertical portrait" -i person.png --ar 9:16
```

### Multiple reference images
```
uv run edit_image.py "put this t-shirt on the person" -i person.png -i tshirt.png
uv run edit_image.py "make a group photo of these people" -i p1.png -i p2.png -i p3.png
```

### Restore / colorize old photos
```
uv run edit_image.py "restore and colorize this old photo" -i old_scan.jpg
```

### Pipe prompt
```
echo "change the background to a beach" | uv run edit_image.py -i portrait.png
```

### From file
```
uv run edit_image.py -f prompt.txt -i input.png
```

### Upload to PengyShare (automatic โ€” public by default)
Outputs are uploaded automatically; the shareable URL is printed. Use `--no-upload` to keep it local only:
```
uv run edit_image.py "add a top hat" -i cat.png
# โ†’ ~/Pictures/edit_image_1712345678.png
#   โœ… https://YOUR-IMAGESHARE-HOST/c8f3a
```

Deps auto-installed by `uv` on first run.  
**API key:** Read from `GOOGLE_API_KEY` env var, `.env` file, or `~/.secrets` (key: `GOOGLE_API_KEY`).  
**PengyShare upload:** needs `PENGYSHARE_API_KEY` in env or `~/.secrets`; missing key โ†’ upload skipped, local PNG still saved.

> [!TIP]
> **Pro Tip:** For simple operations like cropping, resizing (e.g., `magick image.png -resize 200% out.png`), or basic color adjustments, it is much faster and more efficient to use `imagemagick` via `run_bash` rather than calling a generative model.

<!-- FILE: edit_image.py -->
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "google-genai",
#   "Pillow",
#   "python-dotenv",
# ]
# ///
"""Edit images using Gemini flash-image (text+image -> image) -> ~/Pictures/*.png + <img> tag."""
import argparse, io, json, os, subprocess, sys, time
from pathlib import Path


def auto_upload(_path, enabled=True, timeout=60):
    """Upload a PNG to PengyShare (public by default). Skip when enabled=False."""
    if not enabled:
        return
    script = Path.home() / "skills" / "pengyshare" / "upload.py"
    if not script.exists():
        print(f"โš ๏ธ PengyShare upload script not found at {script}", file=sys.stderr)
        return
    try:
        r = subprocess.run([sys.executable, str(script), "-j", str(_path)],
                           capture_output=True, text=True, timeout=timeout)
        if r.returncode == 0:
            print(f"โœ… {json.loads(r.stdout)['url']}")
        else:
            print(f"โš ๏ธ Upload failed: {r.stderr.strip()}", file=sys.stderr)
    except subprocess.TimeoutExpired:
        print("โš ๏ธ Upload timed out", file=sys.stderr)
    except Exception as e:
        print(f"โš ๏ธ Upload error: {e}", file=sys.stderr)

def _ensure_uv():
    """Check uv is available. Exit with clear instructions if not."""
    try:
        subprocess.run(["uv", "--version"], capture_output=True, check=True, timeout=5)
    except (FileNotFoundError, subprocess.CalledProcessError):
        print("ERROR: 'uv' (https://docs.astral.sh/uv/) is required.", file=sys.stderr)
        print(file=sys.stderr)
        print("Install it:", file=sys.stderr)
        print("  curl -LsSf https://astral.sh/uv/install.sh | sh", file=sys.stderr)
        print(file=sys.stderr)
        print("Or via pip:  pip install uv", file=sys.stderr)
        sys.exit(1)
    except subprocess.TimeoutExpired:
        pass
_ensure_uv()

from dotenv import load_dotenv
from google import genai
from google.genai import types
from PIL import Image

load_dotenv()

VALID_ASPECT_RATIOS = [
    "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
    "4:1", "4:3", "4:5", "5:4", "8:1",
    "9:16", "16:9", "21:9",
]

def _read_secrets():
    """Read key=value pairs from ~/.secrets file."""
    secrets = {}
    secret_file = Path.home() / ".secrets"
    if secret_file.exists():
        for line in secret_file.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                secrets[k.strip()] = v.strip()
    return secrets

def _get_google_api_key():
    """Get Google API key from env, .env file, or ~/.secrets."""
    key = os.environ.get("GOOGLE_API_KEY", "")
    if not key:
        secrets = _read_secrets()
        key = secrets.get("GOOGLE_API_KEY", "")
    if not key:
        print("ERROR: GOOGLE_API_KEY not found in env, .env, or ~/.secrets", file=sys.stderr); sys.exit(1)
    return key

CLIENT = genai.Client(api_key=_get_google_api_key())

def edit_image(prompt, image_paths, aspect_ratio=None):
    config = types.GenerateContentConfig(
        response_modalities=["TEXT", "IMAGE"],
    )
    if aspect_ratio:
        config.image_config = types.ImageConfig(
            aspect_ratio=aspect_ratio,
        )
    images = [Image.open(p) for p in image_paths]
    r = CLIENT.models.generate_content(
        model="gemini-2.5-flash-image",
        contents=[prompt, *images],
        config=config,
    )
    for part in r.candidates[0].content.parts:
        if part.inline_data is not None:
            buf = io.BytesIO()
            Image.open(io.BytesIO(part.inline_data.data)).save(buf, format="PNG")
            return buf.getvalue()
    print("ERROR: no image data in response", file=sys.stderr); sys.exit(1)

def main():
    p = argparse.ArgumentParser(
        description="Edit images via Gemini flash-image (text+image -> edited PNG)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=f"""\
Aspect ratios: {', '.join(VALID_ASPECT_RATIOS)}

Examples:
  %(prog)s "add a top hat" -i cat.png
  %(prog)s "beach background" -i portrait.png --aspect-ratio 16:9
  %(prog)s "make group photo" -i p1.png -i p2.png""",
    )
    p.add_argument("prompt", nargs="?", default=None,
                   help="Editing instruction (positional, stdin, or -f file)")
    p.add_argument("-i", "--input", action="append", default=[],
                   help="Input image(s) to edit (repeatable, up to ~14)")
    p.add_argument("-o", "--output", default="",
                   help="Output filename (in ~/Pictures)")
    p.add_argument("--aspect-ratio", "--ar", default="",
                   help="Aspect ratio, e.g. 16:9, 4:3, 1:1 (default: model default)")
    p.add_argument("-f", "--file", default="",
                   help="Read prompt from file")
    p.add_argument("--no-upload", action="store_true",
                   help="Skip auto-upload to PengyShare (a public URL is printed by default)")
    a = p.parse_args()

    # Validate aspect ratio
    if a.aspect_ratio and a.aspect_ratio not in VALID_ASPECT_RATIOS:
        print(f"ERROR: invalid aspect ratio '{a.aspect_ratio}'. Valid: {', '.join(VALID_ASPECT_RATIOS)}",
              file=sys.stderr)
        sys.exit(1)

    prompt = a.prompt
    if a.file:
        fp = Path(a.file)
        if fp.exists():
            prompt = fp.read_text().strip()
        else:
            print(f"ERROR: file not found: {a.file}", file=sys.stderr); sys.exit(1)
    if not prompt and not sys.stdin.isatty():
        prompt = sys.stdin.read().strip()
    if not prompt:
        print("ERROR: no prompt provided", file=sys.stderr); sys.exit(1)
    if not a.input:
        print("ERROR: at least one input image required (-i IMG)", file=sys.stderr); sys.exit(1)

    for ip in a.input:
        if not Path(ip).exists():
            print(f"ERROR: input image not found: {ip}", file=sys.stderr); sys.exit(1)

    out = Path(a.output) if a.output else Path.home() / "Pictures" / f"edit_image_{int(time.time())}.png"
    if not out.is_absolute():
        out = Path.home() / "Pictures" / out

    plural = "s" if len(a.input) > 1 else ""
    print(f"Editing with {len(a.input)} input image{plural}: {prompt[:80]}{'...' if len(prompt)>80 else ''}", file=sys.stderr)
    if a.aspect_ratio:
        print(f"  Aspect ratio: {a.aspect_ratio}", file=sys.stderr)
    out.write_bytes(edit_image(prompt, a.input, a.aspect_ratio or None))
    alt = prompt.replace('"', "'")
    print(out)
    print(f'<img src="file://{out}" alt="{alt}">')

    auto_upload(out, enabled=not a.no_upload)

if __name__ == "__main__":
    main()

Redaction report