Files
ChildHood/ai-art-research/tools/image_gen.py
T
starmen1265 a20eec8d57 feat(ai-art): 新增API易生图脚本和连通性测试
- 新增 image_gen.py:支持文生图/风格校准/双参考融合三种模式
- 新增 .env.example:API易平台配置模板
- 更新 .gitignore:排除 .env 密钥文件
- 首张连通性测试图已生成(GPT-Image-2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:48:29 +08:00

455 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()