9 Commits
Author SHA1 Message Date
xsl f68049f001 save 2026-08-04 22:39:27 +08:00
xsl 7725e461eb save code 2026-08-04 22:38:42 +08:00
xsl 7d8dfd93b8 save 2026-08-04 22:38:42 +08:00
Ubuntu 9379fbb8f8 save 2026-08-02 01:27:04 +08:00
xsl c20606c003 save 2026-07-31 23:29:07 +08:00
xslandCursor 1ca033f25a feat: 接口1/6 标注层字号上调一档 + 眉心改用 9 号点定位
- annotation: 自适应字号系数 0.017→0.020(下限 8→9),标注文字更大更清晰
- measure: _brow_center 只取 FaceMesh 9 号点(眉间上点),不再与 151 取中点

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:02:19 +08:00
UbuntuandCursor 87ff2c15d0 chore: 精简生发提示词,去掉磨皮/美颜要求
统一改为「填充遮罩区域的头发」,涉及后端默认值、ComfyUI 工作流 JSON、
测试页、benchmark 脚本、local_test。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:57:57 +08:00
UbuntuandCursor c536e4ccb1 feat: 屏蔽接口3(B端生发)
网关层直接拦截返回 1007,不再转发到 worker 池;worker 侧路由同步标记
deprecated 并短路返回,保留原参数签名避免老客户端裸 404。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:57:46 +08:00
xslandCursor 9fb5b486c0 fix(接口4): worker 移除脸型 Mock + face_shape 改本机 MediaPipe 计算
- worker /api/v1/face/features 不再返回假成功数据,直接告知仅网关实现,
  避免本机误打 :8187 被 Mock 结果误导。
- 网关 ark_api_key 加载优先级改为 gateway/config.json 优先(原先误读
  worker_config.json 里的失效 key)。
- 接口4 face_shape 不再采信豆包结果,改用本机 face/face_shape_classifier.py
  (MediaPipe 7 类)计算覆盖;其余 5 项特征仍走豆包。
- 修复 face_shape_classifier 共享 FaceMesh 实例的线程安全问题(加锁),
  避免网关侧接口4 并发请求时崩溃/结果错乱。
- 新增 /api/v1/debug/face-shape 调试接口 + static/test_face_shape.html
  单图调试页(worker 侧)。
- 更新文档:网关机现在也需要 mediapipe/opencv-python/numpy<2。

⚠️ 部署前提醒:网关机需先安装 mediapipe==0.10.14 / opencv-python==4.10.0.84 /
numpy==1.26.4,否则接口4 会返回 1007「分析服务异常」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:41:49 +08:00
89 changed files with 752 additions and 254 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"60": { "60": {
"class_type": "JjkText", "class_type": "JjkText",
"inputs": { "inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" "text": "填充遮罩区域的头发"
} }
}, },
"22": { "22": {
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -82,7 +82,8 @@ model.safetensors https://huggingface.co/jonathandinu/face-parsing/resol
模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包: 模型已就位,但**内网机还需要 Python 依赖的离线 wheel 包**,否则 `pip install` 在内网无法联网安装。这部分**与目标机的操作系统、Python 版本、CUDA 版本强相关**,需确认后单独打包:
- **workerGPU 机)**`mediapipe` / `opencv-python` / `numpy<2` / `Pillow` / **`torch`+`torchvision` 的 CUDA 版**(按 GPU 的 CUDA 版本选 cu118/cu121 等)/ `transformers`(接口2 SegFormer+ FastAPI/uvicorn 全家桶。 - **workerGPU 机)**`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,网关保持轻量。 > 架构已拆分(见 `docs/实现说明.md`):算法依赖只装在 worker,网关保持轻量。
+1 -1
View File
@@ -410,7 +410,7 @@
}, },
"60": { "60": {
"inputs": { "inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" "text": "填充遮罩区域的头发"
}, },
"class_type": "JjkText", "class_type": "JjkText",
"_meta": { "_meta": {
+2 -2
View File
@@ -381,7 +381,7 @@
}, },
"60": { "60": {
"inputs": { "inputs": {
"text": "充遮罩区域的头发,区域内填充满头发,不要保留皮肤,发际线下移填充头发。自然的头发生长方向,逼真的头发质感,自然发质。" "text": "充遮罩区域的头发"
}, },
"class_type": "JjkText", "class_type": "JjkText",
"_meta": { "_meta": {
@@ -684,7 +684,7 @@
}, },
"87": { "87": {
"inputs": { "inputs": {
"text": "去掉头发接缝的黄色痕迹,头发完美融合,保持发型不变,发色不变。其他不变。", "text": "填充遮罩区域的头发",
"clip": [ "clip": [
"78", "78",
0 0
+263 -95
View File
@@ -1,6 +1,6 @@
"""旷视五接口 — worker 侧(高性能 GPU 后端)。 """旷视五接口 — worker 侧(高性能 GPU 后端)。
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。 接口 1 等算法在 worker;接口4(用户特征)已迁到网关本机(调豆包),worker 同路径只返回明确错误、无 Mock。
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。 拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token``/health` 供网关探测不校验。 worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token``/health` 供网关探测不校验。
""" """
@@ -675,10 +675,11 @@ async def face_measure_v2(
- 发际线类型 `hairline_type`(英文 key - 发际线类型 `hairline_type`(英文 key
- 顺序 `order`(本期固定 `1..N`,不排序) - 顺序 `order`(本期固定 `1..N`,不排序)
> **female 走「换发型」模式**:生发图 `grown_image_base64` 由换发型(change_hair > **female 走「换发型」模式**1..5 的生发图 `grown_image_base64` 由换发型(change_hair
> + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。 > + Flux-2 整帧重绘(= 接口12 final 管线,整帧美颜+整帧重绘)生成,其余参数用固化默认值。
> **male 走原生发(ComfyUI add_hair)管线**。入参与返回结构不变。 > **6/7bigflower/clasicalflower)与 male 一样走原生发(ComfyUI add_hair)管线**。
> female 依赖 change_hair 与 ComfyUI(:8188) 均在跑 > **male 全部走原生发(ComfyUI add_hair)管线**。入参与返回结构不变
> female 1..5 依赖 change_hair 与 ComfyUI(:8188) 均在跑。
{_image_fields_desc} {_image_fields_desc}
@@ -686,15 +687,15 @@ async def face_measure_v2(
--- ---
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。 - **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
非法或缺失返回 `1004`。 非法或缺失返回 `1004`。
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。 - **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave `female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007`。 `male`1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007`。
- **beauty_enabled**:本期保留但不生效。 - **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female), `hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`female),
`ellipse` / `m` / `straight` / `inverse_arc`male)。 `ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`male)。
""", """,
responses={ responses={
200: { 200: {
@@ -733,17 +734,17 @@ async def hair_grow(
image_url: Optional[str] = Form(default=None, description="图片 URL"), image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), 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"), hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-6"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"), beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"), 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) # 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"): if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female") return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007) # 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
max_styles = {"female": 5, "male": 4}[gender] max_styles = {"female": 7, "male": 6}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles) hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None: if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}") return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
@@ -765,7 +766,8 @@ async def hair_grow(
if gender == "female": if gender == "female":
from hairline.service import generate_grow_results_swap from hairline.service import generate_grow_results_swap
items = await run_in_threadpool( 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,
prompt=prompt)
else: else:
from hairline.service import generate_grow_results from hairline.service import generate_grow_results
items = await run_in_threadpool( items = await run_in_threadpool(
@@ -788,6 +790,149 @@ async def hair_grow(
return err(1007, f"处理失败:{ex}") return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 调试接口:接口2 女性生发 分步计时
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/debug/grow-timing",
summary="调试-接口2女性生发分步计时",
tags=["调试"],
include_in_schema=False,
)
async def debug_grow_timing(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认8960=原图不缩;其他如640/768/1024"),
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
):
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
步骤拆分:
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
2. [每个发型] generate_hairline_redraw
2a. compute_mask:发际线遮罩计算
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
2c. _composite:接缝融合(多频段/羽化)
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
"""
import time as _time
from fastapi.concurrency import run_in_threadpool
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
max_styles = 7
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必须为 1..{max_styles}")
t_total0 = _time.perf_counter()
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
# 步骤1: extract_context
t0 = _time.perf_counter()
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
ctx = await run_in_threadpool(_ec, image)
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
if ctx is None:
return err(1001, "无法识别人像")
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
h, w = image.shape[:2]
eff_side = _REDRAW_MAX_SIDE if redraw_max_side is None else redraw_max_side
redraw_img, hair_mask_redraw = image, hair_mask_reuse
downscale_info = None
if eff_side > 0 and max(h, w) > eff_side:
from hairline.service import _downscale_max_side
redraw_img, _rs = _downscale_max_side(image, 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)
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
textures_map = None
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
textures = get_texture_map()["female"]
items = [(s, textures[s - 1]) for s in hair_styles]
for order, (key, white_path) in items:
hs_t0 = _time.perf_counter()
entry = {"hairline_type": key, "order": order}
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
entry["chang_id"] = chang_id
entry["ok"] = False
entry["error"] = None
entry["grown_b64"] = None
if chang_id is None:
entry["error"] = f"无对应 chang_id"
timings["per_hairstyle"].append(entry)
continue
try:
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
t0 = _time.perf_counter()
data = await run_in_threadpool(
generate_hairline_redraw, redraw_img, chang_id,
hair_mask=hair_mask_redraw, webui_steps=webui_steps, **_V2_FINAL_DEFAULTS)
t_redraw_pipeline = _time.perf_counter() - t0
_tm = data.get("timings_ms") or {}
entry["mask_ms"] = _tm.get("mask", 0)
entry["swap_ms"] = _tm.get("swap", 0)
entry["blend_ms"] = _tm.get("blend", 0)
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
steps = data.get("steps") or {}
final_b64 = steps.get("final_base64") or ""
mask_b64 = steps.get("redraw_band_mask_base64") or ""
if not final_b64 or not mask_b64:
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
timings["per_hairstyle"].append(entry)
continue
if final_b64.startswith("data:"):
final_b64 = final_b64.split(",", 1)[1]
if mask_b64.startswith("data:"):
mask_b64 = mask_b64.split(",", 1)[1]
# 3: ComfyUI 重绘
t0 = _time.perf_counter()
# max_side: 0 或 None 都让 _call_local_redraw 用默认逻辑(外层已控制分辨率)
_ms = redraw_max_side if redraw_max_side is not None and redraw_max_side > 0 else None
grown_png = await run_in_threadpool(
_call_local_redraw,
base64.b64decode(final_b64), base64.b64decode(mask_b64),
max_side=_ms, prompt=redraw_prompt)
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
if grown_png:
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
entry["ok"] = True
else:
entry["error"] = "ComfyUI 重绘返回空"
except NoFaceError:
entry["error"] = "未检出人脸"
except Exception as ex: # noqa: BLE001
entry["error"] = str(ex)[:150]
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
timings["per_hairstyle"].append(entry)
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
timings["downscale"] = downscale_info
timings["image_size"] = f"{w}x{h}"
return ok(timings)
except Exception as ex: # noqa: BLE001
logger.exception("debug/grow-timing 异常")
return err(1007, f"处理失败:{ex}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的 # 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。 # Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
@@ -808,6 +953,7 @@ async def hair_grow_v2():
"/api/v1/hair/grow-b", "/api/v1/hair/grow-b",
summary="接口3 B端生发(医生/操作端)", summary="接口3 B端生发(医生/操作端)",
tags=["生发"], tags=["生发"],
deprecated=True,
description=""" description="""
医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回: 医生/操作端在用户照片上**手动用马克笔划线标注**目标发际线后,**只需上传这一张划线图**,返回:
- 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果) - 生发后效果图(系统检测划线 → 据此生成「植发 3 个月」效果)
@@ -847,36 +993,11 @@ async def hair_grow_b(
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"), marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"), marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"), use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", description="ComfyUI 提示词,会替换工作流节点60的文本"), prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
): ):
# 划线图三选一取图(只需这一张) """接口3 已屏蔽:调用直接返回错误,不再执行生发/ComfyUI 逻辑。
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64) 保留路由(含原参数签名)避免老客户端裸 404,multipart 入参仍被接受但不处理。"""
if e is not None: return err(1007, "接口3/api/v1/hair/grow-b)已屏蔽,暂不提供服务")
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}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -885,56 +1006,28 @@ async def hair_grow_b(
@app.post( @app.post(
"/api/v1/face/features", "/api/v1/face/features",
summary="接口4 用户特征分析", summary="接口4 用户特征分析(仅网关)",
tags=["人脸分析"], tags=["人脸分析"],
description=f""" description="""
输入用户照片,返回 N 个用户面部特征字段。 **本接口不在 worker 实现。** 请调用网关(本机默认 `http://127.0.0.1:8080`)的同路径;
网关本机调火山方舟豆包视觉模型,不转发到 worker。
{_image_fields_desc} 直接打 worker(如 `:8187`)会返回错误,避免误用假数据。
图片同时支持 `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`。
""", """,
responses={ responses={
200: { 200: {
"description": "成功", "description": "worker 不提供本接口",
"content": { "content": {
"application/json": { "application/json": {
"example": { "example": {
"code": 0, "code": 1007,
"message": "success", "message": "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
"request_id": "mock-request-id", "request_id": "mock-request-id",
"data": { "data": None,
"features": '{"face_shape":"鹅蛋脸","eyebrow_shape":"平眉","facial_age":"18-25岁","dynamic_static_type":"静态型","gender":"","gene_style":"少年型"}',
},
} }
} }
}, },
}, },
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
}
},
},
}, },
) )
async def face_features( async def face_features(
@@ -942,16 +1035,12 @@ async def face_features(
image_url: Optional[str] = Form(default=None, description="图片 URL"), image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
): ):
# ⚠️ 接口4 已迁到**网关本机**实现(直接调豆包视觉模型,见 gateway/app.py)。 # 接口4 只在网关实现(gateway/app.py → face_features.analyze_features)。
# 网关不会把本接口转发到 worker,故此处仅留 Mock 占位、保持 worker 无外网依赖 # 不再返回 Mock 成功数据,避免本机打 :8187 时被假结果误导
features = json.dumps( return err(
{ 1007,
"face_shape": "鹅蛋脸", "eyebrow_shape": "平眉", "facial_age": "18-25岁", "接口4 仅在网关实现,请访问网关(本机默认 :8080),worker 不提供本接口",
"dynamic_static_type": "静态型", "gender": "", "gene_style": "少年型",
},
ensure_ascii=False,
) )
return ok({"features": features})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -973,9 +1062,10 @@ async def face_features(
--- ---
**入参**(同接口2:先选性别,再多选发型): **入参**(同接口2:先选性别,再多选发型):
- 必填 `gender``male`/`female`),决定发型集合(female 5 / male 4)。 - 必填 `gender``male`/`female`),决定发型集合(female 7 / male 6)。
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。 - 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
`female`1=ellipse,2=flower,3=heart,4=straight,5=wave`male`1=ellipse,2=inverse_arc,3=m,4=straight。 `female`1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower
`male`1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。 - 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。 注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。 - 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
@@ -1055,16 +1145,16 @@ async def hairline_generate(
image_url: Optional[str] = Form(default=None, description="图片 URL"), image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), 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"), hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-6"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"), 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,仅返回三档发际线叠图与中心点"), generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
): ):
if gender not in ("male", "female"): if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female") return err(1004, "gender 必填且只能为 male / female")
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007 # hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
max_styles = {"female": 5, "male": 4}[gender] max_styles = {"female": 7, "male": 6}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles) hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None: if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}") return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
@@ -1451,7 +1541,7 @@ async def hairline_grow_v2(
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"), 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_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"), 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"), 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_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"), band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
@@ -1617,7 +1707,7 @@ async def hairline_grow_v2_final_v2(
async def api_redraw( async def api_redraw(
image_file: UploadFile = File(..., description="人物图片(JPG/PNG"), image_file: UploadFile = File(..., description="人物图片(JPG/PNG"),
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"), mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
prompt: str = Form(default="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", prompt: str = Form(default="填充遮罩区域的头发",
description="ComfyUI 提示词"), description="ComfyUI 提示词"),
): ):
image_bytes = await image_file.read() image_bytes = await image_file.read()
@@ -1662,6 +1752,84 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
return PlainTextResponse("".join(lines), media_type="text/plain; charset=utf-8") 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 健康检查 # 健康检查
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
img_data = f.read() img_data = f.read()
fields = {"gender": "female", "hair_style": str(hair_style), fields = {"gender": "female", "hair_style": str(hair_style),
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"} "prompt": "填充遮罩区域的头发"}
body, boundary = _multipart( body, boundary = _multipart(
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")}) 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 = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
+1 -1
View File
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
img_data = f.read() img_data = f.read()
fields = {"gender": "female", "hair_style": str(hair_style), fields = {"gender": "female", "hair_style": str(hair_style),
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"} "prompt": "填充遮罩区域的头发"}
body, boundary = _multipart( body, boundary = _multipart(
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")}) 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 = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
+2 -2
View File
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
img_data = f.read() img_data = f.read()
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0", 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")}) 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 = 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}") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
@@ -107,7 +107,7 @@ def call_api3(image_path):
"""接口3B端生发(use_mask=False,直接送图)""" """接口3B端生发(use_mask=False,直接送图)"""
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
img_data = f.read() 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")}) 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 = 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}") req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
+10 -6
View File
@@ -68,7 +68,7 @@
`face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182**
`add_hair.json`(Flux-2) 出图。**关键坑**obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。 `add_hair.json`(Flux-2) 出图。**关键坑**obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64` 返回 `results[].image_base64` + `grown_image_base64`
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wavemale 1=ellipse 2=inverse_arc 3=m 4=straight。 - `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflowermale 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`worker)—— 接口2同款,add_hair2 工作流 ### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。 - **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
@@ -85,8 +85,10 @@
### 接口4 用户特征 `/api/v1/face/features`**网关本机** ### 接口4 用户特征 `/api/v1/face/features`**网关本机**
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。 - **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
- **怎么实现**`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型** - **怎么实现**`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文 `doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`API Key 走网关配置(不入 git) **`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 ### 接口5 发际线PNG生成 `/api/v1/hairline/generate`worker
- **做什么**:入参同接口2`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。 - **做什么**:入参同接口2`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
@@ -108,10 +110,12 @@
- `worker_config.json`(不入 git)`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token` - `worker_config.json`(不入 git)`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`
**网关机** **网关机**
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。 - FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git)`workers` 列表、`shared_password` - ⚠️ 接口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**)。 `ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
- 托管 `/static/annotations/`(落盘的图)定期清理。 - 托管 `/static/annotations/`(落盘的图)永久保留,不自动清理。
--- ---
+6 -6
View File
@@ -265,7 +265,7 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 | | gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` | | hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) | | beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | | use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 | | prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
@@ -278,7 +278,7 @@
|------|------|------| |------|------|------|
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) | | image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
| grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片) | | grown_image_url | string | **生发后图片** URLComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male | | hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) | | order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。 > ⚠️ 生发图由本机 ComfyUIFlux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
@@ -352,7 +352,7 @@
## 接口 4:用户特征接口 ## 接口 4:用户特征接口
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。 **说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
**请求**`POST /api/v1/face/features` **请求**`POST /api/v1/face/features`
@@ -405,8 +405,8 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` | | gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。缺失/越界/非法返回 `1007` | | hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflowermale1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | | use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 | | prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 | | generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
@@ -427,7 +427,7 @@
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male | | hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`male |
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) | | image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) | | image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) | | image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
+6
View File
@@ -14,6 +14,7 @@ face_shape_classifier.py
from __future__ import annotations from __future__ import annotations
import math import math
import threading
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
@@ -343,6 +344,9 @@ def get_mixed_description(details: Dict) -> str:
_face_mesh = None _face_mesh = None
# mediapipe Solutions API 的单个 FaceMesh 实例不是线程安全的;被 web 服务用
# run_in_threadpool 并发调用时(如网关接口4、worker 调试接口)必须加锁串行化。
_face_mesh_lock = threading.Lock()
def _get_face_mesh(): def _get_face_mesh():
@@ -502,6 +506,7 @@ def annotate_face_features(
if landmarks is None: if landmarks is None:
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
with _face_mesh_lock:
results = _get_face_mesh().process(rgb) results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks: if not results.multi_face_landmarks:
raise ValueError("未检测到人脸关键点") raise ValueError("未检测到人脸关键点")
@@ -741,6 +746,7 @@ def classify_from_image(
bgr = _load_image(image) bgr = _load_image(image)
h, w = bgr.shape[:2] h, w = bgr.shape[:2]
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
with _face_mesh_lock:
results = _get_face_mesh().process(rgb) results = _get_face_mesh().process(rgb)
if not results.multi_face_landmarks: if not results.multi_face_landmarks:
+13 -1
View File
@@ -203,7 +203,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 --- # --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h) 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)) line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008)) dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线) gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
@@ -260,6 +260,18 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
font = _load_font(font_size) font = _load_font(font_size)
# --- 2b. 每条横线在「中间线段」(两内眼角之间)中点画原点突出 ---
# 注意:原点不在整条线的中点 face_cx,而在被竖线切出的中间段(左内眼角↔右内眼角)
# 的正中,即脸的竖直中轴附近、两内眼角连线中点。
li_x = pts["left_inner"][0]
ri_x = pts["right_inner"][0]
dot_cx = (li_x + ri_x) / 2
dot_r = max(2, round(s * 0.0045)) # 原点半径,与 arrow_size 同档自适应
for cy in ys:
x0, y0 = dot_cx - dot_r, cy - dot_r
x1, y1 = dot_cx + dot_r, cy + dot_r
draw.ellipse((x0, y0, x1, y1), fill=LINE_COLOR)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 --- # --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
name_x = fx1 + pad name_x = fx1 + pad
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移) name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
+1 -1
View File
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
"hairline_id": hairline_id, "hairline_id": hairline_id,
"blend_method": blend_method, "blend_method": blend_method,
"hairline_push_cm": round(float(hairline_push_cm), 2), "hairline_push_cm": round(float(hairline_push_cm), 2),
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜", "comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
"beauty_alpha": beauty_alpha, "beauty_alpha": beauty_alpha,
"px_per_cm": round(float(px_per_cm), 4), "px_per_cm": round(float(px_per_cm), 4),
"mask_pixels": mask_viz["mask_pixels"], "mask_pixels": mask_viz["mask_pixels"],
+3 -5
View File
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list, estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
) )
from face_analysis.face_mesh_landmarks import ( 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_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION, 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): def _brow_center(lm, w, h):
"""眉心 = 索引 9 / 151 中点""" """眉心 = 索引 9(眉间上点)"""
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h) return 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
def estimate_vertical_landmarks(landmarks, image_width, image_height): def estimate_vertical_landmarks(landmarks, image_width, image_height):
+64 -17
View File
@@ -1,10 +1,11 @@
"""接口4:用户面部特征分析(调用火山方舟 豆包视觉模型 doubao-seed-1-6-vision """接口4:用户面部特征分析。
算法来源/home/xsl/fuyanFaceArk.pyworker 把图片以 base64 data URI 传给方舟 - 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格火山方舟豆包视觉模型
多模态模型模型返回一大堆人脸特征 JSON本模块解析后映射出接口4 的英文优先字段 - face_shape脸型本机 MediaPipe 分类face/face_shape_classifier.py覆盖
face_shape 并保留 doubao 返回的全部中文字段 不用豆包结果
这是**唯一调外网云模型**的接口其余接口全本地API Key 走配置/环境变量不入 git 仍依赖外网豆包其余 5 API Key 走配置/环境变量不入 git
网关本机需可 import face opencv + mediapipe
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,11 +17,10 @@ import os
logger = logging.getLogger("hair.worker") logger = logging.getLogger("hair.worker")
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3") 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 = { _KEY_MAP = {
"脸型": "face_shape",
"眉形": "eyebrow_shape", "眉形": "eyebrow_shape",
"面部年龄": "facial_age", "面部年龄": "facial_age",
"动静类型": "dynamic_static_type", "动静类型": "dynamic_static_type",
@@ -28,11 +28,11 @@ _KEY_MAP = {
"基因风格": "gene_style", "基因风格": "gene_style",
} }
# 仅请求接口4 需要的 6 个字段(+「图片是否有人脸」用于 1001 判定,不进最终输出) # 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
_PROMPT = ( _PROMPT = (
"分析一下图片告诉我以下特征,只要答案,格式为json字符串," "分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
"图片是否有人脸(有人/没人) " "图片是否有人脸(有人/没人) "
"脸型(圆形脸/心形脸/菱形脸/鹅蛋脸/方形脸/长形脸/瓜子脸) 眉形 " "眉形 "
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) " "面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)" "基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
) )
@@ -42,12 +42,13 @@ _client_key: str | None = None # _client 构建时使用的 api_key,用
def _load_api_key() -> str | None: 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") key = os.getenv("ARK_API_KEY")
if key: if key:
return key return key
base = os.path.dirname(__file__) 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) cfg = os.path.join(base, cfg_name)
if os.path.isfile(cfg): if os.path.isfile(cfg):
try: 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() return f"data:image/{fmt};base64," + base64.b64encode(image_bytes).decode()
def analyze_features(image_bytes: bytes = None, image_url: str = None): def _resolve_image_bytes(image_bytes: bytes = None, image_url: str = None) -> bytes:
"""调 doubao 视觉模型分析人脸特征。 """本地分类器用:优先已有字节;仅有 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 dynamic_static_type/gender/gene_style)**无人脸返回 None**调用方据此判 1001
""" """
url = _image_to_url(image_bytes, image_url) 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 raise RuntimeError(f"豆包模型返回格式异常,无法解析为 JSON:{text[:200]}") from e
if not has_face(raw): if not has_face(raw):
return None return None
# 只保留 6 个英文字段(doubao 缺某字段则跳过) # 豆包 5 项 + 本地脸型覆盖
return {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw} 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: def has_face(features: dict) -> bool:
+11 -71
View File
@@ -4,13 +4,10 @@
不跑任何算法 torch/mediapipe/opencv 依赖 不跑任何算法 torch/mediapipe/opencv 依赖
""" """
import asyncio
import base64 import base64
import json import json
import logging import logging
import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -67,32 +64,15 @@ async def lifespan(app: FastAPI):
else: else:
app.state._pool_shutdown = None app.state._pool_shutdown = None
# 确保标注图目录存在 # 确保标注图目录存在(永久保留,不做定期清理)
static_dir = Path(cfg["static_dir"]) static_dir = Path(cfg["static_dir"])
static_dir.mkdir(parents=True, exist_ok=True) static_dir.mkdir(parents=True, exist_ok=True)
logger.info("标注图目录: %s", static_dir) 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
yield yield
# 关闭 # 关闭
logger.info("网关关闭中...") 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: if app.state._pool_shutdown:
await app.state._pool_shutdown() await app.state._pool_shutdown()
logger.info("网关已关闭") logger.info("网关已关闭")
@@ -132,51 +112,6 @@ def _get_pool_status_safe():
return {"total": 0, "healthy": 0, "busy": 0} 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) @app.get("/gateway-health", include_in_schema=False)
async def gateway_health(): async def gateway_health():
"""网关自身健康检查(区别于 worker 的 /health)。""" """网关自身健康检查(区别于 worker 的 /health)。"""
@@ -539,10 +474,15 @@ async def hair_grow(request: Request):
return await _proxy(request, "/api/v1/hair/grow") 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): async def hair_grow_b(request: Request):
"""接口3B端生发""" """接口3B端生发(已屏蔽)"""
return await _proxy(request, "/api/v1/hair/grow-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=["人脸分析"]) @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_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
): ):
"""接口4:用户特征分析 — 本机直接调豆包视觉模型,不经过 worker。""" """接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/,不经过 worker。"""
import uuid as _uuid import uuid as _uuid
# 三选一校验 # 三选一校验
-4
View File
@@ -22,9 +22,5 @@
"request_timeout_seconds": 600, "request_timeout_seconds": 600,
"retry_on_failure": true, "retry_on_failure": true,
"max_retries": 1 "max_retries": 1
},
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24
} }
} }
-4
View File
@@ -31,10 +31,6 @@ DEFAULTS = {
"retry_on_failure": True, "retry_on_failure": True,
"max_retries": 1, "max_retries": 1,
}, },
"cleanup": {
"interval_minutes": 60,
"max_age_hours": 24,
},
"request_log": { "request_log": {
"enabled": True, "enabled": True,
"log_file": "gateway/request_log.jsonl", "log_file": "gateway/request_log.jsonl",
+3 -3
View File
@@ -2,8 +2,8 @@
设计原则与用户约定 设计原则与用户约定
- 入参/出参日志里**绝不内嵌图片 base64**图片统一存盘后用 URL 引用保持日志短小 - 入参/出参日志里**绝不内嵌图片 base64**图片统一存盘后用 URL 引用保持日志短小
- 入参图片image_file / *_base64当前网关不存盘这里补存到 static_dirin_ 前缀 - 入参图片image_file / *_base64补存到 static_dirin_ 前缀永久保留
复用现有 24h 清理循环自动回收url 输入图本身就在远端不重新下载直接记 URL url 输入图本身就在远端不重新下载直接记 URL
- 出参响应在 forward.py 已把 base64 改写成 *_url无大图记录完整 data - 出参响应在 forward.py 已把 base64 改写成 *_url无大图记录完整 data
但用递归摘要器截断超长结构landmarks长字符串单条上限 ~8KB 但用递归摘要器截断超长结构landmarks长字符串单条上限 ~8KB
@@ -39,7 +39,7 @@ def save_image_bytes(
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。 """把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
存到 static_dir/{prefix}{uuid}.{ext}URL = {public_base_url}/static/annotations/{file} 存到 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: if not data:
return None return None
+1 -1
View File
@@ -410,7 +410,7 @@
}, },
"60": { "60": {
"inputs": { "inputs": {
"text": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" "text": "填充遮罩区域的头发"
}, },
"class_type": "JjkText", "class_type": "JjkText",
"_meta": { "_meta": {
+2 -2
View File
@@ -16,7 +16,7 @@ from . import comfyui
logger = logging.getLogger("hair.worker") logger = logging.getLogger("hair.worker")
_DEFAULT_PROMPT = "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" _DEFAULT_PROMPT = "填充遮罩区域的头发"
_REPO = os.path.dirname(os.path.dirname(__file__)) _REPO = os.path.dirname(os.path.dirname(__file__))
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json") _REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
Args: Args:
image_bytes: 人物图片字节JPG/PNG image_bytes: 人物图片字节JPG/PNG
mask_bytes: 遮罩图片字节支持红//alpha 遮罩格式 mask_bytes: 遮罩图片字节支持红//alpha 遮罩格式
prompt: 提示词None 用默认 "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜" prompt: 提示词None 用默认 "填充遮罩区域的头发"
timeout: ComfyUI 超时秒数 timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前接口2 时延敏感路径用 front: True 时任务插到 ComfyUI 队列最前接口2 时延敏感路径用
+62 -10
View File
@@ -27,7 +27,8 @@ import logging
logger = logging.getLogger("hair.worker") logger = logging.getLogger("hair.worker")
# 接口2 女性发型 key → change_hair hair_idchang_*)映射:换发型+Flux-2 整帧重绘用。 # 接口2 女性发型 key → change_hair hair_idchang_*)映射:换发型+Flux-2 整帧重绘用。
# 与接口12 final 的 5 型一一对应。 # 与接口12 final 的 5 型一一对应。female 6/7bigflower/clasicalflower)无对应 LoRA
# 走与男性一致的原生生发(ComfyUI add_hair)管线,故不在本表。
_FEMALE_KEY_TO_CHANG = { _FEMALE_KEY_TO_CHANG = {
"ellipse": "chang_tuoyuan", # 椭圆 "ellipse": "chang_tuoyuan", # 椭圆
"flower": "chang_huaban", # 花瓣 "flower": "chang_huaban", # 花瓣
@@ -36,6 +37,16 @@ _FEMALE_KEY_TO_CHANG = {
"wave": "chang_bolang", # 波浪 "wave": "chang_bolang", # 波浪
} }
# 发际线贴图显式顺序表:决定 hair_style 序号(1-indexed)。
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位,破坏现有前端/客户端取值。
# key 须与 _gender_key 派生结果一致(已去空格):如 "inverse_arc"(源 man_ inverse_arc.png)、
# "Softpetal"(源 man_Soft petal.png,大写 S 保留)。表外未知 key 兜底排到末尾。
_HAIRSTYLE_ORDER = {
"female": ["ellipse", "flower", "heart", "straight", "wave",
"bigflower", "clasicalflower"], # 1..7
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
}
_REPO = os.path.dirname(os.path.dirname(__file__)) _REPO = os.path.dirname(os.path.dirname(__file__))
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture") _TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black") _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
@@ -89,10 +100,13 @@ def _gender_key(stem: str):
def get_texture_map(level: str = "middle") -> dict: def get_texture_map(level: str = "middle") -> dict:
"""扫描指定档位贴图目录建 {gender: [(key, path)]},按 key 排序、按档位缓存。 """扫描指定档位贴图目录建 {gender: [(key, path)]},按显式顺序表排序、按档位缓存。
levelmiddle(默认) / high / low分别对应 hairline_texture[/_high|/_low] levelmiddle(默认) / high / low分别对应 hairline_texture[/_high|/_low]
文件名规范化去空格 `man_ inverse_arc.png` key `inverse_arc` 文件名规范化去空格 `man_ inverse_arc.png` key `inverse_arc`
排序依据 _HAIRSTYLE_ORDER表内 key 按表序表外未知 key 兜底排到末尾再按字母序
保证新增/重命名文件不会打乱现有 hair_style 序号
""" """
if level not in _TEXTURE_DIRS: if level not in _TEXTURE_DIRS:
raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}") raise ValueError(f"hairline_level 必须是 middle/high/low,收到 {level!r}")
@@ -106,7 +120,9 @@ def get_texture_map(level: str = "middle") -> dict:
if gender: if gender:
mapping[gender].append((key, path)) mapping[gender].append((key, path))
for g in mapping: for g in mapping:
mapping[g].sort(key=lambda kp: kp[0]) order = _HAIRSTYLE_ORDER.get(g, [])
idx = {k: i for i, k in enumerate(order)}
mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
_texture_maps[level] = mapping _texture_maps[level] = mapping
return mapping return mapping
@@ -163,7 +179,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
workflow_path: str | None = None): workflow_path: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。 """指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..5male: 1..4 hair_styles1-indexed 列表指定生成哪几张发际线按贴图排序female: 1..7male: 1..6
None 时生成全部兼容旧调用 None 时生成全部兼容旧调用
use_mask默认 True是否启用 inpaint 遮罩用于测试对比同接口3 use_mask默认 True是否启用 inpaint 遮罩用于测试对比同接口3
False 时用**干净原图 + 空遮罩** ComfyUI不烧黑色模板线 False 时用**干净原图 + 空遮罩** ComfyUI不烧黑色模板线
@@ -227,15 +243,22 @@ 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, def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
redraw_defaults: dict): redraw_defaults: dict,
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 换发型重绘图。 redraw_max_side: int | None = None,
unet_name: str | None = None,
prompt: str | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results+ 生发图。
grown 图来源新流程对每个选中发型把 female key 映射到 change_hair chang_* hair_id 生发图来源按发型分两路
face_analysis.hairline_grow.generate_hairline_redraw= 接口12 final 管线参数用 - 换发型chang_* 表内1..5female key change_hair chang_* hair_id
face_analysis.hairline_grow.generate_hairline_redraw= 接口12 final 管线参数用
redraw_defaults拿到 final接缝融合基底+ - 纯红遮罩 PNG**后端直接调 redraw_defaults拿到 final接缝融合基底+ - 纯红遮罩 PNG**后端直接调
ComfyUI**0716add-hair-api.json 工作流完成发际线带重绘重绘结果作为生发图 ComfyUI**0716add-hair-api.json 工作流完成发际线带重绘
- 原生生发表外6/7 bigflower/clasicalflower 无对应 change_hair LoRA改走与男性
一致的原生生发ComfyUI add_hair _grow_native_one 完成黑模板 + inpaint 遮罩
overlay 仍是发际线曲线透明层 generate_grow_results 完全一致 overlay 仍是发际线曲线透明层 generate_grow_results 完全一致
prompt 仅用于原生生发分支换发型分支的提示词由 redraw 流程内部固定
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes None)} Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes None)}
无人脸返回 None单个发型换发型/重绘失败时 grown_png=None不抛异常 无人脸返回 None单个发型换发型/重绘失败时 grown_png=None不抛异常
""" """
@@ -265,7 +288,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
grown_png = None grown_png = None
chang_id = _FEMALE_KEY_TO_CHANG.get(key) chang_id = _FEMALE_KEY_TO_CHANG.get(key)
if chang_id is None: if chang_id is None:
logger.warning("接口2 换发型:female key=%s 无对应 chang_id,跳过生发图", key) # 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
grown_png = _grow_native_one(image_bgr, ctx, white_path,
prompt=prompt, unet_name=unet_name)
else: else:
try: try:
data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults) data = generate_hairline_redraw(image_bgr, chang_id, hair_mask=hair_mask_reuse, **redraw_defaults)
@@ -321,6 +347,32 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
return None return None
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
prompt: str | None = None, unet_name: str | None = None):
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
接口2 女性新发型bigflower/clasicalflower 无对应 change_hair LoRA改走此路径
黑模板 build_inpaint_mask 限边_prep_comfy_input comfyui.run(front=True)
失败返回 None不抛异常结果按限边前原图尺寸放大回原尺寸仅展示对齐
"""
try:
h, w = image_bgr.shape[:2]
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
buf = io.BytesIO()
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, front=True, unet_name=unet_name)
if gsc < 1.0 and grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
return grown_png
except Exception as e: # noqa: BLE001 单张失败不拖垮整请求
logger.warning("接口2 女原生生发图失败:%s", e)
return None
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True, hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None, prompt: str | None = None,
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+1 -1
View File
@@ -206,7 +206,7 @@ def generate():
return jsonify({"error": msg}), 400 return jsonify({"error": msg}), 400
image_file = request.files["image"] image_file = request.files["image"]
mask_file = request.files["mask"] mask_file = request.files["mask"]
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜") prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
log.info( log.info(
"收到请求: image=%s mask=%s prompt=%r", "收到请求: image=%s mask=%s prompt=%r",
image_file.filename, mask_file.filename, prompt_text, image_file.filename, mask_file.filename, prompt_text,
+1 -1
View File
@@ -27,7 +27,7 @@ def prep_and_upload():
def run_once(fname, model, dtype, steps): 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"]["unet_name"] = model
wf["16"]["inputs"]["weight_dtype"] = dtype wf["16"]["inputs"]["weight_dtype"] = dtype
wf["1"]["inputs"]["steps"] = steps wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -33,7 +33,7 @@ def upload(scale=1.0):
def run(fname, steps): def run(fname, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜") wf = A.build_workflow(fname, "填充遮罩区域的头发")
wf["16"]["inputs"]["unet_name"] = MODEL wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -30,7 +30,7 @@ SEED = 123456789
imgs = [] imgs = []
labels = [] labels = []
for steps in [2, 3, 4, 6]: 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"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
</div> </div>
<div class="controls" style="margin-top:16px"> <div class="controls" style="margin-top:16px">
<label>提示词:</label> <label>提示词:</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"> <input type="text" id="promptInput" value="填充遮罩区域的头发">
</div> </div>
<div style="text-align:center; margin-top:16px"> <div style="text-align:center; margin-top:16px">
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button> <button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
+1 -1
View File
@@ -25,7 +25,7 @@ resp = requests.post(
"image": ("original.jpg", img_data, "image/jpeg"), "image": ("original.jpg", img_data, "image/jpeg"),
"mask": ("mask.png", mask_data, "image/png"), "mask": ("mask.png", mask_data, "image/png"),
}, },
data={"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}, data={"prompt": "填充遮罩区域的头发"},
timeout=600, timeout=600,
) )
+4 -1
View File
@@ -26,8 +26,11 @@ transformers==4.45.2 # SegFormer 人脸分割(jonathandinu/face-parsing
# ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2 # ⚠️ 必须 0.24.x —— 0.25+ 强依赖 numpy>=2,会顶掉 mediapipe 需要的 numpy<2
scikit-image==0.24.0 # route_through_array(黑帽响应图上的 Dijkstra 最小路径) 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 # 网关机装: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 pytest==8.3.3
+267
View File
@@ -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>
&nbsp;|&nbsp; 本地 <code>face/face_shape_classifier.py</code>7 类)
&nbsp;|&nbsp; 非接口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 正面照 &nbsp;|&nbsp; 走本机 workerMediaPipe),约 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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function copyJson() {
const t = $('jsonContent').textContent;
if (!t) return;
navigator.clipboard.writeText(t).then(function () {
setStatus('已复制 JSON', 'success');
});
}
</script>
</body>
</html>
+2 -2
View File
@@ -69,7 +69,7 @@
</div> </div>
<div class="upload-row" style="margin-top:10px"> <div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label> <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>
<div class="upload-row" style="margin-top:10px"> <div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label> <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() { async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; } if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜'; const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const btn = $('redrawBtn'); const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...'; btn.disabled = true; btn.textContent = '⏳ 重绘中...';
flog('===== 调后端重绘 =====', 'info'); flog('===== 调后端重绘 =====', 'info');
+2 -2
View File
@@ -56,7 +56,7 @@
</div> </div>
<div class="upload-row" style="margin-top:10px"> <div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label> <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>
<div class="params"> <div class="params">
<div class="pf"> <div class="pf">
@@ -169,7 +169,7 @@ async function submitTest() {
async function runLocalRedraw() { async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; } if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜'; const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const btn = $('redrawBtn'); const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...'; btn.disabled = true; btn.textContent = '⏳ 重绘中...';
+5 -1
View File
@@ -107,7 +107,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div> <div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px"> <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> <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>
<div id="statusBar" class="status hidden"></div> <div id="statusBar" class="status hidden"></div>
</div> </div>
@@ -237,12 +237,16 @@ const HAIR_STYLES = {
{ value:'3', label:'heart 心形' }, { value:'3', label:'heart 心形' },
{ value:'4', label:'straight 直线形' }, { value:'4', label:'straight 直线形' },
{ value:'5', label:'wave 波浪形' }, { value:'5', label:'wave 波浪形' },
{ value:'6', label:'bigflower 大花瓣型' },
{ value:'7', label:'clasicalflower 古典花瓣型' },
], ],
male: [ male: [
{ value:'1', label:'ellipse 椭圆形' }, { value:'1', label:'ellipse 椭圆形' },
{ value:'2', label:'inverse_arc 倒弧形' }, { value:'2', label:'inverse_arc 倒弧形' },
{ value:'3', label:'m M形' }, { value:'3', label:'m M形' },
{ value:'4', label:'straight 直线形' }, { value:'4', label:'straight 直线形' },
{ value:'5', label:'heart 桃心形' },
{ value:'6', label:'Softpetal 柔和花瓣形' },
] ]
}; };
+1 -1
View File
@@ -94,7 +94,7 @@
</div> </div>
<div class="upload-group" style="margin-top:14px"> <div class="upload-group" style="margin-top:14px">
<div class="label">💬 提示词(prompt</div> <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>
<div style="margin-top:14px;display:flex;gap:12px;align-items:center"> <div style="margin-top:14px;display:flex;gap:12px;align-items:center">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button> <button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
+5 -1
View File
@@ -101,7 +101,7 @@
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div> <div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group"> <div class="form-group">
<label>性别</label> <label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female5种)</option><option value="male">👨 Male4种)</option></select> <select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female7种)</option><option value="male">👨 Male6种)</option></select>
</div> </div>
<div class="form-group" style="align-items:flex-start"> <div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型 *</label> <label style="padding-top:3px">发型 *</label>
@@ -174,12 +174,16 @@ const HAIR_STYLES = {
{ value:'3', label:'3. heart 心形' }, { value:'3', label:'3. heart 心形' },
{ value:'4', label:'4. straight 直线' }, { value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. wave 波浪' }, { value:'5', label:'5. wave 波浪' },
{ value:'6', label:'6. bigflower 大花瓣型' },
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
], ],
male: [ male: [
{ value:'1', label:'1. ellipse 椭圆' }, { value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. inverse_arc 倒弧' }, { value:'2', label:'2. inverse_arc 倒弧' },
{ value:'3', label:'3. m M形' }, { value:'3', label:'3. m M形' },
{ value:'4', label:'4. straight 直线' }, { value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. heart 桃心形' },
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
] ]
}; };
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div> <div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px"> <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> <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>
<div id="statusBar" class="status hidden"></div> <div id="statusBar" class="status hidden"></div>
</div> </div>