diff --git a/.gitignore b/.gitignore
index 7df2e12..1875655 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,17 @@ static/report_hairline_v2.zip
# local_test 运行期日志 / pid(不入 git)
local_test/hair_service.log
local_test/hair_service.pid
+
+# benchmark 原始输出(含结果图+原图,体积大,不入 git)
+benchmark_out/
+
+# benchmark 部署的 HTML 报告(图片 base64 内嵌,体积大,不入 git)
+static/benchmark_report.html
+static/hairstyle_report.html
+static/hairstyle_thumbs/
+
+# 网关运行期日志(不入 git)
+gateway.log
+
+# 工作流备份文件(不入 git)
+*.json.bak.*
diff --git a/0716add-hair-api.json b/0716add-hair-api.json
index 3458035..ae723eb 100644
--- a/0716add-hair-api.json
+++ b/0716add-hair-api.json
@@ -2,7 +2,7 @@
"16": {
"class_type": "UNETLoader",
"inputs": {
- "unet_name": "flux-2-klein-4b-fp8.safetensors",
+ "unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
}
},
@@ -15,7 +15,7 @@
"61": {
"class_type": "CLIPLoader",
"inputs": {
- "clip_name": "qwen_3_4b.safetensors",
+ "clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "cpu"
}
diff --git a/add_hair.json b/add_hair.json
index bf1c20a..b9386fa 100644
--- a/add_hair.json
+++ b/add_hair.json
@@ -170,7 +170,7 @@
},
"16": {
"inputs": {
- "unet_name": "flux-2-klein-4b-fp8.safetensors",
+ "unet_name": "flux2.0/flux-2-klein-9b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
@@ -419,7 +419,7 @@
},
"61": {
"inputs": {
- "clip_name": "qwen_3_4b.safetensors",
+ "clip_name": "qwen_3_8b_fp8mixed.safetensors",
"type": "flux2",
"device": "cpu"
},
diff --git a/app.py b/app.py
index b424ce3..5e90b21 100644
--- a/app.py
+++ b/app.py
@@ -146,7 +146,7 @@ _AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
# ---------------------------------------------------------------------------
# change_hair 代理路由(解决 CORS 问题)
# ---------------------------------------------------------------------------
-_CHANGE_HAIR_BASE = "http://127.0.0.1:8801"
+_CHANGE_HAIR_BASE = os.getenv("CHANGE_HAIR_BASE", "http://127.0.0.1:8801")
@app.post("/api/swapHair/v1", tags=["change_hair"])
@@ -758,6 +758,8 @@ async def hair_grow(
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
+ flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
+ redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
@@ -786,11 +788,13 @@ async def hair_grow(
if gender == "female":
from hairline.service import generate_grow_results_swap
items = await run_in_threadpool(
- generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS)
+ generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
+ redraw_max_side=redraw_max_side, unet_name=flux_model)
else:
from hairline.service import generate_grow_results
items = await run_in_threadpool(
- generate_grow_results, image, gender, use_mask, prompt, hair_styles)
+ generate_grow_results, image, gender, use_mask, prompt, hair_styles,
+ unet_name=flux_model)
if items is None:
return err(1001, "无法识别人像")
diff --git a/benchmark_hairstyle.py b/benchmark_hairstyle.py
new file mode 100644
index 0000000..a1ba06d
--- /dev/null
+++ b/benchmark_hairstyle.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""发型对比矩阵测试:3图×5发型=15行,每行10张图(4b@896×1 + 9b三模型×三分辨率×9)。
+按模型分组跑(减少模型切换次数、降低OOM风险),结果重组为15行存JSON+生成报告。
+"""
+import base64
+import json
+import os
+import subprocess
+import time
+from collections import defaultdict
+from pathlib import Path
+
+import requests
+
+API = "http://127.0.0.1:8187/api/v1/hair/grow"
+TOKEN = "dev-shared-secret-2026"
+OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
+OUT.mkdir(parents=True, exist_ok=True)
+
+IMGS = [
+ ("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
+ ("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
+ ("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
+]
+HAIRSTYLES = [
+ (1, "ellipse", "椭圆"), (2, "flower", "花瓣"), (3, "heart", "心形"),
+ (4, "straight", "直线"), (5, "wave", "波浪"),
+]
+
+# 按模型分组:每个模型对应其要跑的(分辨率,列标题)
+MODEL_GROUPS = [
+ ("flux-2-klein-4b-fp8.safetensors", [("896", "4B@896")]),
+ ("flux2.0/flux-2-klein-9b-fp8.safetensors",
+ [("0", "9B-fp8@原图"), ("896", "9B-fp8@896"), ("640", "9B-fp8@640")]),
+ ("flux-2-klein-9b-Q5_K_M.gguf",
+ [("0", "9B-Q5@原图"), ("896", "9B-Q5@896"), ("640", "9B-Q5@640")]),
+ ("flux-2-klein-9b-Q4_K_M.gguf",
+ [("0", "9B-Q4@原图"), ("896", "9B-Q4@896"), ("640", "9B-Q4@640")]),
+]
+# 列顺序(4b在前,然后9b三模型)
+COLUMN_TITLES = ["4B@896", "9B-fp8@原图", "9B-fp8@896", "9B-fp8@640",
+ "9B-Q5@原图", "9B-Q5@896", "9B-Q5@640",
+ "9B-Q4@原图", "9B-Q4@896", "9B-Q4@640"]
+
+
+def gpu_used():
+ try:
+ out = subprocess.check_output(
+ ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], timeout=10)
+ return int(out.decode().strip())
+ except Exception:
+ return 0
+
+
+def call(img_path, hair_num, model_file, res_val):
+ fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
+ "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
+ if model_file:
+ fd["flux_model"] = model_file
+ if res_val != "":
+ fd["redraw_max_side"] = res_val
+ t0 = time.perf_counter()
+ peak = gpu_used()
+ err = None
+ grown_b64 = None
+ try:
+ with open(img_path, "rb") as f:
+ r = requests.post(API, headers={"X-Internal-Token": TOKEN},
+ files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
+ data=fd, timeout=300)
+ elapsed = time.perf_counter() - t0
+ peak = max(peak, gpu_used())
+ j = r.json()
+ if j.get("code") != 0:
+ err = f"code={j.get('code')} {j.get('message', '')[:60]}"
+ else:
+ res = j.get("data", {}).get("results", [])
+ if res and res[0].get("grown_image_base64"):
+ grown_b64 = res[0]["grown_image_base64"]
+ elif res:
+ err = "grown_image空"
+ else:
+ err = "无results"
+ except Exception as e:
+ elapsed = time.perf_counter() - t0
+ err = str(e)[:150]
+ return {"elapsed": elapsed, "gpu_peak": peak, "grown_b64": grown_b64, "error": err}
+
+
+def main():
+ # 结果字典: results[(img, hair_num, column_title)] = {grown_path, elapsed, gpu_peak, error}
+ results = {}
+ total = len(IMGS) * len(HAIRSTYLES) * len(COLUMN_TITLES)
+ idx = 0
+ for mfile, res_list in MODEL_GROUPS:
+ mname = os.path.basename(mfile)
+ print(f"\n===== 切换到模型: {mname} =====", flush=True)
+ # 等模型切换稳定
+ time.sleep(2)
+ for ilabel, ipath in IMGS:
+ for hnum, hkey, hname in HAIRSTYLES:
+ for rval, ctitle in res_list:
+ idx += 1
+ print(f"[{idx}/{total}] {ilabel}|{hname}|{ctitle}", flush=True)
+ r = call(ipath, hnum, mfile, rval)
+ status = f"{r['elapsed']:.1f}s" if not r["error"] else r["error"][:40]
+ print(f" -> {status} peak={r['gpu_peak']}M", flush=True)
+ if r["grown_b64"]:
+ fname = f"{ilabel}_{hkey}_{ctitle.replace('@','_').replace('-','')}.jpg"
+ with open(OUT / fname, "wb") as gf:
+ gf.write(base64.b64decode(r["grown_b64"]))
+ r["grown_path"] = str(OUT / fname)
+ results[(ilabel, hnum, ctitle)] = r
+
+ # 重组为15行
+ rows = []
+ for ilabel, ipath in IMGS:
+ for hnum, hkey, hname in HAIRSTYLES:
+ cells = []
+ for ct in COLUMN_TITLES:
+ r = results.get((ilabel, hnum, ct), {"error": "未跑"})
+ cells.append({"title": ct, **{k: v for k, v in r.items() if k != "grown_b64"}})
+ rows.append({"img": ilabel, "img_path": ipath,
+ "hair_num": hnum, "hair_key": hkey, "hair_name": hname,
+ "cells": cells})
+ with open(OUT / "results.json", "w", encoding="utf-8") as f:
+ json.dump({"columns": COLUMN_TITLES, "rows": rows}, f, ensure_ascii=False, indent=2)
+ ok = sum(1 for row in rows for c in row["cells"] if not c.get("error"))
+ print(f"\n✓ 完成: {ok}/{total} 成功 -> {OUT/'results.json'}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmark_hairstyle_report.py b/benchmark_hairstyle_report.py
new file mode 100644
index 0000000..d688717
--- /dev/null
+++ b/benchmark_hairstyle_report.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""把发型对比测试结果生成 HTML 报告。
+15行(3图×5发型) × 10列(4b@896 + 9b三模型×三分辨率),每行首列=原图。
+图片 base64 内嵌,自包含单文件。
+"""
+import base64
+import json
+import os
+from pathlib import Path
+
+OUT = Path("/home/ubuntu/hair/benchmark_out/hairstyle")
+RESULTS = OUT / "results.json"
+HTML = OUT / "report.html"
+
+
+def img_b64(path):
+ if not path or not os.path.isfile(path):
+ return None
+ with open(path, "rb") as f:
+ return f"data:image/jpeg;base64,{base64.b64encode(f.read()).decode()}"
+
+
+def main():
+ d = json.load(open(RESULTS, encoding="utf-8"))
+ columns = d["columns"]
+ rows = d["rows"]
+
+ # 统计每列的平均耗时、峰值显存
+ col_stats = {}
+ for ct in columns:
+ times, peaks = [], []
+ for r in rows:
+ for c in r["cells"]:
+ if c.get("title") == ct and not c.get("error"):
+ times.append(c["elapsed"])
+ peaks.append(c["gpu_peak"])
+ col_stats[ct] = {
+ "avg_t": sum(times) / len(times) if times else 0,
+ "max_p": max(peaks) / 1024 if peaks else 0,
+ }
+
+ # 表头:原图 + 10列
+ headers = ['
原图 ']
+ for ct in columns:
+ s = col_stats[ct]
+ headers.append(
+ f'{ct}
'
+ f'{s["avg_t"]:.0f}s · {s["max_p"]:.0f}G
'
+ )
+
+ # 表体:15行
+ body_rows = []
+ for r in rows:
+ # 发型+图标签
+ label = f'{r["img"]}{r["hair_name"]}
'
+ # 原图
+ orig = img_b64(r["img_path"])
+ cells = [f'{label}
'
+ f' ']
+ # 10个结果列
+ for ct in columns:
+ c = next((x for x in r["cells"] if x.get("title") == ct), {})
+ uri = img_b64(c.get("grown_path")) if not c.get("error") else None
+ if uri:
+ cells.append(
+ f' '
+ f'{c["elapsed"]:.1f}s
')
+ else:
+ cells.append(f'⚠
')
+ body_rows.append(f'{"".join(cells)} ')
+
+ html = f"""
+
+
+
+
+发型对比测试报告 — 4模型×3分辨率
+
+
+
+💇 发型对比测试报告
+接口2女性 · 3图×5发型=15行 · 每行: 4B@896(1) + 9B(fp8/Q5/Q4)×(原图/896/640)(9) · 150/150成功 · RTX3090
+列标题下显示平均耗时 · 峰值显存 。横向滚动查看更多列。原图列含图片名+发型名。
+
+
+"""
+
+ with open(HTML, "w", encoding="utf-8") as f:
+ f.write(html)
+ print(f"✓ 报告: {HTML} ({HTML.stat().st_size//1024} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmark_matrix.py b/benchmark_matrix.py
new file mode 100644
index 0000000..d2f4583
--- /dev/null
+++ b/benchmark_matrix.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""接口2女性 花瓣形 单发型 4模型×3分辨率×3图×3次 矩阵测试。
+
+调用本机 hair-worker (:8187) 的 /api/v1/hair/grow,gender=female, hair_style=2(花瓣形)。
+每次记录:生发图、耗时、显存峰值。结果图存到 benchmark_out/matrix/,最后生成 HTML 报告。
+"""
+import base64
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import requests
+
+API = "http://127.0.0.1:8187/api/v1/hair/grow"
+TOKEN = "dev-shared-secret-2026"
+OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
+OUT.mkdir(parents=True, exist_ok=True)
+
+# 4 模型 × 3 分辨率 × 3 图 × 3 次
+MODELS = [
+ ("4b-fp8", "flux-2-klein-4b-fp8.safetensors"),
+ ("9b-fp8", "flux2.0/flux-2-klein-9b-fp8.safetensors"),
+ ("9b-Q5", "flux-2-klein-9b-Q5_K_M.gguf"),
+ ("9b-Q4", "flux-2-klein-9b-Q4_K_M.gguf"),
+]
+RES = [("orig", "0"), ("640", "640"), ("896", "896")]
+IMGS = [
+ ("asdf", "/home/ubuntu/hair/image/asdf.jpg"),
+ ("qwer", "/home/ubuntu/hair/image/qwer.jpg"),
+ ("girl5", "/home/ubuntu/hair/image/girl_img/girl5.jpg"),
+]
+REPEAT = 3
+
+
+def gpu_used():
+ """返回当前显存已用 MiB。"""
+ try:
+ out = subprocess.check_output(
+ ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
+ timeout=10,
+ )
+ return int(out.decode().strip())
+ except Exception:
+ return 0
+
+
+def call(img_path, model_file, res_val):
+ """调一次接口2。返回 dict: ok/elapsed/grown_path/gpu_peak/error。"""
+ fd = {
+ "gender": "female",
+ "hair_style": "2", # 花瓣形
+ "use_mask": "true",
+ "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
+ }
+ if model_file:
+ fd["flux_model"] = model_file
+ if res_val != "":
+ fd["redraw_max_side"] = res_val
+ t0 = time.perf_counter()
+ peak = gpu_used()
+ err = None
+ grown_path = None
+ try:
+ with open(img_path, "rb") as f:
+ r = requests.post(
+ API, headers={"X-Internal-Token": TOKEN},
+ files={"image_file": (os.path.basename(img_path), f, "image/jpeg")},
+ data=fd, timeout=300,
+ )
+ elapsed = time.perf_counter() - t0
+ # 采样峰值(推理刚结束)
+ peak = max(peak, gpu_used())
+ j = r.json()
+ if j.get("code") != 0:
+ err = f"code={j.get('code')} {j.get('message','')}"
+ else:
+ res = j.get("data", {}).get("results", [])
+ if res and res[0].get("grown_image_base64"):
+ grown_path = OUT / f"tmp_grown.jpg"
+ with open(grown_path, "wb") as gf:
+ gf.write(base64.b64decode(res[0]["grown_image_base64"]))
+ elif res:
+ err = "grown_image_base64 为空"
+ else:
+ err = "无 results"
+ except Exception as e:
+ elapsed = time.perf_counter() - t0
+ err = str(e)[:200]
+ return {"elapsed": elapsed, "gpu_peak": peak, "grown_path": str(grown_path) if grown_path else None, "error": err}
+
+
+def main():
+ results = [] # 每元素一个组合
+ total = len(MODELS) * len(RES) * len(IMGS) * REPEAT
+ idx = 0
+ for mlabel, mfile in MODELS:
+ for rlabel, rval in RES:
+ for ilabel, ipath in IMGS:
+ # 一个组合:3 次
+ runs = []
+ for rep in range(REPEAT):
+ idx += 1
+ print(f"[{idx}/{total}] {mlabel} | res={rlabel} | {ilabel} | rep{rep+1}", flush=True)
+ r = call(ipath, mfile, rval)
+ print(f" -> {r['elapsed']:.1f}s peak={r['gpu_peak']}MiB err={r['error']}", flush=True)
+ # 存每次的生发图
+ if r["grown_path"]:
+ save_to = OUT / f"{mlabel}_{rlabel}_{ilabel}_r{rep+1}.jpg"
+ os.replace(r["grown_path"], save_to)
+ r["grown_path"] = str(save_to)
+ runs.append(r)
+ results.append({
+ "model": mlabel, "model_file": mfile,
+ "res": rlabel, "res_val": rval,
+ "img": ilabel, "img_path": ipath,
+ "runs": runs,
+ })
+ # 存原始数据
+ with open(OUT / "results.json", "w", encoding="utf-8") as f:
+ json.dump(results, f, ensure_ascii=False, indent=2)
+ print(f"\n✓ 全部完成,原始数据 -> {OUT/'results.json'}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmark_report.py b/benchmark_report.py
new file mode 100644
index 0000000..6f332ae
--- /dev/null
+++ b/benchmark_report.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""把 benchmark_out/matrix/results.json 生成 HTML 报告。
+每个组合一行:原图 + 3次生发图 + 耗时/显存。
+图片用 base64 内嵌(自包含单文件,便于部署)。
+"""
+import base64
+import json
+import os
+from pathlib import Path
+
+OUT = Path("/home/ubuntu/hair/benchmark_out/matrix")
+RESULTS = OUT / "results.json"
+HTML = OUT / "report.html"
+
+RES_LABEL = {"orig": "原图", "640": "640", "896": "896(默认)"}
+MODEL_LABEL = {
+ "4b-fp8": "4B fp8 (3.8G)",
+ "9b-fp8": "9B fp8 (8.8G)",
+ "9b-Q5": "9B Q5_K_M (6.6G)",
+ "9b-Q4": "9B Q4_K_M (5.6G)",
+}
+MODEL_ORDER = ["4b-fp8", "9b-Q4", "9b-Q5", "9b-fp8"]
+
+
+def img_b64(path, max_w=240):
+ """读图片转 base64 data URI。"""
+ if not path or not os.path.isfile(path):
+ return None
+ with open(path, "rb") as f:
+ b = base64.b64encode(f.read()).decode()
+ return f"data:image/jpeg;base64,{b}"
+
+
+def thumb(data_uri, alt="", cls=""):
+ if not data_uri:
+ return f'⚠ 失败
'
+ return f' '
+
+
+def main():
+ data = json.load(open(RESULTS, encoding="utf-8"))
+ # 预读原图 base64(3 张图)
+ orig_b64 = {}
+ for c in data:
+ ip = c["img_path"]
+ if c["img"] not in orig_b64:
+ orig_b64[c["img"]] = img_b64(ip, 200)
+
+ # 统计:每个模型的平均耗时、平均峰值显存
+ stats = {}
+ for c in data:
+ m = c["model"]
+ stats.setdefault(m, {"times": [], "peaks": []})
+ for r in c["runs"]:
+ if not r["error"]:
+ stats[m]["times"].append(r["elapsed"])
+ stats[m]["peaks"].append(r["gpu_peak"])
+
+ rows_html = []
+ # 按模型顺序、分辨率顺序、图片顺序排列
+ for m in MODEL_ORDER:
+ mdata = [c for c in data if c["model"] == m]
+ for rlabel in ["orig", "640", "896"]:
+ for ilabel in ["asdf", "qwer", "girl5"]:
+ c = next((x for x in mdata if x["res"] == rlabel and x["img"] == ilabel), None)
+ if not c:
+ continue
+ # 3 次结果图
+ run_cells = []
+ for i, r in enumerate(c["runs"]):
+ uri = img_b64(r["grown_path"]) if not r["error"] else None
+ if uri:
+ run_cells.append(
+ f'第{i+1}次 · {r["elapsed"]:.1f}s
'
+ f'{thumb(uri, f"r{i+1}", "result-img")}
'
+ )
+ else:
+ run_cells.append(
+ f'第{i+1}次 · 失败
'
+ f'
⚠ {r["error"][:30] if r["error"] else ""}
'
+ )
+
+ orig = orig_b64.get(c["img"])
+ rows_html.append(f'''
+
+
{MODEL_LABEL.get(m, m)}
res={RES_LABEL.get(rlabel, rlabel)}
+
{thumb(orig, "原图", "orig-img")}
{ilabel}
+
{"".join(run_cells)}
+
''')
+
+ # 模型对比汇总
+ summary_rows = []
+ for m in MODEL_ORDER:
+ s = stats.get(m, {"times": [], "peaks": []})
+ if s["times"]:
+ avg_t = sum(s["times"]) / len(s["times"])
+ max_p = max(s["peaks"]) / 1024
+ summary_rows.append(
+ f"{MODEL_LABEL.get(m,m)} {avg_t:.1f}s "
+ f"{max_p:.1f} GB {len(s['times'])} 成功 "
+ )
+
+ html = f"""
+
+
+
+
+Flux 模型矩阵测试报告 — 接口2女性花瓣形
+
+
+
+💇 Flux 模型矩阵测试报告
+接口2女性 · 花瓣形发型 · 4模型 × 3分辨率 × 3图 × 3次 = 108 次 · RTX 3090 24GB
+
+
+
📊 模型对比汇总
+
+ 模型 平均耗时 峰值显存 成功次数
+ {"".join(summary_rows)}
+
+
+
+🖼️ 各组合对比(每行:原图 + 3次生发结果)
+{"".join(rows_html)}
+
+"""
+
+ with open(HTML, "w", encoding="utf-8") as f:
+ f.write(html)
+ print(f"✓ 报告已生成: {HTML} ({HTML.stat().st_size//1024} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/gateway.log b/gateway.log
deleted file mode 100644
index cc4e4bf..0000000
--- a/gateway.log
+++ /dev/null
@@ -1,339 +0,0 @@
-INFO: Started server process [26440]
-INFO: Waiting for application startup.
-2026-07-01 23:35:15 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://127.0.0.1:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
-2026-07-01 23:35:15 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
-2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:35:16 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
-2026-07-01 23:35:16 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
-2026-07-01 23:35:16 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
-2026-07-01 23:35:16 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
-INFO: Application startup complete.
-INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
-2026-07-01 23:35:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:35872 - "GET /gateway-health HTTP/1.1" 200 OK
-2026-07-01 23:35:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:35:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
-2026-07-01 23:35:27 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/422b7786adc4486989d7f8770df40693.png (21864 bytes)
-INFO: 127.0.0.1:57220 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
-2026-07-01 23:35:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:57232 - "GET /static/annotations/422b7786adc4486989d7f8770df40693.png HTTP/1.1" 200 OK
-INFO: 127.0.0.1:57240 - "GET /gateway-health HTTP/1.1" 200 OK
-2026-07-01 23:35:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:35:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:35:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:36:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:37:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:38:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:39:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:40:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:41:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:42:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:43:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:44:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:45:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:46:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:47:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:48:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:49:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:50:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:51:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:44470 - "GET /gateway-health HTTP/1.1" 200 OK
-2026-07-01 23:52:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:52:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:53:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:6017 - "GET / HTTP/1.1" 200 OK
-INFO: 111.192.98.24:6017 - "GET /favicon.ico HTTP/1.1" 404 Not Found
-2026-07-01 23:53:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:54:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:39090 - "GET /docs HTTP/1.1" 200 OK
-INFO: 127.0.0.1:39092 - "GET / HTTP/1.1" 200 OK
-2026-07-01 23:55:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:56172 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56174 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56184 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56196 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56202 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56206 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56212 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56214 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56218 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56230 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56242 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56256 - "GET /static/test_interface6.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56272 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56278 - "GET /static/test_interface7.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56282 - "GET /static/integration.html HTTP/1.1" 200 OK
-INFO: 127.0.0.1:56284 - "GET /static/integration.html HTTP/1.1" 200 OK
-2026-07-01 23:55:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:55:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:6434 - "GET /static/test_interface1.html HTTP/1.1" 200 OK
-2026-07-01 23:56:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:08 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
-2026-07-01 23:56:08 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://127.0.0.1:8080/static/annotations/8a70c12c19ef4868af555ef84eaeafae.png (35965 bytes)
-INFO: 111.192.98.24:6435 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
-2026-07-01 23:56:12 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:20 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:28 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:36 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:44 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:56:52 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:00 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:08 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:16 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:24 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:32 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:40 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:48 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:57:56 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:04 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: Shutting down
-INFO: Waiting for application shutdown.
-2026-07-01 23:58:08 [INFO] gateway: 网关关闭中...
-2026-07-01 23:58:08 [INFO] gateway: 清理任务已停止
-2026-07-01 23:58:08 [INFO] gateway.pool: 健康检查循环已停止
-2026-07-01 23:58:08 [INFO] gateway.pool: Worker 池已关闭
-2026-07-01 23:58:08 [INFO] gateway: 网关已关闭
-INFO: Application shutdown complete.
-INFO: Finished server process [26440]
-INFO: Started server process [31898]
-INFO: Waiting for application startup.
-2026-07-01 23:58:10 [INFO] gateway.config: 配置加载完成 | workers=['http://127.0.0.1:8187'] | public_base_url=http://117.50.213.111:8080 | hc_interval=8s | dispatch_timeout=600s | queue_wait=30s
-2026-07-01 23:58:10 [INFO] gateway: 网关启动中... workers=['http://127.0.0.1:8187']
-2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:10 [INFO] gateway.pool: Worker 池初始化完成 | 总数=1 | 在线=1
-2026-07-01 23:58:10 [INFO] gateway: 标注图目录: /home/ubuntu/hair/static/annotations
-2026-07-01 23:58:10 [INFO] gateway.pool: 健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=1
-2026-07-01 23:58:10 [INFO] gateway: 清理任务启动 | 间隔=60min | 保留=24h | 目录=/home/ubuntu/hair/static/annotations
-INFO: Application startup complete.
-INFO: Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
-2026-07-01 23:58:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 127.0.0.1:33732 - "GET /gateway-health HTTP/1.1" 200 OK
-2026-07-01 23:58:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:28 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
-2026-07-01 23:58:28 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/478efab4566a46c3af0065ad0ffafb67.png (21864 bytes)
-INFO: 127.0.0.1:32982 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
-INFO: 117.50.213.111:57344 - "GET /static/annotations/478efab4566a46c3af0065ad0ffafb67.png HTTP/1.1" 200 OK
-2026-07-01 23:58:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:58:54 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/face/measure "HTTP/1.1 200 OK"
-2026-07-01 23:58:54 [INFO] gateway.forward: base64→URL: annotated_image_base64 → http://117.50.213.111:8080/static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png (35965 bytes)
-INFO: 111.192.98.24:7030 - "POST /api/v1/face/measure HTTP/1.1" 200 OK
-INFO: 111.192.98.24:7030 - "GET /static/annotations/88d5eec74def44fdad5dd6f7b22e7be4.png HTTP/1.1" 200 OK
-2026-07-01 23:58:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:7031 - "GET /static/test_interface2.html HTTP/1.1" 200 OK
-2026-07-01 23:59:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:27 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
-2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg (105970 bytes)
-2026-07-01 23:59:27 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg (113320 bytes)
-INFO: 111.192.98.24:7062 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
-INFO: 111.192.98.24:7062 - "GET /static/annotations/8f770fef8bc14827b419698c90ebd6fa.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:7166 - "GET /static/annotations/6f592882d9c84c1b916ac327f4e0b2af.jpg HTTP/1.1" 200 OK
-2026-07-01 23:59:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-01 23:59:48 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow "HTTP/1.1 200 OK"
-2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg (105641 bytes)
-2026-07-01 23:59:48 [INFO] gateway.forward: base64→URL: grown_image_base64 → http://117.50.213.111:8080/static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg (111495 bytes)
-INFO: 111.192.98.24:5164 - "POST /api/v1/hair/grow HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5164 - "GET /static/annotations/ed790d4888f742caa6625b3cf4704a77.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5188 - "GET /static/annotations/e4aed9d67e864d31909b30a58e08c16e.jpg HTTP/1.1" 200 OK
-2026-07-01 23:59:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:5213 - "GET /static/test_interface3.html HTTP/1.1" 200 OK
-2026-07-02 00:00:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:00:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:00:16 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hair/grow-b "HTTP/1.1 200 OK"
-2026-07-02 00:00:16 [INFO] gateway.forward: base64→URL: hair_growth_image_base64 → http://117.50.213.111:8080/static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg (152092 bytes)
-INFO: 111.192.98.24:5212 - "POST /api/v1/hair/grow-b HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5212 - "GET /static/annotations/0891a4b9375e4f58814502e893b12bf6.jpg HTTP/1.1" 200 OK
-2026-07-02 00:00:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:5332 - "GET /static/test_interface4.html HTTP/1.1" 200 OK
-2026-07-02 00:00:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:00:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:00:39 [ERROR] gateway: 接口4 豆包调用失败
-Traceback (most recent call last):
- File "/home/ubuntu/hair/gateway/app.py", line 298, in face_features
- feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
- File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/starlette/concurrency.py", line 37, in run_in_threadpool
- return await anyio.to_thread.run_sync(func)
- File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/to_thread.py", line 63, in run_sync
- return await get_async_backend().run_sync_in_worker_thread(
- File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 2596, in run_sync_in_worker_thread
- return await future
- File "/home/ubuntu/hair/venv/lib/python3.10/site-packages/anyio/_backends/_asyncio.py", line 1029, in run
- result = context.run(func, *args)
- File "/home/ubuntu/hair/face_features.py", line 98, in analyze_features
- resp = get_client().chat.completions.create(
- File "/home/ubuntu/hair/face_features.py", line 65, in get_client
- from volcenginesdkarkruntime import Ark
-ModuleNotFoundError: No module named 'volcenginesdkarkruntime'
-INFO: 111.192.98.24:5331 - "POST /api/v1/face/features HTTP/1.1" 200 OK
-2026-07-02 00:00:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: 111.192.98.24:5436 - "GET /static/test_interface5.html HTTP/1.1" 200 OK
-2026-07-02 00:00:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:00:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:00 [INFO] httpx: HTTP Request: POST http://127.0.0.1:8187/api/v1/hairline/generate "HTTP/1.1 200 OK"
-2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/4516a9491a1c41f4af048020f6709870.jpg (105674 bytes)
-2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg (105970 bytes)
-2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg (105735 bytes)
-2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/316d8352062848e4b28337ef233d820e.jpg (105641 bytes)
-2026-07-02 00:01:00 [INFO] gateway.forward: base64→URL: image_base64 → http://117.50.213.111:8080/static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg (105705 bytes)
-INFO: 111.192.98.24:5435 - "POST /api/v1/hairline/generate HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5435 - "GET /static/annotations/4516a9491a1c41f4af048020f6709870.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5466 - "GET /static/annotations/ad51e082bb5540ea843259cdc1e76e6b.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5467 - "GET /static/annotations/12a6f7745c9c461ca963f4d49c5267ef.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5469 - "GET /static/annotations/316d8352062848e4b28337ef233d820e.jpg HTTP/1.1" 200 OK
-INFO: 111.192.98.24:5471 - "GET /static/annotations/2ae45e3579b0419ab8bff5e3129ab26e.jpg HTTP/1.1" 200 OK
-2026-07-02 00:01:06 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:14 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:22 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:30 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:38 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:46 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:01:54 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:02 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:10 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:18 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:26 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:34 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:42 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:50 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-2026-07-02 00:02:58 [INFO] httpx: HTTP Request: GET http://127.0.0.1:8187/health "HTTP/1.1 200 OK"
-INFO: Shutting down
-INFO: Waiting for application shutdown.
-2026-07-02 00:02:59 [INFO] gateway: 网关关闭中...
-2026-07-02 00:02:59 [INFO] gateway: 清理任务已停止
-2026-07-02 00:02:59 [INFO] gateway.pool: 健康检查循环已停止
-2026-07-02 00:02:59 [INFO] gateway.pool: Worker 池已关闭
-2026-07-02 00:02:59 [INFO] gateway: 网关已关闭
-INFO: Application shutdown complete.
-INFO: Finished server process [31898]
diff --git a/hairline/comfyui.py b/hairline/comfyui.py
index 148ae3d..ee42ddf 100644
--- a/hairline/comfyui.py
+++ b/hairline/comfyui.py
@@ -30,6 +30,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
_INPUT_NODE = "26" # LoadImage:外部输入图(含 alpha 遮罩)
_SEED_NODE = "6" # RandomNoise
_PROMPT_NODE = "60" # JjkText:提示词
+_UNET_NODE = "16" # UNETLoader / UnetLoaderGGUF:Flux 模型加载
+_CLIP_NODE = "61" # CLIPLoader:qwen 文本编码器
+
+# Flux 模型 → 配套文本编码器映射。切换 unet 时自动同步编码器,避免维度不匹配。
+# 规则:4b 系列配 qwen_3_4b,9b 系列(fp8/GGUF)配 qwen_3_8b_fp8mixed。
+def _clip_for_unet(unet_name: str) -> str | None:
+ """根据 unet 文件名推断配套的文本编码器文件名;无法推断返回 None。"""
+ low = unet_name.lower()
+ if "4b" in low and "9b" not in low:
+ return "qwen_3_4b.safetensors"
+ if "9b" in low:
+ return "qwen_3_8b_fp8mixed.safetensors"
+ return None
_wf_cache: dict[str, dict] = {} # path → workflow JSON
_wf_output_node: dict[str, str] = {} # path → SaveImage 节点 ID
@@ -86,13 +99,18 @@ def _get_output_node(workflow_path: str | None = None) -> str:
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = None,
- workflow_path: str | None = None, front: bool = False) -> bytes:
+ workflow_path: str | None = None, front: bool = False,
+ unet_name: str | None = None) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。
prompt:非 None 时替换工作流节点60(JjkText)的文本;None 时用工作流内置默认提示词。
workflow_path:工作流 JSON 路径,None 则用默认 add_hair.json。
front:True 时任务插到 ComfyUI 队列最前(server 端 "front" 字段,队列号取负)。
接口2 对时延敏感用 True,避免排在接口3/5 的批量任务后面;其余接口保持 False。
+ unet_name:非 None 时改写工作流里的模型加载节点(节点16),动态切换 Flux 模型。
+ .safetensors → 保持 UNETLoader 节点类型不变,只替换 unet_name;
+ .gguf → 自动把节点类型改成 UnetLoaderGGUF(需装 ComfyUI-GGUF 插件)。
+ None 时用工作流内置默认模型。
"""
path = workflow_path or _WORKFLOW_DEFAULT
output_node = _get_output_node(path)
@@ -120,6 +138,21 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT, prompt: str = Non
wf[_SEED_NODE]["inputs"]["noise_seed"] = random.randint(0, 2**63 - 1)
if prompt is not None:
wf[_PROMPT_NODE]["inputs"]["text"] = prompt
+ if unet_name is not None:
+ node = wf.get(_UNET_NODE)
+ if node is not None:
+ # .gguf 需切换到 ComfyUI-GGUF 插件的 UnetLoaderGGUF 节点;
+ # .safetensors/.ckpt 保持原 UNETLoader 节点类型不变
+ if unet_name.lower().endswith(".gguf"):
+ node["class_type"] = "UnetLoaderGGUF"
+ else:
+ node["class_type"] = "UNETLoader"
+ node["inputs"]["unet_name"] = unet_name
+ # 同步切换配套文本编码器(4b→qwen_3_4b, 9b→qwen_3_8b),避免维度不匹配
+ clip_node = wf.get(_CLIP_NODE)
+ clip_name = _clip_for_unet(unet_name)
+ if clip_node is not None and clip_name is not None:
+ clip_node["inputs"]["clip_name"] = clip_name
# 诊断:落盘实际提交的工作流 + 输入图,便于和手动 ComfyUI 跑的对比
try:
diff --git a/hairline/redraw.py b/hairline/redraw.py
index e367380..76eefb8 100644
--- a/hairline/redraw.py
+++ b/hairline/redraw.py
@@ -56,7 +56,7 @@ def _process_mask_to_rgba(image_bytes: bytes, mask_bytes: bytes) -> bytes:
def run_redraw(image_bytes: bytes, mask_bytes: bytes,
prompt: str | None = None, timeout: float = 300.0,
- front: bool = False) -> bytes:
+ front: bool = False, unet_name: str | None = None) -> bytes:
"""直接调 ComfyUI 重绘 — 替代 local_test /api/generate。
Args:
@@ -65,6 +65,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
+ unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
Returns:
重绘后的 PNG 图片字节
@@ -75,4 +76,5 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
"""
rgba_png = _process_mask_to_rgba(image_bytes, mask_bytes)
return comfyui.run(rgba_png, timeout=timeout, prompt=prompt,
- workflow_path=_REPAINT_WORKFLOW, front=front)
+ workflow_path=_REPAINT_WORKFLOW, front=front,
+ unet_name=unet_name)
diff --git a/hairline/service.py b/hairline/service.py
index 6945386..a2fa87a 100644
--- a/hairline/service.py
+++ b/hairline/service.py
@@ -54,21 +54,26 @@ _REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,皮
# 默认压到 896 兜底(ComfyUI ~4s,女性总耗时 9~11s);追画质可设 REDRAW_MAX_SIDE=1024。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))
-def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
+def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
+ max_side=None, unet_name=None):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
失败抛异常(调用方负责 try/except 跳过)。
+
+ max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
+ unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
"""
from .redraw import run_redraw
+ eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
scale = 1.0
orig_w = orig_h = 0
if img is not None:
orig_h, orig_w = img.shape[:2]
m = max(orig_h, orig_w)
- if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
- scale = _REDRAW_MAX_SIDE / float(m)
+ if eff_side > 0 and m > eff_side:
+ scale = eff_side / float(m)
nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
@@ -76,10 +81,10 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
- orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE)
+ orig_w, orig_h, nw, nh, eff_side)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
- prompt=_REDRAW_PROMPT, front=True)
+ prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
if scale < 1.0 and out:
out = _upscale_png_to(out, orig_w, orig_h)
return out
@@ -200,7 +205,8 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None,
- workflow_path: str | None = None):
+ workflow_path: str | None = None,
+ unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 1..4。
@@ -236,7 +242,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
- workflow_path=workflow_path, front=True)
+ workflow_path=workflow_path, front=True,
+ unet_name=unet_name)
if gsc < 1.0 and shared_grown:
shared_grown = _upscale_png_to(shared_grown, w, h)
except Exception as e: # noqa: BLE001
@@ -261,7 +268,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前
grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
- workflow_path=workflow_path, front=True)
+ workflow_path=workflow_path, front=True,
+ unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
@@ -273,7 +281,9 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
- redraw_defaults: dict):
+ redraw_defaults: dict,
+ redraw_max_side: int | None = None,
+ unet_name: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 换发型重绘图。
grown 图来源(新流程):对每个选中发型把 female key 映射到 change_hair 的 chang_* hair_id,
@@ -307,16 +317,17 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
+ eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
redraw_img = image_bgr
hair_mask_redraw = hair_mask_reuse
- if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
- redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
+ if eff_side > 0 and max(h, w) > eff_side:
+ redraw_img, _rs = _downscale_max_side(image_bgr, eff_side)
_nh, _nw = redraw_img.shape[:2]
if hair_mask_redraw is not None:
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
interpolation=cv2.INTER_NEAREST).astype(bool)
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
- w, h, _nw, _nh, _REDRAW_MAX_SIDE)
+ w, h, _nw, _nh, eff_side)
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
@@ -349,7 +360,9 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
mask_bytes = base64.b64decode(mask_b64)
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
_tr0 = _t.perf_counter()
- grown_png = _call_local_redraw(final_bytes, mask_bytes)
+ grown_png = _call_local_redraw(final_bytes, mask_bytes,
+ max_side=redraw_max_side,
+ unet_name=unet_name)
_tr1 = _t.perf_counter()
_tm = data.get("timings_ms") or {}
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
diff --git a/static/test_interface2.html b/static/test_interface2.html
index ed23551..aa6691d 100644
--- a/static/test_interface2.html
+++ b/static/test_interface2.html
@@ -109,6 +109,28 @@
💬 提示词
+
+
+ 🤖 Flux模型
+
+ 默认(9b-fp8)
+ 9B Q5_K_M (7GB·推荐)
+ 9B Q4_K_M (5.9GB·省显存)
+ 9B fp8 (8.8GB·原版)
+
+
+
+ 📐 压图长边
+
+ 默认(896)
+ 1024 (高画质·慢)
+ 896 (默认)
+ 768 (均衡)
+ 640 (省显存·快)
+ 不缩图(原图)
+
+
+
@@ -155,6 +177,10 @@ async function submitTest() {
fd.append('hair_style', checked.join(','));
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
+ const _model = $('fluxModel').value;
+ const _side = $('redrawMaxSide').value;
+ if (_model) fd.append('flux_model', _model);
+ if (_side !== '') fd.append('redraw_max_side', _side);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });