Files
hair/face_analysis/annotation.py
T
2026-06-23 23:26:40 +08:00

323 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。
- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。
- 单位 cm 统一标在底部「单位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")
LINE_COLOR = (255, 255, 255, 255) # #FFFFFF 100%
def _load_font(size):
if not os.path.isfile(FONT_PATH):
raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)")
return ImageFont.truetype(FONT_PATH, size)
def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None, width=1):
"""在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。"""
h, w = buf.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
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
for off in range(-(width // 2), width - width // 2):
y = cy + off
if not (0 <= y < h):
continue
row = buf[y]
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_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None, width=1):
"""在 RGBA numpy 缓冲 buf 上画一条竖线,两端渐变消失(中间实、上下淡)。"""
h, w = buf.shape[:2]
cx = int(round(cx))
y0, y1 = int(round(y0)), int(round(y1))
y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1))
if y1 <= y0:
return
ys = np.arange(y0, y1 + 1)
span = y1 - y0
fade = fade or max(1, span // 5) # 仅两端 ~1/5 段渐隐
d = np.minimum(ys - y0, y1 - ys) # 到最近端点的距离
alpha = np.clip(d / fade, 0.0, 1.0) * color[3]
m = alpha > 0
for off in range(-(width // 2), width - width // 2):
x = cx + off
if not (0 <= x < w):
continue
col = buf[y0:y1 + 1, x]
col[m, 0] = color[0]
col[m, 1] = color[1]
col[m, 2] = color[2]
col[m, 3] = np.maximum(col[m, 3], alpha[m].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, width=1):
"""两点间画稀疏虚线主干,两端用实心三角箭头(尖端精确落在端点,便于对齐)。
虚线只画到「端点向内 arrow_len」处,箭头三角填补剩余,避免虚线穿出箭头。
"""
total = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0:
return
dx = (x2 - x1) / total
dy = (y2 - y1) / total
nx, ny = -dy, dx # 法向量
arrow_len = arrow_size * 2.0 # 三角沿线方向长度
arrow_half = arrow_size # 三角底边半宽
# 主干虚线:两端各留出 arrow_len 给箭头
pos = arrow_len
main_end = max(arrow_len, total - arrow_len)
while pos < main_end:
seg_end = min(pos + dash_len, main_end)
draw.line([(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=width)
pos += dash_len + gap_len
# 两端实心三角箭头:尖端=端点,底边在向内 arrow_len 处展开 ±arrow_half
for (tipx, tipy, ix, iy) in [(x1, y1, dx, dy), (x2, y2, -dx, -dy)]:
bx, by = tipx + ix * arrow_len, tipy + iy * arrow_len
draw.polygon([(tipx, tipy),
(bx + nx * arrow_half, by + ny * arrow_half),
(bx - nx * arrow_half, by - ny * arrow_half)], fill=color)
def _text_size(draw, text, font):
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
_LINE_NAMES = {
"hair_top": "头顶",
"hairline": "发际线",
"brow_center": "眉心",
"nose_bottom": "鼻翼下缘",
"chin_tip": "下巴尖",
}
def _head_edges_from_mask(hair_mask, y0, y1, left_cheek_x, right_cheek_x, min_gap):
"""从头发分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
返回 (head_left_x, head_right_x);某侧无掩膜/向内噪声,或离脸颊线过近
(间距 < min_gap)则该侧为 None —— 即与脸颊线太近时只保留脸颊线。
"""
if hair_mask is None:
return None, None
m = np.asarray(hair_mask)
if m.ndim == 3:
m = m[..., 0]
h = m.shape[0]
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
if y1 <= y0:
return None, None
band = m[y0:y1 + 1] > 0
cols = np.where(band.any(axis=0))[0]
if cols.size == 0:
return None, None
hl, hr = float(cols.min()), float(cols.max())
# 仅当确实在脸颊外侧、且与脸颊线间距足够大时才采用
return (hl if (left_cheek_x - hl) >= min_gap else None,
hr if (hr - right_cheek_x) >= min_gap else None)
def create_annotated_image(image_bgr, measure_result, hair_mask=None, variant="v1"):
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
布局(对齐 img1.png 版本5):
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
取自头发分割掩膜的轮廓(方案 B);无掩膜(方案 A 兜底)时省略头部端线,
只画 6 点 5 段。
- 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。
- 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。
- 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。
- 底部统一标「单位cm」。
variant="v6"(接口6):去掉头顶横线与顶庭(只画发际线/眉心/鼻翼下缘/下巴尖 4 条
横线 + 上/中/下庭),竖线纵向范围改为发际线→下巴尖,且不画人头最左/最右端线
(仅七眼 6 点 5 段,不取头部端)。
"""
h, w = image_bgr.shape[:2]
v = measure_result.vertical
pc = measure_result.px_per_cm
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
s = min(w, h)
font_size = max(11, round(s * 0.026)) # 字体更小
line_w = max(1, round(s * 0.0022))
dash_len = max(4, round(s * 0.008))
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
arrow_size = max(2, round(s * 0.0045)) # 箭头更小
pad = max(4, round(s * 0.012)) # 文字与线的间距
line_h = font_size + max(2, round(font_size * 0.18))
buf = np.zeros((h, w, 4), dtype=np.uint8)
if variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
ys = [v[name][1] for name in order]
pts = measure_result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner",
"right_inner", "right_outer", "right_cheek"]
base_xs = [pts[k][0] for k in seven_keys]
if variant == "v6":
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
else:
# 人头最左/最右:取自头发分割掩膜(方案B),脸纵向范围内统计;无掩膜则省略。
# 与脸颊线间距 < 脸宽×8% 视为太近,只保留脸颊线(不画头部端线)。
face_w = pts["right_cheek"][0] - pts["left_cheek"][0]
min_gap = max(1.0, face_w * 0.08)
head_l, head_r = _head_edges_from_mask(
hair_mask, ys[0], ys[-1], pts["left_cheek"][0], pts["right_cheek"][0], min_gap)
head_xs = [x for x in (head_l, head_r) if x is not None]
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
# 人脸/人头包围盒
fx0, fx1 = xs[0], xs[-1]
fy0, fy1 = ys[0], ys[-1]
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
for cy in ys:
draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w)
# --- 2. 纵向竖线(渐变,覆盖 v_top→v_bot ---
for vx in xs:
draw_gradient_vertical_line(buf, vx, v_top, v_bot, width=line_w)
canvas = Image.fromarray(buf, mode="RGBA")
draw = ImageDraw.Draw(canvas)
font = _load_font(font_size)
# --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 ---
name_x = fx1 + pad
name_gap = max(2, round(pad * 1.6)) # 文字底部到线的间距(再上移)
for i, name in enumerate(order):
text = _LINE_NAMES[name]
tw, th = _text_size(draw, text, font)
x = min(name_x, w - 2 - tw) # 右侧越界时回收
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
if variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
else:
court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"]
n_court = 4
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
for i in range(n_court):
y_a, y_b = ys[i], ys[i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows(
draw, arrow_x, y_a + inset, arrow_x, y_b - inset,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
# 名 + 数值两行,右对齐到箭头左侧
name = court_name[i]
val = f"{court_cm[i]:.2f}"
nw, _ = _text_size(draw, name, font)
vw, _ = _text_size(draw, val, font)
label_right = arrow_x - pad
y_mid = (y_a + y_b) / 2
y_top = y_mid - line_h
draw.text((max(2, label_right - nw), y_top), name, fill=LINE_COLOR, font=font)
draw.text((max(2, label_right - vw), y_top + line_h), val, fill=LINE_COLOR, font=font)
# --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm) ---
# 文字与箭头间留更大间距,避免文字压住箭头
txt_off = arrow_size + pad * 2
y_arrow_top = max(txt_off + font_size + 2, fy0 - pad - arrow_size)
y_arrow_bot = min(h - txt_off - font_size - 2, fy1 + pad + arrow_size)
for i in range(len(xs) - 1):
x_a, x_b = xs[i], xs[i + 1]
if x_b - x_a < 1:
continue
seg_cm = (x_b - x_a) / pc
cx_seg = (x_a + x_b) / 2
text = f"{seg_cm:.2f}"
tw, th = _text_size(draw, text, font)
inset = min(arrow_size, (x_b - x_a) * 0.12)
on_top = (i % 2 == 1) # 奇数段在上 → 上 3 / 下 4
y_arrow = y_arrow_top if on_top else y_arrow_bot
draw_dashed_line_with_arrows(
draw, x_a + inset, y_arrow, x_b - inset, y_arrow,
dash_len=dash_len, gap_len=gap_len, arrow_size=arrow_size, width=line_w)
ty = (y_arrow - th - txt_off) if on_top else (y_arrow + txt_off)
draw.text((cx_seg - tw / 2, ty), text, fill=LINE_COLOR, font=font)
# --- 5. 底部统一单位 ---
unit = "单位cm"
uw, uh = _text_size(draw, unit, font)
draw.text(((w - uw) / 2, h - uh - max(2, pad)), unit, fill=LINE_COLOR, font=font)
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, hair_mask=mask)
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")