feat: 接口5 新增 face_measure(复用接口1测量数值)+ 接口1 七眼 eye1~eye7

接口5(/api/v1/hairline/generate)在发际线结果基础上新增 data.face_measure,
复用接口1的四庭七眼测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),
不含标注图;独立流程容错,测量失败时为 null 不影响发际线主结果。

重构:提取 _run_face_measure_data() 共用函数,接口1/6 改调它再补标注图,
行为不变(43 测试全过,错误码 1001/1003/1008 回归正常)。

接口1/5 七眼新增 eye1~eye7 从左到右 7 段宽度(cm),eye1/eye7 耳朵不可见
时为 null(保留键)。

测试页 test_interface5.html:移除点击切换卡片网格改为所有发型平铺,
新增四庭七眼测量卡片。前端接入页 integration.html / 接口文档同步更新。
This commit is contained in:
xsl
2026-07-13 22:40:55 +08:00
parent b163a3f34a
commit 28255ef7c2
5 changed files with 337 additions and 92 deletions
+122 -46
View File
@@ -310,6 +310,81 @@ class ImageJsonBody(BaseModel):
# ---------------------------------------------------------------------------
def _run_face_measure_data(image, variant="v1"):
"""接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。
返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
"""
h, w = image.shape[:2]
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
landmarks = detector.detect(image)
if landmarks is None:
return None
if not check_frontal_face(landmarks, w, h):
return None
head_pose = estimate_head_pose(landmarks, w, h)
# 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
hair_mask = None
ear_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
from face_analysis.calibration import normalized_to_pixel
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
except Exception as seg_e: # noqa: BLE001
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
data = result.to_response()
if variant == "v6":
vd = result.vertical
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
data["landmarks"].pop("hair_top", None)
# 七眼:从左到右共 7 段宽度(cm),仅接口1(v1)含人头最左/最右端线段。
# eye1=左耳外段 eye2=左脸颊段 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊段 eye7=右耳外段
# 端线取自耳朵分割外缘(与标注图同源);某侧耳朵不可见 → 该侧端线缺失 → 对应 eye 置 null(保留键)。
if variant != "v6":
try:
from face_analysis.annotation import _ear_edges_from_mask
epts = result.eyes["points"]
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
xs = [head_l, lcx, epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], rcx, head_r]
pc = result.px_per_cm
for i in range(7):
a, b = xs[i], xs[i + 1]
data["seven_eyes"][f"eye{i + 1}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
except Exception as seg_e: # noqa: BLE001
logger.warning("七眼段宽度计算失败:%s", seg_e)
return data, result, hair_mask, ear_mask
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
@@ -326,60 +401,24 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
if image is None:
return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
h, w = image.shape[:2]
try:
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
# 5. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return None, err(1001, "无法识别人像")
# 6. 姿态校验
if not check_frontal_face(landmarks, w, h):
ret = _run_face_measure_data(image, variant=variant)
if ret is None:
# 区分错误码:未检出人脸 vs 非正面。重新检测一次以判断。
from face_analysis.detector import detector
from face_analysis.pose import check_frontal_face
if detector.detect(image) is None:
return None, err(1001, "无法识别人像")
return None, err(1003, "角度问题,请上传正面照")
head_pose = estimate_head_pose(landmarks, w, h)
data, result, hair_mask, ear_mask = ret
# 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。
# 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。
hair_mask = None
ear_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
from face_analysis.calibration import normalized_to_pixel
pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
except Exception as seg_e: # noqa: BLE001
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
# 8. 测量 + 标注图(hair_mask 定发际线/头顶;ear_mask 定人头最左/最右竖线)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
# 标注图(接口1/6 才需要;接口5 复用 _run_face_measure_data 时不生成)
from face_analysis.annotation import create_annotated_image
annotated = create_annotated_image(
image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
buf = BytesIO()
annotated.save(buf, format="PNG")
# 9. 拆分架构:返回 base64,不落盘不拼 URL(落盘改 URL 由网关完成)
data = result.to_response()
if variant == "v6":
# 接口6:数据去顶庭(重算三庭比例与总高,移除顶庭/头顶字段)
vd = result.vertical
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
data["landmarks"].pop("hair_top", None)
data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data), None
except Exception as ex: # noqa: BLE001
@@ -442,6 +481,8 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
@@ -1012,6 +1053,31 @@ async def face_features(
{"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
"face_measure": {
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44, "upper_court_cm": 3.44,
"middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": {"top_court": 0.25, "upper_court": 0.25,
"middle_court": 0.25, "lower_court": 0.25},
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"hairline_source": "segmentation",
"head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06},
},
},
}
}
@@ -1079,6 +1145,16 @@ async def hairline_generate(
"hairline_images": hairline_images,
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None),
}
# face_measure:复用接口1测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),不含标注图。
# 独立流程,容错:测量失败(无人脸/非正面/分割失败)→ null,不影响发际线主结果。
try:
fm = _run_face_measure_data(image, variant="v1")
data["face_measure"] = fm[0] if fm is not None else None
except Exception as fm_e: # noqa: BLE001
logger.warning("接口5 face_measure 失败:%s", fm_e)
data["face_measure"] = None
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口5 处理异常")