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:
@@ -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 处理异常")
|
||||
|
||||
Reference in New Issue
Block a user