Files
hair/face_analysis/measure.py
T
xslandClaude 63ee8444c0 feat: 接口1/6 发际线贴近头顶时改为修正而非弃用置null
顶庭(头顶→发际线)<0.7cm 时不再返回 null:
- 上庭 = 0.95×中庭 反推发际线;若越过头顶则顶庭固定 0.7cm(发际线钉在头顶下方)
- hairline_source 新增 "corrected";to_response 始终输出完整四庭
- 标注图始终正常画发际线横线与庭段
- 接口6 三庭逻辑同步,去 discarded 分支

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:47:16 +08:00

300 lines
13 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, 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 几乎重合(如稀疏头发中轴漏检只剩一小撮)→ 上庭改取
# 0.95×中庭反推发际线;若反推后越过头顶,则顶庭固定为 0.7cm(发际线钉在头顶下方
# 0.7cm),上庭取眉心→发际线剩余距离。修正后始终返回完整四庭,不再置 null。
HAIRLINE_CORRECT_TOP_CM = 0.7
UPPER_TO_MIDDLE_RATIO = 0.95 # 贴近头顶时,上庭 = 中庭 × 此比例
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
# 发际线修正:顶庭(头顶→发际线)< 阈值视为发际线贴近头顶、不可靠。
# 改写:上庭 = 0.95×中庭 → 由眉心反推发际线 y;若发际线越过头顶
# hairline_y < hair_top_y),顶庭固定为 0.7cm(发际线钉在头顶下方 0.7cm)。
# 回写 vertical,使 to_response/标注图/v6 复用全部拿到修正后的发际线。
self.detected_top_cm = vertical["top_court_px"] / px_per_cm
self.hairline_corrected = self.detected_top_cm < self.HAIRLINE_CORRECT_TOP_CM
if self.hairline_corrected:
brow_x, brow_y = vertical["brow_center"]
hair_top_y = vertical["hair_top"][1]
new_upper_px = vertical["middle_court_px"] * self.UPPER_TO_MIDDLE_RATIO
hairline_y = brow_y - new_upper_px
if hairline_y < hair_top_y: # 反推发际线越过头顶 → 顶庭固定 0.7cm
hairline_y = hair_top_y + self.HAIRLINE_CORRECT_TOP_CM * px_per_cm
new_upper_px = brow_y - hairline_y
vertical["hairline"] = (brow_x, float(hairline_y))
vertical["upper_court_px"] = new_upper_px
vertical["top_court_px"] = hairline_y - hair_top_y
self.hairline_source = "corrected"
# 各庭厘米(修正后)
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):
# 四庭完整输出(发际线贴近头顶时已在 __init__ 修正 vertical,无 null 分支)。
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))