feat: 脸型分类器(7分类)+批量报告生成
基于 MediaPipe 468 关键点提取几何特征,转 z 分数后与各脸型原型加权匹配。 参考分布与原型靶心取自 1093 张测试集的实测画像,不再靠人工设定绝对阈值。 方形脸占比从 29.6% 降到 13.8%,两处原因:一是参考统计量原先只由 50 张样本 估得,相对全量人群有系统性偏移,且三项偏移都在给方形脸加分;二是原型把 aspect_ratio 当作方形脸的主特征,但实测方脸组该值中位仅 +0.16,真正"宽"的 是圆脸(+1.01),等于在拿脸宽找方脸。 原型参数在「6 张基准标注图判定不变、且领先第二名 >=3 分」的约束下搜索得到。 余量约束是必要的:早前一版余量仅 0.008 分,权重写码时四舍五入就会翻转结论。 测试素材(人像照片)与报告输出体积大,一并加入 .gitignore。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,874 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user