348 lines
13 KiB
Python
348 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""lens_turbo_batch.py — batch generate images from a JSON prompt list.
|
|
|
|
Input JSON shape (list of objects):
|
|
[
|
|
{"word": "apple", "sentence": "...", "prompt": "..."},
|
|
{"word": "cat", "sentence": "...", "prompt": "..."},
|
|
...
|
|
]
|
|
|
|
Each entry's `prompt` is sent to ComfyUI's Lens-Turbo (microsoft/Lens-Turbo)
|
|
and the resulting PNG is saved as `<out_dir>/<word>.png`. Existing files are
|
|
skipped by default, so you can re-run to resume a partially-completed batch.
|
|
|
|
⚠️ 前置:
|
|
- lens_turbo_cli.py 在同一目录 (本脚本 import 它的 ComfyClient + build_workflow)
|
|
- ComfyUI 已运行,LensTurboLoader/LensTurboSampler 已注册
|
|
- microsoft/Lens-Turbo + openai/gpt-oss-20b + black-forest-labs/FLUX.2-dev/vae/
|
|
已 hf download 到 $HF_HOME (~58 GB)
|
|
|
|
依赖:
|
|
uv pip install websocket-client
|
|
|
|
用法:
|
|
# 基础 — 读 JSON,写到 ./out/
|
|
uv run python lens_turbo_batch.py vocab.json --ip 10.8.0.12
|
|
|
|
# 自定义输出目录 + 远程 GPU 选择
|
|
uv run python lens_turbo_batch.py vocab.json \\
|
|
--ip 10.8.0.12 --gpu 0 \\
|
|
--out-dir vocab_images
|
|
|
|
# 覆盖已有文件
|
|
uv run python lens_turbo_batch.py vocab.json --ip 10.8.0.12 --force
|
|
|
|
# 只处理前 3 个 (干跑)
|
|
uv run python lens_turbo_batch.py vocab.json --ip 10.8.0.12 --limit 3
|
|
|
|
# 固定 seed (re-runs deterministic)
|
|
uv run python lens_turbo_batch.py vocab.json --ip 10.8.0.12 --seed 42
|
|
|
|
# 1440x1440, 4 步 turbo 默认即可
|
|
uv run python lens_turbo_batch.py vocab.json --ip 10.8.0.12 \\
|
|
--base-resolution 1440 --aspect-ratio 1:1
|
|
|
|
JSON 也支持 {entries: [...]} 包裹形式,或 {data: [...]} / {items: [...]}.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
# import from neighbor module (lens_turbo_cli.py 必须在同一目录)
|
|
try:
|
|
from lens_turbo_cli import ComfyClient, build_workflow
|
|
except ImportError as e:
|
|
sys.exit(f"❌ 不能 import lens_turbo_cli.py。把它放本脚本同一目录。\n {e}")
|
|
|
|
|
|
def sanitize_filename(word: str) -> str:
|
|
"""Turn a word into a safe filename stem.
|
|
|
|
Examples:
|
|
"a/an" -> "a_an"
|
|
"don't" -> "don_t"
|
|
" hi " -> "hi"
|
|
"学习" -> "学习" (CJK 保留)
|
|
"""
|
|
s = word.strip().lower()
|
|
# 保留字母数字、CJK、下划线、连字符
|
|
s = re.sub(r"[^\w\-\u4e00-\u9fff]+", "_", s)
|
|
s = re.sub(r"_+", "_", s).strip("_")
|
|
return s or "unnamed"
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
description="Lens-Turbo 批量文生图 CLI",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""\
|
|
JSON 格式示例:
|
|
[
|
|
{"word": "apple", "prompt": "a fresh red apple, studio light, 4k"},
|
|
{"word": "sunset", "prompt": "warm sunset over the ocean, cinematic"}
|
|
]
|
|
|
|
注意:
|
|
- 优先用每个 entry 的 prompt 字段
|
|
- 输出文件名由 word 字段决定 (清洗成安全文件名)
|
|
- 已存在的输出会跳过,加 --force 覆盖
|
|
- 第一张会触发 ComfyUI 端加载 LensPipeline,~2 分钟冷启动是正常的
|
|
(服务端 cache 命中后续直接复用,后面每张 ~12 秒)
|
|
""",
|
|
)
|
|
p.add_argument("json_file", help="JSON 文件,list of {word, prompt} 或 {entries: [...]}")
|
|
p.add_argument("--out-dir", default="out", help="PNG 输出目录 (默认 ./out)")
|
|
|
|
# 生成参数 (Turbo defaults)
|
|
p.add_argument("--base-resolution", "-R", type=int, default=1024,
|
|
help="长边 (默认 1024,Turbo 推荐 1024/1440)")
|
|
p.add_argument("--aspect-ratio", "-A", default="1:1",
|
|
choices=["1:1", "16:9", "9:16", "4:3", "3:4",
|
|
"3:2", "2:3", "21:9", "9:21"])
|
|
p.add_argument("--steps", type=int, default=4,
|
|
help="采样步数 (Lens-Turbo 4 步,Lens 20 步,Lens-Base 50 步)")
|
|
p.add_argument("--cfg", type=float, default=1.0,
|
|
help="CFG (Turbo 1.0,Lens/Base 5.0)")
|
|
p.add_argument("--seed", type=int, default=None,
|
|
help="固定 seed (省略 = 每张随机)")
|
|
p.add_argument("--negative-prompt", default="",
|
|
help="negative prompt (CFG=1 时被忽略)")
|
|
|
|
# 模型 / 加载策略
|
|
p.add_argument("--repo-id", default="microsoft/Lens-Turbo")
|
|
p.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"])
|
|
p.add_argument("--gpu", type=int, default=0,
|
|
help="cuda:N (负数 = cpu)")
|
|
p.add_argument("--offload-mode", default="cpu_text_encoder",
|
|
choices=["cpu_text_encoder", "sequential", "model", "none"],
|
|
help="VRAM 策略 (默认 cpu_text_encoder,24 GB 卡跑得动)")
|
|
p.add_argument("--no-cpu-construct", action="store_true",
|
|
help="跳过 cpu_construct 构造守卫")
|
|
|
|
# 流程控制
|
|
p.add_argument("--force", action="store_true", help="覆盖已存在文件")
|
|
p.add_argument("--limit", type=int, default=None, help="只处理前 N 条")
|
|
p.add_argument("--retry", type=int, default=2, help="失败重试次数")
|
|
p.add_argument("--prompt-key", default="prompt",
|
|
help="JSON 里的 prompt 字段名 (默认 'prompt')")
|
|
p.add_argument("--word-key", default="word",
|
|
help="JSON 里的 word/filename 字段名 (默认 'word')")
|
|
|
|
# 服务器
|
|
p.add_argument("--ip", default="127.0.0.1")
|
|
p.add_argument("--port", type=int, default=8188)
|
|
p.add_argument("--server", default=None,
|
|
help="完整 host:port,覆盖 --ip/--port")
|
|
p.add_argument("--timeout", type=float, default=900,
|
|
help="单张超时 (默认 900s,首张可能慢)")
|
|
p.add_argument("--ws-recv-timeout", type=float, default=1200,
|
|
help="WS 闲置超时 (首张冷加载用,默认 1200s)")
|
|
|
|
return p
|
|
|
|
|
|
def health_check(server: str) -> bool:
|
|
"""Quick check ComfyUI 在不在 + 节点是否注册"""
|
|
try:
|
|
with urllib.request.urlopen(f"http://{server}/system_stats", timeout=5) as r:
|
|
stats = json.loads(r.read())
|
|
sys_info = stats.get("system", {})
|
|
print(f"✓ server {server} OK "
|
|
f"comfyui={sys_info.get('comfyui_version', '?')} "
|
|
f"python={sys_info.get('python_version', '?').split()[0]}")
|
|
except (urllib.error.URLError, ConnectionRefusedError) as e:
|
|
print(f"✗ server unreachable at {server}: {e}", file=sys.stderr)
|
|
return False
|
|
except Exception as e:
|
|
# /system_stats 可能不存在(老版本),换个端点
|
|
try:
|
|
with urllib.request.urlopen(f"http://{server}/queue", timeout=5) as r:
|
|
r.read()
|
|
print(f"✓ server {server} OK (no system_stats endpoint)")
|
|
except Exception as e2:
|
|
print(f"✗ server unreachable: {e2}", file=sys.stderr)
|
|
return False
|
|
|
|
# 确认 Lens 节点注册
|
|
try:
|
|
with urllib.request.urlopen(f"http://{server}/object_info", timeout=10) as r:
|
|
info = json.loads(r.read())
|
|
missing = [n for n in ("LensTurboLoader", "LensTurboSampler") if n not in info]
|
|
if missing:
|
|
print(f"✗ ComfyUI 缺节点: {missing}. 检查 ComfyUI-LensTurbo 是否部署。",
|
|
file=sys.stderr)
|
|
return False
|
|
print("✓ LensTurboLoader / LensTurboSampler 都注册了")
|
|
return True
|
|
except Exception as e:
|
|
print(f"! /object_info 查询失败 ({e}),继续尝试", file=sys.stderr)
|
|
return True # 不阻断,让用户的 prompt 自己撞
|
|
|
|
|
|
def extract_first_image(history: dict) -> dict | None:
|
|
"""从 /history 返回里挖出第一张图的 {filename, subfolder, type}"""
|
|
outputs = history.get("outputs", {})
|
|
# 我们的 workflow 里 SaveImage 节点 id 是 'save'
|
|
for node_id in ("save",) + tuple(outputs.keys()):
|
|
node_out = outputs.get(node_id, {})
|
|
images = node_out.get("images") or []
|
|
if images:
|
|
return images[0]
|
|
return None
|
|
|
|
|
|
def generate_one(
|
|
client: ComfyClient,
|
|
*,
|
|
prompt: str,
|
|
args,
|
|
seed: int,
|
|
) -> bytes | None:
|
|
"""跑一个 prompt,返回 PNG bytes (None = 全部重试失败)"""
|
|
device = "cpu" if args.gpu < 0 else f"cuda:{args.gpu}"
|
|
last_err = ""
|
|
for attempt in range(args.retry + 1):
|
|
try:
|
|
wf = build_workflow(
|
|
prompt=prompt,
|
|
negative_prompt=args.negative_prompt,
|
|
base_resolution=args.base_resolution,
|
|
aspect_ratio=args.aspect_ratio,
|
|
steps=args.steps,
|
|
cfg=args.cfg,
|
|
seed=seed,
|
|
device=device,
|
|
dtype=args.dtype,
|
|
offload_mode=args.offload_mode,
|
|
cpu_construct=not args.no_cpu_construct,
|
|
repo_id=args.repo_id,
|
|
force_reload=False, # 批量永远不要 reload,靠 Loader 的 cache
|
|
)
|
|
history = client.run(wf, timeout_s=args.timeout)
|
|
meta = extract_first_image(history)
|
|
if meta is None:
|
|
last_err = f"history 里没有 save.images: {list(history.get('outputs', {}).keys())}"
|
|
else:
|
|
return client.view(meta["filename"],
|
|
meta.get("subfolder", ""),
|
|
meta.get("type", "output"))
|
|
except Exception as e:
|
|
last_err = f"{type(e).__name__}: {e}"
|
|
|
|
if attempt < args.retry:
|
|
wait = 2 * (attempt + 1)
|
|
print(f"\n ! {last_err[:160]} — retry in {wait}s")
|
|
time.sleep(wait)
|
|
|
|
print(f"\n ✗ 放弃: {last_err}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def load_entries(path: Path, prompt_key: str) -> list[dict]:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(raw, dict):
|
|
# 兼容 {entries: [...]} / {data: [...]} / {items: [...]} 形式
|
|
raw = raw.get("entries") or raw.get("data") or raw.get("items") or [raw]
|
|
if not isinstance(raw, list):
|
|
sys.exit("JSON 根必须是 list,或是 {entries: [...]} 包裹的对象")
|
|
return [e for e in raw if isinstance(e, dict) and prompt_key in e]
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
|
|
in_path = Path(args.json_file)
|
|
if not in_path.is_file():
|
|
sys.exit(f"❌ JSON 不存在: {in_path}")
|
|
|
|
try:
|
|
entries = load_entries(in_path, args.prompt_key)
|
|
except json.JSONDecodeError as e:
|
|
sys.exit(f"❌ JSON 解析失败: {e}")
|
|
|
|
if not entries:
|
|
sys.exit(f"❌ {in_path.name} 里没有带 '{args.prompt_key}' 字段的 entry")
|
|
|
|
if args.limit:
|
|
entries = entries[: args.limit]
|
|
|
|
server = args.server or f"{args.ip}:{args.port}"
|
|
if not health_check(server):
|
|
return 1
|
|
|
|
out_dir = Path(args.out_dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"→ {len(entries)} 个 entry,输出 → {out_dir.resolve()}/")
|
|
print(f" size={args.base_resolution}@{args.aspect_ratio} "
|
|
f"steps={args.steps} cfg={args.cfg} "
|
|
f"offload={args.offload_mode} "
|
|
f"seed={'fixed=' + str(args.seed) if args.seed is not None else 'random'}")
|
|
print()
|
|
|
|
client = ComfyClient(server=server, ws_recv_timeout=args.ws_recv_timeout)
|
|
stats = {"ok": 0, "skip": 0, "fail": 0}
|
|
t_start = time.time()
|
|
total = len(entries)
|
|
|
|
for i, entry in enumerate(entries, 1):
|
|
word = str(entry.get(args.word_key, "")).strip()
|
|
prompt = str(entry.get(args.prompt_key, "")).strip()
|
|
|
|
if not word or not prompt:
|
|
print(f"[{i}/{total}] ! 跳过 (缺 {args.word_key} 或 {args.prompt_key}): "
|
|
f"{json.dumps(entry, ensure_ascii=False)[:80]}")
|
|
stats["fail"] += 1
|
|
continue
|
|
|
|
stem = sanitize_filename(word)
|
|
target = out_dir / f"{stem}.png"
|
|
|
|
if target.exists() and not args.force:
|
|
print(f"[{i}/{total}] ⊙ {word!r:<20} → {target.name} (exists, skip)")
|
|
stats["skip"] += 1
|
|
continue
|
|
|
|
seed = args.seed if args.seed is not None else random.randint(0, 2**31 - 1)
|
|
|
|
print(f"[{i}/{total}] ▸ {word!r:<20} ", end="", flush=True)
|
|
t0 = time.time()
|
|
png = generate_one(client, prompt=prompt, args=args, seed=seed)
|
|
dt = time.time() - t0
|
|
|
|
if png is None:
|
|
print(f" ✗ failed ({dt:.1f}s)")
|
|
stats["fail"] += 1
|
|
continue
|
|
|
|
target.write_bytes(png)
|
|
print(f"\r[{i}/{total}] ✓ {word!r:<20} → {target.name} "
|
|
f"({dt:.1f}s, {len(png)//1024} KB, seed={seed})" + " " * 10)
|
|
stats["ok"] += 1
|
|
|
|
total_t = time.time() - t_start
|
|
print()
|
|
print(f"done in {total_t:.1f}s — "
|
|
f"{stats['ok']} ok, {stats['skip']} skipped, {stats['fail']} failed")
|
|
if stats["ok"]:
|
|
avg = total_t / stats["ok"]
|
|
print(f"avg {avg:.1f}s/image")
|
|
print(f"images in: {out_dir.resolve()}")
|
|
return 0 if stats["fail"] == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|