Files
hair/face_analysis/head_band.py
xslandClaude Opus 4.8 3eb60bddc5 接口10:头部外缘膨胀带遮罩 + 分步可视化
新增 POST /api/v1/head/band(worker + 网关代理)与测试页 test_interface10.html:
- 先和接口9 一样得到内缩后的基准遮罩(含额头的闭合区域外缘朝151内缩 erode_cm、
  底线不动,默认1.2cm),在此基础上取外轮廓线,去掉贴着底部分割线的那一段。
- 把外轮廓线膨胀成带子(总宽 dilate_cm,默认2cm;半径=总宽/2,虹膜标定换算)。
- 裁到分割线以上(不越过底线)。BiSeNet/SegFormer 两套并排对比。
- 两个可调参数 erode_cm + dilate_cm(页面数字框+滑块+localStorage)。
- 复用 head_mask 的构件,无新依赖;纯新增,不改动既有接口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:32:45 +08:00

124 lines
5.6 KiB
Python

"""接口10:头部外缘膨胀带遮罩。
先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm),
在这个基础上:
1. 取基准遮罩的**外轮廓线**(1px),去掉贴着底部分界线的那一段(只留头发/头部外缘弧线)。
2. 把这条外轮廓线膨胀成带子(半径 = dilate_cm/2,即带子**总宽 ≈ dilate_cm**,默认 2cm)。
3. 裁到分界线以上(不越过底线)。
输出这条带子作为 mask。BiSeNet / SegFormer 两套并排对比,分步可视化。
两个可调参数:erode_cm(同接口9 的内缩,默认1.2)+ dilate_cm(带子总宽,默认2)。
复用 `head_mask` 的构件,避免重复实现。
"""
import cv2
import numpy as np
from face_analysis.detector import detector
from face_analysis.calibration import estimate_scale_factor
from face_analysis.head_mask import (
BASELINE_IDX, ERODE_CM, NoFaceError,
_baseline_points, _upper_region_mask, _fill_to_baseline, _largest_cc,
_bisenet_hair_mask, _segformer_hair_mask,
_erode, _overlay, _draw_baseline, _b64png, _mask_png,
)
DILATE_CM = 2.0 # 膨胀后带子总宽(厘米,默认;半径 = 总宽/2;可由入参覆盖)
def _dilate(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.dilate(mask_bool.astype(np.uint8), k).astype(bool)
def _baseline_line_mask(baseline_pts, w, h, thickness):
"""底部分割线(含左右水平延长线)画成一条带厚度的线,用于从外轮廓里剔除底边。"""
m = np.zeros((h, w), np.uint8)
y_l = baseline_pts[0][1]
y_r = baseline_pts[-1][1]
chain = [(0, y_l)] + baseline_pts + [(w - 1, y_r)]
for a, b in zip(chain[:-1], chain[1:]):
cv2.line(m, a, b, 1, thickness)
return m.astype(bool)
def _outer_contour_no_bottom(region, baseline_band):
"""区域外轮廓(1px)去掉贴着底部分界线的那一段。"""
contour = region & ~_erode(region, 1)
return contour & ~baseline_band
def _model_result(image_bgr, hair_mask, upper, baseline_pts, baseline_band, r_erode, r_dilate, w):
"""单个分割模型的分步结果(内缩后基准遮罩 / 外轮廓线 / 膨胀带 / 纯遮罩)。"""
top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底
base = _largest_cc(_erode(top_fill, r_erode) & upper) # 接口9 内缩后的基准遮罩
contour = _outer_contour_no_bottom(base, baseline_band) # 外轮廓,去底线
band = _largest_cc(_dilate(contour, r_dilate) & upper) # 膨胀成带、裁到底线以上
return {
"base_pixels": int(base.sum()),
"band_pixels": int(band.sum()),
"base_mask_base64": _b64png(
_draw_baseline(_overlay(image_bgr, base, (255, 150, 0)), baseline_pts, w)),
"contour_base64": _b64png(
_draw_baseline(_overlay(image_bgr, _dilate(contour, 2), (0, 255, 0)), baseline_pts, w)),
"band_overlay_base64": _b64png(
_draw_baseline(_overlay(image_bgr, band, (0, 0, 255)), baseline_pts, w)),
"mask_base64": _mask_png(band),
}
def generate_head_band(image_bgr, erode_cm=ERODE_CM, dilate_cm=DILATE_CM):
"""接口10 完整管线。返回可直接进 ok() 的 data dict。
erode_cm:基准遮罩外缘朝151 内缩距离(厘米,同接口9),页面可调,默认 1.2cm。
dilate_cm:外轮廓线膨胀后带子总宽(厘米),页面可调,默认 2cm。
未检出人脸抛 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))
dilate_cm = max(0.0, float(dilate_cm))
px_per_cm = estimate_scale_factor(landmarks, w, h)
r_erode = int(round(erode_cm * px_per_cm)) # 内缩半径
r_dilate = int(round((dilate_cm / 2.0) * px_per_cm)) # 膨胀半径 = 总宽/2
baseline_pts = _baseline_points(landmarks, w, h)
upper = _upper_region_mask(baseline_pts, w, h)
# 剔除底边用的分界线带:几像素宽即可,独立于膨胀/内缩半径
baseline_band = _dilate(_baseline_line_mask(baseline_pts, w, h, 5), 2)
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_erode,
"dilate_cm": round(dilate_cm, 2),
"dilate_radius_px": r_dilate,
"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,
baseline_band, r_erode, r_dilate, w)
except Exception as ex: # noqa: BLE001 单模型失败不影响整体
data[name] = {"error": f"{type(ex).__name__}: {ex}"}
return data