From a9439e49750323e7ceab6c0432b9d46af5764dfa Mon Sep 17 00:00:00 2001 From: xsl Date: Wed, 24 Jun 2026 21:36:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A31:=20=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E5=9B=BE=E4=BA=BA=E5=A4=B4=E6=9C=80=E5=B7=A6/=E6=9C=80?= =?UTF-8?q?=E5=8F=B3=E7=AB=96=E7=BA=BF=E6=94=B9=E7=94=A8=E8=80=B3=E6=9C=B5?= =?UTF-8?q?=E5=88=86=E5=89=B2=E5=A4=96=E7=BC=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 最左/最右竖线由「头发轮廓」改为同一 BiSeNet 的耳朵类(7/8)外缘, 看不到耳朵(被头发/侧脸遮挡→掩膜空)则该侧不画线 - 外耳轮廓常被误标成头发: 从耳朵外缘沿紧邻前景(耳∪发)按脸宽自适应 外扩回收(上限 face_w*0.045, 遇背景间隙即停) - 先按人脸包围盒裁剪再分割: BiSeNet 在紧裁人脸上训练, 整张全身/街拍图 脸偏小会严重欠分割、丢耳朵; 裁剪后映射回原图, 耳朵稳定可分 - segment_hair_and_ears() 单次推理同出 hair_mask + ear_mask Co-Authored-By: Claude Opus 4.8 --- app.py | 17 +++++-- face_analysis/annotation.py | 86 ++++++++++++++++++++++++--------- face_analysis/hair_segmenter.py | 57 +++++++++++++++++++--- 3 files changed, 124 insertions(+), 36 deletions(-) diff --git a/app.py b/app.py index 4010d47..1a1f284 100644 --- a/app.py +++ b/app.py @@ -362,17 +362,24 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"): return None, err(1003, "角度问题,请上传正面照") head_pose = estimate_head_pose(landmarks, w, h) - # 7. 头发分割(方案 B),失败传 None 由 measure 内部回退方案 A + # 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。 + # 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。 hair_mask = None + ear_mask = None try: from face_analysis.hair_segmenter import get_segmenter - hair_mask = get_segmenter().segment_hair(image) + from face_analysis.calibration import normalized_to_pixel + 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_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box) except Exception as seg_e: # noqa: BLE001 - logger.warning("头发分割失败,回退方案A:%s", seg_e) + logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e) - # 8. 测量 + 标注图 + # 8. 测量 + 标注图(hair_mask 定发际线/头顶;ear_mask 定人头最左/最右竖线) result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) - annotated = create_annotated_image(image, result, hair_mask=hair_mask, variant=variant) + annotated = create_annotated_image( + image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant) buf = BytesIO() annotated.save(buf, format="PNG") diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 3cff2f9..fb07c24 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -5,6 +5,7 @@ - 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。 - 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右, 把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。 + 人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。 - 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。 - 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。 - 单位 cm 统一标在底部「单位cm」。 @@ -116,38 +117,77 @@ _LINE_NAMES = { } -def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_gap): - """从头发分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。 +def _grow_outward(start_col, fg_band, direction, limit): + """从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。 - 返回 (head_left_x, head_right_x);某侧无掩膜/向内噪声,或离脸颊线过近 - (间距 < min_gap)则该侧为 None —— 即与脸颊线太近时只保留脸颊线。 + 用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被 + 分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外 + 延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的 + 那缕头发上。返回外扩后的列号。 """ - if hair_mask is None: + w = fg_band.shape[1] + c = int(start_col) + for _ in range(int(limit)): + nc = c + direction + if not (0 <= nc < w) or not fg_band[:, nc].any(): + break + c = nc + return c + + +def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x, + face_center_x): + """从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。 + + 左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的 + 前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。 + 某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实 + 头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。 + """ + if ear_mask is None: return None, None - m = np.asarray(hair_mask) + m = np.asarray(ear_mask) if m.ndim == 3: m = m[..., 0] + m = m > 0 h = m.shape[0] y0 = max(0, int(y0)); y1 = min(h - 1, int(y1)) if y1 <= y0: return None, None - band = m[y0:y1 + 1] > 0 - cols = np.where(band.any(axis=0))[0] + ear_band = m[y0:y1 + 1] + cols = np.where(ear_band.any(axis=0))[0] if cols.size == 0: return None, None - hl, hr = float(cols.min()), float(cols.max()) - # 仅当确实在脸颊外侧、且与脸颊线间距足够大时才采用 - return (hl if (left_cheek_x - hl) >= min_gap else None, - hr if (hr - right_cheek_x) >= min_gap else None) + # 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应 + fg_band = ear_band + if hair_mask is not None: + hm = np.asarray(hair_mask) + if hm.ndim == 3: + hm = hm[..., 0] + fg_band = ear_band | (hm[y0:y1 + 1] > 0) + grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045)) + + left_cols = cols[cols < face_center_x] + right_cols = cols[cols > face_center_x] + # 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线) + head_l = head_r = None + if right_cols.size: + edge = _grow_outward(right_cols.max(), fg_band, +1, grow) + head_r = float(edge) if edge >= right_cheek_x else None + if left_cols.size: + edge = _grow_outward(left_cols.min(), fg_band, -1, grow) + head_l = float(edge) if edge <= left_cheek_x else None + return head_l, head_r -def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v1"): +def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None, + variant="v1"): """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。 布局(对齐 img1.png 版本5): - 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右 - 取自头发分割掩膜的轮廓(方案 B);无掩膜(方案 A 兜底)时省略头部端线, - 只画 6 点 5 段。 + 取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸 + 遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。 - 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。 - 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。 - 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。 @@ -186,12 +226,11 @@ def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v if variant == "v6": xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线 else: - # 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。 - # 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。 - face_w = pts["right_cheek"][0] - pts["left_cheek"][0] - min_gap = max(1.0, face_w * 0.08) - head_l, head_r = _head_edges_from_mask( - hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap) + # 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。 + # 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。 + lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0] + head_l, head_r = _ear_edges_from_mask( + ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2) head_xs = [x for x in (head_l, head_r) if x is not None] xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点) @@ -304,15 +343,16 @@ if __name__ == "__main__": print("未检出人脸") sys.exit(1) mask = None + ears = None try: from face_analysis.hair_segmenter import get_segmenter - mask = get_segmenter().segment_hair(img) + mask, ears = get_segmenter().segment_hair_and_ears(img) except Exception as e: # noqa: BLE001 print(f"[warn] 分割不可用,回退方案 A:{e}") result = measure_face(lms, mask, w, h) t0 = time.time() - canvas = create_annotated_image(img, result, hair_mask=mask) + canvas = create_annotated_image(img, result, ear_mask=ears, hair_mask=mask) dt = time.time() - t0 os.makedirs(os.path.dirname(out), exist_ok=True) canvas.save(out) diff --git a/face_analysis/hair_segmenter.py b/face_analysis/hair_segmenter.py index 24c6591..ff2c05f 100644 --- a/face_analysis/hair_segmenter.py +++ b/face_analysis/hair_segmenter.py @@ -1,7 +1,8 @@ -"""方案 B:BiSeNet 头发分割 + 发际线/头顶定位。 +"""方案 B:BiSeNet 头发/耳朵分割 + 发际线/头顶定位。 -加载 face-parsing BiSeNet(19 类,hair=17),对整图做像素级语义分割得到头发 -mask,再沿面部中轴线扫描得到真实发际线与头顶。GPU 可用时走 CUDA,否则 CPU。 +加载 face-parsing BiSeNet(CelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图 +做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵 +mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。 单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。 """ import os @@ -15,6 +16,7 @@ import numpy as np _WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth") HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引 +EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名) N_CLASSES = 19 _INPUT_SIZE = 512 # BiSeNet 推理输入边长 @@ -59,8 +61,8 @@ class HairSegmenter: transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)), ]) - def segment_hair(self, image_bgr): - """返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。""" + def _parse(self, image_bgr): + """整图语义分割,返回原图尺寸的类别图(H×W int,值为 0–18 类别号)。""" torch = self._torch h, w = image_bgr.shape[:2] rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) @@ -70,10 +72,49 @@ class HairSegmenter: with torch.no_grad(): out = self.net(inp)[0] # 主输出 (1, C, 512, 512) parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图 - hair_small = (parsing == HAIR_CLASS).astype(np.uint8) # 还原到原图尺寸(最近邻保持类别边界) - hair_mask = cv2.resize(hair_small, (w, h), interpolation=cv2.INTER_NEAREST) - return hair_mask.astype(bool) + return cv2.resize(parsing.astype(np.int32), (w, h), + interpolation=cv2.INTER_NEAREST) + + def segment_hair(self, image_bgr): + """返回 hair_mask(H×W bool,True=头发),尺寸同输入原图。""" + return self._parse(image_bgr) == HAIR_CLASS + + def _parse_face_cropped(self, image_bgr, face_box): + """按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。 + + BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍, + 脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近 + 训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。 + """ + h, w = image_bgr.shape[:2] + x0, y0, x1, y1 = face_box + fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0) + cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8)) + cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5)) + if cx1 - cx0 < 2 or cy1 - cy0 < 2: + return self._parse(image_bgr) + full = np.zeros((h, w), dtype=np.int32) + full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1]) + return full + + def segment_hair_and_ears(self, image_bgr, face_box=None): + """单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。 + + ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空, + 正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判, + 不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。 + + face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再 + 分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。 + """ + if face_box is None: + parsing = self._parse(image_bgr) + else: + parsing = self._parse_face_cropped(image_bgr, face_box) + hair_mask = parsing == HAIR_CLASS + ear_mask = np.isin(parsing, EAR_CLASSES) + return hair_mask, ear_mask _segmenter = None