Files
hair/face_analysis/measure.py
T
xsl a1d458eb20 feat(接口1/5/6): 返回数据新增 left_position/right_position(MediaPipe 21/251号点)
- face_mesh_landmarks.py: 加常量 LEFT_POSITION=21 / RIGHT_POSITION=251
- measure.py: MeasureResult 收 landmarks/宽高, to_response 顶层输出两点(原图像素 {x,y}, 与 landmarks 同格式)
- measure_face 透传 landmarks(签名不变, 6处调用零改动); __init__ 用 None 默认值守卫向后兼容
- 三接口自动生效: 接口1/6 在 data 顶层, 接口5 在 face_measure 对象里(复用同一 to_response)
- 实测坐标左右镜像合理, 44 个现有测试全过无回归
2026-07-24 00:42:26 +08:00

272 lines
11 KiB
Python
Raw 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, 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,
},
}
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
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
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):
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
fw_px = self.eyes["face_width_px"]
def pt(name):
x, y = self.vertical[name]
return {"x": int(round(x)), "y": int(round(y))}
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"] / fw_px, 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3),
},
},
"landmarks": {
"hair_top": pt("hair_top"),
"hairline": pt("hairline"),
"brow_center": pt("brow_center"),
"nose_bottom": pt("nose_bottom"),
"chin_tip": pt("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))