- measure.py: MeasureResult 增 hairline_discarded 判定(顶庭<0.7cm),弃用时 顶/上庭字段置null、face_total只算中下庭、hairline_source=discarded - annotation.py: 弃用时保留头顶线、去掉发际线、只标中/下庭 - app.py: 接口1/6 弃用分支 + eye1/7竖向范围改用眉心 + 接口1调试页(measure-debug) - 新增 static/test_interface1_debug.html 分步可视化页(9步原理)
328 lines
15 KiB
Python
328 lines
15 KiB
Python
"""四庭七眼测量核心:纵向定位(方案 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))
|