Files
hair/face_analysis/head_mask.py
T
xslandClaude Opus 4.8 9774997035 接口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>
2026-07-07 23:09:29 +08:00

224 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""接口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):
"""分割线以上区域(boolH×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 URIPNG 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