""" face_shape_classifier.py 基于 Mediapipe 468 点人脸关键点的脸型分类系统 支持的脸型:圆形脸 / 心形脸 / 菱形脸 / 鹅蛋脸 / 方形脸 / 长形脸 / 瓜子脸 分类策略:多维度特征提取 → 加权评分 → 置信度判断 实现说明: - 特征与评分框架参考 face_shape_classification.md - 距离一律在像素坐标系下用 2D 计算(归一化坐标未校正宽高比会导致面宽被夸大) - 阈值按 MediaPipe 实测分布做了校准 """ from __future__ import annotations import math from pathlib import Path from typing import Dict, List, Optional, Tuple, Union import cv2 import mediapipe as mp import numpy as np ImageInput = Union[str, Path, np.ndarray] class FaceLandmarks: """Mediapipe 关键点索引(脸型分析用)。""" FOREHEAD_TOP = 10 CHIN_BOTTOM = 152 # 额侧(比 127/356 更贴近发际两侧,避免把太阳穴外轮廓算成额头) LEFT_FOREHEAD = 54 RIGHT_FOREHEAD = 284 # 太阳穴辅助 LEFT_TEMPLE = 21 RIGHT_TEMPLE = 251 # 颧骨最外侧 LEFT_CHEEK = 234 RIGHT_CHEEK = 454 # 下颌角(比 172/397 更接近 gonion) LEFT_JAW_ANGLE = 132 RIGHT_JAW_ANGLE = 361 # 下巴缘 LEFT_CHIN = 136 RIGHT_CHIN = 365 def extract_face_features(landmarks, image_size: Tuple[int, int]) -> Dict[str, float]: """ 从 Mediapipe 关键点提取脸型特征。 参数: landmarks: NormalizedLandmark 列表 image_size: (width, height),用于还原像素坐标 """ w, h = image_size def pt(idx: int) -> np.ndarray: lm = landmarks[idx] return np.array([lm.x * w, lm.y * h], dtype=float) def dist(p1: np.ndarray, p2: np.ndarray) -> float: return float(np.linalg.norm(p1 - p2)) def xwidth(p1: np.ndarray, p2: np.ndarray) -> float: """横向宽度(脸型比例更稳定)。""" return abs(float(p1[0] - p2[0])) forehead_top = pt(FaceLandmarks.FOREHEAD_TOP) chin_bottom = pt(FaceLandmarks.CHIN_BOTTOM) left_forehead = pt(FaceLandmarks.LEFT_FOREHEAD) right_forehead = pt(FaceLandmarks.RIGHT_FOREHEAD) left_temple = pt(FaceLandmarks.LEFT_TEMPLE) right_temple = pt(FaceLandmarks.RIGHT_TEMPLE) left_cheek = pt(FaceLandmarks.LEFT_CHEEK) right_cheek = pt(FaceLandmarks.RIGHT_CHEEK) left_jaw = pt(FaceLandmarks.LEFT_JAW_ANGLE) right_jaw = pt(FaceLandmarks.RIGHT_JAW_ANGLE) left_chin = pt(FaceLandmarks.LEFT_CHIN) right_chin = pt(FaceLandmarks.RIGHT_CHIN) face_height = dist(forehead_top, chin_bottom) forehead_width = xwidth(left_forehead, right_forehead) temple_width = xwidth(left_temple, right_temple) cheekbone_width = xwidth(left_cheek, right_cheek) jaw_width = xwidth(left_jaw, right_jaw) chin_width = xwidth(left_chin, right_chin) face_width = cheekbone_width eps = 1e-8 # 下巴顶点夹角:越大越宽圆,越小越尖 v_left = left_jaw - chin_bottom v_right = right_jaw - chin_bottom cos_val = np.dot(v_left, v_right) / ( np.linalg.norm(v_left) * np.linalg.norm(v_right) + eps ) cos_val = float(np.clip(cos_val, -1.0, 1.0)) jaw_angle = math.degrees(math.acos(cos_val)) # 下颌角(左):颧骨→下颌角→下巴,越小越方正硬朗 v1 = left_cheek - left_jaw v2 = chin_bottom - left_jaw cos_g = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + eps) cos_g = float(np.clip(cos_g, -1.0, 1.0)) gonion_angle = math.degrees(math.acos(cos_g)) aspect_ratio = face_width / (face_height + eps) length_ratio = face_height / (face_width + eps) taper_ratio = (forehead_width - chin_width) / (forehead_width + eps) cheek_taper = (cheekbone_width - jaw_width) / (cheekbone_width + eps) forehead_ratio = forehead_width / (face_width + eps) temple_ratio = temple_width / (face_width + eps) jaw_ratio = jaw_width / (face_width + eps) chin_ratio = chin_width / (face_width + eps) chin_sharpness = chin_width / (jaw_width + eps) widths = [forehead_width, cheekbone_width, jaw_width] width_uniformity = (max(widths) - min(widths)) / (max(widths) + eps) jaw_midpoint = (left_jaw + right_jaw) / 2.0 face_curve_score = dist(jaw_midpoint, chin_bottom) / (face_height + eps) forehead_vs_jaw = forehead_width / (jaw_width + eps) cheek_dominance = cheekbone_width / ((forehead_width + jaw_width) / 2.0 + eps) return { "face_height": face_height, "face_width": face_width, "forehead_width": forehead_width, "temple_width": temple_width, "cheekbone_width": cheekbone_width, "jaw_width": jaw_width, "chin_width": chin_width, "aspect_ratio": aspect_ratio, "length_ratio": length_ratio, "taper_ratio": taper_ratio, "cheek_taper": cheek_taper, "forehead_ratio": forehead_ratio, "temple_ratio": temple_ratio, "cheekbone_ratio": 1.0, "jaw_ratio": jaw_ratio, "chin_ratio": chin_ratio, "jaw_angle": jaw_angle, "gonion_angle": gonion_angle, "chin_sharpness": chin_sharpness, "width_uniformity": width_uniformity, "face_curve_score": face_curve_score, "forehead_vs_jaw": forehead_vs_jaw, "cheek_dominance": cheek_dominance, } # ============================================================ # 参考分布:1143 张样本(1093 张脸型测试集合 + 44 张真实照片 + 6 张标注图) # 的稳健统计量 (中位数, 稳健标准差=IQR/1.349),把绝对测量值转成 z 分数。 # 绝对阈值会随镜头、人群漂移;z 分数让评分只依赖"相对人群偏离多少"。 # # 早前这组数字只由 50 张样本估得,相对全量人群有系统性偏移 # (jaw_ratio 中位偏低 0.44 sd、taper_ratio 偏高 0.53 sd 等), # 恰好三项都在给方形脸加分,是方形脸占比虚高的主因之一。 # ============================================================ REFERENCE_STATS: Dict[str, Tuple[float, float]] = { "aspect_ratio": (0.8294, 0.0281), "jaw_angle": (88.6299, 3.9040), "gonion_angle": (144.5014, 3.4991), "taper_ratio": (0.2671, 0.0376), "forehead_ratio": (0.8975, 0.0204), "jaw_ratio": (0.9286, 0.0138), "chin_ratio": (0.6578, 0.0217), "chin_sharpness": (0.7092, 0.0155), "width_uniformity": (0.1028, 0.0184), "forehead_vs_jaw": (0.9669, 0.0341), "cheek_dominance": (1.0953, 0.0084), "face_curve_score": (0.3940, 0.0210), } # 匹配容差(以 z 为单位):偏离目标 1 个容差,该项得分降到约 0.61 MATCH_TOLERANCE = 1.0 # 每种脸型的原型:特征 -> (目标 z, 权重, 模式) # 'high' 超过目标即满分(越极端越像) # 'low' 低于目标即满分 # 'peak' 双侧衰减(该特征应当落在目标附近) # # 只使用互相独立的特征:length_ratio(=1/aspect_ratio) 与 # cheek_taper(=1-jaw_ratio) 是重复信号,纳入会让对应脸型拿双倍权重。 # # 靶心与权重的来源:先按 1093 张测试集中各原始分组(方形脸/长形脸/瓜子脸/ # 标准脸/娃娃脸)的实测 z 画像给出靶心,再在「6 张标注图判定不变」的硬约束下 # 做带边界的退火微调(权重限 [0.5,5]、靶心限 [-2,2],并惩罚失去区分力的空项)。 SHAPE_PROTOTYPES: Dict[str, Dict[str, Tuple[float, float, str]]] = { # 宽、短,下颌圆钝 "圆形脸": { "aspect_ratio": (+2.00, 5.00, "high"), "jaw_angle": (-0.07, 2.28, "high"), "chin_sharpness": (+0.15, 0.50, "peak"), "face_curve_score": (+0.17, 3.67, "low"), }, # 额头宽、下颌与下巴明显收窄 "心形脸": { "forehead_vs_jaw": (+1.94, 1.62, "high"), "taper_ratio": (+2.00, 1.09, "high"), "jaw_ratio": (+1.02, 1.86, "low"), "chin_ratio": (-1.70, 1.71, "low"), "aspect_ratio": (+1.69, 1.31, "peak"), }, # 颧骨最突出,额头与下颌都窄 "菱形脸": { "cheek_dominance": (+1.62, 2.84, "high"), "width_uniformity": (+1.24, 2.12, "high"), "forehead_ratio": (-1.57, 4.58, "low"), "jaw_ratio": (-0.11, 1.85, "low"), "aspect_ratio": (+1.31, 2.60, "peak"), }, # 各项都接近人群中位——没有突出特征即为匀称 "鹅蛋脸": { "aspect_ratio": (-0.26, 5.00, "peak"), "jaw_ratio": (+0.45, 2.47, "peak"), "chin_sharpness": (+0.52, 2.88, "peak"), "width_uniformity": (+0.53, 0.94, "peak"), "cheek_dominance": (-0.51, 1.14, "peak"), }, # 下颌与下巴都宽、几乎不收窄、下颌角锐利、额头相对窄。 # 注意 aspect_ratio 用 peak 而非 high:测试集中 121 张方脸的 # aspect_ratio 中位仅 +0.16,真正"宽"的是娃娃脸(+1.01)—— # 早前把它当成 high 模式的强特征,是方形脸吞掉圆脸的主因。 # # chin_ratio 是方脸组区分度最大的一项(组内中位 z=+1.45,标准脸组仅 -0.06), # 故靶心直接对齐 +1.45。靶心与权重必须同时提:若只加权重而把靶心留在低位, # 全人群八成都能拿满分,等于给所有人同加一笔,反而推高方形脸占比。 "方形脸": { "aspect_ratio": (+1.11, 4.75, "peak"), "jaw_ratio": (-0.02, 2.27, "high"), "chin_ratio": (+1.45, 3.00, "high"), "taper_ratio": (-0.38, 0.51, "low"), "chin_sharpness": (-0.38, 0.99, "high"), "width_uniformity": (+0.58, 4.34, "high"), "gonion_angle": (+0.10, 3.38, "low"), "forehead_ratio": (-1.70, 4.05, "low"), }, # 明显偏长偏窄 "长形脸": { "aspect_ratio": (-1.49, 1.17, "low"), "chin_sharpness": (+1.76, 0.50, "high"), "taper_ratio": (+0.84, 0.50, "low"), }, # 似心形但下巴更长更尖(face_curve_score 高),颧骨不外扩 "瓜子脸": { "face_curve_score": (+1.02, 3.41, "high"), "forehead_ratio": (+0.85, 3.31, "high"), "taper_ratio": (+0.35, 1.02, "high"), "cheek_dominance": (-1.41, 2.21, "low"), "jaw_ratio": (-1.04, 0.57, "low"), "chin_sharpness": (-1.33, 2.53, "low"), }, } def feature_zscores(features: Dict[str, float]) -> Dict[str, float]: """把测量值转成相对参考人群的 z 分数。""" return { key: (features[key] - median) / scale for key, (median, scale) in REFERENCE_STATS.items() if key in features } def _match(z: float, target: float, mode: str) -> float: """单项匹配度 0~1。""" if mode == "high" and z >= target: return 1.0 if mode == "low" and z <= target: return 1.0 return math.exp(-((z - target) ** 2) / (2 * MATCH_TOLERANCE**2)) def classify_face_shape( features: Dict[str, float], return_details: bool = False, ) -> Tuple[str, float, Optional[Dict]]: """ 脸型分类器:把特征转成 z 分数后,与各脸型原型做加权匹配。 返回 (脸型, 置信度, 详情)。置信度 = Top1 / (Top1 + Top2), 0.5 表示两种脸型完全无法区分,接近 1 表示判定明确。 """ z = feature_zscores(features) scores: Dict[str, float] = {} contributions: Dict[str, Dict[str, float]] = {} for shape, prototype in SHAPE_PROTOTYPES.items(): total_weight = sum(w for _, w, _ in prototype.values()) acc = 0.0 per_feature = {} for key, (target, weight, mode) in prototype.items(): m = _match(z[key], target, mode) per_feature[key] = m acc += weight * m scores[shape] = 100.0 * acc / total_weight contributions[shape] = per_feature ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) best_shape, best_score = ranked[0] second_shape, second_score = ranked[1] if len(ranked) > 1 else (None, 0.0) denom = best_score + second_score confidence = best_score / denom if denom > 0 else 0.0 score_gap = best_score - second_score is_mixed = score_gap < 5.0 details = None if return_details: details = { "scores": scores, "ranked": ranked, "confidence": confidence, "score_gap": score_gap, "is_mixed": is_mixed, "second_shape": second_shape, "second_score": second_score, "zscores": z, "contributions": contributions, } return best_shape, confidence, details def get_mixed_description(details: Dict) -> str: if not details or not details.get("is_mixed"): return "" shape1 = details["ranked"][0][0] shape2 = details["ranked"][1][0] return f"{shape1}(偏{shape2})" _face_mesh = None def _get_face_mesh(): global _face_mesh if _face_mesh is None: _face_mesh = mp.solutions.face_mesh.FaceMesh( static_image_mode=True, max_num_faces=1, refine_landmarks=True, min_detection_confidence=0.5, ) return _face_mesh def _load_image(image: ImageInput) -> np.ndarray: if isinstance(image, np.ndarray): if image.ndim != 3 or image.shape[2] not in (3, 4): raise ValueError("numpy 图片需为 HxWx3/4 的彩色图") if image.shape[2] == 4: return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) return image path = Path(image) img = cv2.imread(str(path)) if img is None: raise FileNotFoundError(f"无法读取图片: {path}") return img def _landmark_points(landmarks, image_size: Tuple[int, int]) -> Dict[str, Tuple[int, int]]: """提取标注用像素点。""" w, h = image_size def xy(idx: int) -> Tuple[int, int]: lm = landmarks[idx] return int(round(lm.x * w)), int(round(lm.y * h)) left_jaw = xy(FaceLandmarks.LEFT_JAW_ANGLE) right_jaw = xy(FaceLandmarks.RIGHT_JAW_ANGLE) return { "forehead_top": xy(FaceLandmarks.FOREHEAD_TOP), "chin_bottom": xy(FaceLandmarks.CHIN_BOTTOM), "left_forehead": xy(FaceLandmarks.LEFT_FOREHEAD), "right_forehead": xy(FaceLandmarks.RIGHT_FOREHEAD), "left_cheek": xy(FaceLandmarks.LEFT_CHEEK), "right_cheek": xy(FaceLandmarks.RIGHT_CHEEK), "left_jaw": left_jaw, "right_jaw": right_jaw, "left_chin": xy(FaceLandmarks.LEFT_CHIN), "right_chin": xy(FaceLandmarks.RIGHT_CHIN), "jaw_mid": ( int(round((left_jaw[0] + right_jaw[0]) / 2)), int(round((left_jaw[1] + right_jaw[1]) / 2)), ), } def _put_text_cn( img: np.ndarray, text: str, org: Tuple[int, int], color: Tuple[int, int, int], font_size: int = 18, ) -> None: """在图上绘制中文/英文混合文字(Pillow)。""" from PIL import Image, ImageDraw, ImageFont rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pil = Image.fromarray(rgb) draw = ImageDraw.Draw(pil) font_paths = [ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] font = None for fp in font_paths: if Path(fp).exists(): try: font = ImageFont.truetype(fp, font_size) break except OSError: continue if font is None: font = ImageFont.load_default() x, y = org # 阴影提升可读性 draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0)) draw.text((x, y), text, font=font, fill=(color[2], color[1], color[0])) img[:] = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR) def _draw_h_line( img: np.ndarray, p1: Tuple[int, int], p2: Tuple[int, int], color: Tuple[int, int, int], label: str, thickness: int = 2, label_above: bool = True, ) -> None: """画横向宽度线 + 端点 + 标签。""" y = int(round((p1[1] + p2[1]) / 2)) x1, x2 = min(p1[0], p2[0]), max(p1[0], p2[0]) cv2.line(img, (x1, y), (x2, y), color, thickness, cv2.LINE_AA) cv2.circle(img, (x1, y), 4, color, -1, cv2.LINE_AA) cv2.circle(img, (x2, y), 4, color, -1, cv2.LINE_AA) # 端点小竖线 tick = max(6, thickness * 3) cv2.line(img, (x1, y - tick), (x1, y + tick), color, thickness, cv2.LINE_AA) cv2.line(img, (x2, y - tick), (x2, y + tick), color, thickness, cv2.LINE_AA) mid = ((x1 + x2) // 2, y - 8 if label_above else y + 4) _put_text_cn(img, label, mid, color, font_size=max(14, img.shape[0] // 55)) def _draw_v_line( img: np.ndarray, p1: Tuple[int, int], p2: Tuple[int, int], color: Tuple[int, int, int], label: str, thickness: int = 2, ) -> None: """画纵向高度线 + 端点 + 标签。""" x = int(round((p1[0] + p2[0]) / 2)) y1, y2 = min(p1[1], p2[1]), max(p1[1], p2[1]) cv2.line(img, (x, y1), (x, y2), color, thickness, cv2.LINE_AA) cv2.circle(img, (x, y1), 4, color, -1, cv2.LINE_AA) cv2.circle(img, (x, y2), 4, color, -1, cv2.LINE_AA) tick = max(6, thickness * 3) cv2.line(img, (x - tick, y1), (x + tick, y1), color, thickness, cv2.LINE_AA) cv2.line(img, (x - tick, y2), (x + tick, y2), color, thickness, cv2.LINE_AA) _put_text_cn( img, label, (x + 8, (y1 + y2) // 2), color, font_size=max(14, img.shape[0] // 55), ) def annotate_face_features( image: ImageInput, landmarks=None, features: Optional[Dict[str, float]] = None, ) -> np.ndarray: """ 在原图上标注 face_width / face_height 及文档中的关键比例特征。 返回 BGR 标注图。 """ bgr = _load_image(image).copy() h, w = bgr.shape[:2] if landmarks is None: rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) results = _get_face_mesh().process(rgb) if not results.multi_face_landmarks: raise ValueError("未检测到人脸关键点") landmarks = results.multi_face_landmarks[0].landmark if features is None: features = extract_face_features(landmarks, image_size=(w, h)) pts = _landmark_points(landmarks, (w, h)) overlay = bgr.copy() fs = max(14, h // 55) thick = max(2, h // 400) # ---- 尺寸主轴 ---- # face_height: 额头顶 → 下巴底 _draw_v_line( overlay, pts["forehead_top"], pts["chin_bottom"], (40, 180, 255), f"face_height {features['face_height']:.0f}px", thickness=thick + 1, ) # face_width (= cheekbone): 左颧 → 右颧 _draw_h_line( overlay, pts["left_cheek"], pts["right_cheek"], (0, 220, 120), f"face_width {features['face_width']:.0f}px", thickness=thick + 1, label_above=True, ) # ---- 各级宽度(forehead / jaw / chin)---- # 略微错开 y,避免完全重叠 fh_y = pts["left_forehead"][1] _draw_h_line( overlay, (pts["left_forehead"][0], fh_y), (pts["right_forehead"][0], fh_y), (255, 160, 40), f"forehead ratio={features['forehead_ratio']:.3f}", thickness=thick, label_above=True, ) jy = pts["left_jaw"][1] _draw_h_line( overlay, (pts["left_jaw"][0], jy), (pts["right_jaw"][0], jy), (80, 120, 255), f"jaw ratio={features['jaw_ratio']:.3f}", thickness=thick, label_above=False, ) cy = pts["left_chin"][1] _draw_h_line( overlay, (pts["left_chin"][0], cy), (pts["right_chin"][0], cy), (220, 80, 220), f"chin ratio={features['chin_ratio']:.3f}", thickness=thick, label_above=False, ) # cheekbone_ratio(相对 face_width,恒为 1.0)写在颧骨线旁 cheek_mid = ( (pts["left_cheek"][0] + pts["right_cheek"][0]) // 2, pts["left_cheek"][1] + max(18, h // 40), ) _put_text_cn( overlay, f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", cheek_mid, (0, 200, 100), font_size=fs, ) # ---- jaw_angle:下巴 → 左右下颌角 ---- cv2.line(overlay, pts["chin_bottom"], pts["left_jaw"], (0, 90, 255), thick, cv2.LINE_AA) cv2.line(overlay, pts["chin_bottom"], pts["right_jaw"], (0, 90, 255), thick, cv2.LINE_AA) cv2.circle(overlay, pts["chin_bottom"], 5, (0, 90, 255), -1, cv2.LINE_AA) # 角度弧 v1 = np.array(pts["left_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float) v2 = np.array(pts["right_jaw"], dtype=float) - np.array(pts["chin_bottom"], dtype=float) a1 = math.degrees(math.atan2(-v1[1], v1[0])) a2 = math.degrees(math.atan2(-v2[1], v2[0])) # OpenCV ellipse 角度:从 x 轴顺时针;atan2 转一下 start_ang = -a1 end_ang = -a2 if end_ang < start_ang: start_ang, end_ang = end_ang, start_ang radius = max(28, int(0.08 * features["face_height"])) cv2.ellipse( overlay, pts["chin_bottom"], (radius, radius), 0, start_ang, end_ang, (0, 90, 255), thick, cv2.LINE_AA, ) _put_text_cn( overlay, f"jaw_angle {features['jaw_angle']:.1f}°", (pts["chin_bottom"][0] + radius + 4, pts["chin_bottom"][1] - radius), (0, 90, 255), font_size=fs, ) # ---- taper_ratio:额头两端 → 下巴两端(收窄示意)---- cv2.line( overlay, pts["left_forehead"], pts["left_chin"], (40, 200, 255), max(1, thick - 1), cv2.LINE_AA, ) cv2.line( overlay, pts["right_forehead"], pts["right_chin"], (40, 200, 255), max(1, thick - 1), cv2.LINE_AA, ) taper_anchor = ( pts["left_forehead"][0] - max(10, w // 30), (pts["left_forehead"][1] + pts["left_chin"][1]) // 2, ) _put_text_cn( overlay, f"taper_ratio={features['taper_ratio']:.3f}", taper_anchor, (40, 200, 255), font_size=fs, ) # ---- chin_sharpness:下巴宽 vs 下颌宽 ---- _put_text_cn( overlay, f"chin_sharpness={features['chin_sharpness']:.3f} (chin/jaw)", (pts["left_chin"][0], pts["left_chin"][1] + max(16, h // 45)), (220, 80, 220), font_size=fs, ) # ---- width_uniformity:三宽差异 ---- widths = [ ("F", features["forehead_width"], (255, 160, 40)), ("C", features["cheekbone_width"], (0, 220, 120)), ("J", features["jaw_width"], (80, 120, 255)), ] # 右侧小柱状示意 panel_x = min(w - max(90, w // 8), max(pts["right_cheek"][0] + 20, w - max(100, w // 7))) panel_y = max(40, pts["forehead_top"][1]) max_w = max(x[1] for x in widths) + 1e-8 bar_h = max(10, h // 60) gap = max(4, h // 120) for i, (name, val, color) in enumerate(widths): bw = int((val / max_w) * max(50, w // 10)) y0 = panel_y + i * (bar_h + gap) cv2.rectangle(overlay, (panel_x, y0), (panel_x + bw, y0 + bar_h), color, -1, cv2.LINE_AA) _put_text_cn(overlay, name, (panel_x + bw + 4, y0 - 2), color, font_size=max(12, fs - 2)) _put_text_cn( overlay, f"width_uniformity={features['width_uniformity']:.3f}", (panel_x, panel_y + 3 * (bar_h + gap) + 2), (230, 230, 230), font_size=fs, ) # ---- face_curve_score:下颌中点 → 下巴 ---- cv2.line( overlay, pts["jaw_mid"], pts["chin_bottom"], (180, 255, 80), thick, cv2.LINE_AA, ) cv2.circle(overlay, pts["jaw_mid"], 4, (180, 255, 80), -1, cv2.LINE_AA) curve_label_pos = ( pts["jaw_mid"][0] + 6, pts["jaw_mid"][1] - max(8, h // 80), ) _put_text_cn( overlay, f"face_curve_score={features['face_curve_score']:.3f}", curve_label_pos, (180, 255, 80), font_size=fs, ) # 半透明叠回原图,再叠一层实线标注更清晰:直接用 overlay # 左侧参数图例 legend = [ ("face_width / face_height", (0, 220, 120)), (f"jaw_angle={features['jaw_angle']:.1f}°", (0, 90, 255)), (f"taper_ratio={features['taper_ratio']:.3f}", (40, 200, 255)), (f"forehead_ratio={features['forehead_ratio']:.3f}", (255, 160, 40)), (f"cheekbone_ratio={features['cheekbone_ratio']:.3f}", (0, 200, 100)), (f"jaw_ratio={features['jaw_ratio']:.3f}", (80, 120, 255)), (f"chin_ratio={features['chin_ratio']:.3f}", (220, 80, 220)), (f"chin_sharpness={features['chin_sharpness']:.3f}", (220, 80, 220)), (f"width_uniformity={features['width_uniformity']:.3f}", (230, 230, 230)), (f"face_curve_score={features['face_curve_score']:.3f}", (180, 255, 80)), ] box_h = 12 + len(legend) * (fs + 6) box_w = max(220, w // 3) cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (20, 20, 20), -1) cv2.rectangle(overlay, (8, 8), (8 + box_w, 8 + box_h), (90, 90, 90), 1) for i, (text, color) in enumerate(legend): _put_text_cn(overlay, text, (16, 14 + i * (fs + 6)), color, font_size=fs) return overlay def classify_from_image( image: ImageInput, return_details: bool = True, return_annotated: bool = False, ) -> Dict: """ 从图片判断脸型。 参数: image: 图片路径,或 OpenCV BGR numpy 数组 return_details: 是否返回特征与各脸型得分 return_annotated: 是否同时返回特征标注图(BGR) """ bgr = _load_image(image) h, w = bgr.shape[:2] rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) results = _get_face_mesh().process(rgb) if not results.multi_face_landmarks: raise ValueError("未检测到人脸关键点") landmarks = results.multi_face_landmarks[0].landmark features = extract_face_features(landmarks, image_size=(w, h)) shape, conf, details = classify_face_shape(features, return_details=True) display = get_mixed_description(details) or shape result = { "face_shape": shape, "confidence": conf, "display": display, } if return_details: result["features"] = features result["details"] = details if return_annotated: result["annotated"] = annotate_face_features( bgr, landmarks=landmarks, features=features ) return result def classify_from_mediapipe( multi_face_landmarks, image_size: Tuple[int, int], ) -> List[Dict]: """从 Mediapipe FaceMesh 结果批量分类。image_size=(width, height)。""" results = [] for face_lms in multi_face_landmarks: features = extract_face_features(face_lms.landmark, image_size=image_size) shape, conf, details = classify_face_shape(features, return_details=True) results.append( { "face_shape": shape, "confidence": conf, "display": get_mixed_description(details) or shape, "features": features, "details": details, } ) return results def run_test_images(test_dir: Union[str, Path, None] = None) -> List[Dict]: """用 test_img 做回归测试;文件名(不含扩展名)为期望脸型。""" if test_dir is None: test_dir = Path(__file__).resolve().parent / "test_img" test_dir = Path(test_dir) image_paths = sorted( p for p in test_dir.iterdir() if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp"} ) if not image_paths: raise FileNotFoundError(f"测试目录无图片: {test_dir}") rows = [] for path in image_paths: expected = path.stem try: result = classify_from_image(path, return_details=True) predicted = result["face_shape"] display = result["display"] conf = result["confidence"] top3 = result["details"]["ranked"][:3] ok = predicted == expected error = None except Exception as exc: # noqa: BLE001 predicted = display = conf = None top3 = [] ok = False error = str(exc) rows.append( { "file": path.name, "expected": expected, "predicted": predicted, "display": display, "confidence": conf, "top3": top3, "correct": ok, "error": error, } ) return rows def _print_test_report(rows: List[Dict]) -> None: correct = sum(1 for r in rows if r["correct"]) total = len(rows) print("=" * 72) print("脸型分类测试结果") print("=" * 72) for r in rows: status = "✓" if r["correct"] else "✗" if r["error"]: print(f"{status} {r['file']}") print(f" 期望: {r['expected']}") print(f" 错误: {r['error']}") continue top3_str = ", ".join(f"{name}:{score:.1f}" for name, score in r["top3"]) print(f"{status} {r['file']}") print(f" 期望: {r['expected']}") print(f" 预测: {r['display']} (conf={r['confidence']:.3f})") print(f" Top3: {top3_str}") print("-" * 72) print(f"准确率: {correct}/{total} = {correct / total:.1%}") print("=" * 72) if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] not in {"--test", "-t"}: out = classify_from_image(sys.argv[1], return_details=True) print(f"脸型: {out['display']}") print(f"置信度: {out['confidence']:.3f}") print("各脸型得分:") for name, score in out["details"]["ranked"]: print(f" {name}: {score:.1f}") else: report = run_test_images() _print_test_report(report)