Files
hair/face_analysis/measure.py
xslandCursor 718372dc07 feat: 接口1/6 标注层字号上调一档 + 眉心改用 9 号点定位
- annotation: 自适应字号系数 0.017→0.020(下限 8→9),标注文字更大更清晰
- measure: _brow_center 只取 FaceMesh 9 号点(眉间上点),不再与 151 取中点

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:05:44 +08:00

321 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""四庭七眼测量核心:纵向定位(方案 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, 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(眉间上点)。"""
return normalized_to_pixel(lm[GLABELLA_9], w, h)
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
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_positionmediapipe 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))