终于修改对了,还是opus

This commit is contained in:
xsl
2026-07-11 23:14:07 +08:00
parent 41bb164a52
commit 1b9f3fdb6f
7 changed files with 205 additions and 180 deletions
@@ -13,8 +13,8 @@ pushed 模式是发际线生发的默认遮罩算法(接口11/12 的 `mask_typ
①-a baseline 分割线 ← 眉骨/glabella 关键点折线(含 151 中心点)
①-b 上半区 upper ← baseline 以上的区域(裁剪范围)
①-c 头发分割 hair_mask ← segformer/bisenet 的原始头发像素
①-f 头发/皮肤交界线逐列取头发下沿,用 baseline 水平 y 线截断
①-g 径向外推 + 闭合 ← 以 151 点为圆心逐点外推,与 baseline 组闭合区域 = 最终遮罩
①-f 头发内轮廓线 ← 取头发轮廓中朝脸一侧的那段(额头弧+两侧到下颌),有序折线
①-g 径向外推 + 成带 ← 以 151 点为圆心把内轮廓逐点外推 push_px,内轮廓↔外推线之间的带 = 最终遮罩
```
> ①-d(填充到基线 top_fill)、①-e(闭合区域 closed)是旧 eroded/closed 模式的中间产物,pushed 模式不走这条流程,前端不展示。
@@ -31,44 +31,42 @@ baseline 折线以上的多边形区域(`_upper_region_mask`)。作为后续
segformer(默认)或 bisenet 得到的头发二值掩码。
### ①-f 头发/皮肤交界线(核心改动)
### ①-f 头发内轮廓线(核心改动)
代码 `_extract_hairline`
代码 `_extract_hairline`。目标是提取「头发区域朝脸一侧的内轮廓线」:额头弧 + 左右两侧鬓角/脸颊边界,一直向下到下颌,是一条**有序折线**(不再是逐列一个 y 的数组,因为两侧近乎竖直、一个 x 对多个 y)。
1. **逐列取头发下沿**对每个 x 列,取 `hair_mask` 中最靠下的头发像素 y 坐标(`column` 模式)
2. **baseline 水平截断**:逐列计算 baseline 折线的 y 值 `baseline_y[x]`,只保留「发际线 y < baseline_y」的列(baseline 线以上 = 额头+发际线区域;baseline 以下 = 脸下半部,丢弃
3. 结果 `hairline_y[x]`:长度 = 图宽的数组,baseline 以下或无头发处为 NaN
1. **取头发轮廓**`hair_mask` 最大连通域,`cv2.findContours(RETR_EXTERNAL, CHAIN_APPROX_NONE)` 取稠密、保序的外轮廓点
2. **内侧判定**:轮廓同时含「朝背景的外侧剪影」和「朝脸的内轮廓」。对每个轮廓点,朝脸中心 151 方向采样 `sample_px`(≈0.4cm)像素,落点若是**非头发**像素 → 该点朝向脸(内轮廓);否则是外侧剪影,丢弃。
3. **取最长连续内侧段**:内轮廓点在闭合轮廓上本是一段连续弧,先做 1D 环形闭运算填掉判定抖动的小缝,再取最长连续 True 段并保序
4. **下颌截断**:丢掉 y > `chin_y`(下巴关键点 152 的 y)的点,把两侧末端截到下颌一带 → 得到「环脸」内轮廓弧。
**关键点**截断是**水平方向**用 baseline 的 y 值切割,不是竖线、不是 baseline 的 x 范围。这样得到的是真实的头发/皮肤交界弧线
> `mode=contour` 用 `cv2.findContours` 取轮廓代替逐列下沿,但实测它会混入头顶边缘(y 异常偏小),**推荐用 `column`(默认)**。
**关键点**不再用 baseline 做水平截断、也不再逐列取下沿;截断改为「朝脸内侧」判定 + 下颌 y 截断,因此能同时拿到额头弧和两侧竖直边界
### ①-g 径向外推 + 闭合区域(最终遮罩)
代码 `_pushed_mask`
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对每个发际线有效点 (x, y)计算从圆心指向它的单位向量 `(ux, uy)`,外推后新位置 `(x + ux·push_px, y + uy·push_px)`额头正上方的点往上推,两侧的点斜向外上推。`push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
2. **列归并 + 插值填补锯齿**:外推后新 x 坐标可能落在相邻列,逐列取最靠上的 y 作为遮罩顶界;径向归并产生的空列线性插值填补,保证遮罩顶界连续(否则会被连通域分析切成碎片)
3. **与 baseline 组闭合区域**:逐列从外推后发际线 `pushed_y[x]` 填充到 `baseline_y[x]`,得到遮罩
4. **后处理**`& upper` 去掉越界部分,`_largest_cc` 保留最大连通域。
1. **逐点径向外推**:圆心 = 151 点 (cx, cy)。对内轮廓上每个点 (x, y)沿「从圆心指向它的单位向量 `(ux, uy)` **向外**(远离脸中心 = 推进现有头发)外推 `push_px`,得到外推线(黄线)点 `(x + ux·push_px, y + uy·push_px)``push_px = hairline_push_cm × px_per_cm`(默认 1cm)。
2. **列归并**:只取外推线中落在 baseline 以上的点,逐列取最靠上的 y 作为遮罩顶界 `pushed_y[x]`空列线性插值填补。两侧鬓角落到 baseline 以下的段落自然被排除
3. **与 baseline 组闭合区域**:逐列从 `pushed_y[x]` 填充到 `baseline_y[x]`(仅 `pushed_y < baseline_y` 的列),`& upper` 去越界、`_largest_cc` 保留最大连通域
最终遮罩 = `[径向外推发际线 → baseline]` 之间的闭合区域,顶部含现有头发下沿约 push_cm,底部到 baseline。
最终遮罩 = **外推发际线(①-g 黄线)与 baseline 分割线(①-a)组成的闭合区域**:顶界=外推发际线(覆盖现有头发约 push_cm,底界=baseline。与旧逻辑一致,区别只是 `pushed_y` 现在来自修正后的整条内轮廓,额头弧已延伸到两侧鬓角,额头遮罩宽度不再被截短。
## 关键参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `mask_type` | `eroded`(接口默认)/ `pushed`(当前推荐) | pushed 走上述流程;eroded/closed 走旧的 top_fill→closed/eroded 流程 |
| `hairline_push_cm` | 1.0 | 发际线径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 发际线提取方式:`column`(逐列下沿,推荐)/ `contour`(轮廓,易混入头顶) |
| `hairline_push_cm` | 1.0 | 内轮廓径向外推距离(厘米),= push_px / px_per_cm。`px_per_cm` 由虹膜直径标定 |
| `hairline_edge` | `column` | 兼容保留的入参;新版内轮廓提取(轮廓+内侧判定)不再按它分支,取值不影响结果 |
## 与旧模式(eroded/closed)的区别
| | eroded/closed | pushed(当前) |
|---|---|---|
| 遮罩顶界 | top_fill(头发向下填充含额头)外缘内缩 | 头发/皮肤交界线 径向外推 |
| 是否用 baseline 截断 | 用 baseline 组上半区 upper | 用 baseline 水平 y 线截断发际线 + 作遮罩底界 |
| 遮罩形状 | 整个额头闭合区域 | 发际线附近一带(顶部覆盖现有头发 push_cm |
| 遮罩来源 | top_fill(头发向下填充含额头)外缘内缩 | 头发内轮廓线 径向外推成带 |
| 截断方式 | 用 baseline 组上半区 upper | 内侧判定 + 下颌 y 截断(不再用 baseline) |
| 遮罩形状 | 整个额头闭合区域 | 沿内轮廓的环脸带(额头弧+两侧,压住现有头发 push_cm |
## 调试
+183 -156
View File
@@ -105,172 +105,194 @@ def _gray_b64(gray_float):
# 步骤1:接口9 头发遮罩(复用 head_mask 构件)
# ---------------------------------------------------------------------------
def _extract_hairline(hair_mask, upper=None, mode="column", rid="",
baseline_pts=None, band_expand_px=0):
"""提取头发/皮肤交界线(头发区域内轮廓朝脸一侧),用 baseline 水平 y 线截断。
def _close_cyclic(mask, gap):
"""对 1D 布尔环形序列做闭运算,填掉短于 gap 的 False 缺口(消除内侧判定的小抖动)。"""
if gap <= 0 or mask.all() or not mask.any():
return mask
n = len(mask)
tripled = np.concatenate([mask, mask, mask]).astype(np.uint8).reshape(1, -1)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2 * gap + 1, 1))
closed = cv2.morphologyEx(tripled, cv2.MORPH_CLOSE, kernel).reshape(-1)
return closed[n:2 * n].astype(bool)
直接取头发区域的最大轮廓(头发/皮肤交界),逐列取最靠下的边界点作为该列发际线 y。
然后用 baseline 的 y 值做水平截断:只保留每列发际线 y < baseline_y 的部分
baseline 线以上=额头+发际线区域;baseline 以下=脸下半部,丢弃)。无任何竖线。
upper:兼容旧签名保留,不参与计算。
baseline_ptsbaseline 关键点,其折线 y 值用于水平截断。
band_expand_px:兼容签名,不再使用(已不竖向截断)。
modecontour(轮廓,推荐)| column(逐列下沿,兜底)。
返回 (hairline_y, lo, hi) —— hairline_y: 长度=w 的 y 数组(baseline 以下或无轮廓处置 NaN)。
lo/hi: 发际线有效范围的首末列(用于 _pushed_mask 限定填充范围)。
def _longest_true_run_cyclic(mask):
"""返回环形布尔序列中最长连续 True 段的索引(按顺序)。全 True 返回全体索引。"""
n = len(mask)
if mask.all():
return np.arange(n)
if not mask.any():
return np.empty(0, dtype=np.int64)
# 从一个 False 处断开成线性序列,避免最长段跨越首尾
start = int(np.where(~mask)[0][0])
order = np.roll(np.arange(n), -start)
rmask = mask[order]
best_len = best_s = cur = cur_s = 0
for i, v in enumerate(rmask):
if v:
if cur == 0:
cur_s = i
cur += 1
if cur > best_len:
best_len, best_s = cur, cur_s
else:
cur = 0
return order[best_s:best_s + best_len]
def _draw_polyline(image, pts, color, thickness=3):
"""把有序点列 (Nx2) 画成折线(用于内轮廓/外推线可视化)。点<2 原样返回。"""
out = image.copy()
if pts is not None and len(pts) >= 2:
poly = np.ascontiguousarray(np.asarray(pts).reshape(-1, 1, 2), dtype=np.int32)
cv2.polylines(out, [poly], isClosed=False, color=color,
thickness=thickness, lineType=cv2.LINE_AA)
return out
def _extract_hairline(hair_mask, center, chin_y=None, rid="", sample_px=12):
"""提取头发区域朝脸一侧的内轮廓线(发际线)。
结果是一条有序折线:额头弧线 + 左右两侧鬓角/脸颊边界,一直向下到下颌 chin_y。
做法(轮廓 + 内侧判定,见 docs/发际线生发遮罩算法_pushed模式.md ①-f):
1. 取头发 mask 最大连通域的外轮廓(CHAIN_APPROX_NONE,逐像素稠密点,保序)。
2. 逐点判定「内侧」:从该轮廓点朝脸中心 center 采样 sample_px 像素,若落点是
非头发像素 → 该点朝向脸(头发/皮肤交界的内轮廓);否则是朝背景的外侧剪影,丢弃。
3. 内轮廓点在闭合轮廓上本是一段连续弧,先环形闭运算填小缝,再取最长连续段并保序。
4. 下颌截断:丢掉 y > chin_y 的点(把两侧末端截到下颌一带),得到红真值那样的环脸弧。
center: 脸中心 (cx, cy),取 151 点(眉心)。内侧判定与 ①-g 径向外推共用此圆心。
chin_y: 下颌截断 y(一般取下巴关键点 152 的 y);None 则不截断。
sample_px: 内侧判定的采样距离(像素),一般 ≈ 0.4cm。
返回按顺序排列的内轮廓点 np.ndarray(Nx2, int32);不足 2 点返回空数组 (0,2)。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
h, w = hair_mask.shape
col_down = np.full(w, -1, dtype=np.int32)
empty = np.empty((0, 2), dtype=np.int32)
if mode == "contour":
mask_u8 = hair_mask.astype(np.uint8)
if mask_u8.sum() > 0:
cnts, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
lg(f"_extract_hairline contour: 轮廓数={len(cnts) if cnts else 0}")
if cnts:
big = max(cnts, key=cv2.contourArea)
lg(f" 最大轮廓面积={cv2.contourArea(big):.0f}")
for pt in big[:, 0]:
x, y = int(pt[0]), int(pt[1])
if y > col_down[x]:
col_down[x] = y
else: # column(兜底:逐列下沿,与 contour 结果接近)
cols = np.where(hair_mask.any(axis=0))[0]
lg(f"_extract_hairline column: 有头发的列数={len(cols)}/{w}")
for x in cols:
col_down[x] = int(np.where(hair_mask[:, x])[0].max())
m = _largest_cc(hair_mask).astype(np.uint8)
if m.sum() == 0:
lg("_extract_hairline: 头发 mask 为空")
return empty
cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
if not cnts:
lg("_extract_hairline: 无轮廓")
return empty
big = max(cnts, key=cv2.contourArea)
pts = big[:, 0].astype(np.float64) # N×2 (x, y),沿边界有序
n = len(pts)
lg(f"_extract_hairline: 最大轮廓点数={n} 面积={cv2.contourArea(big):.0f} center={center} chin_y={chin_y}")
if n < 3 or center is None:
return empty
valid = col_down >= 0
if valid.sum() < 2:
lg(f" 警告: 有效列<2,退化为空发际线")
nan = np.full(w, np.nan)
return nan, 0, w - 1
xs = np.where(valid)[0]
ys = col_down[valid].astype(np.float64)
raw = np.interp(np.arange(w), xs, ys)
raw = np.clip(raw, 0, h - 1)
cx, cy = float(center[0]), float(center[1])
# 内侧判定:每个轮廓点朝脸中心方向采样 sample_px,落在非头发上 → 内轮廓点
d = np.stack([cx - pts[:, 0], cy - pts[:, 1]], axis=1)
dist = np.hypot(d[:, 0], d[:, 1])
dist[dist < 1e-3] = 1.0
step = d / dist[:, None] * float(sample_px)
sx = np.clip(np.round(pts[:, 0] + step[:, 0]).astype(int), 0, w - 1)
sy = np.clip(np.round(pts[:, 1] + step[:, 1]).astype(int), 0, h - 1)
inner = m[sy, sx] == 0
lg(f" 内侧点数={int(inner.sum())}/{n}")
if inner.sum() < 3:
lg(" 内侧点<3,退化为空内轮廓")
return empty
# baseline 每列的 y(水平截断线:发际线 y 必须 < baseline_y 才保留
if baseline_pts is not None and len(baseline_pts) >= 2:
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
baseline_y = np.interp(np.arange(w), chain_x, chain_y)
# 水平截断:只保留发际线在 baseline 以上(y 更小)的列
keep = raw < baseline_y
out = np.where(keep, raw, np.nan)
valid_cols = np.where(keep)[0]
lo = int(valid_cols.min()) if len(valid_cols) else 0
hi = int(valid_cols.max()) if len(valid_cols) else w - 1
lg(f" baseline 水平截断: baseline_y范围[{int(baseline_y.min())},{int(baseline_y.max())}] "
f"保留{keep.sum()}列 → 发际线范围[{lo},{hi}]")
else:
out = raw
lo, hi = 0, w - 1
lg(f" 无 baseline,取全宽")
# 环形闭运算填掉短缝隙,再取最长连续内侧段(= 朝脸的整段内轮廓
inner = _close_cyclic(inner, gap=max(3, n // 100))
idx = _longest_true_run_cyclic(inner)
arc = pts[idx].astype(np.int32)
lg(f" 最长内侧段点数={len(arc)}")
return out, lo, hi
# 下颌截断:只保留 y <= chin_y 的点(把两侧末端截到下颌)
if chin_y is not None and len(arc):
keep = arc[:, 1] <= int(chin_y)
arc = arc[keep]
lg(f" 下颌截断(chin_y={int(chin_y)})后点数={len(arc)}")
if len(arc) < 2:
return empty
# 稠密点降采样,减小后续多边形/外推开销(保序)
if len(arc) > 600:
arc = arc[:: len(arc) // 600 + 1]
return arc
def _pushed_mask(hair_mask, upper, baseline_pts, push_px, mode, rid="",
center=None, band_expand_px=0):
"""发际线外推遮罩:发际线(仅额带凹处)以眉心为圆心逐点径向外推 push_px,
与 baseline 组闭合区域。
def _pushed_mask(hair_mask, upper, baseline_pts, push_px, rid="",
center=None, chin_y=None, sample_px=12):
"""发际线外推遮罩:先提取头发内轮廓线(①-f),以眉心为圆心把内轮廓逐点径向
「向外」(远离脸中心、推进现有头发)外推 push_px 得到外推线(①-g 黄线);
再取外推线与 baseline(①-a 分割线)组成的闭合区域作为最终遮罩。
center: 圆心 (cx, cy),一般取 151 点(眉心)。
band_expand_px: 额带边界(黄竖线)两侧再外扩的像素数
返回 (mask_bool, hairline_y, pushed_y, lo, hi)
hairline_y/pushed_y —— 长度=w 的 y 数组(额带外 NaN),每列发际线下沿/外推后下沿
注:径向外推后,同一列可能出现多个外推点,这里按列取最靠上的作为遮罩顶界。
lo/hi —— 扩展后的额带左右边界列
即逐列从外推线 pushed_y[x] 填充到 baseline_y[x](仅 pushed_y 在 baseline 以上的列),
与旧逻辑一致:遮罩顶界=外推发际线(覆盖现有头发 push_cm),底界=baseline
区别只是现在 pushed_y 来自修正后的整条内轮廓(额头弧已延伸到两侧鬓角),
额头遮罩宽度不再被截短
center: 圆心 (cx, cy),取 151 点(眉心);内侧判定与径向外推共用
chin_y: 下颌截断 y,透传给 _extract_hairline。
sample_px: 内侧判定采样距离,透传给 _extract_hairline。
返回 (mask_bool, inner_pts, outer_pts)
inner_pts —— 头发内轮廓有序点列 (Nx2)。
outer_pts —— 内轮廓径向外推 push_px 后的有序点列 (Nx2)。
"""
lg = lambda msg: logger.info("[%s] %s", rid, msg) if rid else None
h, w = hair_mask.shape
# 截断完全交给 _extract_hairline(用 baseline 范围 + 两侧外扩)
hairline_y, lo, hi = _extract_hairline(hair_mask, upper, mode, rid=rid,
baseline_pts=baseline_pts, band_expand_px=band_expand_px)
valid = ~np.isnan(hairline_y)
hairline_y = np.where(valid, np.clip(hairline_y, 0, h - 1), np.nan).astype(np.float64)
valid = ~np.isnan(hairline_y)
empty = np.empty((0, 2), dtype=np.int32)
lg(f"_pushed_mask: push_px={push_px} 额带(baseline截断)[{lo},{hi}] 圆心={center}")
inner_pts = _extract_hairline(hair_mask, center=center, chin_y=chin_y,
rid=rid, sample_px=sample_px)
lg(f"_pushed_mask: push_px={push_px} 内轮廓点数={len(inner_pts)} 圆心={center}")
if len(inner_pts) < 2 or center is None:
return np.zeros((h, w), dtype=bool), inner_pts, empty
# 逐点径向外推:每个发际线点沿「从圆心指向它」的方向往外推 push_px。
# 外推后新位置可能不在原列,按列收集所有外推点取最靠上的 y 作为该列遮罩顶界。
pushed_y = np.full(w, np.nan)
if center is not None and valid.any():
cx, cy = float(center[0]), float(center[1])
xs = np.where(valid)[0]
ys = hairline_y[valid]
# 每个点的径向方向(从圆心指向该点),单位向量
dx = xs - cx
dy = ys - cy
dist = np.sqrt(dx * dx + dy * dy)
dist = np.where(dist < 1e-3, 1.0, dist) # 圆心点本身防除零
ux, uy = dx / dist, dy / dist
# 外推后的新坐标
nx = xs + ux * push_px
ny = ys + uy * push_px
ny = np.clip(ny, 0, h - 1)
# 按新 x 做最近邻归并到整数列,取每列最小 ny(最靠上=遮罩顶界)
nx_int = np.clip(np.round(nx).astype(int), 0, w - 1)
for xi, yi in zip(nx_int, ny):
if np.isnan(pushed_y[xi]) or yi < pushed_y[xi]:
pushed_y[xi] = yi
lg(f" 径向外推: 推前y范围[{int(np.nanmin(ys))},{int(np.nanmax(ys))}] "
f"推后y范围[{int(np.nanmin(ny))},{int(np.nanmax(ny))}]")
# 径向归并会产生空列(锯齿),在额带 [lo,hi] 内插值填补,保证遮罩顶界连续
v3 = ~np.isnan(pushed_y)
if v3.any():
xv = np.where(v3)[0]
yv = pushed_y[v3]
pushed_y[lo:hi + 1] = np.interp(np.arange(lo, hi + 1), xv, yv)
else:
# 无圆心:退化为统一往上推
pushed_y = hairline_y - int(push_px)
v2 = ~np.isnan(pushed_y)
pushed_y = np.where(v2, np.clip(pushed_y, 0, h - 1), np.nan)
cx, cy = float(center[0]), float(center[1])
# 逐点径向外推:沿「从圆心指向该点」方向(远离脸中心 = 推进现有头发)外推 push_px
d = inner_pts.astype(np.float64) - np.array([cx, cy])
dist = np.hypot(d[:, 0], d[:, 1])
dist[dist < 1e-3] = 1.0
u = d / dist[:, None]
outer = inner_pts.astype(np.float64) + u * float(push_px)
outer[:, 0] = np.clip(outer[:, 0], 0, w - 1)
outer[:, 1] = np.clip(outer[:, 1], 0, h - 1)
outer_pts = np.round(outer).astype(np.int32)
# baseline 每列的 y
# baseline 每列的 y(①-a 分割线,含左右水平延长)
x0, y0 = baseline_pts[0]
x1, y1 = baseline_pts[-1]
chain_x = np.array([0] + [p[0] for p in baseline_pts] + [w - 1])
chain_y = np.array([y0] + [p[1] for p in baseline_pts] + [y1])
baseline_y = np.interp(np.arange(w), chain_x, chain_y).astype(np.int32)
baseline_y = np.interp(np.arange(w), chain_x, chain_y)
# 逐列填充:仅额带内、且 pushed_y < baseline_y 的列
# 外推线逐列归并:只取 baseline 以上的外推点,逐列取最靠上 y 作为遮罩顶界 pushed_y。
# 两侧鬓角向下的段落到 baseline 以下,自然被排除(与旧逻辑一致,遮罩=额头闭合区域)。
ox = np.clip(outer_pts[:, 0], 0, w - 1)
oy = outer_pts[:, 1].astype(np.float64)
above = oy < baseline_y[ox]
pushed_y = np.full(w, np.nan)
for xi, yi in zip(ox[above], oy[above]):
if np.isnan(pushed_y[xi]) or yi < pushed_y[xi]:
pushed_y[xi] = yi
cols_valid = np.where(~np.isnan(pushed_y))[0]
if len(cols_valid) >= 2:
lo, hi = int(cols_valid.min()), int(cols_valid.max())
pushed_y[lo:hi + 1] = np.interp(np.arange(lo, hi + 1), cols_valid,
pushed_y[cols_valid])
lg(f" 外推线额带[{lo},{hi}] 推后y范围[{int(np.nanmin(pushed_y))},{int(np.nanmax(pushed_y))}]")
# 逐列从 pushed_y 填充到 baseline_y(闭合区域),仅 pushed_y<baseline_y 的列
baseline_yi = baseline_y.astype(np.int32)
mask = np.zeros((h, w), dtype=bool)
fill_valid = ~np.isnan(pushed_y)
cols = np.where(fill_valid & (pushed_y.astype(int) < baseline_y))[0]
lg(f" 有效填充列数={len(cols)} (额带内且 pushed_y<baseline_y)")
pushed_int = np.nan_to_num(pushed_y, nan=float(h)).astype(np.int32)
cols = np.where(fill_valid & (pushed_int < baseline_yi))[0]
lg(f" 有效填充列数={len(cols)} (pushed_y<baseline_y)")
for x in cols:
mask[int(pushed_y[x]):baseline_y[x] + 1, x] = True
lg(f" 填充后 mask 像素={int(mask.sum())}")
mask[int(pushed_int[x]):baseline_yi[x] + 1, x] = True
mask = _largest_cc(mask & upper)
return mask, hairline_y, pushed_y, lo, hi
def _draw_curve(image, y_per_col, color, thickness=3):
""""每列一个 y"的曲线画到图上(用于发际线/外推线可视化)。NaN 列跳过。"""
out = image.copy()
pts = []
segs = []
for x in range(len(y_per_col)):
v = y_per_col[x]
if np.isnan(v):
if len(pts) >= 2:
segs.append(np.array(pts, dtype=np.int32))
pts = []
else:
pts.append([x, int(v)])
if len(pts) >= 2:
segs.append(np.array(pts, dtype=np.int32))
for seg in segs:
cv2.polylines(out, [seg], isClosed=False, color=color, thickness=thickness, lineType=cv2.LINE_AA)
return out
lg(f" 闭合区域 mask 像素={int(mask.sum())}")
return mask, inner_pts, outer_pts
@@ -313,17 +335,22 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
pushed_info = None
if mask_type == "pushed":
push_px = int(round(max(0.0, hairline_push_cm) * px_per_cm))
# 圆心 = 151 点(眉心)完整坐标,用于额带搜索起点 + 径向外推圆心
# 圆心 = 151 点(眉心)完整坐标,内侧判定与径向外推共用
center = baseline_pts[5] if len(baseline_pts) > 5 else None
# 额带边界两侧各外扩 1cm(像素
band_expand_px = int(round(1.0 * px_per_cm))
lg(f"进入 PUSHED 分支: push_px={push_px} 圆心(151)={center} band_expand={band_expand_px}px "
f"edge={hairline_edge}")
mask_bool, hairline_y, pushed_y, lo, hi = _pushed_mask(
hair_mask, upper, baseline_pts, push_px, hairline_edge, rid=rid,
center=center, band_expand_px=band_expand_px)
pushed_info = (hairline_y, pushed_y, push_px, lo, hi)
lg(f"PUSHED 结果: 额带[{lo},{hi}] mask_pixels={int(mask_bool.sum())}")
# 下颌截断线:下巴关键点 152 的 y(内轮廓两侧向下画到这里为止
try:
chin_y = int(round(landmarks.landmark[152].y * h))
except Exception: # noqa: BLE001
chin_y = None
# 内侧判定采样距离 ≈ 0.4cm
sample_px = max(6, int(round(0.4 * px_per_cm)))
lg(f"进入 PUSHED 分支: push_px={push_px} 圆心(151)={center} chin_y={chin_y} "
f"sample_px={sample_px} edge={hairline_edge}")
mask_bool, inner_pts, outer_pts = _pushed_mask(
hair_mask, upper, baseline_pts, push_px, rid=rid,
center=center, chin_y=chin_y, sample_px=sample_px)
pushed_info = (inner_pts, outer_pts, push_px)
lg(f"PUSHED 结果: 内轮廓点数={len(inner_pts)} mask_pixels={int(mask_bool.sum())}")
elif mask_type == "eroded":
mask_bool = eroded
lg(f"进入 ERODED 分支: 用 eroded 遮罩 pixels={int(eroded.sum())}")
@@ -359,16 +386,16 @@ def compute_mask(image_bgr, landmarks, seg_model, mask_type, erode_cm, px_per_cm
"mask_overlay_base64": _jpg_b64(_overlay(image_bgr, mask_bool, (0, 0, 255))),
"mask_base64": _png_b64((mask_bool.astype(np.uint8)) * 255),
}
# pushed 模式:补充发际线提取 + 外推线可视化
# pushed 模式:补充内轮廓提取 + 外推线可视化
if pushed_info is not None:
hairline_y, pushed_y, push_px, lo, hi = pushed_info
# ①-f 提取发际线:绿=头发下沿发际线(已用 baseline 范围截断),黄=baseline 折线(截断依据)
inner_pts, outer_pts, push_px = pushed_info
# ①-f 提取内轮廓:绿=头发内轮廓线(额头弧+两侧到下颌),黄=baseline 折线
hl_img = _draw_baseline(image_bgr, baseline_pts, w) # 画 baseline(黄线+关键点)
hl_img = _draw_curve(hl_img, hairline_y, (0, 255, 0), 3)
hl_img = _draw_polyline(hl_img, inner_pts, (0, 255, 0), 3)
viz["hairline_overlay_base64"] = _jpg_b64(hl_img)
# ①-g 外推发际线:圆心红点(151) + 原发际线(绿)+ 外推线(青)+ 遮罩(红半透明)
ps_img = _draw_curve(image_bgr.copy(), hairline_y, (0, 255, 0), 2)
ps_img = _draw_curve(ps_img, pushed_y, (0, 255, 255), 3)
# ①-g 外推:圆心红点(151) + 内轮廓(绿)+ 外推线(青)+ 遮罩(红半透明)
ps_img = _draw_polyline(image_bgr.copy(), inner_pts, (0, 255, 0), 2)
ps_img = _draw_polyline(ps_img, outer_pts, (0, 255, 255), 3)
# 画圆心(151 点)红点,标示径向外推的中心
if baseline_pts is not None and len(baseline_pts) > 5:
cx151, cy151 = baseline_pts[5]
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+2 -2
View File
@@ -257,8 +257,8 @@ const STEPS = [
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' },
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' },
// pushed 模式专有(非 pushed 模式无图)
{ key: 'hairline_overlay', title: '①-f 提取发际线', sub: '绿=头发下沿发际线 + 黄=baseline(仅pushed' },
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '=外推线(进头发push_cm) + 红=外推遮罩(仅pushed' },
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌) + 黄=baseline(仅pushed' },
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '=外推线(进头发push_cm) + 红=外推遮罩(仅pushed' },
// 最终遮罩
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
+2 -2
View File
@@ -176,8 +176,8 @@ const STEPS = [
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=头发像素' },
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=top_fill(仅eroded/closed' },
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=closed(仅eroded/closed' },
{ key: 'hairline_overlay', title: '①-f 头发/皮肤交界线', sub: '绿=头发内轮廓(baseline水平截断),黄=baseline折线(仅pushed' },
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=原发际线(仅pushed' },
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌),黄=baseline折线(仅pushed' },
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=内轮廓(仅pushed' },
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=最终遮罩区' },
{ key: 'mask', title: '① 纯遮罩', sub: '白=贴回区' },
{ key: 'swap_raw', title: '② 生成全帧', sub: '换发型结果' },