diff --git a/app.py b/app.py index d054567..e967948 100644 --- a/app.py +++ b/app.py @@ -368,7 +368,7 @@ async def _face_measure_impl(image_file, image_url, image_base64): # 8. 测量 + 标注图 result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) - annotated = create_annotated_image(image, result) + annotated = create_annotated_image(image, result, hair_mask=hair_mask) buf = BytesIO() annotated.save(buf, format="PNG") diff --git a/face_analysis/annotation.py b/face_analysis/annotation.py index 7c2483f..1bb40ff 100644 --- a/face_analysis/annotation.py +++ b/face_analysis/annotation.py @@ -1,9 +1,13 @@ """标注图层生成(透明底 RGBA PNG,仅标注、不含人物)。 -规格(技术方案 §6):线/字色 #FFFFFF、字体 10pt、线宽 1pt、透明底。 +规格(技术方案 §6 + img1.png 版本5):线/字色 #FFFFFF、透明底。 +- 字号/线宽/虚线/箭头尺寸全部按图片尺寸自适应缩放(大图也清晰)。 - 四庭水平分界线:numpy 向量化渐变消失(中间亮、两侧渐隐)。 -- 四庭 cm 数值:图片左侧。 -- 七眼标注:眼宽/两眼间距/脸宽,虚线带箭头,标签上下穿插。 +- 纵向竖线 8 条:人头最左 + 左脸颊/左眼外/内角/右眼内/外角/右脸颊 + 人头最右, + 把头宽切 7 段(七眼),段宽数值上下交替(上 3 / 下 4),带虚线双箭头。 +- 四庭:图片左侧,「名」上「数值」下两行换行(不带 cm),带竖向虚线双箭头。 +- 五条横线右侧标名:头顶/发际线/眉心/鼻翼下缘/下巴尖。 +- 单位 cm 统一标在底部「单位cm」。 中文字体用打包的思源黑体绝对路径加载,缺字体直接抛错(不静默降级成方块)。 """ import os @@ -12,44 +16,39 @@ 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(): +def _load_font(size): if not os.path.isfile(FONT_PATH): raise FileNotFoundError(f"中文字体缺失:{FONT_PATH}(请按 OFFLINE_ASSETS.md 放置)") - return ImageFont.truetype(FONT_PATH, FONT_SIZE) + return ImageFont.truetype(FONT_PATH, size) -def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=None): - """在 RGBA numpy 缓冲 buf 上,以 (cx,cy) 为中心画向两侧渐变消失的水平线。 - - numpy 向量化:一次性算整行 alpha,避免逐像素 draw.point。 - """ +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)) - 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)) + 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): +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)) - if not (0 <= cx < w): - return y0, y1 = int(round(y0)), int(round(y1)) y0, y1 = max(0, min(y0, y1)), min(h - 1, max(y0, y1)) if y1 <= y0: @@ -59,37 +58,48 @@ def draw_gradient_vertical_line(buf, cx, y0, y1, color=LINE_COLOR, fade=None): 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] - col = buf[y0:y1 + 1, cx] m = alpha > 0 - 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)) + 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): - """两点间画虚线,两端带箭头(等腰三角)。""" + 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 - pos = 0.0 - while pos < total: - seg_end = min(pos + dash_len, 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=LINE_WIDTH) + (x1 + dx * seg_end, y1 + dy * seg_end)], fill=color, width=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) + + # 两端实心三角箭头:尖端=端点,底边在向内 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): @@ -106,20 +116,57 @@ _LINE_NAMES = { } -def create_annotated_image(image_bgr, measure_result): +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): """生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。 - 布局: - - 线条只覆盖人脸范围(横线=脸宽,竖线=脸高),渐变消失。 - - 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,**线名在右侧**; - 四庭 cm 数值(顶庭/上庭/中庭/下庭)在左侧各段中点。 - - 纵向 6 条线:左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊,把脸宽切 5 段; - 每段宽度在脸的**上端和下端**各标一次(只标 `X.XXcm`,不写名)。 + 布局(对齐 img1.png 版本5): + - 纵向竖线:人头最左 + 七眼 6 点 + 人头最右,切 7 段(七眼)。人头最左/最右 + 取自头发分割掩膜的轮廓(方案 B);无掩膜(方案 A 兜底)时省略头部端线, + 只画 6 点 5 段。 + - 横向 5 条分界线:头顶/发际线/眉心/鼻翼下缘/下巴尖,右侧标名。 + - 四庭(顶/上/中/下庭)在左侧:名 + 数值两行换行(无 cm),竖向虚线双箭头。 + - 七眼段宽数值上下交替(上 3 / 下 4,无 cm),横向虚线双箭头。 + - 底部统一标「单位cm」。 """ 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.013)) + gap_len = max(4, round(dash_len * 1.2)) # 虚线更稀疏(间隙>划线) + arrow_size = max(2, round(s * 0.007)) # 箭头更小 + 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) order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] @@ -128,60 +175,93 @@ def create_annotated_image(image_bgr, measure_result): pts = measure_result.eyes["points"] seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"] - xs = sorted(pts[k][0] for k in seven_keys) # 自左向右 + base_xs = [pts[k][0] for k in seven_keys] + # 人头最左/最右:取自头发分割掩膜(方案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 点) - # 人脸包围盒:x 为脸宽(左右脸颊),y 为脸高(头顶→下巴) + # 人脸/人头包围盒 fx0, fx1 = xs[0], xs[-1] fy0, fy1 = ys[0], ys[-1] face_cx = (fx0 + fx1) / 2 - face_half = (fx1 - fx0) / 2 * 1.08 # 略放大确保横线覆盖到脸颊 + over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格) + face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点 - # --- 1. 横向 5 条分界线(渐变,覆盖脸宽) --- + # --- 1. 横向 5 条分界线(渐变,覆盖头宽并超出一点) --- for cy in ys: - draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half) + draw_gradient_horizontal_line(buf, face_cx, cy, half_length=face_half, width=line_w) - # --- 2. 纵向 6 条线(渐变,覆盖脸高 头顶→下巴) --- + # --- 2. 纵向竖线(渐变,超出头顶/下巴一点) --- for vx in xs: - draw_gradient_vertical_line(buf, vx, fy0, fy1) + draw_gradient_vertical_line(buf, vx, fy0 - over, fy1 + over, width=line_w) canvas = Image.fromarray(buf, mode="RGBA") draw = ImageDraw.Draw(canvas) - font = _load_font() + font = _load_font(font_size) - # --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖) --- - name_x = fx1 + 8 + # --- 3a. 横线右侧:线名(头顶/发际线/眉心/鼻翼下缘/下巴尖),文字在线上方 --- + name_x = fx1 + pad + name_gap = max(2, round(pad * 0.5)) # 文字底部到线的间距 for i, name in enumerate(order): text = _LINE_NAMES[name] - tw, _ = _text_size(draw, text, font) - x = min(name_x, w - 2 - tw) # 右侧越界时回收 - draw.text((x, ys[i] - FONT_SIZE / 2), text, fill=LINE_COLOR, font=font) + 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 数值(各段中点,右对齐到脸盒左缘) --- + # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- court_cm = [measure_result.top_cm, measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm] court_name = ["顶庭", "上庭", "中庭", "下庭"] + arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线) for i in range(4): - text = f"{court_name[i]} {court_cm[i]:.2f}cm" - tw, _ = _text_size(draw, text, font) - x = max(2, fx0 - 8 - tw) # 贴脸盒左缘,右对齐 - y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2 - draw.text((x, y_mid), text, fill=LINE_COLOR, font=font) + 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. 七眼每段宽度:脸的上端 + 下端各标一次(相邻段上下错行防重叠) --- - row_h = FONT_SIZE + 2 - y_top_a = max(1, fy0 - row_h - 2) # 上端:头顶线上方两行 - y_top_b = max(1, fy0 - 2 * row_h - 2) - y_bot_a = min(h - FONT_SIZE - 1, fy1 + 2) # 下端:下巴线下方两行 - y_bot_b = min(h - FONT_SIZE - 1, fy1 + row_h + 2) + # --- 4. 七眼每段宽度:上下交替(上 3 / 下 4),横向虚线双箭头 + 数值(无 cm) --- + # 文字与箭头间留出「箭头高度 + pad」,避免文字压住箭头 + txt_off = arrow_size + pad + 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): - seg_cm = (xs[i + 1] - xs[i]) / pc - cx_seg = (xs[i] + xs[i + 1]) / 2 - text = f"{seg_cm:.2f}cm" - tw, _ = _text_size(draw, text, font) - ty_top = y_top_a if i % 2 == 0 else y_top_b - ty_bot = y_bot_a if i % 2 == 0 else y_bot_b - draw.text((cx_seg - tw / 2, ty_top), text, fill=LINE_COLOR, font=font) - draw.text((cx_seg - tw / 2, ty_bot), text, fill=LINE_COLOR, font=font) + 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 @@ -213,7 +293,7 @@ if __name__ == "__main__": result = measure_face(lms, mask, w, h) t0 = time.time() - canvas = create_annotated_image(img, result) + 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) diff --git a/image/img1.png b/image/img1.png new file mode 100644 index 0000000..016d80c Binary files /dev/null and b/image/img1.png differ