feat(测量): 接口1/6 返回与标注图一致的竖线坐标
前端需要按标注图重绘竖线,坐标与画线共用同一套几何。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -381,8 +381,8 @@ def _run_face_measure_data(image, variant="v1", landmarks_override=None):
|
||||
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
|
||||
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
|
||||
|
||||
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
|
||||
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
|
||||
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)+ vertical_lines。
|
||||
variant="v6"(接口6):去顶庭、不画端线、无 eye1/eye7,vertical_lines 仅 6 条内线。
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
from face_analysis.detector import detector
|
||||
@@ -428,30 +428,29 @@ def _run_face_measure_data(image, variant="v1", landmarks_override=None):
|
||||
# 占比、标注图仍按三庭处理,显示效果不变。
|
||||
|
||||
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 用竖线坐标里的人头端线,仅接口1 有。
|
||||
# vertical_lines 与标注图竖线同一套几何。
|
||||
try:
|
||||
from face_analysis.annotation import vertical_line_geometry
|
||||
epts = result.eyes["points"]
|
||||
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||
pc = result.px_per_cm
|
||||
inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], rcx]
|
||||
geom = vertical_line_geometry(
|
||||
result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||
data["vertical_lines"] = geom
|
||||
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
|
||||
for i in range(5):
|
||||
a, b = inner_xs[i], inner_xs[i + 1]
|
||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||
if variant != "v6":
|
||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||
# 竖向范围上界用头顶(hair_top 始终可用)。
|
||||
from face_analysis.annotation import _ear_edges_from_mask
|
||||
top_y = vd["hair_top"][1]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask,
|
||||
top_y, vd["chin_tip"][1],
|
||||
lcx, rcx, (lcx + rcx) / 2)
|
||||
by_name = {line["name"]: line["x"] for line in geom["lines"]}
|
||||
head_l = by_name.get("left_head_edge")
|
||||
head_r = by_name.get("right_head_edge")
|
||||
data["seven_eyes"]["eye1"] = (
|
||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||
None if head_l is None else round((by_name["left_cheek"] - head_l) / pc, 2))
|
||||
data["seven_eyes"]["eye7"] = (
|
||||
None if (head_r is None) else round((head_r - rcx) / pc, 2))
|
||||
None if head_r is None else round((head_r - by_name["right_cheek"]) / pc, 2))
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("七眼段宽度计算失败:%s", seg_e)
|
||||
|
||||
@@ -901,6 +900,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比**
|
||||
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
|
||||
- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖)
|
||||
- 竖线坐标 `vertical_lines`(与标注图同一套:共用 y 范围 + 从左到右的 x)
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
@@ -956,6 +956,19 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
"nose_bottom": {"x": 540, "y": 1050},
|
||||
"chin_tip": {"x": 540, "y": 1360},
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 98, "y_bottom": 1410,
|
||||
"lines": [
|
||||
{"name": "left_head_edge", "x": 120},
|
||||
{"name": "left_cheek", "x": 210},
|
||||
{"name": "left_eye_outer", "x": 310},
|
||||
{"name": "left_eye_inner", "x": 420},
|
||||
{"name": "right_eye_inner", "x": 560},
|
||||
{"name": "right_eye_outer", "x": 670},
|
||||
{"name": "right_cheek", "x": 780},
|
||||
{"name": "right_head_edge", "x": 890},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +1045,7 @@ async def face_measure_debug(
|
||||
- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**)
|
||||
- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距
|
||||
- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖)
|
||||
- 竖线坐标 `vertical_lines`(发际线→下巴尖,仅 6 条内线,无人头两端)
|
||||
|
||||
基于接口1 的变体,与接口1 的差异:
|
||||
- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和
|
||||
@@ -1089,6 +1103,17 @@ async def face_measure_debug(
|
||||
"nose_bottom": {"x": 540, "y": 1050},
|
||||
"chin_tip": {"x": 540, "y": 1360},
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 430, "y_bottom": 1360,
|
||||
"lines": [
|
||||
{"name": "left_cheek", "x": 210},
|
||||
{"name": "left_eye_outer", "x": 310},
|
||||
{"name": "left_eye_inner", "x": 420},
|
||||
{"name": "right_eye_inner", "x": 560},
|
||||
{"name": "right_eye_outer", "x": 670},
|
||||
{"name": "right_cheek", "x": 780},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@
|
||||
## 2. 各接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、竖线坐标 `vertical_lines`、一张透明底标注 PNG。
|
||||
- **怎么实现**(`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
|
||||
虹膜直径法定标(px→cm) → **眉心以下实测**;**眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
|
||||
失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。
|
||||
@@ -68,7 +68,7 @@
|
||||
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体
|
||||
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。
|
||||
- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。`vertical_lines` 与标注图共用 `vertical_line_geometry`:接口 6 的 y 范围是发际线到下巴尖,`lines` 只有 6 条内线。
|
||||
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
|
||||
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
|
||||
+50
-1
@@ -113,6 +113,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
| four_courts | object | 四庭数据,见下表 |
|
||||
| seven_eyes | object | 七眼数据,见下表 |
|
||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||
| vertical_lines | object | 标注图竖线坐标,见下表。与标注 PNG 上的竖线同一套几何 |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -142,7 +143,28 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
| 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` 语义一致。
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。`eye1`/`eye7` 的厘米数由 `vertical_lines` 里对应端点的 x 相减得到。
|
||||
|
||||
`vertical_lines`(竖线,原图像素,原点左上,x 向右,y 向下):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| y_top | int | 竖线共用上端。接口 1 在头顶之上,伸出 `max(6, round(短边 × 0.03))` 像素,可能略小于 0 |
|
||||
| y_bottom | int | 竖线共用下端。接口 1 在下巴尖之下,伸出同样像素,可能略大于图高 |
|
||||
| lines | array | 从左到右实际画出的竖线。每项 `{ "name": string, "x": int }` |
|
||||
|
||||
`lines[].name` 从左到右:
|
||||
|
||||
| name | 竖线 |
|
||||
|------|------|
|
||||
| left_head_edge | 人头最左(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye1` 为 `null` |
|
||||
| left_cheek | 左脸颊 |
|
||||
| left_eye_outer | 左眼外角 |
|
||||
| left_eye_inner | 左眼内角 |
|
||||
| right_eye_inner | 右眼内角 |
|
||||
| right_eye_outer | 右眼外角 |
|
||||
| right_cheek | 右脸颊 |
|
||||
| right_head_edge | 人头最右(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye7` 为 `null` |
|
||||
|
||||
### 标注图片(UI)规范
|
||||
|
||||
@@ -186,6 +208,19 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 98, "y_bottom": 1410,
|
||||
"lines": [
|
||||
{ "name": "left_head_edge", "x": 120 },
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 },
|
||||
{ "name": "right_head_edge", "x": 890 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +252,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
||||
| vertical_lines | object | 标注图竖线坐标。结构同接口 1,但 `y_top`/`y_bottom` 为发际线到下巴尖(不伸出),`lines` 只有中间 6 条,无 `left_head_edge` / `right_head_edge` |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -246,6 +282,17 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 430, "y_bottom": 1360,
|
||||
"lines": [
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +500,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
| 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 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
@@ -606,6 +654,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
||||
| 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 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
+59
-16
@@ -180,6 +180,60 @@ def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_
|
||||
return head_l, head_r
|
||||
|
||||
|
||||
# 返回给接口的竖线名 → eyes["points"] 里的键。从左到右的解剖顺序。
|
||||
_VERTICAL_INNER_LINES = (
|
||||
("left_cheek", "left_cheek"),
|
||||
("left_eye_outer", "left_outer"),
|
||||
("left_eye_inner", "left_inner"),
|
||||
("right_eye_inner", "right_inner"),
|
||||
("right_eye_outer", "right_outer"),
|
||||
("right_cheek", "right_cheek"),
|
||||
)
|
||||
|
||||
|
||||
def vertical_line_geometry(measure_result, image_width, image_height,
|
||||
ear_mask=None, hair_mask=None, variant="v1"):
|
||||
"""标注图竖线的像素几何,供画线与接口返回共用。
|
||||
|
||||
返回 {"y_top", "y_bottom", "lines": [{"name", "x"}, ...]}。
|
||||
x / y 均为原图像素整数(四舍五入)。lines 从左到右,只含实际画出的线。
|
||||
variant="v1":竖线从头顶上方延伸到下巴下方;有耳朵外缘时含
|
||||
left_head_edge / right_head_edge。
|
||||
variant="v6":竖线从发际线到下巴尖,不含人头两端。
|
||||
"""
|
||||
v = measure_result.vertical
|
||||
s = min(image_width, image_height)
|
||||
if variant == "v6":
|
||||
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
else:
|
||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
ys = [v[name][1] for name in order]
|
||||
|
||||
pts = measure_result.eyes["points"]
|
||||
named = [(public, float(pts[internal][0])) for public, internal in _VERTICAL_INNER_LINES]
|
||||
if variant != "v6":
|
||||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||
if head_l is not None:
|
||||
named.append(("left_head_edge", float(head_l)))
|
||||
if head_r is not None:
|
||||
named.append(("right_head_edge", float(head_r)))
|
||||
named.sort(key=lambda item: item[1])
|
||||
|
||||
fy0, fy1 = ys[0], ys[-1]
|
||||
over = max(6, round(s * 0.030))
|
||||
if variant == "v6":
|
||||
y_top, y_bottom = fy0, fy1
|
||||
else:
|
||||
y_top, y_bottom = fy0 - over, fy1 + over
|
||||
return {
|
||||
"y_top": int(round(y_top)),
|
||||
"y_bottom": int(round(y_bottom)),
|
||||
"lines": [{"name": name, "x": int(round(x))} for name, x in named],
|
||||
}
|
||||
|
||||
|
||||
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
|
||||
variant="v1"):
|
||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||||
@@ -221,19 +275,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
ys = [v[name][1] for name in order]
|
||||
|
||||
pts = measure_result.eyes["points"]
|
||||
seven_keys = ["left_cheek", "left_outer", "left_inner",
|
||||
"right_inner", "right_outer", "right_cheek"]
|
||||
base_xs = [pts[k][0] for k in seven_keys]
|
||||
if variant == "v6":
|
||||
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
|
||||
else:
|
||||
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
|
||||
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
|
||||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||
head_xs = [x for x in (head_l, head_r) if x is not None]
|
||||
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
||||
# 竖线 x 与纵向范围与接口返回的 vertical_lines 同一份几何。
|
||||
geom = vertical_line_geometry(
|
||||
measure_result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||
xs = [line["x"] for line in geom["lines"]]
|
||||
v_top, v_bot = geom["y_top"], geom["y_bottom"]
|
||||
|
||||
# 人脸/人头包围盒
|
||||
fx0, fx1 = xs[0], xs[-1]
|
||||
@@ -241,9 +287,6 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
face_cx = (fx0 + fx1) / 2
|
||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
|
||||
v_top = fy0 if variant == "v6" else fy0 - over
|
||||
v_bot = fy1 if variant == "v6" else fy1 + over
|
||||
|
||||
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
|
||||
for cy in ys:
|
||||
|
||||
+38
-1
@@ -461,7 +461,7 @@ def test_success_structure(client):
|
||||
data = body["data"]
|
||||
# 业务字段对齐文档
|
||||
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
|
||||
"landmarks", "hairline_source", "head_pose",
|
||||
"landmarks", "hairline_source", "head_pose", "vertical_lines",
|
||||
"annotated_image_base64"]).issubset(data.keys())
|
||||
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
|
||||
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
|
||||
@@ -478,3 +478,40 @@ def test_success_structure(client):
|
||||
assert "annotated_image_url" not in data
|
||||
png = base64.b64decode(data["annotated_image_base64"])
|
||||
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
_assert_vertical_lines(data, "v1")
|
||||
|
||||
|
||||
_INNER_VLINES = [
|
||||
"left_cheek", "left_eye_outer", "left_eye_inner",
|
||||
"right_eye_inner", "right_eye_outer", "right_cheek",
|
||||
]
|
||||
|
||||
|
||||
def _assert_vertical_lines(data, variant):
|
||||
vl = data["vertical_lines"]
|
||||
assert isinstance(vl["y_top"], int) and isinstance(vl["y_bottom"], int)
|
||||
assert vl["y_top"] < vl["y_bottom"]
|
||||
names = [line["name"] for line in vl["lines"]]
|
||||
xs = [line["x"] for line in vl["lines"]]
|
||||
assert xs == sorted(xs)
|
||||
assert all(isinstance(x, int) for x in xs)
|
||||
if variant == "v6":
|
||||
assert names == _INNER_VLINES
|
||||
assert vl["y_top"] == data["landmarks"]["hairline"]["y"]
|
||||
assert vl["y_bottom"] == data["landmarks"]["chin_tip"]["y"]
|
||||
assert "eye1" not in data["seven_eyes"]
|
||||
assert "eye7" not in data["seven_eyes"]
|
||||
return
|
||||
assert set(_INNER_VLINES).issubset(names)
|
||||
assert names[0] in ("left_head_edge", "left_cheek")
|
||||
assert names[-1] in ("right_head_edge", "right_cheek")
|
||||
by_name = {line["name"]: line["x"] for line in vl["lines"]}
|
||||
assert ("left_head_edge" in by_name) == (data["seven_eyes"]["eye1"] is not None)
|
||||
assert ("right_head_edge" in by_name) == (data["seven_eyes"]["eye7"] is not None)
|
||||
|
||||
|
||||
def test_measure_v2_vertical_lines_have_no_head_edges(client):
|
||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||
body = client.post("/api/v1/face/measure-v2", headers=H, files=files).json()
|
||||
assert body["code"] == 0, body
|
||||
_assert_vertical_lines(body["data"], "v6")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""竖线坐标:与标注图同一套几何,接口1/6 的返回字段由此而来。"""
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.measure import MeasureResult
|
||||
from face_analysis.annotation import vertical_line_geometry
|
||||
|
||||
|
||||
def _result():
|
||||
"""已知像素坐标的测量结果。顶庭 2cm,不会触发发际线修正。"""
|
||||
vertical = {
|
||||
"hair_top": (500.0, 100.0),
|
||||
"hairline": (500.0, 200.2),
|
||||
"brow_center": (500.0, 350.0),
|
||||
"nose_bottom": (500.0, 500.0),
|
||||
"chin_tip": (500.0, 700.4),
|
||||
"top_court_px": 100.0,
|
||||
"upper_court_px": 150.0,
|
||||
"middle_court_px": 150.0,
|
||||
"lower_court_px": 200.0,
|
||||
}
|
||||
eyes = {
|
||||
"eye_width_px": 80.0,
|
||||
"face_width_px": 400.0,
|
||||
"inter_eye_distance_px": 90.0,
|
||||
"points": {
|
||||
"left_cheek": (300.4, 400.0),
|
||||
"left_outer": (360.6, 400.0),
|
||||
"left_inner": (440.2, 400.0),
|
||||
"right_inner": (530.8, 400.0),
|
||||
"right_outer": (610.4, 400.0),
|
||||
"right_cheek": (700.7, 400.0),
|
||||
},
|
||||
}
|
||||
return MeasureResult(vertical, eyes, 50.0, "estimated", None)
|
||||
|
||||
|
||||
_INNER = [
|
||||
"left_cheek", "left_eye_outer", "left_eye_inner",
|
||||
"right_eye_inner", "right_eye_outer", "right_cheek",
|
||||
]
|
||||
|
||||
|
||||
def test_v1_inner_lines_and_extended_span():
|
||||
geom = vertical_line_geometry(_result(), 1000, 800, variant="v1")
|
||||
# 短边 800,伸出 max(6, round(800*0.03)) = 24
|
||||
assert geom["y_top"] == 76
|
||||
assert geom["y_bottom"] == 724
|
||||
assert [line["name"] for line in geom["lines"]] == _INNER
|
||||
assert [line["x"] for line in geom["lines"]] == [300, 361, 440, 531, 610, 701]
|
||||
|
||||
|
||||
def test_v6_span_is_hairline_to_chin_without_head_edges():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
ear[120:680, 760] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v6")
|
||||
assert geom["y_top"] == 200
|
||||
assert geom["y_bottom"] == 700
|
||||
assert [line["name"] for line in geom["lines"]] == _INNER
|
||||
|
||||
|
||||
def test_v1_includes_ear_edges_left_to_right():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
ear[120:680, 760] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
|
||||
names = [line["name"] for line in geom["lines"]]
|
||||
xs = [line["x"] for line in geom["lines"]]
|
||||
assert names == ["left_head_edge", *_INNER, "right_head_edge"]
|
||||
assert xs[0] == 250 and xs[-1] == 760
|
||||
assert xs == sorted(xs)
|
||||
|
||||
|
||||
def test_missing_ear_omits_that_edge():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
|
||||
names = [line["name"] for line in geom["lines"]]
|
||||
assert names[0] == "left_head_edge"
|
||||
assert "right_head_edge" not in names
|
||||
Reference in New Issue
Block a user