diff --git a/docs/发际线生发遮罩算法_pushed模式.md b/docs/发际线生发遮罩算法_pushed模式.md index 6063edf..9b4d0ea 100644 --- a/docs/发际线生发遮罩算法_pushed模式.md +++ b/docs/发际线生发遮罩算法_pushed模式.md @@ -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) | ## 调试 diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 590953b..8764425 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -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_pts:baseline 关键点,其折线 y 值用于水平截断。 - band_expand_px:兼容签名,不再使用(已不竖向截断)。 - mode:contour(轮廓,推荐)| 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= 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] diff --git a/image/内轮廓线真值.jpg b/image/内轮廓线真值.jpg new file mode 100644 index 0000000..2b61f75 Binary files /dev/null and b/image/内轮廓线真值.jpg differ diff --git a/image/头发区域.jpg b/image/头发区域.jpg new file mode 100644 index 0000000..2d891ce Binary files /dev/null and b/image/头发区域.jpg differ diff --git a/image/错误的线.jpg b/image/错误的线.jpg new file mode 100644 index 0000000..ab30384 Binary files /dev/null and b/image/错误的线.jpg differ diff --git a/static/test_interface11.html b/static/test_interface11.html index 5bbf551..d0dc06f 100644 --- a/static/test_interface11.html +++ b/static/test_interface11.html @@ -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: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' }, diff --git a/static/test_interface11_debug.html b/static/test_interface11_debug.html index 08a8337..3057204 100644 --- a/static/test_interface11_debug.html +++ b/static/test_interface11_debug.html @@ -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: '换发型结果' },