107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""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 .calibration import normalized_to_pixel
|
||
from .special_points import detect_special_points
|
||
|
||
override = to_landmark_list(landmarks)
|
||
data = detect_special_points(image, landmarks_override=override)
|
||
points = data["points"]
|
||
height, width = image.shape[:2]
|
||
x, y = normalized_to_pixel(override.landmark[454], width, height)
|
||
points["face_edge_454"] = {"x": round(x), "y": round(y)}
|
||
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}
|