diff --git a/app.py b/app.py index 0650510..ab42ce7 100644 --- a/app.py +++ b/app.py @@ -14,7 +14,7 @@ from typing import Any, List, Optional import cv2 import numpy as np -from PIL import Image +from PIL import Image, ImageDraw, ImageFont from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -401,21 +401,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 有。 @@ -430,11 +445,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 result.hairline_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)) @@ -446,6 +464,398 @@ def _run_face_measure_data(image, variant="v1"): return data, result, hair_mask, ear_mask +# --------------------------------------------------------------------------- +# 接口1 调试:分步可视化(每一步的中间产物图) +# --------------------------------------------------------------------------- + +# 调试接口 9 张分步图的 key(与前端 STEPS 一一对应) +_DEBUG_STEP_KEYS = [ + "input", "landmarks", "pose", "segmentation", + "hairline", "vertical", "seven_eyes", "scale", "final", +] + + +def _overlay_mask(image_bgr, mask, color, alpha=0.45): + """在 BGR 图上把 mask 区域以 color(BGR) 半透明叠加。mask 为 bool/uint8。""" + out = image_bgr.copy() + m = np.asarray(mask).astype(bool) + if m.shape[:2] != out.shape[:2]: + return out + overlay = out[m] + # alpha 混合 + overlay = (overlay * (1 - alpha) + np.array(color, dtype=np.float32) * alpha) + out[m] = np.clip(overlay, 0, 255).astype(np.uint8) + return out + + +_DEBUG_FONT_PATH = os.path.join( + os.path.dirname(__file__), "face_analysis", "fonts", "NotoSansCJKsc-Regular.otf") +_debug_font_cache = {} + + +def _debug_font(size): + f = _debug_font_cache.get(size) + if f is None: + f = ImageFont.truetype(_DEBUG_FONT_PATH, size) + _debug_font_cache[size] = f + return f + + +def _draw_text_cv2(img, text, org, color=(255, 255, 255), scale=None, thickness=None, + bg=True, anchor="lt"): + """在 BGR 图上绘制文字(支持中文,用 PIL + 思源黑体)。org=(x,y)。 + + cv2.putText 不支持中文(会显示成问号),故统一改用 PIL 渲染。color 为 BGR 三元组。 + anchor: lt=左上角对齐 org / lb=左下角 / ct=水平垂直居中。bg=True 时画黑色背景框。 + """ + h, w = img.shape[:2] + s = min(w, h) + scale = scale if scale else max(0.4, s * 0.0016) + thickness = thickness if thickness else max(1, round(s * 0.0022)) + # PIL 字号与 cv2 scale 大致对应(cv2 scale≈字号/30) + font_size = max(10, round(scale * 30)) + font = _debug_font(font_size) + # BGR → RGB + rgb = (int(color[2]), int(color[1]), int(color[0])) + pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) + draw = ImageDraw.Draw(pil_img) + bbox = draw.textbbox((0, 0), text, font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + x, y = org + if anchor == "lb": + text_y = y - th + elif anchor == "ct": + x = x - tw // 2 + text_y = y - th // 2 + else: + text_y = y + if bg: + pad = max(2, round(thickness * 1.2)) + draw.rectangle( + [max(0, x - pad), max(0, text_y - pad), + min(w, x + tw + pad), min(h, text_y + th + pad)], + fill=(0, 0, 0)) + # PIL text 的 y 是文字顶部基线,bbox 偏移需校正 + draw.text((x, text_y - bbox[1]), text, fill=rgb, font=font) + img[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR) + return img + + +def _run_face_measure_data_debug(image): + """接口1 调试:产出 9 步中间图 + 数值,逐步塞进返回 dict。 + + 与 _run_face_measure_data 同链路,但每步把中间产物渲染成叠加图(JPG base64) + 放进 data["steps"][key + "_base64"],关键数值放进 data["debug"]。 + 检测/姿态失败时,仍返回已完成的步骤图 + 对应错误码,供前端展示「卡在哪一步」。 + + 返回 (data, error_code_or_None, error_msg_or_None)。 + """ + 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, _brow_center + from face_analysis.calibration import ( + normalized_to_pixel, estimate_scale_factor, + _iris_diameter_px, _eye_width_px, _lm_list, + AVG_IRIS_DIAMETER_CM, AVG_EYE_WIDTH_CM, + ) + from face_analysis.face_mesh_landmarks import ( + GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP, + LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER, + LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION, + IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT, IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT, + PNP_INDICES, + ) + from face_analysis.hair_segmenter import locate_hairline_by_segmentation + + data = {"steps": {}, "debug": {"image_width": w, "image_height": h}} + steps = data["steps"] + dbg = data["debug"] + + def put(key, bgr_img): + steps[key + "_base64"] = "data:image/jpeg;base64," + _jpg_b64(bgr_img) + + # ① 输入原图 + put("input", image) + + # ② 人脸关键点检测 + landmarks = detector.detect(image) + if landmarks is None: + dbg["num_landmarks"] = 0 + return data, 1001, "无法识别人像" + lm = _lm_list(landmarks) + dbg["num_landmarks"] = len(lm) + + vis_lm = image.copy() + # 先画全部 478 点(小白点) + s = min(w, h) + r_all = max(1, round(s * 0.0018)) + for p in lm: + px = normalized_to_pixel(p, w, h) + cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_all, (220, 220, 220), -1) + # 虹膜点 468~477(青色稍大) + r_iris = max(2, round(s * 0.0035)) + for idx in [468, 469, 470, 471, 472, 473, 474, 475, 476, 477]: + if idx < len(lm): + px = normalized_to_pixel(lm[idx], w, h) + cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_iris, (255, 200, 0), -1) + # 七眼 6 点 + 5 纵向点(红色 + 标号) + key_pts = { + "头顶(推算)": None, # 纵向点除眉心外由后续 measure 给出,这里只画能拿到的 + "眉心": GLABELLA_9, + } + important = [ + (GLABELLA_9, "眉间9"), (GLABELLA_151, "眉间151"), (NOSE_BOTTOM, "鼻翼下94"), + (CHIN_TIP, "下巴152"), (LEFT_EYE_OUTER, "左眼外33"), (LEFT_EYE_INNER, "左眼内133"), + (RIGHT_EYE_INNER, "右眼内362"), (RIGHT_EYE_OUTER, "右眼外263"), + (LEFT_CHEEK, "左脸234"), (RIGHT_CHEEK, "右脸454"), + ] + r_imp = max(3, round(s * 0.005)) + for idx, name in important: + px = normalized_to_pixel(lm[idx], w, h) + cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_imp, (0, 0, 255), -1) + _draw_text_cv2(vis_lm, name, (int(px[0]) + r_imp + 2, int(px[1])), + color=(0, 255, 255), scale=max(0.35, s * 0.0013)) + put("landmarks", vis_lm) + + # ③ 头部姿态校验 + head_pose = estimate_head_pose(lm, w, h) if hasattr(landmarks, "landmark") else estimate_head_pose(lm, w, h) + frontal = check_frontal_face(landmarks, w, h) + vis_pose = image.copy() + # 画 6 个 PnP 点(黄) + nose_tip_px = None + for idx in PNP_INDICES: + px = normalized_to_pixel(lm[idx], w, h) + cv2.circle(vis_pose, (int(px[0]), int(px[1])), max(3, round(s * 0.004)), (0, 255, 255), -1) + if idx == 1: + nose_tip_px = (int(px[0]), int(px[1])) + # 三轴箭头(鼻尖为原点) + if nose_tip_px is not None and head_pose is not None: + L = max(30, round(s * 0.08)) + # yaw 绕 Y(竖轴) → 在屏幕上表现为左右;pitch 绕 X → 上下;roll 绕 Z → 面内旋转 + yaw, pitch, roll = head_pose + import math + # 简化:用 roll 直接旋转 X/Y 轴示意,yaw 投影到横向、pitch 到纵向 + cosr, sinr = math.cos(math.radians(roll)), math.sin(math.radians(roll)) + # X 轴(红,向右) + cv2.arrowedLine(vis_pose, nose_tip_px, + (int(nose_tip_px[0] + L * cosr), int(nose_tip_px[1] + L * sinr)), + (0, 0, 255), max(2, round(s * 0.003)), tipLength=0.2) + # Y 轴(绿,向下) + cv2.arrowedLine(vis_pose, nose_tip_px, + (int(nose_tip_px[0] - L * sinr), int(nose_tip_px[1] + L * cosr)), + (0, 255, 0), max(2, round(s * 0.003)), tipLength=0.2) + # Z 轴(青,向内用圆圈示意) + cv2.circle(vis_pose, nose_tip_px, max(6, round(s * 0.012)), (255, 255, 0), max(1, round(s * 0.002))) + # 角度文字 + txt = f"yaw={yaw:.1f} pitch={pitch:.1f} roll={roll:.1f}" + _draw_text_cv2(vis_pose, txt, (10, 10), color=(50, 255, 50), + scale=max(0.5, s * 0.0022)) + _draw_text_cv2(vis_pose, f"frontal={'YES' if frontal else 'NO'}", (10, 40), + color=(50, 255, 50) if frontal else (50, 50, 255), + scale=max(0.5, s * 0.0022)) + dbg["head_pose"] = {"yaw": round(yaw, 2), "pitch": round(pitch, 2), + "roll": round(roll, 2), "frontal": bool(frontal)} + put("pose", vis_pose) + + if not frontal: + return data, 1003, "角度问题,请上传正面照" + + # ④ 头发/耳朵分割 + hair_mask = None + ear_mask = None + try: + from face_analysis.hair_segmenter import get_segmenter + pxs = [normalized_to_pixel(p, w, h) for p in lm] + 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("[debug] 头发/耳朵分割失败:%s", seg_e) + + vis_seg = image.copy() + if hair_mask is not None: + vis_seg = _overlay_mask(vis_seg, hair_mask, (0, 200, 0), alpha=0.45) + dbg["hair_pixels"] = int(np.asarray(hair_mask).astype(bool).sum()) + if ear_mask is not None: + vis_seg = _overlay_mask(vis_seg, ear_mask, (200, 80, 0), alpha=0.5) + dbg["ear_pixels"] = int(np.asarray(ear_mask).astype(bool).sum()) + _draw_text_cv2(vis_seg, "绿=头发(hair=17) 蓝=耳朵(ear=7/8)", (10, 10), + color=(50, 255, 50), scale=max(0.45, s * 0.0018)) + put("segmentation", vis_seg) + + # 主测量(复用 measure_face,内部含 ⑤ 纵向决策 + 七眼 + 尺度) + result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) + v = result.vertical + dbg["hairline_source"] = result.hairline_source + + # ⑤ 纵向定位(发际线/头顶)—— 复刻方案 B 的中轴线扫描 + vis_hl = image.copy() + if hair_mask is not None: + vis_hl = _overlay_mask(vis_hl, hair_mask, (0, 180, 0), alpha=0.3) + brow_x, brow_y = _brow_center(lm, w, h) + # 中轴线 ±3px 列带高亮(黄) + cx = int(round(brow_x)) + cv2.line(vis_hl, (max(0, cx - 3), 0), (max(0, cx - 3), h), (0, 230, 255), 1) + cv2.line(vis_hl, (min(w - 1, cx + 3), 0), (min(w - 1, cx + 3), h), (0, 230, 255), 1) + # 画 hairline_y / hair_top_y 两条横线 + hairline_y = int(v["hairline"][1]) + hair_top_y = int(v["hair_top"][1]) + cv2.line(vis_hl, (0, hair_top_y), (w, hair_top_y), (255, 255, 0), max(2, round(s * 0.0025))) + cv2.line(vis_hl, (0, hairline_y), (w, hairline_y), (0, 100, 255), max(2, round(s * 0.0025))) + _draw_text_cv2(vis_hl, f"hair_top_y={hair_top_y}", (hair_top_y if hair_top_y < h - 40 else h - 40, 0), + color=(255, 255, 0), scale=max(0.4, s * 0.0015)) + # 文字标注位置:hairline_y 行右侧 + _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 + _draw_text_cv2(vis_hl, + f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已弃用", + (10, 10), color=(40, 40, 255), scale=max(0.5, s * 0.0022)) + put("hairline", vis_hl) + + # ⑥ 四庭纵向点 + vis_v = image.copy() + v_names = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] + v_labels = ["头顶", "发际线", "眉心", "鼻翼下缘", "下巴尖"] + v_court_px = [v["top_court_px"], v["upper_court_px"], v["middle_court_px"], v["lower_court_px"]] + court_names = ["顶庭", "上庭", "中庭", "下庭"] + court_cm = [result.top_cm, result.upper_cm, result.middle_cm, result.lower_cm] + vx0 = min(int(v[n][0]) for n in v_names) + for i, name in enumerate(v_names): + x, y = int(v[name][0]), int(v[name][1]) + cv2.circle(vis_v, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1) + # 画一条横线 + cv2.line(vis_v, (vx0 - max(20, round(s * 0.04)), y), + (min(w - 1, vx0 + int(s * 0.02)), y), (0, 200, 255), 1) + _draw_text_cv2(vis_v, v_labels[i], (min(w - 60, x + 8), y), + color=(50, 255, 255), scale=max(0.4, s * 0.0015)) + # 各庭段高(竖向虚线 + cm 文字) + for i in range(4): + y_a = int(v[v_names[i]][1]) + y_b = int(v[v_names[i + 1]][1]) + lx = max(10, vx0 - max(40, round(s * 0.08))) + cv2.line(vis_v, (lx, y_a), (lx, y_b), (0, 255, 100), max(2, round(s * 0.0025))) + cv2.circle(vis_v, (lx, y_a), 3, (0, 255, 100), -1) + cv2.circle(vis_v, (lx, y_b), 3, (0, 255, 100), -1) + _draw_text_cv2(vis_v, f"{court_names[i]} {court_cm[i]:.2f}cm", + (lx - int(s * 0.18), (y_a + y_b) // 2), + color=(100, 255, 100), scale=max(0.4, s * 0.0015)) + dbg["vertical_points"] = {n: {"x": int(v[n][0]), "y": int(v[n][1])} for n in v_names} + put("vertical", vis_v) + + # ⑦ 七眼横向点 + vis_e = image.copy() + epts = result.eyes["points"] + seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"] + seven_labels = ["左脸颊", "左眼外", "左眼内", "右眼内", "右眼外", "右脸颊"] + ey0 = min(int(epts[k][1]) for k in seven_keys) + for i, k in enumerate(seven_keys): + x, y = int(epts[k][0]), int(epts[k][1]) + cv2.circle(vis_e, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1) + cv2.line(vis_e, (x, max(0, ey0 - 20)), (x, min(h - 1, ey0 + 20)), + (0, 200, 255), 1) + _draw_text_cv2(vis_e, seven_labels[i], (x, ey0 - max(25, round(s * 0.04))), + color=(50, 255, 255), scale=max(0.4, s * 0.0015), anchor="ct") + # 头部最左/最右端线(耳朵外缘) + try: + from face_analysis.annotation import _ear_edges_from_mask + lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0] + head_l, head_r = _ear_edges_from_mask( + ear_mask, hair_mask, v["hair_top"][1], v["chin_tip"][1], + lcx, rcx, (lcx + rcx) / 2) + if head_l is not None: + cv2.line(vis_e, (int(head_l), 0), (int(head_l), h), (255, 100, 255), max(1, round(s * 0.002))) + _draw_text_cv2(vis_e, "人头最左", (int(head_l), 10), + color=(255, 150, 255), scale=max(0.35, s * 0.0013)) + if head_r is not None: + cv2.line(vis_e, (int(head_r), 0), (int(head_r), h), (255, 100, 255), max(1, round(s * 0.002))) + _draw_text_cv2(vis_e, "人头最右", (int(head_r), 10), + color=(255, 150, 255), scale=max(0.35, s * 0.0013)) + dbg["head_left_x"] = head_l + dbg["head_right_x"] = head_r + except Exception as e: # noqa: BLE001 + logger.warning("[debug] 七眼端线绘制失败:%s", e) + dbg["seven_eye_points"] = {k: {"x": int(epts[k][0]), "y": int(epts[k][1])} for k in seven_keys} + put("seven_eyes", vis_e) + + # ⑧ 尺度校准 + vis_sc = image.copy() + px_per_cm = result.px_per_cm + iris_px = _iris_diameter_px(lm, w, h) + if iris_px is not None and iris_px > 0: + # 画左右虹膜直径线(青) + for (li, ri) in [(IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT), (IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT)]: + p1 = normalized_to_pixel(lm[li], w, h) + p2 = normalized_to_pixel(lm[ri], w, h) + cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])), + (255, 200, 0), max(2, round(s * 0.004))) + cv2.circle(vis_sc, (int(p1[0]), int(p1[1])), max(2, round(s * 0.003)), (255, 200, 0), -1) + cv2.circle(vis_sc, (int(p2[0]), int(p2[1])), max(2, round(s * 0.003)), (255, 200, 0), -1) + method = "iris" + _draw_text_cv2(vis_sc, f"虹膜直径法: {iris_px:.1f}px / {AVG_IRIS_DIAMETER_CM}cm", (10, 10), + color=(255, 200, 0), scale=max(0.45, s * 0.0018)) + else: + # 降级眼宽法(黄) + eye_px = _eye_width_px(lm, w, h) + method = "eye_width" + for (oi, ii) in [(LEFT_EYE_OUTER, LEFT_EYE_INNER), (RIGHT_EYE_INNER, RIGHT_EYE_OUTER)]: + p1 = normalized_to_pixel(lm[oi], w, h) + p2 = normalized_to_pixel(lm[ii], w, h) + cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])), + (0, 255, 255), max(2, round(s * 0.004))) + _draw_text_cv2(vis_sc, f"眼宽法(降级): {eye_px:.1f}px / {AVG_EYE_WIDTH_CM}cm", (10, 10), + color=(0, 255, 255), scale=max(0.45, s * 0.0018)) + _draw_text_cv2(vis_sc, f"px_per_cm = {px_per_cm:.3f}", (10, 40), + color=(50, 255, 50), scale=max(0.5, s * 0.002)) + dbg["px_per_cm"] = round(px_per_cm, 4) + dbg["scale_method"] = method + put("scale", vis_sc) + + # 把 to_response 的数值并入 data(前端指标速览复用) + data.update(result.to_response()) + # 七眼段宽 + try: + pc = result.px_per_cm + 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]] + data.setdefault("seven_eyes", {}) + 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)) + 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] + 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) + data["seven_eyes"]["eye7"] = None if head_r is None else round((head_r - rcx) / pc, 2) + except Exception as seg_e: # noqa: BLE001 + logger.warning("[debug] 七眼段宽计算失败:%s", seg_e) + + # ⑨ 最终标注图(原图 + 标注层叠加) + try: + from face_analysis.annotation import create_annotated_image + annotated = create_annotated_image(image, result, ear_mask=ear_mask, hair_mask=hair_mask) + anno_rgba = np.asarray(annotated) + # 叠加到原图 + vis_final = image.copy() + alpha = anno_rgba[:, :, 3:4].astype(np.float32) / 255.0 + vis_final = (vis_final.astype(np.float32) * (1 - alpha) + + anno_rgba[:, :, :3].astype(np.float32) * alpha) + vis_final = np.clip(vis_final, 0, 255).astype(np.uint8) + put("final", vis_final) + except Exception as e: # noqa: BLE001 + logger.warning("[debug] 标注图叠加失败:%s", e) + + return data, None, None + + async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"): """接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。 @@ -581,6 +991,39 @@ async def face_measure( return ok_data if ok_data is not None else err_data +# --------------------------------------------------------------------------- +# 接口1 调试:分步可视化(每一步中间产物图 + 原理) +# --------------------------------------------------------------------------- + +@app.post("/api/v1/face/measure-debug", include_in_schema=False) +async def face_measure_debug( + image_file: Optional[UploadFile] = File(default=None), + image_url: Optional[str] = Form(default=None), + image_base64: Optional[str] = Form(default=None), +): + """接口1 调试:返回算法每一步的中间产物图(data.steps.*_base64)+ 数值(data.debug)。 + + 与正式接口同链路,但额外产出 9 张分步叠加图(输入/关键点/姿态/分割/发际线/ + 四庭/七眼/尺度/最终标注),供调试页分步可视化。错误时仍返回已完成的步骤图。 + """ + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + return err(1008, "图片格式不支持(仅 JPG / PNG)") + try: + data, code, msg = _run_face_measure_data_debug(image) + if code is not None: + # 仍带分步图返回,前端可展示卡在哪一步 + return {"code": code, "message": msg, + "request_id": "mock-request-id", "data": data} + return ok(data) + except Exception as ex: # noqa: BLE001 + logger.exception("接口1 调试处理异常") + return err(1007, f"处理失败:{ex}") + + # --------------------------------------------------------------------------- # 接口 6:四庭七眼测量标注 v2(复刻接口1) # --------------------------------------------------------------------------- diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 6b1d85b..fd2ff03 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -212,7 +212,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"] @@ -239,7 +243,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 @@ -265,19 +269,28 @@ 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)+ 竖向虚线双箭头 --- - 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(脸左侧,贴近最左竖线) court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和 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..714ab85 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,19 @@ 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 + self.face_width_cm = eyes["face_width_px"] / px_per_cm + self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm # 七眼厘米 self.eye_width_cm = eyes["eye_width_px"] / px_per_cm @@ -172,46 +197,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: diff --git a/static/test_interface1_debug.html b/static/test_interface1_debug.html new file mode 100644 index 0000000..a297b22 --- /dev/null +++ b/static/test_interface1_debug.html @@ -0,0 +1,366 @@ + + +
+ + +POST /api/v1/face/measure-debug | 与正式接口 /api/v1/face/measure 同算法链路,额外返回 9 张分步图。