"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。 规格(技术方案 §6):线/字色 #FFFFFF、字体 10pt、线宽 1pt、透明底。 - 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。 - 四庭 cm 数值:图片左侧。 - 七眼标注:眼宽/两眼间距/脸宽,虚线带箭头,标签上下穿插。 中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。 """ import os import numpy as np from PIL import Image, ImageDraw, ImageFont FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-Regular.otf") FONT_SIZE = 10 LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100% LINE_WIDTH = 1 def _load_font(): if not os.path.isfile(FONT_PATH): raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)") return ImageFont.truetype(FONT_PATH, FONT_SIZE) def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None): """在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。 numpy 向量化:一次性算整行 alpha,避免逐像素 draw.point。 """ h, w = buf.shape[:2] cy = int(round(cy)); cx = int(round(cx)) if not (0 <= cy < h): return half = half_length or (w // 3) xs = np.arange(w) dist = np.abs(xs - cx) alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] mask = alpha > 0 row = buf[cy] row[mask, 0] = color[0] row[mask, 1] = color[1] row[mask, 2] = color[2] row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8)) def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR, dash_len=6, gap_len=4, arrow_size=5): """两点间画虚线,两端带箭头(等腰三角)。""" total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5 if total == 0: return dx = (x2 - x1) / total dy = (y2 - y1) / total pos = 0.0 while pos < total: seg_end = min(pos + dash_len, total) draw.line([(x1 + dx * pos, y1 + dy * pos), (x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=LINE_WIDTH) pos += dash_len + gap_len # 法向量(用于箭头两翼张开) nx, ny = -dy, dx for (ex, ey, sdx, sdy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]: p1 = (ex + sdx * arrow_size + nx * arrow_size * 0.6, ey + sdy * arrow_size + ny * arrow_size * 0.6) p2 = (ex + sdx * arrow_size - nx * arrow_size * 0.6, ey + sdy * arrow_size - ny * arrow_size * 0.6) draw.line([p1, (ex, ey)], fill=color, width=LINE_WIDTH) draw.line([p2, (ex, ey)], fill=color, width=LINE_WIDTH) def create_annotated_image(image_bgr, measure_result): """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。""" h, w = image_bgr.shape[:2] v = measure_result.vertical # --- 1. 四庭水平分界线(numpy 渐变) --- buf = np.zeros((h, w, 4), dtype=np.uint8) order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] ys = [v[name][1] for name in order] cx_line = v["brow_center"][0] for cy in ys: draw_gradient_horizontal_line(buf, cx_line, cy) canvas = Image.fromarray(buf, mode="RGBA") draw = ImageDraw.Draw(canvas) font = _load_font() # --- 2. 四庭 cm 数值(左侧) --- court_labels = [ ("顶庭", measure_result.top_cm), ("上庭", measure_result.upper_cm), ("中庭", measure_result.middle_cm), ("下庭", measure_result.lower_cm), ] left_margin = 16 for i, (label, cm_val) in enumerate(court_labels): y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2 draw.text((left_margin, y_mid), f"{label} {cm_val:.2f}cm", fill=LINE_COLOR, font=font) # --- 3. 七眼标注(虚线箭头 + 上下穿插标签) --- pts = measure_result.eyes["points"] pc = measure_result.px_per_cm eye_y = (pts["left_inner"][1] + pts["right_inner"][1]) / 2 def hline(p_left, p_right, label, cm_val, above): y = eye_y draw_dashed_line_with_arrows(draw, p_left[0], y, p_right[0], y) text = f"{label} {cm_val:.2f}cm" tx = (p_left[0] + p_right[0]) / 2 ty = y - FONT_SIZE - 4 if above else y + 4 bbox = draw.textbbox((0, 0), text, font=font) tw = bbox[2] - bbox[0] draw.text((tx - tw / 2, ty), text, fill=LINE_COLOR, font=font) # 眼宽(左眼,标签在上)、两眼间距(标签在下)、脸宽(标签在上)—— 上下穿插 hline(pts["left_outer"], pts["left_inner"], "眼宽", measure_result.eye_width_cm, above=True) hline(pts["left_inner"], pts["right_inner"], "间距", measure_result.inter_eye_cm, above=False) hline(pts["left_cheek"], pts["right_cheek"], "脸宽", measure_result.face_width_cm, above=True) return canvas if __name__ == "__main__": import sys import time import cv2 from face_analysis.detector import detector from face_analysis.measure import measure_face path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg" out = sys.argv[2] if len(sys.argv) > 2 else "tests/output/annotated.png" img = cv2.imread(path) if img is None: print(f"无法读取图片: {path}") sys.exit(1) h, w = img.shape[:2] lms = detector.detect(img) if lms is None: print("未检出人脸") sys.exit(1) mask = None try: from face_analysis.hair_segmenter import get_segmenter mask = get_segmenter().segment_hair(img) except Exception as e: # noqa: BLE001 print(f"[warn] 分割不可用,回退方案 A:{e}") result = measure_face(lms, mask, w, h) t0 = time.time() canvas = create_annotated_image(img, result) dt = time.time() - t0 os.makedirs(os.path.dirname(out), exist_ok=True) canvas.save(out) arr = np.asarray(canvas) print(f"saved {out} mode={canvas.mode} size={canvas.size} " f"transparent={bool((arr[:,:,3]==0).any())} opaque={bool((arr[:,:,3]>0).any())} " f"elapsed={dt*1000:.1f}ms")