cang-ying/video-ai-system/tools/character_turnaround.py

259 lines
16 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
"""
character_turnaround.py — Z-Image 角色三视图/四视图生成
漫剧角色资产:同角色 正面/侧面/背面(或 3/4 视角)展板,单图一张出。
用法:
python character_turnaround.py --desc "长黑发红发箍白裙少女" --output char.png
python character_turnaround.py --desc "...(详细角色描述)" --views 4 --seed 888
# --desc 支持中英文;建议给足细节:发型/发色/发饰/服装/配饰/鞋/体型
"""
import os, sys, json, urllib.request, time, argparse, uuid
COMFY = "http://127.0.0.1:8188"
COMFY_OUT = os.path.expanduser("~/comfy/ComfyUI/output")
VIEWS_3 = ("front view on the left, 90 degree side profile view in the middle, back view on the right")
# 人物四视图标准:正面 / 背面 / 侧面 / 脸部特写(动画·游戏角色资产规范)
VIEWS_4 = ("character sheet, 16:9 horizontal composition, pure white background, flat lighting no shadow, masterpiece, best quality, "
"left 1/3 of the image is a face extreme close-up portrait with hair-level facial detail looking at viewer, "
"right 2/3 of the image shows three full-body views arranged in a horizontal row: front view, 90 degree side profile view, back view, "
"the same character in every panel, identical face hairstyle and outfit across all views, "
"full body standing pose for the three right-side views")
# 社区布局:面部特写+三视图16:9 游戏立绘风)
PORTRAIT = ("character design sheet, horizontal layout divided into four panels: "
"front view, back view, 90 degree side profile view, and a large close-up portrait of the face, "
"the SAME character in all four panels, identical face hairstyle and outfit, "
"plain white background, anime style, clean lineart, high quality, detailed")
# 3D展示台布局爸爸发的开源格式上方三视图 + 下方细节特写镜头组
SHOWCASE = ("3D model display, three views of the character (front view, side view, back view), "
"clean neutral background, below the three main views are close-up detail shots showing "
"fabric, clothing details, face and accessories, "
"detail shots of face, collar, fabric texture, accessories, "
"modern style, 3D render, high quality, masterpiece")
# 场景四视图(动画场景设计标准):同一空间的 4 种视角,**纯场景无人物**
# ①正面外观 ②背面外观 ③室内视角 ④鸟瞰俯视 —— 强制 empty scene no people
SCENE_VIEWS = [
("front", "front exterior view from street level showing the main facade and entrance, empty scene, no characters, no people"),
("back", "back exterior view from behind showing the rear side of the building, empty scene, no characters, no people"),
("interior", "interior view inside the empty room showing indoor layout, tables chairs lanterns and architectural details, no characters, no people"),
("aerial", "top-down orthographic bird eye view of the whole building layout with rooms courtyard trees stone path, floor plan style, no characters, no people"),
]
def submit_wide(desc, seed, prefix, width=2048, height=1024):
"""Z-Image 宽幅全景图(用于场景切段法)。"""
prompt = (f"wide panoramic establishing shot, continuous sweeping vista of: {desc}, "
f"single unbroken scene from left to right, consistent architecture and scenery throughout, "
f"clean background, anime style, game environment design, high quality, detailed")
wf = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": NEG_PROMPT}},
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0,
"sampler_name": "res_multistep", "scheduler": "simple",
"positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
data = json.dumps({"prompt": wf}).encode()
req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
for _ in range(120):
time.sleep(2)
h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
if pid in h and h[pid].get("outputs"):
return h[pid]["outputs"]["10"]["images"][0]["filename"]
if pid in h and h[pid].get("status", {}).get("status_str") == "error":
for m in h[pid]["status"].get("messages", []):
if isinstance(m, list) and len(m) > 1 and m[0] == "execution_error":
raise RuntimeError(m[1].get("exception_message", "").strip()[:300])
raise TimeoutError("全景生成超时")
def scene_panorama_views(desc, seed, output_dir, out_name="scene_4view"):
"""全景图切四段法:宽幅全景 → 切 4 段 → 拼成四视图。
四段天然来自同一张图,一致性 100% 锁定。"""
from PIL import Image
import shutil
prefix = "eed_scene_pan_" + time.strftime("%H%M%S")
f = submit_wide(desc, seed, prefix, width=2048, height=1024)
src = os.path.join(COMFY_OUT, f)
os.makedirs(output_dir, exist_ok=True)
im = Image.open(src)
w, h = im.size
seg_w = w // 4
views = []
for i in range(4):
seg = im.crop((i * seg_w, 0, (i + 1) * seg_w, h))
seg_path = os.path.join(output_dir, f"{out_name}_view{i+1}.png")
seg.save(seg_path)
views.append(seg_path)
# 拼四视图展示版
gap = 16
canvas = Image.new("RGB", (seg_w * 4 + gap * 3, h), "white")
for i, v in enumerate(views):
canvas.paste(Image.open(v), (i * (seg_w + gap), 0))
final = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(final)
return final, views
def char_four_views_compose(desc, seed, output_dir, out_name="char_4view"):
"""人物四视图(逐张生成 + PIL 拼图,**精确 4 张**
①正面 ②背面 ③侧面 ④脸部特写。
逐张提交 Z-Image 标准出图,角色一致性靠相似 seed 锁定。"""
import shutil
from PIL import Image
os.makedirs(output_dir, exist_ok=True)
# 每张强制 ONLY ONE single character避免 Z-Image 自由发挥画多个),简化元素避免歧义
cmds = [
("front", f"character design, ONLY ONE single character, {desc}, front view looking at viewer, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("back", f"character design, ONLY ONE single character, {desc}, back view facing away, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("side", f"character design, ONLY ONE single character, {desc}, 90 degree side profile view facing left, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("face", f"character design, ONLY ONE single character, {desc}, face close-up portrait, head and shoulders only, looking at viewer, isolated on plain white background, no other figures, anime style, high quality"),
]
paths = []
for i, (key, prompt) in enumerate(cmds):
f = submit(prompt, 1, seed + i * 13, f"eed_char_{key}_{time.strftime('%H%M%S')}")
p = os.path.join(output_dir, f"{out_name}_{key}.png")
shutil.copy(os.path.join(COMFY_OUT, f), p)
paths.append(p)
print(f"{key}: {p}")
# 2x2 拼图
ims = [Image.open(p) for p in paths]
w, h = ims[0].size
gap = 12
canvas = Image.new("RGB", (w * 2 + gap, h * 2 + gap), "white")
canvas.paste(ims[0], (0, 0))
canvas.paste(ims[1], (w + gap, 0))
canvas.paste(ims[2], (0, h + gap))
canvas.paste(ims[3], (w + gap, h + gap))
combo = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(combo)
return combo, paths
def scene_four_views(desc, seed, output_dir, out_name="scene_4view"):
"""场景四视图(专业语义):同一场景的 ①正面外观 ②背面外观 ③室内 ④鸟瞰。
统一场景描述串 + 相近 seed 逐张生成,保证是"同一个空间""""
import shutil
from PIL import Image
os.makedirs(output_dir, exist_ok=True)
views = []
base_seed = seed
for i, (key, view_word) in enumerate(SCENE_VIEWS):
prompt = f"{desc}, {view_word}, empty scene no characters no people, the same building and location as the other views, consistent architecture details, anime style, game environment concept art, clean style, high quality, detailed"
print(f" 🎨 视角{i+1}/4 [{key}]: {view_word[:40]}...")
f = submit(prompt, 1, base_seed + i * 7, f"eed_scene_{key}_{time.strftime('%H%M%S')}")
v = os.path.join(output_dir, f"{out_name}_{key}.png")
shutil.copy(os.path.join(COMFY_OUT, f), v)
views.append(v)
print(f"{v}")
# 拼 2x2 四视图展示版
ims = [Image.open(v) for v in views]
w, h = ims[0].size
gap = 12
canvas = Image.new("RGB", (w * 2 + gap, h * 2 + gap), "white")
pos = [(0, 0), (w + gap, 0), (0, h + gap), (w + gap, h + gap)]
for im, (x, y) in zip(ims, pos):
canvas.paste(im, (x, y))
combo = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(combo)
return combo, views
NEG_PROMPT = "nsfw, lowres, bad anatomy, text, error, missing fingers, blurry, distorted, watermark, multiple characters, characters, people, persons, human figures, crowd"
def submit(desc, views, seed, prefix, ctype="char", layout="standard"):
if ctype == "scene":
prompt = f"environment concept art, {desc}, anime style, high quality"
elif layout == "showcase":
prompt = f"{SHOWCASE}. Character and outfit: {desc}"
elif layout == "portrait":
prompt = f"{PORTRAIT}. Character: {desc}"
else:
view_part = VIEWS_4 if views >= 4 else VIEWS_3
prompt = (f"character reference sheet, model sheet, {views} views of the SAME character: "
f"{view_part}, identical character design across all views, same face hairstyle outfit, "
f"plain white background, anime style, clean lineart, "
f"character design sheet, high quality, detailed. Character: {desc}")
wf = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": NEG_PROMPT}}, # 真正的负面词
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0,
"sampler_name": "res_multistep", "scheduler": "simple",
"positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
data = json.dumps({"prompt": wf}).encode()
req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
for _ in range(90):
time.sleep(2)
h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
if pid in h and h[pid].get("outputs"):
return h[pid]["outputs"]["10"]["images"][0]["filename"]
if pid in h and h[pid].get("status", {}).get("status_str") == "error":
for m in h[pid]["status"].get("messages", []):
if isinstance(m, list) and len(m) > 1 and m[0] == "execution_error":
raise RuntimeError(m[1].get("exception_message", "").strip()[:300])
raise TimeoutError("Z-Image 生成超时")
def main():
ap = argparse.ArgumentParser(description="Z-Image 角色三视图/四视图生成")
ap.add_argument("--desc", required=True, help="角色描述(细节越足越好)")
ap.add_argument("--views", type=int, default=3, choices=[3, 4], help="视图数3或4仅 char 用)")
ap.add_argument("--type", dest="ctype", default="char", choices=["char", "scene"], help="char=人物三视图 / scene=场景四视图")
ap.add_argument("--layout", default="standard", choices=["standard", "portrait", "showcase"], help="char布局standard=标准三栏 / portrait=面部特写+三视图 / showcase=3D展示台(上三视图+下细节特写)")
ap.add_argument("--method", default="prompt", choices=["prompt", "panorama", "compose"], help="场景: prompt/panorama; 人物四视图: compose=逐张生成+PIL拼图(精确4张)")
ap.add_argument("--seed", type=int, default=777)
ap.add_argument("--output", default="", help="输出路径(默认 cang-ying/outputs/character_<time>.png")
args = ap.parse_args()
prefix = ("eed_scene_" if args.ctype == "scene" else "eed_char_") + time.strftime("%H%M%S")
label = "场景四视图" if args.ctype == "scene" else f"角色{args.views}视图"
print(f"🎨 Z-Image 生成 {label}: {args.desc[:60]}...")
import shutil
if args.ctype == "char" and args.views >= 4 and args.method == "compose":
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
final, views = char_four_views_compose(args.desc, args.seed, out_dir)
print(f"✅ 人物四视图(逐张生成+拼图): {final}")
print(f" 四张图: {', '.join(os.path.basename(v) for v in views)}")
return
if args.ctype == "scene":
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
if args.method == "panorama":
final, views = scene_panorama_views(args.desc, args.seed, out_dir)
print(f"✅ 场景四视图(全景切段,备选): {final}")
else:
final, views = scene_four_views(args.desc, args.seed, out_dir)
print(f"✅ 场景四视图(正面/背面/室内/鸟瞰): {final}")
print(f" 四张视角图: {', '.join(os.path.basename(v) for v in views)}")
return
f = submit(args.desc, args.views, args.seed, prefix, args.ctype, args.layout)
src = os.path.join(COMFY_OUT, f)
if not args.output:
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
args.output = os.path.join(out_dir, f"character_{time.strftime('%m%d_%H%M%S')}.png")
shutil.copy(src, args.output)
print(f"{label}: {args.output}")
if __name__ == "__main__":
main()