From 2dc15d99ad334c86cb3e72e5393d90c615038544 Mon Sep 17 00:00:00 2001 From: backup-bot Date: Tue, 14 Jul 2026 17:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20gen-image=20?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过大模型中转站调用 OpenAI 图片接口生图(文生图/图生图/局部重绘/透明背景),零第三方依赖。 含可安装 zip 与可浏览源码。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 +- categories/图片生成/28772__gen-image.zip | Bin 0 -> 10596 bytes gen-image/.env.example | 12 ++ gen-image/.gitignore | 9 + gen-image/AGENTS.md | 60 +++++++ gen-image/README.md | 40 +++++ gen-image/SKILL.md | 91 ++++++++++ gen-image/gen.sh | 13 ++ gen-image/scripts/gen_image.py | 220 +++++++++++++++++++++++ 9 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 categories/图片生成/28772__gen-image.zip create mode 100644 gen-image/.env.example create mode 100644 gen-image/.gitignore create mode 100644 gen-image/AGENTS.md create mode 100644 gen-image/README.md create mode 100644 gen-image/SKILL.md create mode 100755 gen-image/gen.sh create mode 100644 gen-image/scripts/gen_image.py diff --git a/README.md b/README.md index 135a185..0edba8d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ -# backup +# backup-bot / skills 备份仓库 -自动备份仓库 \ No newline at end of file +个人技能备份 / 分享库,供 TD工具箱(codex-market)安装。 + +## 技能清单 + +| 技能 | 分类 | 说明 | 安装包 | +|------|------|------|--------| +| **gen-image · 生图** | 图片生成 | 通过大模型中转站调用 OpenAI 图片接口生图(文生图/图生图/局部重绘/透明背景),零依赖 | `categories/图片生成/28772__gen-image.zip` | + +## gen-image 用法 + +首次需在 skill 目录 `cp .env.example .env` 并填入中转站 `IMAGE_API_BASE_URL` / `IMAGE_API_KEY`,然后: + +```bash +./gen.sh "提示词" --size 1024x1024 --quality high # 文生图 +./gen.sh "换成雪山背景" --edit input.png # 图生图 +``` diff --git a/categories/图片生成/28772__gen-image.zip b/categories/图片生成/28772__gen-image.zip new file mode 100644 index 0000000000000000000000000000000000000000..446586ddfc3bfe3d7cec572a93c55da4f0a182e7 GIT binary patch literal 10596 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/dev/null || echo "未配置" +``` + +若无,从模板创建并让用户填写(**不要把 key 写进对话或提交到 git**): + +```bash +cp .env.example .env +# 然后引导用户编辑 .env,填入 IMAGE_API_BASE_URL 和 IMAGE_API_KEY +``` + +`.env` 需要三项: +- `IMAGE_API_BASE_URL` — 中转站基址,如 `https://your-relay.com`(脚本自动补 `/v1/images/...`) +- `IMAGE_API_KEY` — API Key +- `IMAGE_MODEL` — 默认模型,默认 `gpt-image-2` + +## 标准流程 + +**1. 打磨提示词。** 用户给的描述往往简短,先在心里扩写成一句信息充分的英文或中文提示词——主体、风格、光线、构图、色调、画质关键词。除非用户明确要求逐字使用,否则主动增强。生图模型对细节敏感,「一只猫」远不如「a fluffy orange tabby cat sitting on a windowsill, soft morning light, shallow depth of field, cozy warm tones」。 + +**2. 选参数:** +- `--size`:`1024x1024`(方,头像/图标)、`1536x1024`(横,海报/封面)、`1024x1536`(竖,手机壁纸/人像)、`auto` +- `--quality`:`low`/`medium`/`high`/`auto`(gpt-image 系支持;越高越慢越贵) +- `--background transparent`:要透明底的图标/贴纸时加 +- `--n`:一次出几张(默认 1;多给几张让用户挑时用 `--n 2` 或 `4`) + +**3. 执行(文生图):** + +```bash +cd "" && python3 scripts/gen_image.py \ + "a fluffy orange tabby cat on a windowsill, soft morning light, cozy tones, highly detailed" \ + --size 1024x1024 --quality high +``` + +图片存到 `output/img-<时间戳>-<序号>.png`,脚本会打印完整路径。 + +**4. 图生图 / 编辑**(用户给了输入图): + +```bash +python3 scripts/gen_image.py "把背景换成雪山日落" --edit /path/to/input.png --size 1024x1024 +``` + +多图参考:多次 `--edit a.png --edit b.png`。局部重绘:加 `--mask mask.png`(PNG 中**透明区域**为要重绘的部分,其余保留)。 + +**5. 交付。** 打印出保存路径后,简要告诉用户生成了什么、存在哪。图无法在终端直接显示,用户会自己打开;若在支持预览的环境可提示路径。 + +## 常见参数速查 + +| 需求 | 命令片段 | +|------|---------| +| 头像/图标(方图) | `--size 1024x1024` | +| 海报/封面(横) | `--size 1536x1024` | +| 手机壁纸/人像(竖) | `--size 1024x1536` | +| 透明背景贴纸 | `--background transparent` | +| 多出几张挑选 | `--n 4` | +| 高画质 | `--quality high` | +| 换模型 | `--model dall-e-3` | +| 基于图改 | `--edit input.png` | +| 局部重绘 | `--edit input.png --mask mask.png` | + +## 排错 + +- **缺 IMAGE_API_BASE_URL / IMAGE_API_KEY** → `.env` 没配好,回到「配置凭证」。 +- **HTTP 401/403** → key 无效或额度不足,让用户核对中转站账户。 +- **HTTP 404** → base_url 写错,或该中转站不支持 `/v1/images/generations`;确认接口路径。 +- **HTTP 400 model 相关** → 该中转站不认 `gpt-image-2`,换 `--model dall-e-3` 或问用户支持的模型名。 +- **响应没有图片数据** → 部分中转站返回结构不同,把打印的原始响应给用户看以便排查。 + +不要把 API Key 输出到对话里,也不要提交 `.env`。 diff --git a/gen-image/gen.sh b/gen-image/gen.sh new file mode 100755 index 0000000..900d9c3 --- /dev/null +++ b/gen-image/gen.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# gen.sh — 生图快捷入口。用法: +# ./gen.sh "提示词" # 文生图,默认 1024x1024 +# ./gen.sh "提示词" --size 1536x1024 # 透传任意 gen_image.py 参数 +# ./gen.sh "换成雪山背景" --edit in.png # 图生图 +# 无论从哪个目录调用都能正确定位脚本与 .env。 +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ "$#" -eq 0 ]; then + echo "用法: ./gen.sh \"提示词\" [--size 1024x1024] [--quality high] [--edit 图.png] ..." >&2 + exit 1 +fi +exec python3 "$DIR/scripts/gen_image.py" "$@" diff --git a/gen-image/scripts/gen_image.py b/gen-image/scripts/gen_image.py new file mode 100644 index 0000000..96b6d4f --- /dev/null +++ b/gen-image/scripts/gen_image.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +gen_image.py — 通过大模型中转站调用 OpenAI 图片接口生图(文生图 / 图生图)。 + +零第三方依赖,仅用 Python 标准库。凭证从环境变量或 skill 目录下的 .env 读取。 + +用法: + # 文生图 + python3 gen_image.py "一只戴墨镜的柴犬,赛博朋克霓虹背景" --size 1024x1024 --n 1 + + # 图生图 / 编辑(可选 --mask 做局部重绘) + python3 gen_image.py "把背景换成雪山" --edit input.png --mask mask.png + +环境变量(也可写进 .env): + IMAGE_API_BASE_URL 中转站基址,如 https://your-relay.com (会自动补 /v1/images/...) + IMAGE_API_KEY API Key + IMAGE_MODEL 默认模型,如 gpt-image-2(默认 gpt-image-2) +""" + +import argparse +import base64 +import json +import mimetypes +import os +import sys +import time +import urllib.error +import urllib.request +import uuid + + +# --------------------------------------------------------------------------- +# 配置加载 +# --------------------------------------------------------------------------- +def load_dotenv(): + """从脚本上级目录(skill 根)的 .env 读取 KEY=VALUE,不覆盖已存在的环境变量。""" + skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(skill_root, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key, val = key.strip(), val.strip().strip('"').strip("'") + os.environ.setdefault(key, val) + + +def get_config(): + base = os.environ.get("IMAGE_API_BASE_URL", "").rstrip("/") + key = os.environ.get("IMAGE_API_KEY", "") + model = os.environ.get("IMAGE_MODEL", "gpt-image-2") + if not base: + die("缺少 IMAGE_API_BASE_URL(中转站基址)。请在 .env 中配置,参考 .env.example。") + if not key: + die("缺少 IMAGE_API_KEY。请在 .env 中配置,参考 .env.example。") + # 允许用户直接填到 /v1,做个兼容归一 + if base.endswith("/v1"): + base = base[: -len("/v1")] + return base, key, model + + +# --------------------------------------------------------------------------- +# HTTP 辅助 +# --------------------------------------------------------------------------- +def die(msg, code=1): + print(f"[gen_image] 错误: {msg}", file=sys.stderr) + sys.exit(code) + + +# 部分中转站前置 Cloudflare/WAF,会按 UA 拦截默认的 Python-urllib(error 1010)。 +# 用常规浏览器 UA 规避。 +BROWSER_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) + + +def _request(url, key, data, content_type): + req = urllib.request.Request(url, data=data, method="POST") + req.add_header("Authorization", f"Bearer {key}") + req.add_header("Content-Type", content_type) + req.add_header("User-Agent", BROWSER_UA) + req.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(req, timeout=300) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + die(f"HTTP {e.code} — {body[:800]}") + except urllib.error.URLError as e: + die(f"网络错误: {e.reason}") + + +def post_json(url, key, payload): + return _request(url, key, json.dumps(payload).encode("utf-8"), "application/json") + + +def post_multipart(url, key, fields, files): + """fields: dict[str,str]; files: list[(field, filepath)]。手写 multipart,无依赖。""" + boundary = "----genimg" + uuid.uuid4().hex + buf = bytearray() + + def w(s): + buf.extend(s.encode("utf-8") if isinstance(s, str) else s) + + for name, value in fields.items(): + if value is None: + continue + w(f"--{boundary}\r\n") + w(f'Content-Disposition: form-data; name="{name}"\r\n\r\n') + w(f"{value}\r\n") + + for name, path in files: + if not path: + continue + fname = os.path.basename(path) + ctype = mimetypes.guess_type(path)[0] or "application/octet-stream" + with open(path, "rb") as fh: + content = fh.read() + w(f"--{boundary}\r\n") + w(f'Content-Disposition: form-data; name="{name}"; filename="{fname}"\r\n') + w(f"Content-Type: {ctype}\r\n\r\n") + w(content) + w("\r\n") + + w(f"--{boundary}--\r\n") + return _request(url, key, bytes(buf), f"multipart/form-data; boundary={boundary}") + + +# --------------------------------------------------------------------------- +# 结果保存 +# --------------------------------------------------------------------------- +def save_images(resp, out_dir, prefix): + data = resp.get("data") + if not data: + die(f"响应中没有图片数据。原始响应: {json.dumps(resp)[:800]}") + os.makedirs(out_dir, exist_ok=True) + saved = [] + for i, item in enumerate(data): + ts = time.strftime("%Y%m%d-%H%M%S") + fname = f"{prefix}-{ts}-{i}.png" + fpath = os.path.join(out_dir, fname) + if item.get("b64_json"): + with open(fpath, "wb") as f: + f.write(base64.b64decode(item["b64_json"])) + elif item.get("url"): + try: + dl = urllib.request.Request(item["url"], headers={"User-Agent": BROWSER_UA}) + with urllib.request.urlopen(dl, timeout=120) as r: + with open(fpath, "wb") as f: + f.write(r.read()) + except Exception as e: + die(f"下载图片失败 {item['url']}: {e}") + else: + die(f"未知的图片条目格式: {json.dumps(item)[:400]}") + saved.append(fpath) + return saved + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- +def main(): + load_dotenv() + p = argparse.ArgumentParser(description="通过中转站调用 OpenAI 图片接口生图") + p.add_argument("prompt", help="生图提示词") + p.add_argument("--model", default=None, help="覆盖默认模型") + p.add_argument("--size", default="1024x1024", help="尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto") + p.add_argument("--n", type=int, default=1, help="生成张数") + p.add_argument("--quality", default=None, help="质量: low/medium/high/auto(gpt-image 系)") + p.add_argument("--background", default=None, help="transparent/opaque/auto(gpt-image 系,透明背景)") + p.add_argument("--edit", action="append", default=[], help="图生图/编辑的输入图(可多次传入多图)") + p.add_argument("--mask", default=None, help="局部重绘蒙版 PNG(透明处为要重绘的区域)") + p.add_argument("--output", default=None, help="输出目录(默认 skill 根下 output/)") + args = p.parse_args() + + base, key, default_model = get_config() + model = args.model or default_model + skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + out_dir = args.output or os.path.join(skill_root, "output") + + if args.edit: + # 图生图 / 编辑:multipart 到 /v1/images/edits + url = f"{base}/v1/images/edits" + fields = {"model": model, "prompt": args.prompt, "n": str(args.n), "size": args.size} + if args.quality: + fields["quality"] = args.quality + if args.background: + fields["background"] = args.background + files = [("image[]", pth) for pth in args.edit] # 多图统一用 image[] + if len(args.edit) == 1: + files = [("image", args.edit[0])] # 单图用 image,兼容 dall-e-2 风格 + if args.mask: + files.append(("mask", args.mask)) + print(f"[gen_image] 图生图 · model={model} · {len(args.edit)} 张输入 · size={args.size}") + resp = post_multipart(url, key, fields, files) + prefix = "edit" + else: + # 文生图:JSON 到 /v1/images/generations + url = f"{base}/v1/images/generations" + payload = {"model": model, "prompt": args.prompt, "n": args.n, "size": args.size} + if args.quality: + payload["quality"] = args.quality + if args.background: + payload["background"] = args.background + print(f"[gen_image] 文生图 · model={model} · n={args.n} · size={args.size}") + resp = post_json(url, key, payload) + prefix = "img" + + saved = save_images(resp, out_dir, prefix) + print(f"[gen_image] 完成,已保存 {len(saved)} 张:") + for s in saved: + print(f" {s}") + + +if __name__ == "__main__": + main()