feat(ai-art): 增强生图工具(多图融合/prompt文件/短名)+概述文档

image_gen.py: --fuse支持1风格+N张内容图(-c可重复); 新增-pf/--prompt-file从文件读prompt; 修复images.edit参数格式bug(dict->tuple); save_result改用短英文文件名(修复长中文文件名导致图像分析工具URL解析失败). README.md: 工具链概述文档.
This commit is contained in:
2026-06-17 18:43:24 +08:00
parent 4bc4e88ad3
commit 7fbef405ba
2 changed files with 293 additions and 56 deletions
+82 -56
View File
@@ -124,16 +124,30 @@ def _sanitize_filename(s: str, max_len: int = 40) -> str:
return result[:max_len].rstrip("_")
# 模式 → 简短英文文件名 tag
# 避免把整段中文 prompt 塞进文件名(CDN URL 过长/编码异常会让图像分析工具解析失败)
_MODE_SLUG = {
"text_to_image": "gen",
"style_calibration": "calib",
"multi_reference_fusion": "fusion",
"dual_reference_fusion": "fusion",
"connection_test": "test",
}
def save_result(image_bytes: bytes, metadata: dict, subdir: str = "") -> Path:
"""保存图片和元数据,返回图片路径"""
"""保存图片和元数据,返回图片路径。
文件名用「时间戳_模型_模式」短英文命名(如 260617-183020_gpt-image-2_fusion.png);
完整 prompt 与参数存在 .meta.json 里,不丢信息。
"""
ts = datetime.now().strftime("%y%m%d-%H%M%S")
model_slug = metadata.get("model", "unknown").replace("/", "-").replace(".", "-")
mode_slug = _MODE_SLUG.get(metadata.get("mode", ""), "img")
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"
filename = f"{ts}_{model_slug}_{mode_slug}.png"
img_path = out_dir / filename
img_path.write_bytes(image_bytes)
@@ -270,99 +284,100 @@ def cmd_calibrate(client, model: str):
# ---------------------------------------------------------------------------
# 功能:双参考融合
# ---------------------------------------------------------------------------
def cmd_fuse(client, style_path: str, content_path: str, prompt: str,
def cmd_fuse(client, style_path: str, content_paths, prompt: str,
model: str, subdir: str):
"""双参考图融合"""
"""参考图融合(1 张风格参考 + 1~N 张内容参考)"""
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("🔗 双参考融合")
# content_paths 兼容单个 str/Path 或 list
if isinstance(content_paths, (str, Path)):
content_paths = [content_paths]
content_files = []
for cp in content_paths:
cf = Path(cp).resolve()
if not cf.exists():
print(f"❌ 内容参考图不存在: {cf}")
sys.exit(1)
content_files.append(cf)
label = "双参考融合" if len(content_files) == 1 else f"多参考融合(1 风格 + {len(content_files)} 内容)"
print(f"🔗 {label}")
print(f" 模型: {model}")
print(f" 风格参考: {style_file.name}")
print(f" 内容参考: {content_file.name}")
for cf in content_files:
print(f" 内容参考: {cf.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(带多图的编辑模式)
# openai 库文件上传格式:tuple = (filename, bytes, content_type)
style_tuple = (style_file.name, style_file.read_bytes(),
ext_map.get(style_file.suffix.lower(), "image/png"))
content_tuples = [
(cf.name, cf.read_bytes(), ext_map.get(cf.suffix.lower(), "image/png"))
for cf in content_files
]
image_list = [style_tuple] + content_tuples
image_bytes = None
errors = []
# 方法 1: images.edit
print(" 尝试方式 1: images.edit ...")
# 方式 1: images.edit 多图(风格参考 + 全部内容参考)
print(f" 尝试方式 1: images.edit ({len(image_list)} 图) 融合 ...")
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,
}},
],
image=image_list,
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}")
errors.append(f"images.edit 多图: {e}")
print(f" ⚠️ 方式 1 失败: {e}")
# 方法 2: images.generate with b64 image in prompt (chat-style)
# 方式 2: images.edit 单图(仅第一张内容参考,风格靠文字)—— 降级
if image_bytes is None:
print(" 尝试方式 2: images.generate with extra_body ...")
print(" 尝试方式 2: images.edit 单图(风格写入 prompt)...")
single_prompt = (
f"{STYLE_SEED_EN}\n\n"
"Re-render the uploaded photo into this 3D low-poly cel-shaded game art style. "
"Keep the photo's scene layout, building structure and objects, but re-render "
"everything in the style described above. Warm nostalgic late-afternoon lighting, "
"clean silhouette, game concept art."
)
try:
response = client.images.generate(
response = client.images.edit(
model=model,
prompt=fusion_prompt,
image=content_tuples[0],
prompt=single_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 成功")
print(" ✅ 方式 2 成功(单图 + 文字风格,效果弱于多图)")
except Exception as e:
errors.append(f"images.generate+extra_body: {e}")
errors.append(f"images.edit 单图: {e}")
print(f" ⚠️ 方式 2 失败: {e}")
if image_bytes is None:
print()
print("❌ 双参考融合 API 调用失败。")
print(" 这可能是因为 API易 不支持多图输入的 images.edit 端点。")
print("❌ 双参考融合 API 调用失败(双图 edit 与单图 edit 均失败)。")
print()
print("💡 替代方案:")
print(" 1. 使用 API易 网页界面上传多张参考图手动操作")
print(" 2. 或将风格描述写入 prompt,仅上传内容参考图(单图模式)")
print("💡 可能原因与替代方案:")
print(" 1. API易 的该模型可能不支持 images.edit → 改用网页端手动上传两张图")
print(" 2. 换支持参考图的模型重试: -m gemini-3-1-flash-image-preview")
print(" 3. 改走纯文生图: python tools/image_gen.py -g \"<建筑内容描述>\"")
print()
print(" 所有错误:")
for err in errors:
@@ -372,9 +387,9 @@ def cmd_fuse(client, style_path: str, content_path: str, prompt: str,
save_result(image_bytes, {
"model": model,
"prompt": fusion_prompt,
"mode": "dual_reference_fusion",
"mode": "multi_reference_fusion",
"style_reference": str(style_file),
"content_reference": str(content_file),
"content_references": [str(cf) for cf in content_files],
"size": "1024x1024",
"timestamp": datetime.now().isoformat(),
}, subdir=subdir)
@@ -412,8 +427,11 @@ def main():
# 参数
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("-c", "--content", action="append", type=str,
help="内容参考图路径(配合 --fuse,可重复传多张:首张为整楼,后续为细节特写如晾衣架)")
parser.add_argument("-p", "--prompt", type=str, help="自定义 prompt(短)")
parser.add_argument("-pf", "--prompt-file", type=str,
help="从 .txt 文件读取 prompt(配合 --fuse,适合长 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="图片尺寸")
@@ -444,9 +462,17 @@ def main():
elif args.fuse:
if not args.style or not args.content:
parser.error("双参考融合需要 --style (-s) 和 --content (-c) 参数")
if args.prompt_file:
pf = Path(args.prompt_file)
if not pf.exists():
parser.error(f"prompt 文件不存在: {pf}")
fuse_prompt = pf.read_text(encoding="utf-8")
print(f" 📄 从文件读取 prompt: {pf.name}")
else:
fuse_prompt = args.prompt
cmd_fuse(
client, args.style, args.content,
prompt=args.prompt, model=args.model, subdir=args.subdir,
prompt=fuse_prompt, model=args.model, subdir=args.subdir,
)