From 81c7a0b55cbcf3e8eeeefbb813e4bd5fc413e7fd Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 27 Jul 2026 23:21:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A31/5/6=20=E5=8F=91?= =?UTF-8?q?=E9=99=85=E7=BA=BF=E5=BC=83=E7=94=A8=E9=80=BB=E8=BE=91(?= =?UTF-8?q?=E9=A1=B6=E5=BA=AD<0.7cm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发际线离头顶<0.7cm时判定分割不可靠,弃用发际线: 顶/上庭字段置null、face_total只算中下庭、标注图保留头顶线去掉发际线、 只标中/下庭。eye1/7竖向范围改用眉心。 --- app.py | 48 +++++++++----- face_analysis/annotation.py | 19 +++++- face_analysis/measure.py | 129 +++++++++++++++++++++++++----------- 3 files changed, 139 insertions(+), 57 deletions(-) diff --git a/app.py b/app.py index e9b356d..6113cb5 100644 --- a/app.py +++ b/app.py @@ -362,21 +362,36 @@ 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": - vd = result.vertical - 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 保留返回(供前端/下游定位头顶),但顶庭数值、 - # 占比、标注图仍按三庭处理,显示效果不变。 + 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 保留返回(供前端/下游定位头顶),但顶庭数值、 + # 占比、标注图仍按三庭处理,显示效果不变。 # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。 # eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 @@ -391,11 +406,14 @@ def _run_face_measure_data(image, variant="v1"): 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(左/右耳外段),需耳朵分割端线 + # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。 + # 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。 from face_analysis.annotation import _ear_edges_from_mask + top_y = (vd["brow_center"][1] if discarded + else vd["hair_top"][1]) head_l, head_r = _ear_edges_from_mask( ear_mask, hair_mask, - result.vertical["hair_top"][1], result.vertical["chin_tip"][1], + top_y, vd["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/face_analysis/annotation.py b/face_analysis/annotation.py index fb07c24..60b1a10 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -213,7 +213,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N buf = np.zeros((h, w, 4), dtype=np.uint8) - if variant == "v6": + # 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线, + # 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。 + if getattr(measure_result, "hairline_discarded", False): + order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"] + elif variant == "v6": order = ["hairline", "brow_center", "nose_bottom", "chin_tip"] else: order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] @@ -266,18 +270,27 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font) # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- - if variant == "v6": + # 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_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["上庭", "中庭", "下庭"] n_court = 3 + court_start = 0 else: court_cm = [measure_result.top_cm, measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["顶庭", "上庭", "中庭", "下庭"] n_court = 4 + court_start = 0 arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线) for i in range(n_court): - y_a, y_b = ys[i], ys[i + 1] + y_a, y_b = ys[court_start + i], ys[court_start + i + 1] # 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线) inset = min(arrow_size, (y_b - y_a) * 0.12) draw_dashed_line_with_arrows( diff --git a/face_analysis/measure.py b/face_analysis/measure.py index 49a4d4d..5cfac61 100644 --- a/face_analysis/measure.py +++ b/face_analysis/measure.py @@ -144,9 +144,22 @@ def measure_seven_eyes(landmarks, image_width, image_height): } +def pt_or_none(vertical, name): + """vertical dict 的点 → {"x","y"},值为 None 时返回 None。""" + v = vertical.get(name) + if v is None: + return None + return {"x": int(round(v[0])), "y": int(round(v[1]))} + + class MeasureResult: """测量结果,提供 to_response() 输出与接口文档同构的 data 字段。""" + # 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。 + # hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线 + # 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。 + HAIRLINE_DISCARD_TOP_CM = 0.7 + def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose, landmarks=None, image_width=None, image_height=None): self.vertical = vertical @@ -164,7 +177,14 @@ class MeasureResult: 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 - self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_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.eye_width_cm = eyes["eye_width_px"] / px_per_cm @@ -172,46 +192,77 @@ class MeasureResult: self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm def to_response(self): - total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] - + self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) - fw_px = self.eyes["face_width_px"] - - def pt(name): - x, y = self.vertical[name] - return {"x": int(round(x)), "y": int(round(y))} - - 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), + # 发际线弃用:顶/上庭相关字段置 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), + }, }, - }, - "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"] / fw_px, 3), - "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_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("hair_top"), - "hairline": pt("hairline"), - "brow_center": pt("brow_center"), - "nose_bottom": pt("nose_bottom"), - "chin_tip": pt("chin_tip"), - }, - "hairline_source": self.hairline_source, - } + "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, + } # left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。 # landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。 if self.landmarks is not None and self.w and self.h: