接口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:
@@ -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
|
||||
Reference in New Issue
Block a user