a20eec8d57
- 新增 image_gen.py:支持文生图/风格校准/双参考融合三种模式 - 新增 .env.example:API易平台配置模板 - 更新 .gitignore:排除 .env 密钥文件 - 首张连通性测试图已生成(GPT-Image-2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
455 lines
17 KiB
Python
455 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
ChildHood AI Art Generation Tool
|
||
通过 API易 平台调用 AI 生图模型
|
||
|
||
Usage:
|
||
python image_gen.py --test # 测试 API 连通性
|
||
python image_gen.py --generate "千禧年中国城郊的小卖部门口" # 文生图(自动加风格前缀)
|
||
python image_gen.py --generate "a red apple" --no-style # 纯文生图(不加风格前缀)
|
||
python image_gen.py --calibrate # 风格校准(5 个测试物体)
|
||
python image_gen.py --fuse --style style.png --content photo.jpg # 双参考融合
|
||
python image_gen.py --fuse --style style.png --content photo.jpg --prompt "自定义 prompt"
|
||
|
||
Requirements:
|
||
pip install openai python-dotenv
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import io
|
||
import json
|
||
import os
|
||
import sys
|
||
import urllib.request
|
||
|
||
# Windows 控制台 UTF-8 支持
|
||
if sys.platform == "win32":
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||
import os
|
||
import sys
|
||
import urllib.request
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 路径配置
|
||
# ---------------------------------------------------------------------------
|
||
SCRIPT_DIR = Path(__file__).resolve().parent # ai-art-research/tools/
|
||
RESEARCH_DIR = SCRIPT_DIR.parent # ai-art-research/
|
||
DOTENV_PATH = RESEARCH_DIR / ".env"
|
||
OUTPUT_BASE = RESEARCH_DIR / "生成结果"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 加载 .env
|
||
# ---------------------------------------------------------------------------
|
||
if DOTENV_PATH.exists():
|
||
from dotenv import load_dotenv
|
||
load_dotenv(DOTENV_PATH)
|
||
|
||
API_KEY = os.getenv("APIYI_API_KEY", "")
|
||
BASE_URL = os.getenv("APIYI_BASE_URL", "https://api.apiyi.com/v1")
|
||
DEFAULT_MODEL = os.getenv("APIYI_MODEL", "gpt-image-2")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 风格 Prompt 种子(来自工作流方案 v0.1)
|
||
# ---------------------------------------------------------------------------
|
||
STYLE_SEED_EN = (
|
||
"3D low-poly game art, flat cel-shaded with solid color blocks, "
|
||
"bright warm-cool balanced saturated palette, soft diffused lighting "
|
||
"with subtle shadows, simplified geometric forms with clean edges, "
|
||
"low-angle third-person perspective, nostalgic warm summer atmosphere"
|
||
)
|
||
|
||
# 风格校准测试物体(中文名, 英文 prompt)
|
||
CALIBRATION_OBJECTS = [
|
||
("乌鸦", "a crow standing on the ground"),
|
||
("茶杯", "a teacup on a wooden table"),
|
||
("老人", "an old man standing in a village street"),
|
||
("山湖", "a mountain lake with trees"),
|
||
("苹果", "a red apple on the ground"),
|
||
]
|
||
|
||
# 双参考融合默认 prompt
|
||
DEFAULT_FUSION_PROMPT = """\
|
||
图1 是我的风格参考(一张游戏截图)。图2 是我的内容参考(一张现实照片)。
|
||
请生成一张新图片,用图1的美术风格来呈现图2中的场景内容。
|
||
|
||
风格要求:
|
||
- 高度简化的低多边形几何形体,干净利落的边缘
|
||
- 平面色块着色,不用渐变或写实纹理
|
||
- 鲜艳明亮的暖冷平衡色调(暖黄、天蓝、翠绿为主)
|
||
- 柔和散射光,阴影淡而柔和
|
||
- 微低角度第三人称视角
|
||
- 温暖怀旧的夏日午后氛围
|
||
|
||
内容要求:
|
||
- 保留图2中的建筑类型、布局、空间关系
|
||
- 但把所有建筑元素替换为千禧年中国城郊的风格
|
||
- 色调保持温暖怀旧"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具函数
|
||
# ---------------------------------------------------------------------------
|
||
def get_client() -> "OpenAI":
|
||
"""创建 OpenAI 客户端"""
|
||
if not API_KEY:
|
||
print("❌ 未找到 API Key")
|
||
print(f" 请在 {DOTENV_PATH} 中设置: APIYI_API_KEY=sk-xxx")
|
||
sys.exit(1)
|
||
from openai import OpenAI
|
||
return OpenAI(api_key=API_KEY, base_url=BASE_URL)
|
||
|
||
|
||
def _extract_image_bytes(img_obj) -> bytes:
|
||
"""从 API 响应中提取图片字节数据"""
|
||
# 优先 base64
|
||
b64 = getattr(img_obj, "b64_json", None)
|
||
if b64:
|
||
return base64.b64decode(b64)
|
||
# 其次 URL
|
||
url = getattr(img_obj, "url", None)
|
||
if url:
|
||
print(" ⬇️ 正在下载图片...")
|
||
return urllib.request.urlopen(url).read()
|
||
raise ValueError("响应中未找到图片数据(无 b64_json 也无 url)")
|
||
|
||
|
||
def _sanitize_filename(s: str, max_len: int = 40) -> str:
|
||
"""清理文件名中的非法字符"""
|
||
result = "".join(c if c.isalnum() or c in "._-" else "_" for c in s)
|
||
return result[:max_len].rstrip("_")
|
||
|
||
|
||
def save_result(image_bytes: bytes, metadata: dict, subdir: str = "") -> Path:
|
||
"""保存图片和元数据,返回图片路径"""
|
||
ts = datetime.now().strftime("%y%m%d-%H%M%S")
|
||
model_slug = metadata.get("model", "unknown").replace("/", "-").replace(".", "-")
|
||
|
||
out_dir = OUTPUT_BASE / subdir if subdir else OUTPUT_BASE
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
prompt_tag = _sanitize_filename(metadata.get("prompt", "gen"))
|
||
filename = f"{ts}_{model_slug}_{prompt_tag}.png"
|
||
|
||
img_path = out_dir / filename
|
||
img_path.write_bytes(image_bytes)
|
||
|
||
meta_path = img_path.with_suffix(".meta.json")
|
||
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
print(f"✅ 已保存: {img_path.name}")
|
||
return img_path
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 功能:测试连通性
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_test(client):
|
||
"""测试 API 易连通性,生成一张简单测试图"""
|
||
print("🔗 测试 API易 连通性...")
|
||
print(f" Base URL: {BASE_URL}")
|
||
print(f" Model: {DEFAULT_MODEL}")
|
||
print(f" API Key: {API_KEY[:8]}...{API_KEY[-4:]}")
|
||
print()
|
||
|
||
prompt = f"{STYLE_SEED_EN}, a simple red cube on a white background, test image"
|
||
print(f" Prompt: {prompt[:80]}...")
|
||
|
||
response = client.images.generate(
|
||
model=DEFAULT_MODEL,
|
||
prompt=prompt,
|
||
size="1024x1024",
|
||
quality="low",
|
||
n=1,
|
||
)
|
||
|
||
image_bytes = _extract_image_bytes(response.data[0])
|
||
save_result(image_bytes, {
|
||
"model": DEFAULT_MODEL,
|
||
"prompt": prompt,
|
||
"mode": "connection_test",
|
||
"size": "1024x1024",
|
||
"quality": "low",
|
||
"timestamp": datetime.now().isoformat(),
|
||
}, subdir="测试")
|
||
|
||
print()
|
||
print("✅ API 连通性测试通过!")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 功能:文生图
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_generate(client, prompt: str, use_style: bool, size: str, quality: str,
|
||
model: str, subdir: str):
|
||
"""文生图"""
|
||
model = model or DEFAULT_MODEL
|
||
full_prompt = f"{STYLE_SEED_EN}, {prompt}" if use_style else prompt
|
||
|
||
print(f"🎨 文生图")
|
||
print(f" 模型: {model}")
|
||
print(f" 尺寸: {size} 质量: {quality}")
|
||
print(f" 风格前缀: {'是' if use_style else '否'}")
|
||
print(f" Prompt: {full_prompt[:100]}{'...' if len(full_prompt) > 100 else ''}")
|
||
print()
|
||
|
||
response = client.images.generate(
|
||
model=model,
|
||
prompt=full_prompt,
|
||
size=size,
|
||
quality=quality,
|
||
n=1,
|
||
)
|
||
|
||
image_bytes = _extract_image_bytes(response.data[0])
|
||
save_result(image_bytes, {
|
||
"model": model,
|
||
"prompt": full_prompt,
|
||
"mode": "text_to_image",
|
||
"style_prefix_used": use_style,
|
||
"size": size,
|
||
"quality": quality,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}, subdir=subdir)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 功能:风格校准
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_calibrate(client, model: str):
|
||
"""风格校准 — 5 个测试物体"""
|
||
model = model or DEFAULT_MODEL
|
||
subdir = "风格校准"
|
||
|
||
print("🎯 开始风格校准测试")
|
||
print(f" 模型: {model}")
|
||
print(f" 风格种子: {STYLE_SEED_EN[:60]}...")
|
||
print(f" 测试物体: {', '.join(zh for zh, _ in CALIBRATION_OBJECTS)}")
|
||
print()
|
||
|
||
success = 0
|
||
for zh_name, en_prompt in CALIBRATION_OBJECTS:
|
||
full_prompt = f"{STYLE_SEED_EN}, {en_prompt}"
|
||
print(f" 🎨 生成: {zh_name} ...")
|
||
|
||
try:
|
||
response = client.images.generate(
|
||
model=model,
|
||
prompt=full_prompt,
|
||
size="1024x1024",
|
||
quality="medium",
|
||
n=1,
|
||
response_format="b64_json",
|
||
)
|
||
image_bytes = _extract_image_bytes(response.data[0])
|
||
save_result(image_bytes, {
|
||
"model": model,
|
||
"prompt": full_prompt,
|
||
"mode": "style_calibration",
|
||
"object_zh": zh_name,
|
||
"object_en": en_prompt,
|
||
"style_seed": STYLE_SEED_EN,
|
||
"timestamp": datetime.now().isoformat(),
|
||
}, subdir=subdir)
|
||
success += 1
|
||
except Exception as e:
|
||
print(f" ❌ 失败: {e}")
|
||
print()
|
||
|
||
print(f"🎉 风格校准完成!成功 {success}/{len(CALIBRATION_OBJECTS)} 张")
|
||
print(f" 输出目录: {OUTPUT_BASE / subdir}")
|
||
print()
|
||
print("📋 验证方法:打开上述目录,检查 5 张图是否视觉风格统一。")
|
||
print(" 如果风格不一致,需要调整 STYLE_SEED_EN 后重新运行。")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 功能:双参考融合
|
||
# ---------------------------------------------------------------------------
|
||
def cmd_fuse(client, style_path: str, content_path: str, prompt: str,
|
||
model: str, subdir: str):
|
||
"""双参考图融合"""
|
||
model = model or DEFAULT_MODEL
|
||
fusion_prompt = prompt or DEFAULT_FUSION_PROMPT
|
||
subdir = subdir or "双参考融合"
|
||
|
||
style_file = Path(style_path).resolve()
|
||
content_file = Path(content_path).resolve()
|
||
|
||
if not style_file.exists():
|
||
print(f"❌ 风格参考图不存在: {style_file}")
|
||
sys.exit(1)
|
||
if not content_file.exists():
|
||
print(f"❌ 内容参考图不存在: {content_file}")
|
||
sys.exit(1)
|
||
|
||
print("🔗 双参考融合")
|
||
print(f" 模型: {model}")
|
||
print(f" 风格参考: {style_file.name}")
|
||
print(f" 内容参考: {content_file.name}")
|
||
print(f" Prompt: {fusion_prompt[:80]}...")
|
||
print()
|
||
|
||
# 编码图片为 base64
|
||
style_b64 = base64.b64encode(style_file.read_bytes()).decode()
|
||
content_b64 = base64.b64encode(content_file.read_bytes()).decode()
|
||
|
||
# 确定图片 MIME 类型
|
||
ext_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||
".webp": "image/webp", ".gif": "image/gif"}
|
||
style_mime = ext_map.get(style_file.suffix.lower(), "image/png")
|
||
content_mime = ext_map.get(content_file.suffix.lower(), "image/png")
|
||
|
||
# 尝试方法 1: images.edit(带多图的编辑模式)
|
||
image_bytes = None
|
||
errors = []
|
||
|
||
# 方法 1: images.edit
|
||
print(" 尝试方式 1: images.edit ...")
|
||
try:
|
||
response = client.images.edit(
|
||
model=model,
|
||
image=[
|
||
{"type": "image_file", "image_file": {
|
||
"file_data": f"data:{style_mime};base64,{style_b64}",
|
||
"filename": style_file.name,
|
||
}},
|
||
{"type": "image_file", "image_file": {
|
||
"file_data": f"data:{content_mime};base64,{content_b64}",
|
||
"filename": content_file.name,
|
||
}},
|
||
],
|
||
prompt=fusion_prompt,
|
||
size="1024x1024",
|
||
)
|
||
image_bytes = _extract_image_bytes(response.data[0])
|
||
print(" ✅ 方式 1 成功")
|
||
except Exception as e:
|
||
errors.append(f"images.edit: {e}")
|
||
print(f" ⚠️ 方式 1 失败: {e}")
|
||
|
||
# 方法 2: images.generate with b64 image in prompt (chat-style)
|
||
if image_bytes is None:
|
||
print(" 尝试方式 2: images.generate with extra_body ...")
|
||
try:
|
||
response = client.images.generate(
|
||
model=model,
|
||
prompt=fusion_prompt,
|
||
size="1024x1024",
|
||
quality="medium",
|
||
n=1,
|
||
response_format="b64_json",
|
||
extra_body={
|
||
"image": [
|
||
f"data:{style_mime};base64,{style_b64[:100]}...", # 截断防止请求过大
|
||
],
|
||
},
|
||
)
|
||
image_bytes = _extract_image_bytes(response.data[0])
|
||
print(" ✅ 方式 2 成功")
|
||
except Exception as e:
|
||
errors.append(f"images.generate+extra_body: {e}")
|
||
print(f" ⚠️ 方式 2 失败: {e}")
|
||
|
||
if image_bytes is None:
|
||
print()
|
||
print("❌ 双参考融合 API 调用失败。")
|
||
print(" 这可能是因为 API易 不支持多图输入的 images.edit 端点。")
|
||
print()
|
||
print("💡 替代方案:")
|
||
print(" 1. 使用 API易 网页界面上传多张参考图手动操作")
|
||
print(" 2. 或将风格描述写入 prompt,仅上传内容参考图(单图模式)")
|
||
print()
|
||
print(" 所有错误:")
|
||
for err in errors:
|
||
print(f" - {err}")
|
||
sys.exit(1)
|
||
|
||
save_result(image_bytes, {
|
||
"model": model,
|
||
"prompt": fusion_prompt,
|
||
"mode": "dual_reference_fusion",
|
||
"style_reference": str(style_file),
|
||
"content_reference": str(content_file),
|
||
"size": "1024x1024",
|
||
"timestamp": datetime.now().isoformat(),
|
||
}, subdir=subdir)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI 入口
|
||
# ---------------------------------------------------------------------------
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="ChildHood AI Art Generation Tool — 通过 API易 生图",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""\
|
||
示例:
|
||
python image_gen.py --test # 测试连通性
|
||
python image_gen.py -g "千禧年中国城郊的小卖部门口" # 文生图
|
||
python image_gen.py -g "a red apple" --no-style # 不加风格前缀
|
||
python image_gen.py --calibrate # 风格校准
|
||
python image_gen.py --fuse --style ref.png --content photo.jpg # 双参考融合
|
||
python image_gen.py --fuse -s style.png -c photo.jpg -p "自定义" # 自定义 prompt
|
||
|
||
环境变量 (ai-art-research/.env):
|
||
APIYI_API_KEY API易 令牌 (必填)
|
||
APIYI_BASE_URL API 地址 (默认 https://api.apiyi.com/v1)
|
||
APIYI_MODEL 默认模型 (默认 gpt-image-2)
|
||
""",
|
||
)
|
||
|
||
# 互斥的操作模式
|
||
mode = parser.add_mutually_exclusive_group()
|
||
mode.add_argument("--test", action="store_true", help="测试 API 连通性")
|
||
mode.add_argument("-g", "--generate", type=str, metavar="PROMPT", help="文生图")
|
||
mode.add_argument("--calibrate", action="store_true", help="风格校准(5 个测试物体)")
|
||
mode.add_argument("--fuse", action="store_true", help="双参考图融合模式")
|
||
|
||
# 参数
|
||
parser.add_argument("-s", "--style", type=str, help="风格参考图路径(配合 --fuse)")
|
||
parser.add_argument("-c", "--content", type=str, help="内容参考图路径(配合 --fuse)")
|
||
parser.add_argument("-p", "--prompt", type=str, help="自定义 prompt")
|
||
parser.add_argument("-m", "--model", type=str, help=f"模型 (默认: {DEFAULT_MODEL})")
|
||
parser.add_argument("--no-style", action="store_true", help="文生图不加风格前缀")
|
||
parser.add_argument("--size", type=str, default="1024x1024", help="图片尺寸")
|
||
parser.add_argument("--quality", type=str, default="medium",
|
||
choices=["low", "medium", "high"], help="质量")
|
||
parser.add_argument("--subdir", type=str, default="", help="输出子目录名")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 无参数时显示帮助
|
||
if len(sys.argv) == 1:
|
||
parser.print_help()
|
||
return
|
||
|
||
client = get_client()
|
||
|
||
if args.test:
|
||
cmd_test(client)
|
||
elif args.generate:
|
||
cmd_generate(
|
||
client, args.generate,
|
||
use_style=not args.no_style,
|
||
size=args.size, quality=args.quality,
|
||
model=args.model, subdir=args.subdir or "文生图",
|
||
)
|
||
elif args.calibrate:
|
||
cmd_calibrate(client, model=args.model)
|
||
elif args.fuse:
|
||
if not args.style or not args.content:
|
||
parser.error("双参考融合需要 --style (-s) 和 --content (-c) 参数")
|
||
cmd_fuse(
|
||
client, args.style, args.content,
|
||
prompt=args.prompt, model=args.model, subdir=args.subdir,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|