3 Commits
Author SHA1 Message Date
xslandClaude Opus 4.8 a86125246e docs: 网关侧改动清单(JPG落盘扩展名嗅探等)
汇总 worker 近期变更里与网关有关的点供网关侧应用:
- [功能必需] base64→url 落盘按内容嗅探扩展名(PNG/JPG),因接口2/3/5 改 JPG(已在 forward.py 改)
- [建议] 生发接口超时≥120s
- [确认] 递归改写覆盖数组内字段/可空 null
- [可选] OpenAPI 表单 gender/grow-b 声明
- 已完成项:接口4 网关实现/200状态/接口3去original

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:44:41 +08:00
xslandClaude Opus 4.8 4c7681b338 perf(图片): 接口2/3/5 返回 JPG(体积~9×↓),接口1 标注图仍 PNG(透明)
- app.py: 接口2(预览+生发)/3(生发)/5(发际线叠图) 编码改 JPG(质量90,env JPG_QUALITY);
  接口1 annotated_image 含透明仍 PNG。_png_to_jpg_b64 把 ComfyUI 的 PNG 重编码为 JPG(无法解码则透传)
- gateway/forward.py: 落盘按内容嗅探扩展名(PNG头→.png 否则.jpg),原先硬编码 .png
- 测试/文档同步;实测接口5 一张 59KB(JPG) vs 548KB(PNG)。pytest 44 全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:35:24 +08:00
xslandClaude Opus 4.8 e3c67fc8cf feat(comfyui): ComfyUI 改 8188 + HTTP Basic Auth
本机 ComfyUI 开了 Basic Auth(user admin),端口 8182→8188:
- hairline/comfyui.py: 默认 URL 8188;所有 httpx 请求带 auth=(user,password)
  密码来源 环境变量 COMFYUI_PASSWORD → worker_config.json.comfyui_password → password.txt
- password.txt 入 .gitignore(含密码不入 git);worker_config.example 加 comfyui_password
- 实现说明文档同步(8188 + Basic Auth)
实测:ping + 实跑一张生发图均通过(带鉴权)。pytest 44 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:10:09 +08:00
8 changed files with 130 additions and 24 deletions
+3
View File
@@ -10,6 +10,9 @@ gateway/config.json
# worker 配置(含鉴权密码,不入 git)
worker_config.json
# ComfyUI Basic Auth 密码(不入 git
password.txt
# worker 运行期文件(PID / 日志)
worker.pid
worker.log
+23 -8
View File
@@ -226,6 +226,24 @@ async def resolve_image_bytes(image_file, image_url, image_base64):
return None, err(1008, "base64 解码失败")
# 接口2/3/5 返回不透明照片,用 JPG 显著减小体积(接口1 标注图含透明,仍用 PNG)
_JPG_QUALITY = int(os.getenv("JPG_QUALITY", "90"))
def _jpg_b64(bgr) -> str:
"""BGR 图 → JPG base64。"""
_ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, _JPG_QUALITY])
return base64.b64encode(buf.tobytes()).decode()
def _png_to_jpg_b64(png_bytes) -> str:
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return base64.b64encode(png_bytes).decode()
return _jpg_b64(img)
# ---------------------------------------------------------------------------
# 通用图片请求 BodyJSON 方式,用于 url / base64
# ---------------------------------------------------------------------------
@@ -514,12 +532,10 @@ async def hair_grow(
results = []
for p in items:
_ok, png = cv2.imencode(".png", p["image_bgr"])
grown_b64 = (base64.b64encode(p["grown_png"]).decode()
if p["grown_png"] else None)
results.append({
"image_base64": base64.b64encode(png.tobytes()).decode(),
"grown_image_base64": grown_b64,
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
"hairline_type": p["hairline_type"],
"order": p["order"],
})
@@ -602,7 +618,7 @@ async def hair_grow_b(
if res["status"] == "no_line":
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
grown_b64 = base64.b64encode(res["grown_png"]).decode() if res["grown_png"] else None
grown_b64 = _png_to_jpg_b64(res["grown_png"]) if res["grown_png"] else None # 生发图 JPG
data = {
"hair_growth_image_base64": grown_b64,
"hairline_type": "custom",
@@ -783,9 +799,8 @@ async def hairline_generate(
hairline_images = []
for it in res["images"]:
_ok, png = cv2.imencode(".png", it["image_bgr"])
hairline_images.append({
"image_base64": base64.b64encode(png.tobytes()).decode(),
"image_base64": _jpg_b64(it["image_bgr"]), # 发际线叠图 JPG
"order": it["order"],
})
c = res["best_center"]
+5 -1
View File
@@ -35,6 +35,8 @@
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码
@@ -84,7 +86,9 @@
- ⚠️ 本机 **RTX 5090(sm_120)**pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image"
代码已自动**回退 CPU**BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md)BiSeNet/SegFormer/face_landmarker.task 本地。
- 生发接口依赖本机 **ComfyUI(8182)**Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
- 生发接口依赖本机 **ComfyUI(8188)**Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
ComfyUI 开了 **HTTP Basic Auth**user `admin` + 密码);密码放 `password.txt`(不入 git) /
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`URL 用 `COMFYUI_URL`
- `worker_config.json`(不入 git)`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`
**网关机**
+55
View File
@@ -0,0 +1,55 @@
# 网关侧改动清单(worker 近期变更引发)
> 给网关开发:以下是 worker/契约近期变化里**与网关有关**的点。标 ✅ 的我已在本仓库 `gateway/`
> 改好(你 review/拉取即可);标 🔲 的是**建议你确认或改**。功能必需只有第 1 条。
---
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
网关把 `*_base64` 落盘时若**硬编码 `.png`**JPG 会被存成 `.png`(内容是 JPG、扩展名错)。
**改动**`gateway/forward.py``rewrite_base64_to_url`,已改):
```python
# 原:filename = f"{uuid.uuid4().hex}.png"
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg" # 按内容嗅探
filename = f"{uuid.uuid4().hex}.{ext}"
```
> 这样接口1 存 `.png`、接口2/3/5 存 `.jpg`,对外 URL 后缀也就正确。**若你的网关是独立部署/独立代码,按上面这两行改一下即可。**
---
## 2. 🔲【建议】生发接口超时调大
接口 **2(一次 N 张 Flux~18s/ 3~6s** 经 ComfyUI 同步出图较慢。
`gateway/config.json``dispatch.request_timeout_seconds` 建议 **≥ 120**,否则网关会先超时换 worker 重试。
---
## 3. 🔲【确认】base64→URL 通用改写仍覆盖这些场景
- **数组里的图片字段**:接口2 `results[].image_base64` / `results[].grown_image_base64`
接口5 `hairline_images[].image_base64` 在数组元素内——改写要**递归进数组**(你现有的递归实现已覆盖)。
- **可空字段**:接口2/3 的生发图(ComfyUI 没起/失败时)`*_base64`**null** → 保留 null、不落盘。
---
## 4. 🔲【可选·仅影响 /docs】OpenAPI 表单声明
纯文档展示,不影响转发功能(网关是盲转发)。若想让网关 `/docs` 准确:
- 接口2 `/hair/grow`、接口5 `/hairline/generate` 入参**新增必填 `gender`**male/female)。
- 接口3 `/hair/grow-b` 入参**只剩 `marked_image_*`**(已去掉 `original_image_*`)。
- `gateway/app.py` 里的 `_*_FORMS` 字典当前未被路由引用,所以不改也不影响实际行为。)
---
## 已经做好、无需再动的
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`
- **接口4 业务错误 HTTP 状态**已统一为 200(与其余接口一致)。
- **接口3 去 original / best_hairline**——网关盲转发,无需改(映射表里也没有 best_hairline)。
---
> 对外字段映射总表见 [`实现说明.md`](实现说明.md) §1;契约以 [`接口文档.md`](接口文档.md) 为准。
+3 -2
View File
@@ -102,8 +102,9 @@ def rewrite_base64_to_url(
if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value)
if img_bytes is not None:
# 生成文件名并落盘
filename = f"{uuid.uuid4().hex}.png"
# 按内容嗅探扩展名:PNG(接口1标注图,含透明) / JPEG(接口2/3/5 照片)
ext = "png" if img_bytes[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = Path(static_dir) / filename
filepath.write_bytes(img_bytes)
+31 -4
View File
@@ -1,7 +1,8 @@
"""ComfyUI 客户端:用 add_hair.json 工作流跑生发图(Flux-2 inpaint)。
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8182)
worker 不跑 Flux,只把「划线图 + 遮罩」的 RGBA 上传到本机 ComfyUI(默认 8188)
替换工作流节点 26 的输入图、随机 seed,提交 /prompt,轮询 /history,取回 /view 输出。
ComfyUI 开启了 HTTP Basic Authuser `admin` + 密码),所有请求都带凭据。
"""
from __future__ import annotations
@@ -14,12 +15,13 @@ import uuid
import httpx
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8182").rstrip("/")
COMFYUI_URL = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
WORKFLOW_PATH = 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
@@ -28,6 +30,31 @@ _OUTPUT_NODE = "17" # SaveImage
_workflow = None
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() -> dict:
global _workflow
if _workflow is None:
@@ -39,7 +66,7 @@ def _load_workflow() -> dict:
def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
"""提交一次生发任务,返回输出 PNG 字节。失败抛异常。"""
client_id = uuid.uuid4().hex
with httpx.Client(base_url=COMFYUI_URL, timeout=30.0) as cli:
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")},
@@ -92,7 +119,7 @@ def run(rgba_png_bytes: bytes, timeout: float = COMFY_TIMEOUT) -> bytes:
def ping() -> bool:
"""探测 ComfyUI 是否在线(/system_stats)。"""
try:
with httpx.Client(base_url=COMFYUI_URL, timeout=3.0) as cli:
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
+8 -7
View File
@@ -82,9 +82,10 @@ def test_grow_missing_gender_1004(client):
assert r.json()["code"] == 1004
_PNG_1x1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
# mock ComfyUI 输出:一张合法 PNGworker 会把它重编码成 JPG
import cv2 as _cv2
import numpy as _np
_PNG_1x1 = _cv2.imencode(".png", _np.full((8, 8, 3), 200, _np.uint8))[1].tobytes()
def test_grow_female_returns_5(client, monkeypatch):
@@ -99,8 +100,8 @@ def test_grow_female_returns_5(client, monkeypatch):
results = body["data"]["results"]
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert base64.b64decode(results[0]["grown_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert base64.b64decode(results[0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert "image_url" not in results[0]
@@ -126,7 +127,7 @@ def test_growb_success(client, monkeypatch):
assert body["code"] == 0, body
d = body["data"]
assert d["hairline_type"] == "custom"
assert base64.b64decode(d["hair_growth_image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert base64.b64decode(d["hair_growth_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
assert "best_hairline_image_base64" not in d # 已去掉该字段
assert "best_hairline_image_url" not in d
@@ -145,7 +146,7 @@ def test_hairline_gen_female(client):
assert body["code"] == 0, body
d = body["data"]
assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5]
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
assert "image_url" not in d["hairline_images"][0]
+2 -2
View File
@@ -1,5 +1,5 @@
{
"_comment": "复制为 worker_config.json(不入 git)。accept_passwordsworker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。ark_api_key:接口4 火山方舟豆包视觉模型的 API Key(也可用环境变量 ARK_API_KEY 覆盖)。",
"_comment": "复制为 worker_config.json(不入 git)。accept_passwordsworker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。comfyui_password:本机 ComfyUI(8188) Basic Auth 密码(user 默认 admin;也可放 password.txt 或环境变量 COMFYUI_PASSWORD)。注:接口4 火山方舟 ark_api_key 已迁到网关配置。",
"accept_passwords": ["change-me-to-a-strong-secret"],
"ark_api_key": "your-volcengine-ark-api-key"
"comfyui_password": "your-comfyui-basic-auth-password"
}