From 0eaaf05ac3eeb9f83b8890f96b6a29f50cd916e9 Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 14 Jun 2026 17:33:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(annotation):=20=E6=A0=87=E6=B3=A8=E5=9B=BE?= =?UTF-8?q?=E9=87=8D=E6=8E=92=EF=BC=8C8=20=E4=B8=AA=E9=87=8F=E5=85=A8?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E4=B8=94=E4=B8=8D=E9=87=8D=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复七眼三个横向量(单眼宽度/两眼间距/脸宽)叠在同一条眼睛线上的重叠问题, 改为上下错开三个高度,各自虚线带箭头 + 紧贴标签 - 新增「全脸总高」标注:右侧竖向标尺(虚线双箭头,头顶→下巴)+ 标签 - 四庭(顶/上/中/下庭)仍在左侧各段中点 - 标签用全名:单眼宽度/两眼间距/脸宽/全脸总高 - 新增 tests/test_annotation.py 防回归 Co-Authored-By: Claude Opus 4.8 --- face_analysis/annotation.py | 65 +++++++++++++++++++++++++------------ tests/test_annotation.py | 30 +++++++++++++++++ 2 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 tests/test_annotation.py diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 76aeb38..4251067 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -69,8 +69,20 @@ def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR, draw.line([p2, (ex, ey)], fill=color, width=LINE_WIDTH) +def _text_size(draw, text, font): + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0], bbox[3] - bbox[1] + + def create_annotated_image(image_bgr, measure_result): - """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。""" + """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。 + + 布局(避免重叠): + - 四庭水平分界线(渐变消失),四庭 cm 数值在**左侧**各段中点。 + - **全脸总高**:右侧竖向标尺(虚线双箭头,头顶→下巴),标签在标尺左侧竖排。 + - 七眼:单眼宽度 / 两眼间距 / 脸宽 三个量**上下错开三个高度**, + 各自虚线带箭头 + 紧贴各自线条的标签,互不重叠。 + """ h, w = image_bgr.shape[:2] v = measure_result.vertical @@ -86,41 +98,52 @@ def create_annotated_image(image_bgr, measure_result): draw = ImageDraw.Draw(canvas) font = _load_font() - # --- 2. 四庭 cm 数值(左侧) --- + # --- 2. 四庭 cm 数值(左侧,各段中点) --- court_labels = [ ("顶庭", measure_result.top_cm), ("上庭", measure_result.upper_cm), ("中庭", measure_result.middle_cm), ("下庭", measure_result.lower_cm), ] - left_margin = 16 + left_margin = 12 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 + # --- 3. 全脸总高:右侧竖向标尺(头顶→下巴,虚线双箭头) --- + top_y, chin_y = ys[0], ys[-1] + ruler_x = w - 30 # 内移,使竖线与箭头完整可见 + draw_dashed_line_with_arrows(draw, ruler_x, top_y, ruler_x, chin_y) + th_lines = ["全脸总高", f"{measure_result.face_total_cm:.2f}cm"] + th_w = max(_text_size(draw, t, font)[0] for t in th_lines) + th_x = ruler_x - 6 - th_w # 标签放标尺左侧,避免出界 + th_y = (top_y + chin_y) / 2 - FONT_SIZE # 竖向居中(两行) + for j, t in enumerate(th_lines): + draw.text((th_x, th_y + j * (FONT_SIZE + 3)), t, fill=LINE_COLOR, font=font) - def hline(p_left, p_right, label, cm_val, above): - y = eye_y + # --- 4. 七眼:上下错开三个高度,各自虚线箭头 + 标签 --- + pts = measure_result.eyes["points"] + eye_y = (pts["left_inner"][1] + pts["right_inner"][1]) / 2 + vgap = FONT_SIZE + 12 # 行间距,保证标签放得下 + + def span(p_left, p_right, y, label, cm_val, label_above): 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) + tw, _ = _text_size(draw, text, font) + tx = (p_left[0] + p_right[0]) / 2 - tw / 2 + ty = y - FONT_SIZE - 3 if label_above else y + 3 + draw.text((tx, 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) + # 单眼宽度(眼睛线,标签在上) + span(pts["left_outer"], pts["left_inner"], eye_y, + "单眼宽度", measure_result.eye_width_cm, label_above=True) + # 两眼间距(稍下一层,标签在线上方的空档) + span(pts["left_inner"], pts["right_inner"], eye_y + vgap, + "两眼间距", measure_result.inter_eye_cm, label_above=True) + # 脸宽(再下一层,标签在下) + span(pts["left_cheek"], pts["right_cheek"], eye_y + 2 * vgap, + "脸宽", measure_result.face_width_cm, label_above=False) return canvas diff --git a/tests/test_annotation.py b/tests/test_annotation.py new file mode 100644 index 0000000..edf275b --- /dev/null +++ b/tests/test_annotation.py @@ -0,0 +1,30 @@ +"""标注图生成测试:RGBA 透明底 + 尺寸 + 有内容(不重叠由人工目视核验)。""" +import cv2 +import numpy as np +from conftest import fixture + +from face_analysis.detector import detector +from face_analysis.measure import measure_face +from face_analysis.annotation import create_annotated_image + + +def test_annotation_rgba_transparent(): + img = cv2.imread(fixture("frontal.jpg")) + h, w = img.shape[:2] + lms = detector.detect(img) + r = measure_face(lms, None, w, h) # 方案A,确定性 + canvas = create_annotated_image(img, r) + assert canvas.mode == "RGBA" + assert canvas.size == (w, h) + arr = np.asarray(canvas) + assert (arr[:, :, 3] == 0).any() # 存在透明像素 + assert (arr[:, :, 3] > 0).any() # 存在不透明像素(线/字) + + +def test_annotation_runs_on_hard_sample(): + img = cv2.imread(fixture("hard_longhair.jpg")) + h, w = img.shape[:2] + lms = detector.detect(img) + r = measure_face(lms, None, w, h) + canvas = create_annotated_image(img, r) + assert canvas.size == (w, h)