feat: 新增42号人脸特征与72号B端关键点接口
This commit is contained in:
@@ -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
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user