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"):
|
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
@@ -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
@@ -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
@@ -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 ? ' <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) + '° pitch ' + _FMT(p.pitch) + '° 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() {
|
||||||
|
|||||||
@@ -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~eye7(eye1/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
|
||||||
|
|||||||
Reference in New Issue
Block a user