From 63ee8444c0a552090acf979656a1ea09708803a5 Mon Sep 17 00:00:00 2001 From: xsl Date: Sat, 8 Aug 2026 11:47:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A31/6=20=E5=8F=91?= =?UTF-8?q?=E9=99=85=E7=BA=BF=E8=B4=B4=E8=BF=91=E5=A4=B4=E9=A1=B6=E6=97=B6?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BF=AE=E6=AD=A3=E8=80=8C=E9=9D=9E=E5=BC=83?= =?UTF-8?q?=E7=94=A8=E7=BD=AEnull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顶庭(头顶→发际线)<0.7cm 时不再返回 null: - 上庭 = 0.95×中庭 反推发际线;若越过头顶则顶庭固定 0.7cm(发际线钉在头顶下方) - hairline_source 新增 "corrected";to_response 始终输出完整四庭 - 标注图始终正常画发际线横线与庭段 - 接口6 三庭逻辑同步,去 discarded 分支 Co-Authored-By: Claude --- app.py | 57 ++++++-------- docs/接口文档.md | 2 +- face_analysis/annotation.py | 20 ++--- face_analysis/measure.py | 143 +++++++++++++++--------------------- tests/test_api.py | 4 +- 5 files changed, 91 insertions(+), 135 deletions(-) diff --git a/app.py b/app.py index 7b76d2b..5b51fb9 100644 --- a/app.py +++ b/app.py @@ -401,36 +401,22 @@ def _run_face_measure_data(image, variant="v1"): logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e) result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) - discarded = result.hairline_discarded data = result.to_response() vd = result.vertical if variant == "v6": - if discarded: - # 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。 - base_px = vd["middle_court_px"] + vd["lower_court_px"] - data["four_courts"]["upper_court_cm"] = None - data["four_courts"]["ratios"] = { - "upper_court": None, - "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.middle_cm + result.lower_cm, 2) - data["landmarks"]["hairline"] = None - else: - base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"] - # 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top) - 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) - # 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、 - # 占比、标注图仍按三庭处理,显示效果不变。 + # 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court), + # 比例分母按上/中/下三庭。发际线贴近头顶时已在 measure 修正,hairline 正常返回。 + 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) + # 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、 + # 占比、标注图仍按三庭处理,显示效果不变。 # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。 # eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 @@ -446,10 +432,9 @@ def _run_face_measure_data(image, variant="v1"): None if (a is None or b is None) else round((b - a) / pc, 2)) if variant != "v6": # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。 - # 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。 + # 竖向范围上界用头顶(hair_top 始终可用)。 from face_analysis.annotation import _ear_edges_from_mask - top_y = (vd["brow_center"][1] if result.hairline_discarded - else vd["hair_top"][1]) + top_y = vd["hair_top"][1] head_l, head_r = _ear_edges_from_mask( ear_mask, hair_mask, top_y, vd["chin_tip"][1], @@ -709,11 +694,11 @@ def _run_face_measure_data_debug(image): _draw_text_cv2(vis_hl, f"hairline_y={hairline_y} (source={result.hairline_source})", (hairline_y, w - int(s * 0.5)), color=(0, 200, 255), scale=max(0.4, s * 0.0015)) - # 发际线弃用提示:顶庭 < 0.7cm 视为贴近头顶、不可靠 - if result.hairline_discarded: - gap_cm = result.top_cm + # 发际线修正提示:检测到的顶庭 < 0.7cm 视为贴近头顶、已按 0.95×中庭修正 + if getattr(result, "hairline_corrected", False): + gap_cm = result.detected_top_cm _draw_text_cv2(vis_hl, - f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已弃用", + f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已按 0.95×中庭修正", (10, 10), color=(40, 40, 255), scale=max(0.5, s * 0.0022)) put("hairline", vis_hl) @@ -829,8 +814,8 @@ def _run_face_measure_data_debug(image): None if (a is None or b is None) else round((b - a) / pc, 2)) from face_analysis.annotation import _ear_edges_from_mask as _eef lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0] - # 弃用时用眉心做上界(与 _run_face_measure_data 一致) - top_y = v["brow_center"][1] if result.hairline_discarded else v["hair_top"][1] + # 竖向范围上界用头顶(hair_top 始终可用) + top_y = v["hair_top"][1] head_l, head_r = _eef(ear_mask, hair_mask, top_y, v["chin_tip"][1], lcx, rcx, (lcx + rcx) / 2) data["seven_eyes"]["eye1"] = None if head_l is None else round((lcx - head_l) / pc, 2) diff --git a/docs/接口文档.md b/docs/接口文档.md index cf6b0e5..fef0150 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -446,7 +446,7 @@ | 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`(比例估算) | +| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) | | head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) | | left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` | | right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` | diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 3bc6483..771219a 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -212,11 +212,9 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N buf = np.zeros((h, w, 4), dtype=np.uint8) - # 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线, - # 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。 - if getattr(measure_result, "hairline_discarded", False): - order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"] - elif variant == "v6": + # 横线:v1 = 头顶/发际线/眉心/鼻翼下缘/下巴尖;v6 = 发际线/眉心/鼻翼下缘/下巴尖。 + # (发际线贴近头顶时已在 MeasureResult 修正 vertical,此处始终正常画发际线。) + if variant == "v6": order = ["hairline", "brow_center", "nose_bottom", "chin_tip"] else: order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] @@ -243,7 +241,7 @@ 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(含发际线弃用)= 头顶→下巴尖并两端超出一点 + # 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点 v_top = fy0 if variant == "v6" else fy0 - over v_bot = fy1 if variant == "v6" else fy1 + over @@ -281,14 +279,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm") # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- - # court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线, - # 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。 - if getattr(measure_result, "hairline_discarded", False): - court_cm = [measure_result.middle_cm, measure_result.lower_cm] - court_name = ["中庭", "下庭"] - n_court = 2 - court_start = 1 - elif variant == "v6": + # court_start:庭段在 order 里的起始索引。v6 为三庭(无顶庭),其余四庭。 + if variant == "v6": court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["上庭", "中庭", "下庭"] n_court = 3 diff --git a/face_analysis/measure.py b/face_analysis/measure.py index 975c5c4..9f2e889 100644 --- a/face_analysis/measure.py +++ b/face_analysis/measure.py @@ -153,10 +153,12 @@ def pt_or_none(vertical, name): class MeasureResult: """测量结果,提供 to_response() 输出与接口文档同构的 data 字段。""" - # 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。 - # hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线 - # 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。 - HAIRLINE_DISCARD_TOP_CM = 0.7 + # 发际线修正阈值/固定值:发际线离头顶(顶庭)< 此值时判定分割不可靠,需修正发际线。 + # hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮)→ 上庭改取 + # 0.95×中庭反推发际线;若反推后越过头顶,则顶庭固定为 0.7cm(发际线钉在头顶下方 + # 0.7cm),上庭取眉心→发际线剩余距离。修正后始终返回完整四庭,不再置 null。 + HAIRLINE_CORRECT_TOP_CM = 0.7 + UPPER_TO_MIDDLE_RATIO = 0.95 # 贴近头顶时,上庭 = 中庭 × 此比例 def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose, landmarks=None, image_width=None, image_height=None): @@ -170,19 +172,32 @@ class MeasureResult: self.w = image_width self.h = image_height - # 各庭厘米 + # 发际线修正:顶庭(头顶→发际线)< 阈值视为发际线贴近头顶、不可靠。 + # 改写:上庭 = 0.95×中庭 → 由眉心反推发际线 y;若发际线越过头顶 + # (hairline_y < hair_top_y),顶庭固定为 0.7cm(发际线钉在头顶下方 0.7cm)。 + # 回写 vertical,使 to_response/标注图/v6 复用全部拿到修正后的发际线。 + self.detected_top_cm = vertical["top_court_px"] / px_per_cm + self.hairline_corrected = self.detected_top_cm < self.HAIRLINE_CORRECT_TOP_CM + if self.hairline_corrected: + brow_x, brow_y = vertical["brow_center"] + hair_top_y = vertical["hair_top"][1] + new_upper_px = vertical["middle_court_px"] * self.UPPER_TO_MIDDLE_RATIO + hairline_y = brow_y - new_upper_px + if hairline_y < hair_top_y: # 反推发际线越过头顶 → 顶庭固定 0.7cm + hairline_y = hair_top_y + self.HAIRLINE_CORRECT_TOP_CM * px_per_cm + new_upper_px = brow_y - hairline_y + vertical["hairline"] = (brow_x, float(hairline_y)) + vertical["upper_court_px"] = new_upper_px + vertical["top_court_px"] = hairline_y - hair_top_y + self.hairline_source = "corrected" + + # 各庭厘米(修正后) self.top_cm = vertical["top_court_px"] / px_per_cm self.upper_cm = vertical["upper_court_px"] / px_per_cm self.middle_cm = vertical["middle_court_px"] / px_per_cm self.lower_cm = vertical["lower_court_px"] / px_per_cm - # 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。 - # 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。 - self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM - if self.hairline_discarded: - self.hairline_source = "discarded" - self.face_total_cm = self.middle_cm + self.lower_cm - else: - self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm + self.face_total_cm = (self.top_cm + self.upper_cm + + self.middle_cm + self.lower_cm) # 七眼厘米 self.eye_width_cm = eyes["eye_width_px"] / px_per_cm @@ -190,77 +205,41 @@ class MeasureResult: self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm def to_response(self): - # 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭; - # landmarks.hair_top/hairline 置 null。否则按四庭正常输出。 - if self.hairline_discarded: - base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) - data = { - "face_total_height_cm": round(self.face_total_cm, 2), - "four_courts": { - "top_court_cm": None, - "upper_court_cm": None, - "middle_court_cm": round(self.middle_cm, 2), - "lower_court_cm": round(self.lower_cm, 2), - "ratios": { - "top_court": None, - "upper_court": None, - "middle_court": round(self.vertical["middle_court_px"] / base_px, 3), - "lower_court": round(self.vertical["lower_court_px"] / base_px, 3), - }, + # 四庭完整输出(发际线贴近头顶时已在 __init__ 修正 vertical,无 null 分支)。 + total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] + + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) + data = { + "face_total_height_cm": round(self.face_total_cm, 2), + "four_courts": { + "top_court_cm": round(self.top_cm, 2), + "upper_court_cm": round(self.upper_cm, 2), + "middle_court_cm": round(self.middle_cm, 2), + "lower_court_cm": round(self.lower_cm, 2), + "ratios": { + "top_court": round(self.vertical["top_court_px"] / total_px, 3), + "upper_court": round(self.vertical["upper_court_px"] / total_px, 3), + "middle_court": round(self.vertical["middle_court_px"] / total_px, 3), + "lower_court": round(self.vertical["lower_court_px"] / total_px, 3), }, - "seven_eyes": { - "eye_width_cm": round(self.eye_width_cm, 2), - "face_width_cm": round(self.face_width_cm, 2), - "inter_eye_distance_cm": round(self.inter_eye_cm, 2), - "ratios": { - "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), - "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), - }, + }, + "seven_eyes": { + "eye_width_cm": round(self.eye_width_cm, 2), + "face_width_cm": round(self.face_width_cm, 2), + "inter_eye_distance_cm": round(self.inter_eye_cm, 2), + "ratios": { + "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), + "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), }, - "landmarks": { - "hair_top": None, - "hairline": None, - "brow_center": pt_or_none(self.vertical, "brow_center"), - "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), - "chin_tip": pt_or_none(self.vertical, "chin_tip"), - }, - "hairline_source": self.hairline_source, - } - else: - total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] - + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) - data = { - "face_total_height_cm": round(self.face_total_cm, 2), - "four_courts": { - "top_court_cm": round(self.top_cm, 2), - "upper_court_cm": round(self.upper_cm, 2), - "middle_court_cm": round(self.middle_cm, 2), - "lower_court_cm": round(self.lower_cm, 2), - "ratios": { - "top_court": round(self.vertical["top_court_px"] / total_px, 3), - "upper_court": round(self.vertical["upper_court_px"] / total_px, 3), - "middle_court": round(self.vertical["middle_court_px"] / total_px, 3), - "lower_court": round(self.vertical["lower_court_px"] / total_px, 3), - }, - }, - "seven_eyes": { - "eye_width_cm": round(self.eye_width_cm, 2), - "face_width_cm": round(self.face_width_cm, 2), - "inter_eye_distance_cm": round(self.inter_eye_cm, 2), - "ratios": { - "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3), - "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3), - }, - }, - "landmarks": { - "hair_top": pt_or_none(self.vertical, "hair_top"), - "hairline": pt_or_none(self.vertical, "hairline"), - "brow_center": pt_or_none(self.vertical, "brow_center"), - "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), - "chin_tip": pt_or_none(self.vertical, "chin_tip"), - }, - "hairline_source": self.hairline_source, - } + }, + "landmarks": { + "hair_top": pt_or_none(self.vertical, "hair_top"), + "hairline": pt_or_none(self.vertical, "hairline"), + "brow_center": pt_or_none(self.vertical, "brow_center"), + "nose_bottom": pt_or_none(self.vertical, "nose_bottom"), + "chin_tip": pt_or_none(self.vertical, "chin_tip"), + }, + "hairline_source": self.hairline_source, + } # left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。 # landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。 if self.landmarks is not None and self.w and self.h: diff --git a/tests/test_api.py b/tests/test_api.py index 2a6e086..2e71dd5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -173,7 +173,7 @@ def test_hairline_gen_female(client, monkeypatch): 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 fm["hairline_source"] in ("segmentation", "estimated", "corrected") assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys()) @@ -200,7 +200,7 @@ def test_success_structure(client): 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", "corrected") # base64 解码为合法 PNG(非 URL) assert "annotated_image_url" not in data png = base64.b64decode(data["annotated_image_base64"])