Files
hair/face_analysis/b_end_points.py
T

142 lines
5.6 KiB
Python

"""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