"""四庭七眼测量核心:纵向定位(方案 B 主 / 方案 A 兜底)+ 七眼 + 厘米换算。 整合: - estimate_vertical_landmarks:方案 A,按三庭比例推算上/顶庭(兜底)。 - 决策逻辑:优先方案 B(分割发际线/头顶),合理性校验不过则回退方案 A。 - measure_seven_eyes:眼宽/脸宽/两眼间距实测。 - measure_face:主入口,产出结构化结果 MeasureResult(含 to_response)。 详见技术方案 §4 / §5。本模块不依赖 torch,可在纯几何环境单独运行。 """ from face_analysis.calibration import ( estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list, ) from face_analysis.face_mesh_landmarks import ( GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP, LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER, LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION, ) from face_analysis.hair_segmenter import locate_hairline_by_segmentation # 方案 A 推算比例常量(顶:上:中:下 = 0.22:0.25:0.28:0.25),见技术方案 §4.2 _UPPER_RATIO = 0.25 / 0.265 # 上庭 ÷ 中下庭均值 _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786) def _brow_center(lm, w, h): """眉心 = 索引 9 / 151 中点。""" g9 = normalized_to_pixel(lm[GLABELLA_9], w, h) g151 = normalized_to_pixel(lm[GLABELLA_151], w, h) return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2 def estimate_vertical_landmarks(landmarks, image_width, image_height): """方案 A(兜底):实测中/下庭,按比例推算上/顶庭。 返回 5 个纵向点像素坐标 + 各段像素高度。注意其循环论证局限: 上/顶庭为估算值,不反映真实脸型(详见技术方案 §4.1)。 """ lm = _lm_list(landmarks) w, h = image_width, image_height brow_x, brow_y = _brow_center(lm, w, h) nose_bottom = normalized_to_pixel(lm[NOSE_BOTTOM], w, h) chin_tip = normalized_to_pixel(lm[CHIN_TIP], w, h) middle_court_px = abs(brow_y - nose_bottom[1]) # 眉心 → 鼻翼下缘 lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖 one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中/下庭均值 upper_court_px = one_unit_px * _UPPER_RATIO top_court_px = one_unit_px * _TOP_RATIO hairline_y = brow_y - upper_court_px hair_top_y = hairline_y - top_court_px return { "hair_top": (brow_x, hair_top_y), "hairline": (brow_x, hairline_y), "brow_center": (brow_x, brow_y), "nose_bottom": (nose_bottom[0], nose_bottom[1]), "chin_tip": (chin_tip[0], chin_tip[1]), "top_court_px": top_court_px, "upper_court_px": upper_court_px, "middle_court_px": middle_court_px, "lower_court_px": lower_court_px, } def _vertical_from_segmentation(lm, w, h, hair_mask): """方案 B:用分割得到的发际线/头顶替换方案 A 的上/顶庭。 成功且通过合理性校验返回 vertical dict,否则返回 None。 """ res = locate_hairline_by_segmentation(hair_mask, _brow_center(lm, w, h)[0], h) if res is None: return None hairline_y, hair_top_y = res brow_x, brow_y = _brow_center(lm, w, h) nose_bottom = normalized_to_pixel(lm[NOSE_BOTTOM], w, h) chin_tip = normalized_to_pixel(lm[CHIN_TIP], w, h) middle_court_px = abs(brow_y - nose_bottom[1]) lower_court_px = abs(nose_bottom[1] - chin_tip[1]) upper_court_px = brow_y - hairline_y # 发际线 → 眉心 top_court_px = hairline_y - hair_top_y # 头顶 → 发际线 # 合理性校验:发际线在眉心上方、头顶在发际线上方、各庭为正 if not (hair_top_y < hairline_y < brow_y): return None if upper_court_px <= 0 or top_court_px <= 0: return None if middle_court_px <= 0 or lower_court_px <= 0: return None return { "hair_top": (brow_x, float(hair_top_y)), "hairline": (brow_x, float(hairline_y)), "brow_center": (brow_x, brow_y), "nose_bottom": (nose_bottom[0], nose_bottom[1]), "chin_tip": (chin_tip[0], chin_tip[1]), "top_court_px": top_court_px, "upper_court_px": upper_court_px, "middle_court_px": middle_court_px, "lower_court_px": lower_court_px, } def decide_vertical(landmarks, image_width, image_height, hair_mask): """纵向定位决策:方案 B 优先,失败回退方案 A。 返回 (vertical_dict, hairline_source),source ∈ {"segmentation","estimated"}。 """ lm = _lm_list(landmarks) vb = _vertical_from_segmentation(lm, image_width, image_height, hair_mask) if vb is not None: return vb, "segmentation" return estimate_vertical_landmarks(landmarks, image_width, image_height), "estimated" def measure_seven_eyes(landmarks, image_width, image_height): """七眼:眼宽(左右均值)、脸宽、两眼间距(像素)。""" lm = _lm_list(landmarks) w, h = image_width, image_height left_outer = normalized_to_pixel(lm[LEFT_EYE_OUTER], w, h) left_inner = normalized_to_pixel(lm[LEFT_EYE_INNER], w, h) right_inner = normalized_to_pixel(lm[RIGHT_EYE_INNER], w, h) right_outer = normalized_to_pixel(lm[RIGHT_EYE_OUTER], w, h) left_cheek = normalized_to_pixel(lm[LEFT_CHEEK], w, h) right_cheek = normalized_to_pixel(lm[RIGHT_CHEEK], w, h) left_eye = pixel_distance(left_outer, left_inner) right_eye = pixel_distance(right_inner, right_outer) return { "eye_width_px": (left_eye + right_eye) / 2, "face_width_px": pixel_distance(left_cheek, right_cheek), "inter_eye_distance_px": pixel_distance(left_inner, right_inner), # 标注图用的横向点像素坐标(不进 to_response) "points": { "left_outer": left_outer, "left_inner": left_inner, "right_inner": right_inner, "right_outer": right_outer, "left_cheek": left_cheek, "right_cheek": right_cheek, }, } def pt_or_none(vertical, name): """vertical dict 的点 → {"x","y"},值为 None 时返回 None。""" v = vertical.get(name) if v is None: return None return {"x": int(round(v[0])), "y": int(round(v[1]))} class MeasureResult: """测量结果,提供 to_response() 输出与接口文档同构的 data 字段。""" # 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。 # hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线 # 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。 HAIRLINE_DISCARD_TOP_CM = 0.7 def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose, landmarks=None, image_width=None, image_height=None): self.vertical = vertical self.eyes = eyes self.px_per_cm = px_per_cm self.hairline_source = hairline_source self.head_pose = head_pose # (yaw, pitch, roll) 或 None # 原始 mediapipe 点集 + 图像尺寸,供 to_response 输出 21/251 号定位点 self.landmarks = landmarks self.w = image_width self.h = image_height # 各庭厘米 self.top_cm = vertical["top_court_px"] / px_per_cm self.upper_cm = vertical["upper_court_px"] / px_per_cm self.middle_cm = vertical["middle_court_px"] / px_per_cm self.lower_cm = vertical["lower_court_px"] / px_per_cm # 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。 # 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。 self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM if self.hairline_discarded: self.hairline_source = "discarded" self.face_total_cm = self.middle_cm + self.lower_cm else: self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm # 七眼厘米 self.eye_width_cm = eyes["eye_width_px"] / px_per_cm self.face_width_cm = eyes["face_width_px"] / px_per_cm self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm # 七眼厘米 self.eye_width_cm = eyes["eye_width_px"] / px_per_cm self.face_width_cm = eyes["face_width_px"] / px_per_cm self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm def to_response(self): # 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭; # landmarks.hair_top/hairline 置 null。否则按四庭正常输出。 if self.hairline_discarded: base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) data = { "face_total_height_cm": round(self.face_total_cm, 2), "four_courts": { "top_court_cm": None, "upper_court_cm": None, "middle_court_cm": round(self.middle_cm, 2), "lower_court_cm": round(self.lower_cm, 2), "ratios": { "top_court": None, "upper_court": None, "middle_court": round(self.vertical["middle_court_px"] / base_px, 3), "lower_court": round(self.vertical["lower_court_px"] / base_px, 3), }, }, "seven_eyes": { "eye_width_cm": round(self.eye_width_cm, 2), "face_width_cm": round(self.face_width_cm, 2), "inter_eye_distance_cm": round(self.inter_eye_cm, 2), "ratios": { "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), }, }, "landmarks": { "hair_top": None, "hairline": None, "brow_center": pt_or_none(self.vertical, "brow_center"), "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), "chin_tip": pt_or_none(self.vertical, "chin_tip"), }, "hairline_source": self.hairline_source, } else: total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) data = { "face_total_height_cm": round(self.face_total_cm, 2), "four_courts": { "top_court_cm": round(self.top_cm, 2), "upper_court_cm": round(self.upper_cm, 2), "middle_court_cm": round(self.middle_cm, 2), "lower_court_cm": round(self.lower_cm, 2), "ratios": { "top_court": round(self.vertical["top_court_px"] / total_px, 3), "upper_court": round(self.vertical["upper_court_px"] / total_px, 3), "middle_court": round(self.vertical["middle_court_px"] / total_px, 3), "lower_court": round(self.vertical["lower_court_px"] / total_px, 3), }, }, "seven_eyes": { "eye_width_cm": round(self.eye_width_cm, 2), "face_width_cm": round(self.face_width_cm, 2), "inter_eye_distance_cm": round(self.inter_eye_cm, 2), "ratios": { "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), }, }, "landmarks": { "hair_top": pt_or_none(self.vertical, "hair_top"), "hairline": pt_or_none(self.vertical, "hairline"), "brow_center": pt_or_none(self.vertical, "brow_center"), "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), "chin_tip": pt_or_none(self.vertical, "chin_tip"), }, "hairline_source": self.hairline_source, } # left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。 # landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。 if self.landmarks is not None and self.w and self.h: lm = _lm_list(self.landmarks) def _pt_lm(idx): px, py = normalized_to_pixel(lm[idx], self.w, self.h) return {"x": int(round(px)), "y": int(round(py))} data["left_position"] = _pt_lm(LEFT_POSITION) data["right_position"] = _pt_lm(RIGHT_POSITION) if self.head_pose is not None: yaw, pitch, roll = self.head_pose data["head_pose"] = { "yaw": round(yaw, 2), "pitch": round(pitch, 2), "roll": round(roll, 2), } return data def measure_face(landmarks, hair_mask, image_width, image_height, head_pose=None): """主入口:纵向决策 + 七眼 + 尺度换算 → MeasureResult。""" vertical, source = decide_vertical(landmarks, image_width, image_height, hair_mask) eyes = measure_seven_eyes(landmarks, image_width, image_height) px_per_cm = estimate_scale_factor(landmarks, image_width, image_height) return MeasureResult(vertical, eyes, px_per_cm, source, head_pose, landmarks, image_width, image_height) if __name__ == "__main__": import sys import json import cv2 from face_analysis.detector import detector from face_analysis.pose import estimate_head_pose path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg" img = cv2.imread(path) if img is None: print(f"无法读取图片: {path}") sys.exit(1) h, w = img.shape[:2] lms = detector.detect(img) if lms is None: print("未检出人脸") sys.exit(1) # 尝试分割(若 torch 不可用则走方案 A) mask = None try: from face_analysis.hair_segmenter import get_segmenter mask = get_segmenter().segment_hair(img) except Exception as e: # noqa: BLE001 print(f"[warn] 分割不可用,回退方案 A:{e}") pose = estimate_head_pose(lms, w, h) result = measure_face(lms, mask, w, h, head_pose=pose) print(json.dumps(result.to_response(), ensure_ascii=False, indent=2))