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"): async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。 """接口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: if image is None:
return None, err(1008, "图片格式不支持(仅 JPG / PNG)") return None, err(1008, "图片格式不支持(仅 JPG / PNG)")
h, w = image.shape[:2]
try: try:
from face_analysis.detector import detector ret = _run_face_measure_data(image, variant=variant)
from face_analysis.pose import estimate_head_pose, check_frontal_face if ret is None:
from face_analysis.measure import measure_face # 区分错误码:未检出人脸 vs 非正面。重新检测一次以判断。
from face_analysis.annotation import create_annotated_image from face_analysis.detector import detector
from face_analysis.pose import check_frontal_face
# 5. 人脸检测 if detector.detect(image) is None:
landmarks = detector.detect(image) return None, err(1001, "无法识别人像")
if landmarks is None:
return None, err(1001, "无法识别人像")
# 6. 姿态校验
if not check_frontal_face(landmarks, w, h):
return None, err(1003, "角度问题,请上传正面照") return None, err(1003, "角度问题,请上传正面照")
head_pose = estimate_head_pose(landmarks, w, h) data, result, hair_mask, ear_mask = ret
# 7. 头发/耳朵分割(方案 B,单次推理),失败传 None 由 measure 内部回退方案 A。 # 标注图(接口1/6 才需要;接口5 复用 _run_face_measure_data 时不生成)
# 传人脸包围盒 → 先按人脸裁剪再分割(全身/街拍等脸偏小的图也能稳出耳朵)。 from face_analysis.annotation import create_annotated_image
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)
annotated = create_annotated_image( annotated = create_annotated_image(
image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant) image, result, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
buf = BytesIO() buf = BytesIO()
annotated.save(buf, format="PNG") 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() data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
return ok(data), None return ok(data), None
except Exception as ex: # noqa: BLE001 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, "face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44, "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": { "landmarks": {
"hair_top": {"x": 540, "y": 120}, "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}, {"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}, "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, "hairline_images": hairline_images,
"best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None), "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) return ok(data)
except Exception as ex: # noqa: BLE001 except Exception as ex: # noqa: BLE001
logger.exception("接口5 处理异常") logger.exception("接口5 处理异常")
+52 -2
View File
@@ -128,6 +128,15 @@
| face_width_cm | number | 脸宽(cm | | face_width_cm | number | 脸宽(cm |
| inter_eye_distance_cm | number | 两眼间距(cm | | inter_eye_distance_cm | number | 两眼间距(cm |
| ratios | object | 七眼各段占脸宽的比例 | | 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)规范 ### 标注图片(UI)规范
@@ -161,7 +170,9 @@
"eye_width_cm": 3.44, "eye_width_cm": 3.44,
"face_width_cm": 24.08, "face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44, "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": { "landmarks": {
"hair_top": { "x": 540, "y": 120 }, "hair_top": { "x": 540, "y": 120 },
@@ -396,6 +407,7 @@
|------|------|------| |------|------|------|
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 | | hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | | best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素: `hairline_images` 元素:
@@ -410,6 +422,19 @@
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 > 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 返回值) ### 响应示例(当前 Mock 返回值)
```json ```json
@@ -436,7 +461,32 @@
"order": 3 "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 }
}
} }
} }
``` ```
+19 -3
View File
@@ -103,10 +103,12 @@
<table> <table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr> <tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>annotated_image_url</code></td><td>string</td><td>标注 PNG URL(仅标注图层,透明底,叠加到原图上显示)</td></tr> <tr><td><code>annotated_image_url</code></td><td>string</td><td>标注 PNG URL(仅标注图层,透明底,叠加到原图上显示)</td></tr>
<tr><td><code>face_total_height_cm</code></td><td>number</td><td>全脸高度(cm</td></tr> <tr><td><code>face_total_height_cm</code></td><td>number</td><td>全脸高度(cm= 四庭之和</td></tr>
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 _cm 和 ratios</td></tr> <tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code><code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:eye_width/face_width/inter_eye_distance,各含 _cm 和 ratios</td></tr> <tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr> <tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
</table> </table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p> <p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端把标注图叠加到原图上即可呈现测量效果(标注图白色线条 #FFFFFF,透明底)。</p>
@@ -233,7 +235,21 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<tr><th>字段</th><th>类型</th><th>说明</th></tr> <tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档叠图、<code>grown_image_url</code> 生发图(失败为 null)、<code>order</code></td></tr> <tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档叠图、<code>grown_image_url</code> 生发图(失败为 null)、<code>order</code></td></tr>
<tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr> <tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
<tr><td><code>face_measure</code></td><td>object \| null</td><td>复用<a href="#if1">接口1</a><strong>四庭七眼测量数值</strong>(不含标注图)。独立流程,测量失败时为 <code>null</code>,不影响发际线主结果。结构见下表</td></tr>
</table> </table>
<p style="margin-top:12px"><code>face_measure</code> 字段(与接口1 的 <code>data</code> 同构,<strong>不含</strong> <code>annotated_image_*</code>):</p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>face_total_height_cm</code></td><td>number</td><td>全脸高度(cm= 四庭之和</td></tr>
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code><code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
</table>
<p style="margin-top:12px;font-size:12px;color:#64748b">💡 前端无需额外请求接口1 即可拿到四庭七眼测量数值;<code>face_measure</code><code>null</code> 时(角度过大/无人脸等)仅隐藏测量区块,发际线结果照常展示。</p>
</div> </div>
</div> </div>
+119 -41
View File
@@ -45,15 +45,11 @@
.col-main { flex: 1.5; min-width: 0; } .col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; 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; } .hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.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; } .hair-block:first-child { padding-top: 4px; }
.result-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); } .hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
.result-card.selected { border-color: #2563eb; } .hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
.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; }
/* 三档 + 生发图 横向排列 */ /* 三档 + 生发图 横向排列 */
.level-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 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 .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; } .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-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; } .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 @@
<div class="results-layout hidden" id="resultsArea"> <div class="results-layout hidden" id="resultsArea">
<div class="col-main"> <div class="col-main">
<!-- 预览 --> <!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card"> <div class="card">
<div class="card-header"><span>📐 三档发际线叠图 + 生发图</span></div> <div class="card-header"><span>📐 四庭七眼测量(face_measure</span><span style="font-weight:400;font-size:12px;color:#9ca3af">复用接口1测量数值</span></div>
<div class="card-body" style="text-align:center"> <div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af"></span></div></div>
<div id="previewArea"><span style="color:#9ca3af">← 点击下方发型卡片</span></div> </div>
<div class="coord-box"> <!-- 所有发型平铺 -->
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个选中发型 middle 档)— 原图像素坐标</div> <div class="card">
<div class="card-header"><span>🎯 所有发型结果(三档 + 生发图)</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
<div class="card-body">
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个发型 middle 档)— 原图像素坐标</div>
<div class="value" id="centerPoint"></div> <div class="value" id="centerPoint"></div>
</div> </div>
<div id="resultsGrid"></div>
</div> </div>
</div> </div>
<!-- 发型列表 -->
<div class="card">
<div class="card-header"><span>🎯 选中发型</span><span style="font-weight:400;font-size:12px;color:#9ca3af">点击切换预览</span></div>
<div class="card-body"><div class="results-grid" id="resultsGrid"></div></div>
</div>
</div> </div>
<div class="col-side"> <div class="col-side">
<div class="card"> <div class="card">
@@ -198,9 +208,10 @@ async function submitTest() {
_images = json.data.hairline_images || []; _images = json.data.hairline_images || [];
_center = json.data.best_hairline_center_point; _center = json.data.best_hairline_center_point;
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—'; $('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success'); setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success');
renderGrid(); renderGrid();
if (_images.length) selectCard(0); renderFaceMeasure(json.data.face_measure);
} else { } else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error'); setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
} }
@@ -211,38 +222,105 @@ async function submitTest() {
} }
function renderGrid() { function renderGrid() {
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
let h = ''; let h = '';
_images.forEach((img, i) => {
h += '<div class="result-card'+(i===0?' selected':'')+'" onclick="selectCard('+i+',this)">'+
'<div class="img-wrap"><img src="'+img.image_middle_url+'" alt="#'+img.order+'" loading="lazy"></div>'+
'<div class="info"><span class="badge">#'+(img.order||'—')+' '+(img.hairline_type||'')+'</span>'+
(img.grown_image_url?'<span style="font-size:11px;color:#7c3aed">🌱 生发</span>':'')+'</div></div>';
});
$('resultsGrid').innerHTML = h;
}
function selectCard(idx, el) {
document.querySelectorAll('.result-card').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected');
const it = _images[idx];
const cell = (label, url) => url const cell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>' ? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>'; : '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
$('previewArea').innerHTML = _images.forEach(function(it) {
'<div class="level-row">'+ h += '<div class="hair-block">' +
cell('middle', it.image_middle_url)+ '<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
cell('high', it.image_high_url)+ (it.grown_image_url ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
cell('low', it.image_low_url)+ '<div class="level-row">' +
cell('🌱 生发图', it.grown_image_url)+ cell('middle', it.image_middle_url) +
cell('high', it.image_high_url) +
cell('low', it.image_low_url) +
cell('生发图', it.grown_image_url) +
'</div>' +
'</div>'; '</div>';
});
$('resultsGrid').innerHTML = h;
} }
function clearResults() { function clearResults() {
_images=[]; _origUrl=''; _center=null; _images=[]; _origUrl=''; _center=null;
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden'; $('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML=''; $('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('previewArea').innerHTML='<span style="color:#9ca3af">← 点击下方发型卡片</span>';
$('centerPoint').textContent='—'; $('centerPoint').textContent='—';
$('resultCount').textContent = '共 0 个发型';
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
}
// 四庭七眼测量渲染(face_measure,接口1数值)
const _PCT = r => (r == null ? '' : '' + (r * 100).toFixed(1) + '%');
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
function renderFaceMeasure(fm) {
const box = $('faceMeasureArea');
if (!fm) {
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
return;
}
// 发际线来源 + 头部姿态
const src = fm.hairline_source;
const srcTag = src === 'segmentation'
? '<span class="fm-tag ok">segmentation 真实分割</span>'
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
let poseHtml = '—';
if (fm.head_pose) {
const p = fm.head_pose;
poseHtml = 'yaw ' + _FMT(p.yaw) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; roll ' + _FMT(p.roll) + '°';
}
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
+ '</div>';
// 四庭
const fc = fm.four_courts || {};
const fr = (fc.ratios || {});
const courts = [
['顶庭', fc.top_court_cm, fr.top_court],
['上庭', fc.upper_court_cm, fr.upper_court],
['中庭', fc.middle_court_cm, fr.middle_court],
['下庭', fc.lower_court_cm, fr.lower_court],
];
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
courts.forEach(c => {
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
});
h += '</div></div>';
// 七眼
const se = fm.seven_eyes || {};
const ser = (se.ratios || {});
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
});
h += '</div></div>';
// 七眼 7 段(从左到右)
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
segLabels.forEach((lab, i) => {
const v = se['eye' + (i + 1)];
const muted = v == null ? ' muted' : '';
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
});
h += '</div></div>';
// 关键点坐标
const lm = fm.landmarks || {};
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(原图像素坐标)</div><div class="fm-grid">';
Object.keys(lmLabels).forEach(k => {
const p = lm[k];
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
});
h += '</div></div>';
box.innerHTML = h;
} }
function copyJson() { function copyJson() {
+25
View File
@@ -155,6 +155,25 @@ def test_hairline_gen_female(client, monkeypatch):
assert "image_middle_url" not in imgs[0] assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"] c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内 assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
fm = d["face_measure"]
assert fm is not None, "face_measure 不应为 null(正常正面照)"
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
# 不应含标注图字段(接口5 只要数值,不要画线图)
assert "annotated_image_base64" not in fm
assert "annotated_image_url" not in fm
# 子结构
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(fm["seven_eyes"].keys())
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
assert set(["hair_top", "hairline", "brow_center",
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
assert fm["hairline_source"] in ("segmentation", "estimated")
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker; # 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
@@ -174,6 +193,12 @@ def test_success_structure(client):
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys()) "lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm", assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(data["seven_eyes"].keys()) "ratios"]).issubset(data["seven_eyes"].keys())
# 七眼:从左到右 eye1~eye7eye1/eye7 在耳朵不可见时可为 null,但键必须存在)
assert set([f"eye{i}" for i in range(1, 8)]).issubset(data["seven_eyes"].keys())
# eye3/eye5 为左右眼宽、eye4 为两眼间距,与 eye_width_cm/inter_eye_distance_cm 语义一致
assert data["seven_eyes"]["eye3"] is not None
assert data["seven_eyes"]["eye4"] is not None
assert data["seven_eyes"]["eye5"] is not None
assert data["hairline_source"] in ("segmentation", "estimated") assert data["hairline_source"] in ("segmentation", "estimated")
# base64 解码为合法 PNG(非 URL # base64 解码为合法 PNG(非 URL
assert "annotated_image_url" not in data assert "annotated_image_url" not in data