From 28255ef7c2da4a273bb4a3299039a7a073509d18 Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 13 Jul 2026 22:40:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A35=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20face=5Fmeasure=EF=BC=88=E5=A4=8D=E7=94=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A31=E6=B5=8B=E9=87=8F=E6=95=B0=E5=80=BC=EF=BC=89+=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A31=20=E4=B8=83=E7=9C=BC=20eye1~eye7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接口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 / 接口文档同步更新。 --- app.py | 168 ++++++++++++++++++++++++++---------- docs/接口文档.md | 54 +++++++++++- static/integration.html | 22 ++++- static/test_interface5.html | 160 +++++++++++++++++++++++++--------- tests/test_api.py | 25 ++++++ 5 files changed, 337 insertions(+), 92 deletions(-) diff --git a/app.py b/app.py index 82c27db..8eadf5b 100644 --- a/app.py +++ b/app.py @@ -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 处理异常") diff --git a/docs/接口文档.md b/docs/接口文档.md index fbc81b3..4656bb8 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -128,6 +128,15 @@ | face_width_cm | number | 脸宽(cm) | | inter_eye_distance_cm | number | 两眼间距(cm) | | ratios | object | 七眼各段占脸宽的比例 | +| eye1 | number \| null | 从左到右第 1 段宽度(cm):人头最左 → 左脸颊(左耳外侧段)。该侧耳朵不可见时为 null | +| eye2 | number | 从左到右第 2 段宽度(cm):左脸颊 → 左眼外角 | +| eye3 | number | 从左到右第 3 段宽度(cm):左眼外角 → 左眼内角(左眼宽度) | +| eye4 | number | 从左到右第 4 段宽度(cm):左眼内角 → 右眼内角(两眼间距) | +| eye5 | number | 从左到右第 5 段宽度(cm):右眼内角 → 右眼外角(右眼宽度) | +| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 | +| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null | + +> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。 ### 标注图片(UI)规范 @@ -161,7 +170,9 @@ "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 } + "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 }, @@ -396,6 +407,7 @@ |------|------|------| | hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 | | best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | +| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 | `hairline_images` 元素: @@ -410,6 +422,19 @@ > worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 +`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段): + +| 字段 | 类型 | 说明 | +|------|------|------| +| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 | +| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 | +| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 | +| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 | +| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) | +| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) | + +> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。 + ### 响应示例(当前 Mock 返回值) ```json @@ -436,7 +461,32 @@ "order": 3 } ], - "best_hairline_center_point": { "x": 540, "y": 430 } + "best_hairline_center_point": { "x": 540, "y": 430 }, + "face_measure": { + "face_total_height_cm": 26.76, + "four_courts": { + "top_court_cm": 5.77, "upper_court_cm": 5.93, + "middle_court_cm": 7.62, "lower_court_cm": 7.44, + "ratios": { "top_court": 0.216, "upper_court": 0.222, + "middle_court": 0.285, "lower_court": 0.278 } + }, + "seven_eyes": { + "eye_width_cm": 2.76, "face_width_cm": 15.08, + "inter_eye_distance_cm": 3.9, + "ratios": { "eye_width": 0.183, "inter_eye_distance": 0.259 }, + "eye1": null, "eye2": 3.0, "eye3": 2.76, "eye4": 3.9, + "eye5": 2.76, "eye6": 3.0, "eye7": null + }, + "landmarks": { + "hair_top": { "x": 504, "y": 103 }, + "hairline": { "x": 504, "y": 228 }, + "brow_center": { "x": 504, "y": 357 }, + "nose_bottom": { "x": 505, "y": 522 }, + "chin_tip": { "x": 506, "y": 683 } + }, + "hairline_source": "segmentation", + "head_pose": { "yaw": -1.39, "pitch": 2.49, "roll": -0.06 } + } } } ``` diff --git a/static/integration.html b/static/integration.html index ec8b77d..cee4908 100644 --- a/static/integration.html +++ b/static/integration.html @@ -103,10 +103,12 @@ - - - + + + + +
字段类型说明
annotated_image_urlstring标注 PNG URL(仅标注图层,透明底,叠加到原图上显示)
face_total_height_cmnumber全脸高度(cm)
four_courtsobject四庭:top/upper/middle/lower,各含 _cm 和 ratios
seven_eyesobject七眼:eye_width/face_width/inter_eye_distance,各含 _cm 和 ratios
face_total_height_cmnumber全脸高度(cm)= 四庭之和
four_courtsobject四庭:top/upper/middle/lower,各含 _cmratios
seven_eyesobject七眼:eye_width_cm/face_width_cm/inter_eye_distance_cm + ratios + eye1~eye7(从左到右 7 段宽度 cm,eye1/eye7 耳朵不可见时为 null)
landmarksobject5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip
hairline_sourcestring发际线来源:"segmentation"(真实分割,可信度高)/ "estimated"(比例估算,可信度低)
head_poseobject头部姿态角度:{ yaw, pitch, roll }(度),接近 0 表示正面照

💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。

@@ -233,7 +235,21 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保 字段类型说明 hairline_images[]object[]选中发型列表,每项含 hairline_typeimage_middle_url/image_high_url/image_low_url 三档叠图、grown_image_url 生发图(失败为 null)、order best_hairline_center_pointobject首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number } + face_measureobject \| null复用接口1四庭七眼测量数值(不含标注图)。独立流程,测量失败时为 null,不影响发际线主结果。结构见下表 + +

face_measure 字段(与接口1 的 data 同构,不含 annotated_image_*):

+ + + + + + + + +
字段类型说明
face_total_height_cmnumber全脸高度(cm)= 四庭之和
four_courtsobject四庭:top/upper/middle/lower,各含 _cmratios
seven_eyesobject七眼:eye_width_cm/face_width_cm/inter_eye_distance_cm + ratios + eye1~eye7(从左到右 7 段宽度 cm,eye1/eye7 耳朵不可见时为 null)
landmarksobject5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip
hairline_sourcestring发际线来源:"segmentation"(真实分割)/ "estimated"(比例估算)
head_poseobject头部姿态角度:{ yaw, pitch, roll }(度)
+ +

💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;face_measurenull 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。

diff --git a/static/test_interface5.html b/static/test_interface5.html index 40e340a..75ee160 100644 --- a/static/test_interface5.html +++ b/static/test_interface5.html @@ -45,15 +45,11 @@ .col-main { flex: 1.5; min-width: 0; } .col-side { flex: 1; min-width: 0; } - /* 方案网格 */ - .results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; margin-top: 12px; } - .result-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; cursor: pointer; transition: .15s; border: 3px solid transparent; } - .result-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); } - .result-card.selected { border-color: #2563eb; } - .result-card .img-wrap { background: #222; min-height: 150px; max-height: 200px; display: flex; align-items: center; justify-content: center; overflow: hidden; } - .result-card .img-wrap img { max-width: 100%; max-height: 200px; object-fit: contain; } - .result-card .info { padding: 10px 12px; font-size: 12px; display: flex; justify-content: space-between; align-items: center; } - .result-card .info .badge { background: #2563eb; color: #fff; padding: 1px 8px; border-radius: 10px; font-size: 11px; } + /* 每个发型区块 */ + .hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; } + .hair-block:first-child { padding-top: 4px; } + .hair-block:last-child { border-bottom: none; padding-bottom: 4px; } + .hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; } /* 三档 + 生发图 横向排列 */ .level-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } @@ -68,6 +64,20 @@ .coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; } .coord-box .value { font-weight: 700; font-size: 18px; color: #111827; } + /* 四庭七眼测量(face_measure) */ + .fm-section { margin-bottom: 18px; } + .fm-section:last-child { margin-bottom: 0; } + .fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; } + .fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } + .fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; } + .fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; } + .fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; } + .fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; } + .fm-cell.muted .v { color: #9ca3af; font-weight: 600; } + .fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; } + .fm-tag.ok { background: #d1fae5; color: #065f46; } + .fm-tag.warn { background: #fef3c7; color: #92400e; } + .json-panel { max-height: 550px; overflow: auto; } .json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } @@ -108,22 +118,22 @@