问题:接口2 与接口3/5 乱序调用时耗时抖动(最差 15~22s)。两个根因: 1. GPU 24G 常驻 21.4G,Flux-2(3.9G) 无法完全驻留显存,每次采样动态换页, 速度随空闲显存波动(2s~8s); 2. ComfyUI 单队列 FIFO,接口2 排在接口3/5 批量任务后面。 改动: - hairline/comfyui.py: run() 新增 front 参数,/prompt 带 "front": true 插队到队列最前; redraw.py 透传;service.py 接口2 三处调用(女重绘 + 男有/无遮罩)传 front=True, 接口3/5 仍走普通队列。 - add_hair.json / 0716add-hair-api.json: 节点61 CLIPLoader device default→cpu。 qwen CLIP(4G) 不再占显存(文本条件缓存常年命中),ComfyUI 显存 8.8G→4.5G, Flux-2 完全驻留,采样稳定 ~3-5s。代价:换 prompt 后首次请求 CPU 编码 ~11s(一次性)。 - 提示词全局统一为「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」: app.py 4处默认值、service.py _REDRAW_PROMPT、redraw.py _DEFAULT_PROMPT、 4个工作流节点60内置文案、测试页(test_interface2/3/7/12/12_final)、local_test。 任何两个不同 prompt 交替提交都会打爆 CLIP 编码缓存(--cache-classic 只存最近一次), 之前测试页旧文案与服务端不一致导致交替测试每次 +11s。 - app.py: 接口7 /api/v1/hair/grow-v2 下线(业务弃用;add_hair2.json 的 Klein-9b 会把常驻 Klein-4b 挤出显存)。保留 stub 返回 1007 明确报错,避免裸 404。 实测(1024 档):接口2女 8.5~10s、接口2男 ~5s、接口3 ~7-10s,交替混跑无尖刺。 Co-authored-by: Cursor <cursoragent@cursor.com> (cherry-picked from ubuntu3090 e7b62f2;已适配 main 分支代码结构:main 无 _REDRAW_PROMPT/_REDRAW_MAX_SIDE 缩图逻辑,front=True 直接加在 _call_local_redraw / generate_grow_results 的调用点;另把 main 独有的 benchmark_*.py 里的 prompt 一并统一) Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
7.8 KiB
Python
188 lines
7.8 KiB
Python
"""ComfyUI 客户端:用 add_hair.json / add_hair2.json 工作流跑生发图(Flux-2 inpaint)。
|
||
|
||
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到远端 ComfyUI
|
||
(默认 http://10.60.74.221:8188,可用环境变量 COMFYUI_URL 覆盖),
|
||
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
|
||
ComfyUI 若开启了 HTTP Basic Auth(user `admin` + 密码),所有请求都带凭据。
|
||
|
||
支持多工作流:run() 可通过 workflow_path 指定不同工作流 JSON,自动检测 SaveImage 输出节点。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import os
|
||
import random
|
||
import time
|
||
import uuid
|
||
|
||
import httpx
|
||
|
||
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||
_WORKFLOW_DEFAULT = os.getenv(
|
||
"ADD_HAIR_WORKFLOW",
|
||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "add_hair.json"),
|
||
)
|
||
COMFY_TIMEOUT = float(os.getenv("COMFYUI_TIMEOUT", "600")) # 单张出图最长等待(秒)
|
||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||
|
||
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
|
||
_SEED_NODE = "6" # RandomNoise
|
||
_PROMPT_NODE = "60" # JjkText:提示词
|
||
|
||
_wf_cache: dict[str, dict] = {} # path → workflow JSON
|
||
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
|
||
|
||
|
||
def _comfy_auth():
|
||
"""ComfyUI Basic Auth 凭据 (user, password)。
|
||
|
||
user:环境变量 COMFYUI_USER,默认 admin。
|
||
password:环境变量 COMFYUI_PASSWORD → worker_config.json.comfyui_password → password.txt。
|
||
无密码则返回 None(不带鉴权,兼容未开启 auth 的实例)。
|
||
"""
|
||
user = os.getenv("COMFYUI_USER", "admin")
|
||
pw = os.getenv("COMFYUI_PASSWORD")
|
||
if not pw:
|
||
cfg = os.path.join(_REPO, "worker_config.json")
|
||
if os.path.isfile(cfg):
|
||
try:
|
||
with open(cfg, encoding="utf-8") as f:
|
||
pw = json.load(f).get("comfyui_password")
|
||
except Exception: # noqa: BLE001
|
||
pw = None
|
||
if not pw:
|
||
pwfile = os.path.join(_REPO, "password.txt")
|
||
if os.path.isfile(pwfile):
|
||
with open(pwfile, encoding="utf-8") as f:
|
||
pw = f.read().strip()
|
||
return (user, pw) if pw else None
|
||
|
||
|
||
def _load_workflow(workflow_path: str | None = None) -> dict:
|
||
"""加载工作流 JSON(按路径缓存)。自动检测 SaveImage 节点 ID。"""
|
||
path = workflow_path or _WORKFLOW_DEFAULT
|
||
if path not in _wf_cache:
|
||
with open(path, encoding="utf-8") as f:
|
||
wf = json.load(f)
|
||
_wf_cache[path] = wf
|
||
# 自动检测 SaveImage 输出节点
|
||
for node_id, node in wf.items():
|
||
if node.get("class_type") == "SaveImage":
|
||
_wf_output_node[path] = node_id
|
||
break
|
||
else:
|
||
raise ValueError(f"工作流 {path} 中未找到 SaveImage 节点")
|
||
return _wf_cache[path]
|
||
|
||
|
||
def _get_output_node(workflow_path: str | None = None) -> str:
|
||
"""返回指定工作流的 SaveImage 节点 ID。"""
|
||
path = workflow_path or _WORKFLOW_DEFAULT
|
||
if path not in _wf_output_node:
|
||
_load_workflow(path) # 触发检测
|
||
return _wf_output_node[path]
|
||
|
||
|
||
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
|
||
workflow_path: str | None = None, front: bool = False) -> bytes:
|
||
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
|
||
|
||
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
|
||
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
|
||
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
|
||
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
|
||
"""
|
||
path = workflow_path or _WORKFLOW_DEFAULT
|
||
output_node = _get_output_node(path)
|
||
client_id = uuid.uuid4().hex
|
||
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0, auth=_comfy_auth()) as cli:
|
||
# 1. 上传输入图(含 alpha 遮罩)到 ComfyUI input 目录
|
||
fname = f"hair_{client_id}.png"
|
||
r = cli.post("/upload/image", files={"image": (fname, rgba_png_bytes, "image/png")},
|
||
data={"overwrite": "true", "type": "input"})
|
||
r.raise_for_status()
|
||
up = r.json()
|
||
name = (up.get("subfolder") + "/" if up.get("subfolder") else "") + up["name"]
|
||
|
||
# 2. 改工作流:节点26 输入图 + 随机 seed
|
||
wf = copy.deepcopy(_load_workflow(path))
|
||
wf[_INPUT_NODE]["inputs"]["image"] = name
|
||
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
|
||
if prompt is not None:
|
||
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
|
||
|
||
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
|
||
try:
|
||
import os as _os
|
||
_diag = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
|
||
"log", "comfyui_last_submit")
|
||
_os.makedirs(_diag, exist_ok=True)
|
||
with open(_os.path.join(_diag, "workflow.json"), "w", encoding="utf-8") as _f:
|
||
json.dump(wf, _f, ensure_ascii=False, indent=2)
|
||
with open(_os.path.join(_diag, "input.png"), "wb") as _f:
|
||
_f.write(rgba_png_bytes)
|
||
with open(_os.path.join(_diag, "prompt.txt"), "w", encoding="utf-8") as _f:
|
||
_f.write(prompt if prompt is not None else "(None=用工作流内置默认)")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# 3. 提交(front=True 时插队到队列最前)
|
||
payload = {"prompt": wf, "client_id": client_id}
|
||
if front:
|
||
payload["front"] = True
|
||
r = cli.post("/prompt", json=payload)
|
||
r.raise_for_status()
|
||
prompt_id = r.json()["prompt_id"]
|
||
|
||
# 4. 轮询 /history
|
||
deadline = time.time() + timeout
|
||
outputs = None
|
||
while time.time() < deadline:
|
||
hr = cli.get(f"/history/{prompt_id}")
|
||
hr.raise_for_status()
|
||
hist = hr.json()
|
||
if prompt_id in hist:
|
||
entry = hist[prompt_id]
|
||
status = entry.get("status", {})
|
||
if status.get("status_str") == "error":
|
||
raise RuntimeError(f"ComfyUI 执行报错: {status}")
|
||
outputs = entry.get("outputs")
|
||
if outputs and output_node in outputs:
|
||
break
|
||
time.sleep(0.2)
|
||
if not outputs or output_node not in outputs:
|
||
raise TimeoutError(f"ComfyUI 出图超时({timeout}s) prompt_id={prompt_id}")
|
||
|
||
# 5. 取回输出图
|
||
imgs = outputs[output_node].get("images") or []
|
||
if not imgs:
|
||
raise RuntimeError("ComfyUI 输出无图像")
|
||
info = imgs[0]
|
||
vr = cli.get("/view", params={"filename": info["filename"],
|
||
"subfolder": info.get("subfolder", ""),
|
||
"type": info.get("type", "output")})
|
||
vr.raise_for_status()
|
||
return vr.content
|
||
|
||
|
||
def ping() -> bool:
|
||
"""探测 ComfyUI 是否在线(/system_stats)。"""
|
||
try:
|
||
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0, auth=_comfy_auth()) as cli:
|
||
return cli.get("/system_stats").status_code == 200
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
inp = sys.argv[1] if len(sys.argv) > 1 else "tests/output/comfy_input.png"
|
||
print("ComfyUI:", COMFYUI_URL, "online:", ping())
|
||
with open(inp, "rb") as f:
|
||
png = run(f.read())
|
||
out = "tests/output/grown.png"
|
||
with open(out, "wb") as f:
|
||
f.write(png)
|
||
print(f"生发图已存 {out}({len(png)} bytes)")
|