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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user