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
|
||||
Reference in New Issue
Block a user