发际线离头顶<0.7cm时判定分割不可靠,弃用发际线: 顶/上庭字段置null、face_total只算中下庭、标注图保留头顶线去掉发际线、 只标中/下庭。eye1/7竖向范围改用眉心。
376 lines
17 KiB
Python
376 lines
17 KiB
Python
"""标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。
|
||
|
||
规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。
|
||
- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。
|
||
- 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。
|
||
- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右,
|
||
把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。
|
||
人头最左/最右取自耳朵分割外缘,看不到耳朵则省略该侧(最少 6 点 5 段)。
|
||
- 四庭:图片左侧,「名」上「数值」下两行换行(不带 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 _grow_outward(start_col, fg_band, direction, limit):
|
||
"""从 start_col 沿 direction(+1 右 / -1 左) 在前景带 fg_band 内逐列外扩。
|
||
|
||
用于回收被误标成「头发」的外耳轮廓:耳朵被头发遮挡时,外耳轮廓那一圈常被
|
||
分割并入头发类,故耳朵掩膜外缘会偏内。这里把外缘沿紧邻的前景(耳∪发)向外
|
||
延伸,最多 limit 列;一旦下一列无前景(背景间隙)立即停止,绝不窜到分离的
|
||
那缕头发上。返回外扩后的列号。
|
||
"""
|
||
w = fg_band.shape[1]
|
||
c = int(start_col)
|
||
for _ in range(int(limit)):
|
||
nc = c + direction
|
||
if not (0 <= nc < w) or not fg_band[:, nc].any():
|
||
break
|
||
c = nc
|
||
return c
|
||
|
||
|
||
def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_x,
|
||
face_center_x):
|
||
"""从耳朵分割掩膜取人头最左/最右 x(仅在脸纵向范围 [y0,y1] 内统计)。
|
||
|
||
左线 = 脸中线左侧耳朵像素的最左列;右线 = 右侧耳朵像素的最右列;再沿紧邻的
|
||
前景(耳∪发)按脸宽自适应外扩,回收被误标成头发的外耳轮廓(见 _grow_outward)。
|
||
某侧耳朵不可见(被头发/侧脸遮挡 → 掩膜为空),或外缘未越过对应脸颊线(非真实
|
||
头宽边缘)时该侧返回 None —— 即「看不到耳朵就不画这条线」。
|
||
"""
|
||
if ear_mask is None:
|
||
return None, None
|
||
m = np.asarray(ear_mask)
|
||
if m.ndim == 3:
|
||
m = m[..., 0]
|
||
m = m > 0
|
||
h = m.shape[0]
|
||
y0 = max(0, int(y0)); y1 = min(h - 1, int(y1))
|
||
if y1 <= y0:
|
||
return None, None
|
||
ear_band = m[y0:y1 + 1]
|
||
cols = np.where(ear_band.any(axis=0))[0]
|
||
if cols.size == 0:
|
||
return None, None
|
||
# 前景带 = 耳∪发(外耳轮廓常被误标为发),外扩上限按脸宽自适应
|
||
fg_band = ear_band
|
||
if hair_mask is not None:
|
||
hm = np.asarray(hair_mask)
|
||
if hm.ndim == 3:
|
||
hm = hm[..., 0]
|
||
fg_band = ear_band | (hm[y0:y1 + 1] > 0)
|
||
grow = max(2, round(max(1.0, right_cheek_x - left_cheek_x) * 0.045))
|
||
|
||
left_cols = cols[cols < face_center_x]
|
||
right_cols = cols[cols > face_center_x]
|
||
# 左/右耳外缘(外扩后),且必须在对应脸颊线外侧(否则视为残缺/噪声,只保留脸颊线)
|
||
head_l = head_r = None
|
||
if right_cols.size:
|
||
edge = _grow_outward(right_cols.max(), fg_band, +1, grow)
|
||
head_r = float(edge) if edge >= right_cheek_x else None
|
||
if left_cols.size:
|
||
edge = _grow_outward(left_cols.min(), fg_band, -1, grow)
|
||
head_l = float(edge) if edge <= left_cheek_x else None
|
||
return head_l, head_r
|
||
|
||
|
||
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
|
||
variant="v1"):
|
||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||
|
||
布局(对齐 img1.png 版本5):
|
||
- 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右
|
||
取自耳朵分割掩膜的外缘(方案 B,BiSeNet 类 7/8);耳朵不可见(被头发/侧脸
|
||
遮挡 → 掩膜空)或无掩膜时省略该侧端线,只画对应脸颊线。
|
||
- 横向 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)
|
||
|
||
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
|
||
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
|
||
if getattr(measure_result, "hairline_discarded", False):
|
||
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
|
||
elif 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,类7/8),脸纵向范围内统计。
|
||
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
|
||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||
head_l, head_r = _ear_edges_from_mask(
|
||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||
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)+ 竖向虚线双箭头 ---
|
||
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
|
||
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
|
||
if getattr(measure_result, "hairline_discarded", False):
|
||
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
|
||
court_name = ["中庭", "下庭"]
|
||
n_court = 2
|
||
court_start = 1
|
||
elif variant == "v6":
|
||
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
|
||
court_name = ["上庭", "中庭", "下庭"]
|
||
n_court = 3
|
||
court_start = 0
|
||
else:
|
||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||
measure_result.middle_cm, measure_result.lower_cm]
|
||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||
n_court = 4
|
||
court_start = 0
|
||
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
|
||
for i in range(n_court):
|
||
y_a, y_b = ys[court_start + i], ys[court_start + 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
|
||
ears = None
|
||
try:
|
||
from face_analysis.hair_segmenter import get_segmenter
|
||
mask, ears = get_segmenter().segment_hair_and_ears(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, ear_mask=ears, 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")
|