226 lines
9.8 KiB
Python
226 lines
9.8 KiB
Python
"""接口9:头发遮罩生成。
|
||
|
||
流程(详见需求讨论):
|
||
1. MediaPipe 关键点检测。
|
||
2. 底部分割线 = 关键点 [162,71,68,104,69,108,151,337,299,333,298,301,389] 的连线(左端162→中心151→右端389),
|
||
再把左端点 162 水平延伸到图片最左边、右端点 389 水平延伸到图片最右边。
|
||
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
|
||
|
||
# 底部分割线关键点(图像上从左到右,眉骨弧线 → 中心 151 → 右侧对称)
|
||
# 左端 104 → 中心 151 → 右端 333;首末点向图片左右边缘水平延长
|
||
BASELINE_IDX = [104, 69, 108, 151, 337, 299, 333]
|
||
# BASELINE_IDX = [123, 116, 143, 71, 68, 104, 69, 108, 151, 337, 299, 333, 298, 301, 372, 345, 352,]
|
||
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, y左端)→ 弧线各点 →(w-1, y右端)→ 右上角,闭合后填充。
|
||
左端/右端为两段水平延长线(向图片左右边缘延伸)。
|
||
"""
|
||
x0, y0 = baseline_pts[0]
|
||
x1, y1 = baseline_pts[-1]
|
||
poly = [(0, 0), (0, y0)] + baseline_pts + [(w - 1, y1), (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()
|
||
y0 = baseline_pts[0][1]
|
||
y1 = baseline_pts[-1][1]
|
||
chain = [(0, y0)] + baseline_pts + [(w - 1, y1)]
|
||
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
|