feat: 新增42号人脸特征与72号B端关键点接口

This commit is contained in:
xsl
2026-11-07 20:35:55 +08:00
parent ccb9c142c4
commit c9a47a76c9
16 changed files with 983 additions and 25 deletions
+141
View File
@@ -0,0 +1,141 @@
"""Interface 72: calibrated landmarks and a transparent annotation overlay."""
import math
import cv2
import numpy as np
from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel
from face_analysis.special_points import detect_special_points
from hairline.face_landmarks import to_landmark_list
from hairline.framing import prepare_portrait
class AnnotationUnavailable(ValueError):
"""The image does not contain the landmarks needed for all annotations."""
def build_b_end_annotation(image):
image, normalized, _ = prepare_portrait(image)
height, width = image.shape[:2]
landmarks = to_landmark_list(normalized)
special = detect_special_points(image, landmarks_override=landmarks)["points"]
hairline_keys = ("hair_line_center", "hair_line_left", "hair_line_left_left")
if any(special[key] is None for key in hairline_keys):
raise AnnotationUnavailable("未找到完整发际线,请露出额头和两侧发际线后重拍")
px_per_cm = estimate_scale_factor(landmarks, width, height)
if not math.isfinite(px_per_cm) or px_per_cm <= 0:
raise AnnotationUnavailable("无法估算厘米比例,请使用清晰正面照")
center, left, left_left = (
(special[key]["x"], special[key]["y"]) for key in hairline_keys
)
points = {
1: normalized_to_pixel(landmarks.landmark[4], width, height),
2: center,
3: (center[0] + 2 * px_per_cm, center[1]),
4: left,
5: left_left,
6: normalized_to_pixel(landmarks.landmark[446], width, height),
}
if any(not (0 <= x < width and 0 <= y < height) for x, y in points.values()):
raise AnnotationUnavailable("扶正裁切后无法保留全部标注点,请使用留有边距的照片")
pairs = ((2, 1), (2, 3), (4, 6), (5, 6))
measurements = {
f"{start}-{end}": (2.0 if (start, end) == (2, 3)
else round(math.dist(points[start], points[end]) / px_per_cm, 1))
for start, end in pairs
}
overlay = _render(points, measurements, width, height)
data = {
"coordinate_space": "processed_image",
"image_size": {"width": width, "height": height},
"points": {str(index): {"x": x, "y": y} for index, (x, y) in points.items()},
"measurements_cm": measurements,
"px_per_cm": px_per_cm,
}
return image, overlay, data
def _dashed_line(alpha, start, target, scale, *, ray, length_multiplier=1.0):
x, y = start
distance = math.dist(start, target)
if distance == 0:
return
dx, dy = (target[0] - x) / distance, (target[1] - y) / distance
length = distance
if ray:
height, width = alpha.shape
# Fade within a bounded distance, ending sooner when the ray hits an edge.
limits = []
if dx:
limits.append(((width - 1 if dx > 0 else 0) - x) / dx)
if dy:
limits.append(((height - 1 if dy > 0 else 0) - y) / dy)
edge_limit = min(limits)
length = min(min(2.5 * distance, edge_limit) * length_multiplier, edge_limit)
dash = max(4, round(7 * scale))
gap = max(3, round(5 * scale))
thickness = max(1, round(1.6 * scale))
if length <= 0:
return
# Separate layer so a faint crossing cannot overwrite a brighter ray.
layer = np.zeros_like(alpha)
for offset in range(0, math.ceil(length), dash + gap):
end = min(offset + dash, length)
opacity = round(255 * (1 - offset / length)) if ray else 255
cv2.line(
layer,
(round(x + dx * offset), round(y + dy * offset)),
(round(x + dx * end), round(y + dy * end)),
opacity, thickness, cv2.LINE_AA,
)
np.maximum(alpha, layer, out=alpha)
def _render(points, measurements, width, height):
scale = max(0.65, min(width, height) / 650)
alpha = np.zeros((height, width), dtype=np.uint8)
_dashed_line(alpha, points[2], points[1], scale, ray=True, length_multiplier=0.85)
for start, end, ray in ((2, 3, False), (4, 6, True)):
_dashed_line(alpha, points[start], points[end], scale, ray=ray)
_dashed_line(alpha, points[5], points[6], scale, ray=True, length_multiplier=1.3)
shift = 4
subpixel = 1 << shift
radius = round(1.4 * max(2, round(4 * scale)) * subpixel)
for point in points.values():
center = tuple(round(value * subpixel) for value in point)
cv2.circle(alpha, center, radius, 255, -1, cv2.LINE_AA, shift=shift)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.53 * scale
thickness = max(1, round(scale))
gap = max(4, round(8 * scale))
for start, end, placement in ((2, 1, "left"), (2, 3, "above"),
(4, 6, "left"), (5, 6, "right")):
a, b = points[start], points[end]
x, y = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
unit = " CM" if (start, end) == (2, 3) else "cm"
label = f"{measurements[f'{start}-{end}']:.1f}{unit}"
(text_width, text_height), baseline = cv2.getTextSize(label, font, font_scale, thickness)
text_x = {
"left": x - text_width - gap,
"above": x - text_width / 2,
"right": x + gap,
}[placement]
text_y = y - gap if placement == "above" else y + text_height / 2
origin = (
max(0, min(width - text_width, round(text_x))),
max(text_height, min(height - baseline - 1, round(text_y))),
)
cv2.putText(alpha, label, origin, font, font_scale, 255, thickness, cv2.LINE_AA)
overlay = np.full((height, width, 4), 255, dtype=np.uint8)
overlay[:, :, 3] = alpha
return overlay
+101
View File
@@ -0,0 +1,101 @@
"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview."""
import math
FT_POINTS = ("hair_line_right_right", "hair_line_left_left")
ZY_POINTS = ("face_edge_234", "face_edge_454")
class PortraitQualityError(ValueError):
"""An input that must be rejected before feature analysis."""
def __init__(self, code, message):
super().__init__(message)
self.code = code
def prepare_feature_portrait(image_bytes):
"""Check the source image before applying interface 52 framing."""
import cv2
import numpy as np
from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose
from hairline.face_landmarks import get_landmarker, to_landmark_list
from hairline.framing import decode_portrait, normalize_portrait
image = decode_portrait(image_bytes)
if image is None:
raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)")
landmarks = get_landmarker().detect(
cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True
)
if landmarks is None:
raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片")
h, w = image.shape[:2]
face_xy = landmarks[:468, :2] * [w, h]
if not np.isfinite(face_xy).all() or (
(face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any()
):
raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓")
pose = estimate_head_pose(to_landmark_list(landmarks), w, h)
# In the camera coordinate system, positive pitch points the face down.
if pose is not None and pose[1] > PITCH_THRESHOLD:
raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视")
try:
image, landmarks, _ = normalize_portrait(image, landmarks)
except ValueError as exc:
raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc
return image, landmarks
def detect_framed_ratio_points(image):
"""Use the interface 52 crop and return the endpoints in its coordinate space."""
from hairline.framing import prepare_portrait
image, landmarks, framing = prepare_portrait(image)
data = _detect_ratio_points(image, landmarks)
data.update(framing=framing, coordinate_space="processed_image")
return image, data
def _detect_ratio_points(image, landmarks):
from hairline.face_landmarks import to_landmark_list
from .special_points import detect_special_points
data = detect_special_points(image, landmarks_override=to_landmark_list(landmarks))
points = data["points"]
data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
a, b = (points[key] for key in FT_POINTS)
if a is not None and b is not None:
middle_y = (a["y"] + b["y"]) / 2
points[FT_POINTS[0]] = {**a, "y": middle_y}
points[FT_POINTS[1]] = {**b, "y": middle_y}
data["ft_alignment"] = "horizontal_at_mean_y"
return data
def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]:
"""Return both labels from the same measurement of the processed image."""
from .special_points import NoFaceError, NonFrontalError
matched = None
try:
data = _detect_ratio_points(image, landmarks)
except (NoFaceError, NonFrontalError):
pass
else:
ft_left, ft_right, zy_left, zy_right = (
data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS)
)
if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)):
ft = abs(ft_left["x"] - ft_right["x"])
zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"])
if ft > 0 and zy > 0:
matched = ft / zy < 0.8
return {"zygoma_expansion": matched, "temple_hollowing": matched}
+3 -3
View File
@@ -30,7 +30,7 @@ GROUPS = {
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251, 454],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
@@ -87,7 +87,7 @@ def ray_hairline_intersection(p1, p2, hair_mask):
return None
def detect_special_points(image_bgr):
def detect_special_points(image_bgr, landmarks_override=None):
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
Returns: {"points": {...}, "image_size": {"width","height"},
@@ -99,7 +99,7 @@ def detect_special_points(image_bgr):
from face_analysis.pose import check_frontal_face
h, w = image_bgr.shape[:2]
landmarks = detector.detect(image_bgr)
landmarks = landmarks_override if landmarks_override is not None else detector.detect(image_bgr)
if landmarks is None:
raise NoFaceError("未检出人脸")
if not check_frontal_face(landmarks, w, h):