Files
hair/face_analysis/annotation.py
T
xslandClaude Opus 4.8 0eaaf05ac3 fix(annotation): 标注图重排,8 个量全标注且不重叠
- 修复七眼三个横向量(单眼宽度/两眼间距/脸宽)叠在同一条眼睛线上的重叠问题,
  改为上下错开三个高度,各自虚线带箭头 + 紧贴标签
- 新增「全脸总高」标注:右侧竖向标尺(虚线双箭头,头顶→下巴)+ 标签
- 四庭(顶/上/中/下庭)仍在左侧各段中点
- 标签用全名:单眼宽度/两眼间距/脸宽/全脸总高
- 新增 tests/test_annotation.py 防回归

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:33:58 +08:00

186 lines
7.5 KiB
Python

"""标注图层生成(透明底 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 _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。
布局(避免重叠):
- 四庭水平分界线(渐变消失),四庭 cm 数值在**左侧**各段中点。
- **全脸总高**:右侧竖向标尺(虚线双箭头,头顶→下巴),标签在标尺左侧竖排。
- 七眼:单眼宽度 / 两眼间距 / 脸宽 三个量**上下错开三个高度**,
各自虚线带箭头 + 紧贴各自线条的标签,互不重叠。
"""
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 = 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. 全脸总高:右侧竖向标尺(头顶→下巴,虚线双箭头) ---
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)
# --- 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"
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)
# 单眼宽度(眼睛线,标签在上)
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
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")