Compare commits
6
Commits
a748dfd1e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9379fbb8f8 | ||
|
|
c20606c003 | ||
|
|
1ca033f25a | ||
|
|
87ff2c15d0 | ||
|
|
c536e4ccb1 | ||
|
|
9fb5b486c0 |
@@ -29,7 +29,7 @@
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-1
@@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
|
||||
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
|
||||
|
||||
- **worker(GPU 机)**:`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer)+ FastAPI/uvicorn 全家桶。
|
||||
- **网关机**:很轻,只需 FastAPI/uvicorn/httpx 等代理依赖,**不需要 torch/mediapipe**。
|
||||
- **网关机**:FastAPI/uvicorn/httpx 等代理依赖 + **接口4 现需 `mediapipe`/`opencv-python`/`numpy<2`**(脸型本机计算),
|
||||
仍**不需要 torch**(无 GPU 推理需求)。
|
||||
|
||||
> 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
|
||||
|
||||
|
||||
+1
-1
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
+2
-2
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "补充遮罩内区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"87": {
|
||||
"inputs": {
|
||||
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。",
|
||||
"text": "填充遮罩区域的头发",
|
||||
"clip": [
|
||||
"78",
|
||||
0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""旷视五接口 — worker 侧(高性能 GPU 后端)。
|
||||
|
||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
||||
接口 1 等算法在 worker;接口4(用户特征)已迁到网关本机(调豆包),worker 同路径只返回明确错误、无 Mock。
|
||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||
"""
|
||||
@@ -736,7 +736,7 @@ async def hair_grow(
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-5 male:1-4"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
@@ -808,6 +808,7 @@ async def hair_grow_v2():
|
||||
"/api/v1/hair/grow-b",
|
||||
summary="接口3 B端生发(医生/操作端)",
|
||||
tags=["生发"],
|
||||
deprecated=True,
|
||||
description="""
|
||||
医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回:
|
||||
- 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果)
|
||||
@@ -847,36 +848,11 @@ async def hair_grow_b(
|
||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 划线图三选一取图(只需这一张)
|
||||
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
|
||||
if e is not None:
|
||||
return e
|
||||
|
||||
marked = cv2.imdecode(np.frombuffer(marked_raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if marked is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
try:
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from hairline.service import generate_grow_b
|
||||
|
||||
res = await run_in_threadpool(generate_grow_b, marked, use_mask, prompt)
|
||||
if res["status"] == "no_face":
|
||||
return err(1001, "无法识别人像")
|
||||
if res["status"] == "no_line":
|
||||
return err(1001, "未检测到发际线划线,请确认划线图额头有清晰的手绘发际线")
|
||||
|
||||
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",
|
||||
}
|
||||
return ok(data)
|
||||
except Exception as ex: # noqa: BLE001
|
||||
logger.exception("接口3 处理异常")
|
||||
return err(1007, f"处理失败:{ex}")
|
||||
"""接口3 已屏蔽:调用直接返回错误,不再执行生发/ComfyUI 逻辑。
|
||||
保留路由(含原参数签名)避免老客户端裸 404,multipart 入参仍被接受但不处理。"""
|
||||
return err(1007, "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -885,56 +861,28 @@ async def hair_grow_b(
|
||||
|
||||
@app.post(
|
||||
"/api/v1/face/features",
|
||||
summary="接口4 用户特征分析",
|
||||
summary="接口4 用户特征分析(仅网关)",
|
||||
tags=["人脸分析"],
|
||||
description=f"""
|
||||
输入用户照片,返回 N 个用户面部特征字段。
|
||||
description="""
|
||||
**本接口不在 worker 实现。** 请调用网关(本机默认 `http://127.0.0.1:8080`)的同路径;
|
||||
网关本机调火山方舟豆包视觉模型,不转发到 worker。
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
|
||||
|
||||
---
|
||||
|
||||
由**火山方舟 豆包视觉模型**分析,返回**固定 6 个英文字段**。
|
||||
|
||||
**返回格式**:`data.features` 为一个 **JSON 字符串**(不是对象),需要在客户端 `JSON.parse()` 后使用。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| face_shape | 脸形(如"鹅蛋脸") |
|
||||
| eyebrow_shape | 眉形(如"平眉") |
|
||||
| facial_age | 面部年龄(区间,如"18-25岁") |
|
||||
| dynamic_static_type | 动静类型("静态型"/"动态型") |
|
||||
| gender | 性别("男"/"女") |
|
||||
| gene_style | 基因风格(如"自然型") |
|
||||
|
||||
> 无人脸返回 `1001`。
|
||||
直接打 worker(如 `:8187`)会返回错误,避免误用假数据。
|
||||
""",
|
||||
responses={
|
||||
200: {
|
||||
"description": "成功",
|
||||
"description": "worker 不提供本接口",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"code": 1007,
|
||||
"message": "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"女","gene_style":"少年型"}',
|
||||
},
|
||||
"data": None,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
400: {
|
||||
"description": "参数错误 / 图片识别失败",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
async def face_features(
|
||||
@@ -942,16 +890,12 @@ async def face_features(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
|
||||
):
|
||||
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py)。
|
||||
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖。
|
||||
features = json.dumps(
|
||||
{
|
||||
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁",
|
||||
"dynamic_static_type": "静态型", "gender": "女", "gene_style": "少年型",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
# 接口4 只在网关实现(gateway/app.py → face_features.analyze_features)。
|
||||
# 不再返回 Mock 成功数据,避免本机打 :8187 时被假结果误导。
|
||||
return err(
|
||||
1007,
|
||||
"接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
|
||||
)
|
||||
return ok({"features": features})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1057,7 +1001,7 @@ async def hairline_generate(
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"),
|
||||
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
|
||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||
):
|
||||
if gender not in ("male", "female"):
|
||||
@@ -1451,7 +1395,7 @@ async def hairline_grow_v2(
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
|
||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||
@@ -1617,7 +1561,7 @@ async def hairline_grow_v2_final_v2(
|
||||
async def api_redraw(
|
||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
prompt: str = Form(default="填充遮罩区域的头发",
|
||||
description="ComfyUI 提示词"),
|
||||
):
|
||||
image_bytes = await image_file.read()
|
||||
@@ -1662,6 +1606,84 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调试:MediaPipe 脸型分类(face/face_shape_classifier.py,非接口4 豆包)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
"/api/v1/debug/face-shape",
|
||||
summary="调试 单张脸型分类(MediaPipe)",
|
||||
tags=["调试"],
|
||||
description="""
|
||||
离线脸型分类调试接口(`face/face_shape_classifier.py`),**不是**接口4 的豆包视觉分析。
|
||||
|
||||
上传正面照 → MediaPipe 468 点 → 7 类脸型评分 + 特征标注图。
|
||||
""",
|
||||
include_in_schema=True,
|
||||
)
|
||||
async def debug_face_shape(
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64"),
|
||||
):
|
||||
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
|
||||
if e:
|
||||
return e
|
||||
try:
|
||||
nparr = np.frombuffer(raw, np.uint8)
|
||||
bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
except Exception: # noqa: BLE001
|
||||
return err(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
def _jsonable(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: _jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_jsonable(v) for v in obj]
|
||||
if hasattr(obj, "item"):
|
||||
return obj.item()
|
||||
if isinstance(obj, (float, int, str, bool)) or obj is None:
|
||||
return obj
|
||||
return obj
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
try:
|
||||
from face.face_shape_classifier import classify_from_image
|
||||
|
||||
result = await run_in_threadpool(
|
||||
classify_from_image, bgr, True, True,
|
||||
)
|
||||
except ValueError as ex:
|
||||
return err(1001, str(ex) or "无法识别人像")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return err(1007, f"脸型分类失败:{ex}")
|
||||
|
||||
details = result.get("details") or {}
|
||||
ranked = [
|
||||
{"shape": name, "score": round(float(score), 2)}
|
||||
for name, score in (details.get("ranked") or [])
|
||||
]
|
||||
annotated = result.pop("annotated", None)
|
||||
h, w = bgr.shape[:2]
|
||||
data = {
|
||||
"face_shape": result["face_shape"],
|
||||
"display": result["display"],
|
||||
"confidence": round(float(result["confidence"]), 4),
|
||||
"is_mixed": bool(details.get("is_mixed")),
|
||||
"second_shape": details.get("second_shape"),
|
||||
"score_gap": round(float(details["score_gap"]), 2) if details.get("score_gap") is not None else None,
|
||||
"ranked": ranked,
|
||||
"features": _jsonable(result.get("features") or {}),
|
||||
"zscores": _jsonable(details.get("zscores") or {}),
|
||||
"image_size": {"width": w, "height": h},
|
||||
"annotated_image_base64": _jpg_b64(annotated) if annotated is not None else None,
|
||||
}
|
||||
return ok(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发"}
|
||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
+9
-5
@@ -85,8 +85,10 @@
|
||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
|
||||
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
`doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||
**`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。
|
||||
无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。
|
||||
|
||||
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
|
||||
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
|
||||
@@ -108,10 +110,12 @@
|
||||
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
|
||||
|
||||
**网关机**
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
- FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
- ⚠️ 接口4 `face_shape` 改本机 MediaPipe 计算后,网关机**也需要装** `mediapipe`/`opencv-python`/`numpy<2`
|
||||
(不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。
|
||||
- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
- 托管 `/static/annotations/`(落盘的图),永久保留,不自动清理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@
|
||||
|
||||
## 接口 4:用户特征接口
|
||||
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
|
||||
|
||||
**请求**:`POST /api/v1/face/features`
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ face_shape_classifier.py
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -343,6 +344,9 @@ def get_mixed_description(details: Dict) -> str:
|
||||
|
||||
|
||||
_face_mesh = None
|
||||
# mediapipe Solutions API 的单个 FaceMesh 实例不是线程安全的;被 web 服务用
|
||||
# run_in_threadpool 并发调用时(如网关接口4、worker 调试接口)必须加锁串行化。
|
||||
_face_mesh_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_face_mesh():
|
||||
@@ -502,7 +506,8 @@ def annotate_face_features(
|
||||
|
||||
if landmarks is None:
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
results = _get_face_mesh().process(rgb)
|
||||
with _face_mesh_lock:
|
||||
results = _get_face_mesh().process(rgb)
|
||||
if not results.multi_face_landmarks:
|
||||
raise ValueError("未检测到人脸关键点")
|
||||
landmarks = results.multi_face_landmarks[0].landmark
|
||||
@@ -741,7 +746,8 @@ def classify_from_image(
|
||||
bgr = _load_image(image)
|
||||
h, w = bgr.shape[:2]
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
results = _get_face_mesh().process(rgb)
|
||||
with _face_mesh_lock:
|
||||
results = _get_face_mesh().process(rgb)
|
||||
|
||||
if not results.multi_face_landmarks:
|
||||
raise ValueError("未检测到人脸关键点")
|
||||
|
||||
@@ -203,7 +203,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
|
||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||
s = min(w, h)
|
||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
||||
font_size = max(12, round(s * 0.030)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.008))
|
||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||
|
||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜",
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
|
||||
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
|
||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||
)
|
||||
from face_analysis.face_mesh_landmarks import (
|
||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
|
||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
)
|
||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
||||
|
||||
|
||||
def _brow_center(lm, w, h):
|
||||
"""眉心 = 索引 9 / 151 中点。"""
|
||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
||||
"""眉心 = 索引 9(眉间上点)。"""
|
||||
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
|
||||
|
||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||
|
||||
+64
-17
@@ -1,10 +1,11 @@
|
||||
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision)。
|
||||
"""接口4:用户面部特征分析。
|
||||
|
||||
算法来源:/home/xsl/fuyan(FaceArk.py)。worker 把图片以 base64 data URI 传给方舟
|
||||
多模态模型,模型返回一大堆人脸特征 JSON;本模块解析后映射出接口4 的英文优先字段
|
||||
(face_shape 等),并保留 doubao 返回的全部中文字段。
|
||||
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格:火山方舟豆包视觉模型
|
||||
- face_shape(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
|
||||
不用豆包结果
|
||||
|
||||
⚠️ 这是**唯一调外网云模型**的接口(其余接口全本地)。API Key 走配置/环境变量,不入 git。
|
||||
⚠️ 仍依赖外网豆包(其余 5 项)。API Key 走配置/环境变量,不入 git。
|
||||
网关本机需可 import face 包(opencv + mediapipe)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,11 +17,10 @@ import os
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-2-0-lite-260428")
|
||||
|
||||
# doubao 中文键 → 接口4 英文优先字段(仅保留这 6 项)
|
||||
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape)
|
||||
_KEY_MAP = {
|
||||
"脸型": "face_shape",
|
||||
"眉形": "eyebrow_shape",
|
||||
"面部年龄": "facial_age",
|
||||
"动静类型": "dynamic_static_type",
|
||||
@@ -28,11 +28,11 @@ _KEY_MAP = {
|
||||
"基因风格": "gene_style",
|
||||
}
|
||||
|
||||
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出)
|
||||
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
|
||||
_PROMPT = (
|
||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||
"图片是否有人脸(有人/没人) "
|
||||
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 "
|
||||
"眉形 "
|
||||
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||
)
|
||||
@@ -42,12 +42,13 @@ _client_key: str | None = None # _client 构建时使用的 api_key,用
|
||||
|
||||
|
||||
def _load_api_key() -> str | None:
|
||||
"""ARK_API_KEY 环境变量优先,否则读 worker_config.json / gateway/config.json 的 ark_api_key。"""
|
||||
"""ARK_API_KEY 环境变量优先;否则优先 gateway/config.json(接口4 已迁网关),
|
||||
再回退 worker_config.json(兼容旧配置)。"""
|
||||
key = os.getenv("ARK_API_KEY")
|
||||
if key:
|
||||
return key
|
||||
base = os.path.dirname(__file__)
|
||||
for cfg_name in ("worker_config.json", "gateway/config.json"):
|
||||
for cfg_name in ("gateway/config.json", "worker_config.json"):
|
||||
cfg = os.path.join(base, cfg_name)
|
||||
if os.path.isfile(cfg):
|
||||
try:
|
||||
@@ -97,10 +98,47 @@ def _image_to_url(image_bytes: bytes = None, image_url: str = None) -> str:
|
||||
return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
|
||||
|
||||
|
||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
"""调 doubao 视觉模型分析人脸特征。
|
||||
def _resolve_image_bytes(image_bytes: bytes = None, image_url: str = None) -> bytes:
|
||||
"""本地分类器用:优先已有字节;仅有 URL 时下载。"""
|
||||
if image_bytes:
|
||||
return image_bytes
|
||||
if not image_url:
|
||||
raise ValueError("缺少图片数据")
|
||||
if image_url.startswith("data:"):
|
||||
# data URI
|
||||
b64 = image_url.split(",", 1)[1] if "," in image_url else image_url
|
||||
return base64.b64decode(b64)
|
||||
import httpx
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
r = client.get(image_url)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
Returns: dict —— 仅含接口4 的 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||
|
||||
def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str:
|
||||
"""MediaPipe 脸型分类,返回 display(含混合脸型描述)或主脸型。"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from face.face_shape_classifier import classify_from_image
|
||||
|
||||
raw = _resolve_image_bytes(image_bytes, image_url)
|
||||
bgr = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if bgr is None:
|
||||
raise ValueError("图片格式不支持,无法解码")
|
||||
result = classify_from_image(bgr, return_details=True, return_annotated=False)
|
||||
shape = result.get("display") or result["face_shape"]
|
||||
logger.info(
|
||||
"local face_shape=%s conf=%.3f",
|
||||
shape,
|
||||
float(result.get("confidence") or 0),
|
||||
)
|
||||
return shape
|
||||
|
||||
|
||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
"""豆包分析 5 项特征 + 本机 MediaPipe 覆盖 face_shape。
|
||||
|
||||
Returns: dict —— 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||
"""
|
||||
url = _image_to_url(image_bytes, image_url)
|
||||
@@ -131,8 +169,17 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
|
||||
if not has_face(raw):
|
||||
return None
|
||||
# 只保留 6 个英文字段(doubao 缺某字段则跳过)
|
||||
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||
# 豆包 5 项 + 本地脸型覆盖
|
||||
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||
try:
|
||||
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
|
||||
except ValueError as e:
|
||||
logger.warning("本地脸型分类未检测到人脸: %s", e)
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("本地脸型分类失败")
|
||||
raise RuntimeError(f"本地脸型分类失败:{e}") from e
|
||||
return feats
|
||||
|
||||
|
||||
def has_face(features: dict) -> bool:
|
||||
|
||||
+11
-71
@@ -4,13 +4,10 @@
|
||||
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -67,32 +64,15 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
app.state._pool_shutdown = None
|
||||
|
||||
# 确保标注图目录存在
|
||||
# 确保标注图目录存在(永久保留,不做定期清理)
|
||||
static_dir = Path(cfg["static_dir"])
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("标注图目录: %s", static_dir)
|
||||
|
||||
# 启动定期清理任务(阶段四)
|
||||
cleanup_shutdown = asyncio.Event()
|
||||
cleanup_task = asyncio.create_task(
|
||||
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
|
||||
)
|
||||
app.state._cleanup_shutdown = cleanup_shutdown
|
||||
app.state._cleanup_task = cleanup_task
|
||||
logger.info("标注图目录: %s(永久保留,不自动清理)", static_dir)
|
||||
|
||||
yield
|
||||
|
||||
# 关闭
|
||||
logger.info("网关关闭中...")
|
||||
# 停止清理任务
|
||||
if app.state._cleanup_shutdown:
|
||||
app.state._cleanup_shutdown.set()
|
||||
if app.state._cleanup_task:
|
||||
app.state._cleanup_task.cancel()
|
||||
try:
|
||||
await app.state._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if app.state._pool_shutdown:
|
||||
await app.state._pool_shutdown()
|
||||
logger.info("网关已关闭")
|
||||
@@ -132,51 +112,6 @@ def _get_pool_status_safe():
|
||||
return {"total": 0, "healthy": 0, "busy": 0}
|
||||
|
||||
|
||||
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
|
||||
"""定期清理 static/annotations/ 中过期的标注图文件。
|
||||
|
||||
配置项(可选,在 config.json 中设定):
|
||||
- cleanup.interval_minutes: 清理间隔,默认 60
|
||||
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
|
||||
"""
|
||||
cleanup_cfg = cfg.get("cleanup", {})
|
||||
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
|
||||
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
|
||||
|
||||
logger.info(
|
||||
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
|
||||
interval_s // 60, max_age_s // 3600, annotations_dir,
|
||||
)
|
||||
|
||||
while not shutdown.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
|
||||
break # shutdown
|
||||
except asyncio.TimeoutError:
|
||||
pass # 正常到时,执行清理
|
||||
|
||||
now = time.time()
|
||||
deleted = 0
|
||||
for f in annotations_dir.iterdir():
|
||||
if f.name == ".gitkeep":
|
||||
continue
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
age_s = now - f.stat().st_mtime
|
||||
if age_s > max_age_s:
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
|
||||
except Exception:
|
||||
logger.warning("清理文件失败: %s", f.name, exc_info=True)
|
||||
|
||||
if deleted:
|
||||
logger.info("清理完成: 删除 %d 个过期文件", deleted)
|
||||
|
||||
logger.info("清理任务已停止")
|
||||
|
||||
|
||||
@app.get("/gateway-health", include_in_schema=False)
|
||||
async def gateway_health():
|
||||
"""网关自身健康检查(区别于 worker 的 /health)。"""
|
||||
@@ -539,10 +474,15 @@ async def hair_grow(request: Request):
|
||||
return await _proxy(request, "/api/v1/hair/grow")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"])
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
|
||||
async def hair_grow_b(request: Request):
|
||||
"""接口3:B端生发"""
|
||||
return await _proxy(request, "/api/v1/hair/grow-b")
|
||||
"""接口3:B端生发(已屏蔽)"""
|
||||
# 在网关层直接拦截,不转发到 worker 池——对所有上游 worker 立即生效。
|
||||
import uuid as _uuid
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1007, "message": "接口3(/api/v1/hair/grow-b)已屏蔽,暂不提供服务",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/v1/face/features", tags=["人脸分析"])
|
||||
@@ -552,7 +492,7 @@ async def face_features(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||
):
|
||||
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。"""
|
||||
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
|
||||
import uuid as _uuid
|
||||
|
||||
# 三选一校验
|
||||
|
||||
@@ -22,9 +22,5 @@
|
||||
"request_timeout_seconds": 600,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ DEFAULTS = {
|
||||
"retry_on_failure": True,
|
||||
"max_retries": 1,
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24,
|
||||
},
|
||||
"request_log": {
|
||||
"enabled": True,
|
||||
"log_file": "gateway/request_log.jsonl",
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
|
||||
设计原则(与用户约定):
|
||||
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||
- 入参图片(image_file / *_base64)当前网关不存盘——这里补存到 static_dir(in_ 前缀),
|
||||
复用现有 24h 清理循环自动回收;url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀),永久保留;
|
||||
url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||
|
||||
@@ -39,7 +39,7 @@ def save_image_bytes(
|
||||
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||
|
||||
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致,可被同一清理循环回收。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
"text": "填充遮罩区域的头发"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ def generate():
|
||||
return jsonify({"error": msg}), 400
|
||||
image_file = request.files["image"]
|
||||
mask_file = request.files["mask"]
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
|
||||
log.info(
|
||||
"收到请求: image=%s mask=%s prompt=%r",
|
||||
image_file.filename, mask_file.filename, prompt_text,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ def prep_and_upload():
|
||||
|
||||
|
||||
def run_once(fname, model, dtype, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf["16"]["inputs"]["unet_name"] = model
|
||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
||||
|
||||
|
||||
def run(fname, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
||||
imgs = []
|
||||
labels = []
|
||||
for steps in [2, 3, 4, 6]:
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", seed=SEED)
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
||||
</div>
|
||||
<div class="controls" style="margin-top:16px">
|
||||
<label>提示词:</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||
</div>
|
||||
<div style="text-align:center; margin-top:16px">
|
||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_data, "image/png"),
|
||||
},
|
||||
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"},
|
||||
data={"prompt": "填充遮罩区域的头发"},
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
|
||||
+4
-1
@@ -26,8 +26,11 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing,
|
||||
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
|
||||
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径)
|
||||
|
||||
# 接口4:用户特征(火山方舟 豆包视觉模型)—— 已迁到**网关**实现,worker 不需要。
|
||||
# 接口4:用户特征(火山方舟 豆包视觉模型 + 本机 MediaPipe 脸型覆盖)—— 已迁到**网关**实现,worker 不需要。
|
||||
# 网关机装:volcengine-python-sdk[ark](from volcenginesdkarkruntime import Ark);API Key 走配置不入 git
|
||||
# ⚠️ face_shape 字段改为本机 face/face_shape_classifier.py 计算(覆盖豆包结果),
|
||||
# 因此网关机现在也需要 mediapipe==0.10.14 + opencv-python==4.10.0.84 + numpy==1.26.4
|
||||
# (不再是"网关不需要 mediapipe",见 docs/实现说明.md 需同步更新)
|
||||
|
||||
# 测试
|
||||
pytest==8.3.3
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>脸型分类调试 — MediaPipe</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 6px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
.subtitle code { background: #eef2ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.results-layout { display: flex; gap: 24px; }
|
||||
.col-main { flex: 1.4; min-width: 0; }
|
||||
.col-side { flex: 1; min-width: 0; }
|
||||
|
||||
.verdict { display: flex; gap: 16px; flex-wrap: wrap; align-items: stretch; }
|
||||
.verdict-item { flex: 1; min-width: 140px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 16px; }
|
||||
.verdict-item .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||
.verdict-item .value { font-size: 20px; font-weight: 700; color: #0f172a; }
|
||||
.verdict-item .value.accent { color: #2563eb; }
|
||||
.badge-mixed { display: inline-block; margin-left: 8px; background: #fef3c7; color: #92400e; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 700; vertical-align: middle; }
|
||||
|
||||
.img-preview { text-align: center; background: #222; border-radius: 8px; overflow: hidden; min-height: 200px; display: flex; align-items: center; justify-content: center; }
|
||||
.img-preview img { max-width: 100%; max-height: 560px; object-fit: contain; display: block; }
|
||||
.img-preview .placeholder { color: #9ca3af; padding: 40px; font-size: 14px; }
|
||||
|
||||
.bar-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar-row { display: grid; grid-template-columns: 72px 1fr 52px; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.bar-row .name { font-weight: 600; color: #334155; }
|
||||
.bar-row .track { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
|
||||
.bar-row .fill { height: 100%; background: #94a3b8; border-radius: 999px; }
|
||||
.bar-row.top .fill { background: #2563eb; }
|
||||
.bar-row.second .fill { background: #60a5fa; }
|
||||
.bar-row .score { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
|
||||
|
||||
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 180px; }
|
||||
.feat-table tr:hover td { background: #f8fafc; }
|
||||
|
||||
.json-panel { max-height: 520px; overflow: auto; }
|
||||
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||
</style>
|
||||
<script src="/static/img_downscale.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>脸型分类调试(MediaPipe)</h1>
|
||||
<p class="subtitle">
|
||||
POST <code>/api/v1/debug/face-shape</code>
|
||||
| 本地 <code>face/face_shape_classifier.py</code>(7 类)
|
||||
| 非接口4 豆包分析
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">分析脸型</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||
</div>
|
||||
<div class="hint">JPG/PNG 正面照 | 走本机 worker(MediaPipe),约 1s 内</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-layout hidden" id="resultsArea">
|
||||
<div class="col-main">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>判定结果</span></div>
|
||||
<div class="card-body">
|
||||
<div class="verdict" id="verdictBox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span>特征标注图</span></div>
|
||||
<div class="card-body">
|
||||
<div class="img-preview" id="imgPreview"><span class="placeholder">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span>各脸型得分</span></div>
|
||||
<div class="card-body">
|
||||
<div class="bar-list" id="scoreBars"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>几何特征</span></div>
|
||||
<div class="card-body" style="padding:0;max-height:360px;overflow:auto">
|
||||
<table class="feat-table" id="featTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span>原始 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">复制</button></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
const FEAT_LABELS = {
|
||||
face_height: '脸高 (px)',
|
||||
face_width: '脸宽 (px)',
|
||||
forehead_width: '额宽 (px)',
|
||||
cheekbone_width: '颧宽 (px)',
|
||||
jaw_width: '下颌宽 (px)',
|
||||
chin_width: '下巴宽 (px)',
|
||||
aspect_ratio: '长宽比 (宽/高)',
|
||||
forehead_ratio: '额宽/面宽',
|
||||
cheekbone_ratio: '颧宽/面宽',
|
||||
jaw_ratio: '下颌宽/面宽',
|
||||
chin_ratio: '下巴宽/面宽',
|
||||
chin_sharpness: '下巴尖锐度',
|
||||
taper_ratio: '额头→下巴收窄',
|
||||
jaw_angle: '下颌角 (°)',
|
||||
width_uniformity: '宽度均匀度',
|
||||
face_curve_score: '面部曲线分',
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(t, type) {
|
||||
const b = $('statusBar');
|
||||
b.textContent = t;
|
||||
b.className = 'status ' + type;
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden');
|
||||
$('statusBar').className = 'status hidden';
|
||||
$('imageFile').value = '';
|
||||
$('jsonContent').textContent = '';
|
||||
$('imgPreview').innerHTML = '<span class="placeholder">—</span>';
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
let f = $('imageFile').files[0];
|
||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||
if (window.downscaleImageFile) f = await window.downscaleImageFile(f);
|
||||
|
||||
$('submitBtn').disabled = true;
|
||||
$('submitBtn').textContent = '分析中...';
|
||||
setStatus('调用 MediaPipe 脸型分类...', 'info');
|
||||
$('resultsArea').classList.add('hidden');
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('image_file', f);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: fd,
|
||||
});
|
||||
const json = await r.json();
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
setStatus('完成 (' + elapsed + 's)', 'success');
|
||||
renderResult(json.data);
|
||||
} else {
|
||||
setStatus('(' + elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('请求失败: ' + e.message, 'error');
|
||||
} finally {
|
||||
$('submitBtn').disabled = false;
|
||||
$('submitBtn').textContent = '分析脸型';
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
const mixed = data.is_mixed
|
||||
? '<span class="badge-mixed">混合 · 次选 ' + (data.second_shape || '—') + '</span>'
|
||||
: '';
|
||||
$('verdictBox').innerHTML =
|
||||
'<div class="verdict-item"><div class="label">脸型</div><div class="value accent">' +
|
||||
esc(data.display || data.face_shape) + mixed + '</div></div>' +
|
||||
'<div class="verdict-item"><div class="label">置信度</div><div class="value">' +
|
||||
(data.confidence * 100).toFixed(1) + '%</div></div>' +
|
||||
'<div class="verdict-item"><div class="label">分差 score_gap</div><div class="value">' +
|
||||
(data.score_gap == null ? '—' : data.score_gap) + '</div></div>' +
|
||||
'<div class="verdict-item"><div class="label">尺寸</div><div class="value" style="font-size:16px">' +
|
||||
(data.image_size ? data.image_size.width + '×' + data.image_size.height : '—') + '</div></div>';
|
||||
|
||||
if (data.annotated_image_base64) {
|
||||
$('imgPreview').innerHTML =
|
||||
'<img src="data:image/jpeg;base64,' + data.annotated_image_base64 + '" alt="annotated">';
|
||||
} else {
|
||||
$('imgPreview').innerHTML = '<span class="placeholder">无标注图</span>';
|
||||
}
|
||||
|
||||
const ranked = data.ranked || [];
|
||||
const maxScore = ranked.length ? Math.max.apply(null, ranked.map(function (x) { return x.score; })) : 100;
|
||||
$('scoreBars').innerHTML = ranked.map(function (row, i) {
|
||||
const cls = i === 0 ? ' top' : (i === 1 ? ' second' : '');
|
||||
const pct = maxScore > 0 ? (100 * row.score / maxScore) : 0;
|
||||
return '<div class="bar-row' + cls + '">' +
|
||||
'<div class="name">' + esc(row.shape) + '</div>' +
|
||||
'<div class="track"><div class="fill" style="width:' + pct.toFixed(1) + '%"></div></div>' +
|
||||
'<div class="score">' + Number(row.score).toFixed(1) + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
const feats = data.features || {};
|
||||
const keys = Object.keys(feats);
|
||||
let html = '<tr><th>特征</th><th>值</th></tr>';
|
||||
keys.forEach(function (k) {
|
||||
const label = FEAT_LABELS[k] || k;
|
||||
const v = feats[k];
|
||||
const text = typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(4)) : String(v);
|
||||
html += '<tr><td>' + esc(label) + '</td><td>' + esc(String(text)) + '</td></tr>';
|
||||
});
|
||||
$('featTable').innerHTML = html;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
const t = $('jsonContent').textContent;
|
||||
if (!t) return;
|
||||
navigator.clipboard.writeText(t).then(function () {
|
||||
setStatus('已复制 JSON', 'success');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -169,7 +169,7 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div class="upload-group" style="margin-top:14px">
|
||||
<div class="label">💬 提示词(prompt)</div>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user