diff --git a/app.py b/app.py index 202a448..17fa12f 100644 --- a/app.py +++ b/app.py @@ -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}, + ], + }, }, } } diff --git a/docs/实现说明.md b/docs/实现说明.md index 052cf9c..ed09170 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -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)—— 预览 + 生发图 diff --git a/docs/接口文档.md b/docs/接口文档.md index 2300eb6..e806121 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -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 }` | diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 771219a..691c8dd 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -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: diff --git a/tests/test_api.py b/tests/test_api.py index 06c7b8a..4d12818 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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") diff --git a/tests/test_vertical_lines.py b/tests/test_vertical_lines.py new file mode 100644 index 0000000..8d25814 --- /dev/null +++ b/tests/test_vertical_lines.py @@ -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