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