新增 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>
124 lines
5.6 KiB
Python
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
|