接口9:头发遮罩生成 + 分步可视化
新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html:
- MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251,
左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。
- 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线,
得到含额头的闭合区域(不从发际线割断)。
- 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。
- 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增,
不改动既有接口。
顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填,
补上 hair_style=1,2,3,4,5。全套 42 passed。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1061,6 +1061,91 @@ async def hairline_generate(
|
|||||||
return err(1007, f"处理失败:{ex}")
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接口 9:头发遮罩生成
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/api/v1/head/mask",
|
||||||
|
summary="接口9 头发遮罩生成",
|
||||||
|
tags=["人脸分析"],
|
||||||
|
description=f"""
|
||||||
|
输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试):
|
||||||
|
|
||||||
|
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,
|
||||||
|
21 水平延伸到最左边、251 延伸到最右边)。
|
||||||
|
2. 分割线以上为「上半区」。
|
||||||
|
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,
|
||||||
|
得到**含额头**的闭合区域(头发+额头,不割断)。
|
||||||
|
4. 外缘朝中心点 151 内缩 **erode_cm(默认 1.2cm,可调)**(虹膜标定换算像素)、底线不动 → 最终遮罩。
|
||||||
|
|
||||||
|
{_image_fields_desc}
|
||||||
|
|
||||||
|
返回 `data` 内含 `steps_common`(关键点/分割线、上半区)与 `bisenet` / `segformer`
|
||||||
|
两组结果(各含 `hair_mask` / `closed_region` / `final_overlay` / `mask`)。经网关时所有
|
||||||
|
`*_base64` 图片字段会被落盘改写为 `*_url`。
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "成功",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "mock-request-id",
|
||||||
|
"data": {
|
||||||
|
"px_per_cm": 48.12,
|
||||||
|
"erode_cm": 1.2,
|
||||||
|
"erode_px": 58,
|
||||||
|
"image_size": {"width": 1080, "height": 1440},
|
||||||
|
"steps_common": {"landmarks_baseline_url": SAMPLE_IMAGE_URL,
|
||||||
|
"upper_region_url": SAMPLE_IMAGE_URL},
|
||||||
|
"bisenet": {"hair_pixels": 123456, "closed_pixels": 110000, "mask_pixels": 98765,
|
||||||
|
"hair_mask_url": SAMPLE_IMAGE_URL,
|
||||||
|
"closed_region_url": SAMPLE_IMAGE_URL,
|
||||||
|
"final_overlay_url": SAMPLE_IMAGE_URL,
|
||||||
|
"mask_url": SAMPLE_IMAGE_URL},
|
||||||
|
"segformer": {"hair_pixels": 130000, "closed_pixels": 112000, "mask_pixels": 99000,
|
||||||
|
"hair_mask_url": SAMPLE_IMAGE_URL,
|
||||||
|
"closed_region_url": SAMPLE_IMAGE_URL,
|
||||||
|
"final_overlay_url": SAMPLE_IMAGE_URL,
|
||||||
|
"mask_url": SAMPLE_IMAGE_URL},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def head_mask(
|
||||||
|
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(需带 data:image/...;base64, 前缀)"),
|
||||||
|
erode_cm: float = Form(default=1.2, description="外缘朝中心151内缩的距离(厘米),默认 1.2"),
|
||||||
|
):
|
||||||
|
"""接口9:头发遮罩生成 + 分步可视化"""
|
||||||
|
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:
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from face_analysis.head_mask import generate_head_mask, NoFaceError
|
||||||
|
try:
|
||||||
|
data = await run_in_threadpool(generate_head_mask, image, erode_cm)
|
||||||
|
except NoFaceError:
|
||||||
|
return err(1001, "无法识别人像")
|
||||||
|
return ok(data)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
logger.exception("接口9 处理异常")
|
||||||
|
return err(1007, f"处理失败:{ex}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 健康检查
|
# 健康检查
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""接口9:头发遮罩生成。
|
||||||
|
|
||||||
|
流程(详见需求讨论):
|
||||||
|
1. MediaPipe 关键点检测。
|
||||||
|
2. 底部分割线 = 关键点 [21,68,104,69,108,151,337,299,333,298,251] 的连线(左端21→中心151→右端251),
|
||||||
|
再把左端点 21 水平延伸到图片最左边、右端点 251 水平延伸到图片最右边。
|
||||||
|
3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。
|
||||||
|
4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。
|
||||||
|
5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤
|
||||||
|
也包进来(不再从发际线割断),底边即分割线。
|
||||||
|
6. 外缘内缩 erode_cm(默认 1.2cm,可调)、底线不动:对「填充到图底的实心块」做半径 r 的腐蚀,
|
||||||
|
再与上半区相交。腐蚀只把外轮廓(顶/两侧)朝内(朝 151)收 r;平底边是相交后才产生的,
|
||||||
|
所以底线纹丝不动。cm→像素用虹膜标定(calibration.estimate_scale_factor)。
|
||||||
|
|
||||||
|
对外返回每一步叠加在原图上的可视化图(base64 PNG,data URI),供测试页逐步展示。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from face_analysis.detector import detector
|
||||||
|
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
|
||||||
|
|
||||||
|
# 底部额头弧线关键点(图像上从左到右:左端 21 → 中心 151 → 右端 251)
|
||||||
|
BASELINE_IDX = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251]
|
||||||
|
CENTER_IDX = 151 # 内缩方向的目标点(额头中心)
|
||||||
|
ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖)
|
||||||
|
SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引
|
||||||
|
|
||||||
|
|
||||||
|
class NoFaceError(Exception):
|
||||||
|
"""未检测到人脸。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 几何:分割线与上半区
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _px(landmarks, idx, w, h):
|
||||||
|
p = landmarks.landmark[idx]
|
||||||
|
return (int(round(p.x * w)), int(round(p.y * h)))
|
||||||
|
|
||||||
|
|
||||||
|
def _baseline_points(landmarks, w, h):
|
||||||
|
"""额头弧线各关键点的像素坐标(按 BASELINE_IDX 顺序,左→右)。"""
|
||||||
|
return [_px(landmarks, i, w, h) for i in BASELINE_IDX]
|
||||||
|
|
||||||
|
|
||||||
|
def _upper_region_mask(baseline_pts, w, h):
|
||||||
|
"""分割线以上区域(bool,H×W)。
|
||||||
|
|
||||||
|
多边形顶点:左上角 →(0, y54)→ 弧线各点 →(w-1, y284)→ 右上角,闭合后填充。
|
||||||
|
其中 54→左边缘、284→右边缘为两段水平延长线。
|
||||||
|
"""
|
||||||
|
x54, y54 = baseline_pts[0]
|
||||||
|
x284, y284 = baseline_pts[-1]
|
||||||
|
poly = [(0, 0), (0, y54)] + baseline_pts + [(w - 1, y284), (w - 1, 0)]
|
||||||
|
mask = np.zeros((h, w), np.uint8)
|
||||||
|
cv2.fillPoly(mask, [np.array(poly, np.int32)], 1)
|
||||||
|
return mask.astype(bool)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 头发分割(两套)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _bisenet_hair_mask(image_bgr, landmarks, w, h):
|
||||||
|
"""BiSeNet(接口1 同款):先按人脸框裁剪再分割,稳住小脸大图。"""
|
||||||
|
from face_analysis.hair_segmenter import get_segmenter
|
||||||
|
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
|
||||||
|
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
|
||||||
|
max(p[0] for p in pxs), max(p[1] for p in pxs))
|
||||||
|
hair_mask, _ear = get_segmenter().segment_hair_and_ears(image_bgr, face_box=face_box)
|
||||||
|
return np.asarray(hair_mask, dtype=bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _segformer_hair_mask(image_bgr):
|
||||||
|
"""复用接口2/3 的 SegFormer 单例(hairline.service.get_parser),
|
||||||
|
共用权重与设备策略(SEG_DEVICE,默认 cpu;本机 5090 上 CUDA 内核不可用故走 CPU)。"""
|
||||||
|
from hairline.service import get_parser
|
||||||
|
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
|
labels = get_parser().parse(rgb)
|
||||||
|
return labels == SEGFORMER_HAIR
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 形态学 & 可视化
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _erode(mask_bool, r):
|
||||||
|
"""圆盘核腐蚀半径 r(像素)。r<=0 原样返回。"""
|
||||||
|
if r <= 0:
|
||||||
|
return mask_bool.copy()
|
||||||
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
|
||||||
|
return cv2.erode(mask_bool.astype(np.uint8), k).astype(bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay(image, mask_bool, color, alpha=0.45):
|
||||||
|
"""把纯色以 alpha 叠加到 mask 区域上(非 mask 区域保持原样)。"""
|
||||||
|
out = image.copy()
|
||||||
|
if mask_bool.any():
|
||||||
|
out[mask_bool] = (out[mask_bool] * (1 - alpha)
|
||||||
|
+ np.array(color, np.float32) * alpha).astype(np.uint8)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_baseline(image, baseline_pts, w):
|
||||||
|
"""画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。"""
|
||||||
|
out = image.copy()
|
||||||
|
y54 = baseline_pts[0][1]
|
||||||
|
y284 = baseline_pts[-1][1]
|
||||||
|
chain = [(0, y54)] + baseline_pts + [(w - 1, y284)]
|
||||||
|
for a, b in zip(chain[:-1], chain[1:]):
|
||||||
|
cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA)
|
||||||
|
for idx, p in zip(BASELINE_IDX, baseline_pts):
|
||||||
|
col = (0, 0, 255) if idx == CENTER_IDX else (0, 200, 0)
|
||||||
|
cv2.circle(out, p, 4, col, -1, cv2.LINE_AA)
|
||||||
|
cv2.putText(out, str(idx), (p[0] + 4, p[1] - 6),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.5, col, 1, cv2.LINE_AA)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _b64png(bgr):
|
||||||
|
"""BGR 图 → data URI(PNG base64)。gateway 会把 *_base64 字段落盘改成 *_url。"""
|
||||||
|
ok, buf = cv2.imencode(".png", bgr)
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_png(mask_bool):
|
||||||
|
"""纯遮罩:白(255)为遮罩、黑为背景。"""
|
||||||
|
m = (mask_bool.astype(np.uint8)) * 255
|
||||||
|
return _b64png(cv2.cvtColor(m, cv2.COLOR_GRAY2BGR))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 主入口
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _largest_cc(mask_bool):
|
||||||
|
"""保留最大连通域,去掉背景里孤立的杂散头发列。空掩膜原样返回。"""
|
||||||
|
m = mask_bool.astype(np.uint8)
|
||||||
|
if m.sum() == 0:
|
||||||
|
return mask_bool
|
||||||
|
n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
|
||||||
|
if n <= 2: # 只有背景 + 一个前景
|
||||||
|
return mask_bool
|
||||||
|
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
||||||
|
return labels == largest
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_to_baseline(hair_mask, upper):
|
||||||
|
"""含额头的实心区域:每列从最顶端头发像素向下填充(延伸到图底,未按基线裁剪)。
|
||||||
|
|
||||||
|
这样头发与画线之间的额头皮肤被包进闭合区域(不再被割断);未裁剪到基线是为了
|
||||||
|
后续腐蚀时底线不动(腐蚀在延伸到图底的实心块上做,再与上半区相交切平底边)。
|
||||||
|
"""
|
||||||
|
has_hair = (hair_mask & upper).astype(np.uint8)
|
||||||
|
return np.maximum.accumulate(has_hair, axis=0).astype(bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w):
|
||||||
|
"""单个分割模型的分步结果(闭合区域 / 最终遮罩 / 可视化)。"""
|
||||||
|
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
|
||||||
|
closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线
|
||||||
|
final = _largest_cc(_erode(top_fill, r) & upper) # 外缘朝151内缩 r、底线不动
|
||||||
|
return {
|
||||||
|
"hair_pixels": int(hair_mask.sum()),
|
||||||
|
"closed_pixels": int(closed.sum()),
|
||||||
|
"mask_pixels": int(final.sum()),
|
||||||
|
"hair_mask_base64": _b64png(_overlay(image_bgr, hair_mask, (255, 150, 0))),
|
||||||
|
"closed_region_base64": _b64png(
|
||||||
|
_draw_baseline(_overlay(image_bgr, closed, (255, 150, 0)), baseline_pts, w)),
|
||||||
|
"final_overlay_base64": _b64png(
|
||||||
|
_draw_baseline(_overlay(image_bgr, final, (0, 0, 255)), baseline_pts, w)),
|
||||||
|
"mask_base64": _mask_png(final),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_head_mask(image_bgr, erode_cm=ERODE_CM):
|
||||||
|
"""接口9 完整管线。返回可直接进 ok() 的 data dict。
|
||||||
|
|
||||||
|
erode_cm:外缘朝 151 内缩的距离(厘米),页面可调,默认 1cm。
|
||||||
|
未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。
|
||||||
|
"""
|
||||||
|
h, w = image_bgr.shape[:2]
|
||||||
|
landmarks = detector.detect(image_bgr)
|
||||||
|
if landmarks is None:
|
||||||
|
raise NoFaceError()
|
||||||
|
|
||||||
|
erode_cm = max(0.0, float(erode_cm))
|
||||||
|
px_per_cm = estimate_scale_factor(landmarks, w, h)
|
||||||
|
r = int(round(erode_cm * px_per_cm))
|
||||||
|
baseline_pts = _baseline_points(landmarks, w, h)
|
||||||
|
upper = _upper_region_mask(baseline_pts, w, h)
|
||||||
|
|
||||||
|
baseline_viz = _draw_baseline(image_bgr, baseline_pts, w)
|
||||||
|
data = {
|
||||||
|
"px_per_cm": round(float(px_per_cm), 4),
|
||||||
|
"erode_cm": round(erode_cm, 2),
|
||||||
|
"erode_px": r,
|
||||||
|
"image_size": {"width": w, "height": h},
|
||||||
|
"baseline_landmarks": [
|
||||||
|
{"index": idx, "x": p[0], "y": p[1]}
|
||||||
|
for idx, p in zip(BASELINE_IDX, baseline_pts)
|
||||||
|
],
|
||||||
|
"steps_common": {
|
||||||
|
"landmarks_baseline_base64": _b64png(baseline_viz),
|
||||||
|
"upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
seg_fns = {
|
||||||
|
"bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h),
|
||||||
|
"segformer": lambda: _segformer_hair_mask(image_bgr),
|
||||||
|
}
|
||||||
|
for name, fn in seg_fns.items():
|
||||||
|
try:
|
||||||
|
hair_mask = fn()
|
||||||
|
data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w)
|
||||||
|
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
|
||||||
|
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
return data
|
||||||
@@ -210,6 +210,7 @@ async def index():
|
|||||||
"if5_hairline": "/static/test_interface5.html",
|
"if5_hairline": "/static/test_interface5.html",
|
||||||
"if6_measure_v2": "/static/test_interface6.html",
|
"if6_measure_v2": "/static/test_interface6.html",
|
||||||
"if7_hair_grow_v2": "/static/test_interface7.html",
|
"if7_hair_grow_v2": "/static/test_interface7.html",
|
||||||
|
"if9_head_mask": "/static/test_interface9.html",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,3 +594,9 @@ async def hairline_generate(request: Request):
|
|||||||
async def hair_grow_v2(request: Request):
|
async def hair_grow_v2(request: Request):
|
||||||
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
||||||
return await _proxy(request, "/api/v1/hair/grow-v2")
|
return await _proxy(request, "/api/v1/hair/grow-v2")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/head/mask", tags=["人脸分析"])
|
||||||
|
async def head_mask(request: Request):
|
||||||
|
"""接口9:头发遮罩生成 + 分步可视化"""
|
||||||
|
return await _proxy(request, "/api/v1/head/mask")
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>接口9 — 头发遮罩生成 测试页</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: 8px; }
|
||||||
|
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
|
||||||
|
h2 { font-size: 16px; margin: 24px 0 12px; }
|
||||||
|
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||||
|
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||||
|
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
|
||||||
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
|
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.metrics { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.metric { background: #f9fafb; border: 1px solid #eee; border-radius: 8px; padding: 10px 16px; min-width: 120px; }
|
||||||
|
.metric .label { font-size: 12px; color: #888; }
|
||||||
|
.metric .value { font-size: 18px; font-weight: 700; color: #111; }
|
||||||
|
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
|
||||||
|
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||||
|
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.step .cap small { color: #999; font-weight: 400; }
|
||||||
|
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||||
|
|
||||||
|
.cols { display: flex; gap: 20px; }
|
||||||
|
.cols > div { flex: 1; min-width: 0; }
|
||||||
|
.col-title { font-size: 15px; font-weight: 700; padding: 8px 12px; border-radius: 8px; margin-bottom: 12px; color: #fff; }
|
||||||
|
.col-title.bisenet { background: #7c3aed; }
|
||||||
|
.col-title.segformer { background: #0891b2; }
|
||||||
|
.model-err { background: #fee2e2; color: #991b1b; padding: 12px; border-radius: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
.json-panel { max-height: 360px; overflow: auto; background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font: 12px/1.5 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* 灯箱 */
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||||
|
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>接口9 — 头发遮罩生成</h1>
|
||||||
|
<div class="subtitle">MediaPipe 关键点 → 额头分割线 → 上半区 → 头发分割(BiSeNet / SegFormer) → 闭合区域(含额头) → 外缘内缩(可调) → 遮罩。每一步可视化,两套分割并排对比。</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="upload-row">
|
||||||
|
<input type="file" id="imageFile" accept="image/*">
|
||||||
|
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
|
||||||
|
<button class="btn btn-outline" onclick="clearResults()">清空</button>
|
||||||
|
</div>
|
||||||
|
<div class="upload-row" style="margin-top:14px">
|
||||||
|
<label style="font-size:14px; font-weight:600; white-space:nowrap">外缘内缩 (cm)</label>
|
||||||
|
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2"
|
||||||
|
style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px">
|
||||||
|
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="1.2" style="flex:1; min-width:140px">
|
||||||
|
</div>
|
||||||
|
<div class="hint">默认 1.2cm。数值越大,遮罩外缘朝中心点 151 收得越多(底线不动)。改动后点「提交测试」重新计算;设置会自动记住。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status hidden" id="statusBar"></div>
|
||||||
|
|
||||||
|
<div id="resultsArea" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">📏 标定信息</h2>
|
||||||
|
<div class="metrics" id="metricsBar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">① 公共步骤(与分割模型无关)</h2>
|
||||||
|
<div class="steps" id="commonSteps"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0">② 两套分割对比</h2>
|
||||||
|
<div class="cols">
|
||||||
|
<div>
|
||||||
|
<div class="col-title bisenet">BiSeNet(接口1同款)</div>
|
||||||
|
<div id="bisenetPanel"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="col-title segformer">SegFormer(接口2/3同款)</div>
|
||||||
|
<div id="segformerPanel"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||||
|
<span>原始 JSON</span>
|
||||||
|
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
|
||||||
|
</h2>
|
||||||
|
<div class="json-panel" id="jsonContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
const ENDPOINT = '/api/v1/head/mask';
|
||||||
|
|
||||||
|
// 每个分割模型要展示的步骤(key 前缀,标题,副标题)
|
||||||
|
const MODEL_STEPS = [
|
||||||
|
{ key: 'hair_mask', title: '③ 头发分割 hair_mask', sub: '整张头发掩膜' },
|
||||||
|
{ key: 'closed_region', title: '④ 闭合区域', sub: '头发+额头(含额头,底=画线)' },
|
||||||
|
{ key: 'final_overlay', title: '⑥ 最终遮罩叠加', sub: '外缘内缩,底线不动' },
|
||||||
|
{ key: 'mask', title: '⑦ 纯遮罩', sub: '白=遮罩' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function setStatus(text, type) {
|
||||||
|
const bar = $('statusBar');
|
||||||
|
bar.textContent = text;
|
||||||
|
bar.className = 'status ' + type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网关会把 *_base64 改写为 *_url;直连 worker 则保留 *_base64(data URI)。两者都兼容。
|
||||||
|
function pick(obj, name) {
|
||||||
|
if (!obj) return null;
|
||||||
|
return obj[name + '_url'] || obj[name + '_base64'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepCard(title, sub, src) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'step';
|
||||||
|
const img = src
|
||||||
|
? '<img src="' + src + '" alt="' + title + '" onclick="zoom(this.src)">'
|
||||||
|
: '<div style="padding:30px;text-align:center;color:#bbb;font-size:13px">无图</div>';
|
||||||
|
div.innerHTML = '<div class="cap">' + title + ' <small>' + (sub || '') + '</small></div>' + img;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoom(src) {
|
||||||
|
$('lightboxImg').src = src;
|
||||||
|
$('lightbox').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommon(data) {
|
||||||
|
const c = $('commonSteps');
|
||||||
|
c.innerHTML = '';
|
||||||
|
c.appendChild(stepCard('① 关键点 + 分割线', '21..251 连线,21/251 延伸到边缘', pick(data.steps_common, 'landmarks_baseline')));
|
||||||
|
c.appendChild(stepCard('② 上半区', '分割线以上(绿)', pick(data.steps_common, 'upper_region')));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModel(panelId, model) {
|
||||||
|
const panel = $(panelId);
|
||||||
|
panel.innerHTML = '';
|
||||||
|
if (!model) { panel.innerHTML = '<div class="model-err">无数据</div>'; return; }
|
||||||
|
if (model.error) { panel.innerHTML = '<div class="model-err">分割失败:' + model.error + '</div>'; return; }
|
||||||
|
|
||||||
|
const px = document.createElement('div');
|
||||||
|
px.className = 'metrics';
|
||||||
|
px.style.marginBottom = '12px';
|
||||||
|
px.innerHTML =
|
||||||
|
'<div class="metric"><div class="label">头发像素</div><div class="value">' + (model.hair_pixels ?? '—') + '</div></div>' +
|
||||||
|
'<div class="metric"><div class="label">遮罩像素</div><div class="value">' + (model.mask_pixels ?? '—') + '</div></div>';
|
||||||
|
panel.appendChild(px);
|
||||||
|
|
||||||
|
const steps = document.createElement('div');
|
||||||
|
steps.className = 'steps';
|
||||||
|
steps.style.gridTemplateColumns = 'repeat(auto-fill, minmax(180px, 1fr))';
|
||||||
|
MODEL_STEPS.forEach(s => steps.appendChild(stepCard(s.title, s.sub, pick(model, s.key))));
|
||||||
|
panel.appendChild(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(data) {
|
||||||
|
const sz = data.image_size || {};
|
||||||
|
const items = [
|
||||||
|
{ label: 'px_per_cm', value: data.px_per_cm },
|
||||||
|
{ label: '内缩距离', value: data.erode_cm + ' cm' },
|
||||||
|
{ label: '内缩像素', value: data.erode_px + ' px' },
|
||||||
|
{ label: '图片尺寸', value: (sz.width || '?') + '×' + (sz.height || '?') },
|
||||||
|
];
|
||||||
|
$('metricsBar').innerHTML = items.map(m =>
|
||||||
|
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTest() {
|
||||||
|
const file = $('imageFile').files[0];
|
||||||
|
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
const btn = $('submitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||||
|
setStatus('正在请求 ' + API_BASE + ENDPOINT + ' ...', 'info');
|
||||||
|
$('resultsArea').classList.add('hidden');
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image_file', file);
|
||||||
|
form.append('erode_cm', $('erodeCm').value || '1.2');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
||||||
|
const json = await resp.json();
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||||
|
|
||||||
|
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
|
||||||
|
if (json.code === 0) {
|
||||||
|
setStatus('✅ 请求成功 (' + dt + 's) — request_id: ' + json.request_id, 'success');
|
||||||
|
const data = json.data;
|
||||||
|
renderMetrics(data);
|
||||||
|
renderCommon(data);
|
||||||
|
renderModel('bisenetPanel', data.bisenet);
|
||||||
|
renderModel('segformerPanel', data.segformer);
|
||||||
|
} else {
|
||||||
|
setStatus('❌ 业务错误 (' + dt + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||||
|
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||||
|
$('resultsArea').classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = '🚀 提交测试';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
$('resultsArea').classList.add('hidden');
|
||||||
|
$('statusBar').className = 'status hidden';
|
||||||
|
$('imageFile').value = '';
|
||||||
|
$('jsonContent').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyJson() {
|
||||||
|
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||||
|
const b = event.target; const o = b.textContent;
|
||||||
|
b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERODE_KEY = 'if9_erode_cm';
|
||||||
|
|
||||||
|
function syncErode(from) {
|
||||||
|
const num = $('erodeCm'), rng = $('erodeRange');
|
||||||
|
let v = parseFloat(from.value);
|
||||||
|
if (isNaN(v) || v < 0) v = 0;
|
||||||
|
num.value = v;
|
||||||
|
rng.value = Math.min(parseFloat(rng.max), v); // 滑块封顶但数值框可超范围
|
||||||
|
try { localStorage.setItem(ERODE_KEY, String(v)); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const dz = $('imageFile');
|
||||||
|
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
|
||||||
|
|
||||||
|
// 恢复上次的内缩值
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(ERODE_KEY);
|
||||||
|
if (saved !== null) { $('erodeCm').value = saved; }
|
||||||
|
} catch (e) {}
|
||||||
|
syncErode($('erodeCm'));
|
||||||
|
|
||||||
|
$('erodeCm').addEventListener('input', e => syncErode(e.target));
|
||||||
|
$('erodeRange').addEventListener('input', e => syncErode(e.target));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-1
@@ -83,7 +83,8 @@ def test_grow_female_returns_5(client, monkeypatch):
|
|||||||
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
|
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||||
r = client.post(GROW, headers=H, files=files, data={"gender": "female"})
|
# hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线
|
||||||
|
r = client.post(GROW, headers=H, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["code"] == 0, body
|
assert body["code"] == 0, body
|
||||||
results = body["data"]["results"]
|
results = body["data"]["results"]
|
||||||
|
|||||||
Reference in New Issue
Block a user