feat(annotation): 标注图改版——横线标名+四庭cm,竖线标七眼段宽(顶/底)
按需求重排标注图: - 去掉右侧全脸总高标尺,去掉单眼宽度/两眼间距/脸宽 三条横向标注 - 横向 5 条线标注线名(头顶/发际线/眉心/鼻翼下缘/下巴尖) + 保留左侧四庭 cm - 新增纵向 6 条渐变竖线(左脸颊/左右眼内外角/右脸颊),切脸宽为 5 段 - 每段宽度在顶部和底部各标一次(只标 X.XXcm),相邻段上下错行防重叠 - 新增 draw_gradient_vertical_line 竖向渐变线 - test_api 用模块级 ACCEPT_PASSWORDS 固定测试密码,不依赖 worker_config.json Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+76
-49
@@ -44,6 +44,29 @@ def draw_gradient_horizontal_line(buf, cx, cy, color=LINE_COLOR, half_length=Non
|
||||
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):
|
||||
"""在 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:
|
||||
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]
|
||||
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))
|
||||
|
||||
|
||||
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color=LINE_COLOR,
|
||||
dash_len=6, gap_len=4, arrow_size=5):
|
||||
"""两点间画虚线,两端带箭头(等腰三角)。"""
|
||||
@@ -74,76 +97,80 @@ def _text_size(draw, text, font):
|
||||
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
|
||||
_LINE_NAMES = {
|
||||
"hair_top": "头顶",
|
||||
"hairline": "发际线",
|
||||
"brow_center": "眉心",
|
||||
"nose_bottom": "鼻翼下缘",
|
||||
"chin_tip": "下巴尖",
|
||||
}
|
||||
|
||||
|
||||
def create_annotated_image(image_bgr, measure_result):
|
||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||||
|
||||
布局(避免重叠):
|
||||
- 四庭水平分界线(渐变消失),四庭 cm 数值在**左侧**各段中点。
|
||||
- **全脸总高**:右侧竖向标尺(虚线双箭头,头顶→下巴),标签在标尺左侧竖排。
|
||||
- 七眼:单眼宽度 / 两眼间距 / 脸宽 三个量**上下错开三个高度**,
|
||||
各自虚线带箭头 + 紧贴各自线条的标签,互不重叠。
|
||||
布局:
|
||||
- 横向 5 条分界线(渐变消失):头顶/发际线/眉心/鼻翼下缘/下巴尖,左侧标线名 +
|
||||
四庭 cm 数值(顶庭/上庭/中庭/下庭,各段中点)。
|
||||
- 纵向 6 条线(渐变消失):左脸颊/左眼外角/左眼内角/右眼内角/右眼外角/右脸颊,
|
||||
把脸宽切成 5 段;每段宽度在图的**顶部和底部**各标一次(只标 `X.XXcm`,不写名)。
|
||||
"""
|
||||
h, w = image_bgr.shape[:2]
|
||||
v = measure_result.vertical
|
||||
pc = measure_result.px_per_cm
|
||||
|
||||
# --- 1. 四庭水平分界线(numpy 渐变) ---
|
||||
buf = np.zeros((h, w, 4), dtype=np.uint8)
|
||||
|
||||
# --- 1. 横向 5 条分界线(渐变) ---
|
||||
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)
|
||||
|
||||
# --- 2. 纵向 6 条线(渐变),切出 5 段七眼宽度 ---
|
||||
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) # 自左向右
|
||||
# 顶/底各两行错开放置,避免窄段(眼宽~2cm)数字横向重叠
|
||||
row_h = FONT_SIZE + 2
|
||||
y_top0, y_top1 = 6, 6 + row_h
|
||||
y_bot0 = h - FONT_SIZE - 6
|
||||
y_bot1 = y_bot0 - row_h
|
||||
yv0 = y_top1 + FONT_SIZE + 4 # 竖线避开顶部两行标签
|
||||
yv1 = y_bot1 - 4 # 避开底部两行标签
|
||||
for vx in xs:
|
||||
draw_gradient_vertical_line(buf, vx, yv0, yv1)
|
||||
|
||||
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",
|
||||
# --- 3. 横线左侧:线名 + 四庭 cm 数值 ---
|
||||
left_margin = 8
|
||||
court_indent = 30
|
||||
court_cm = [measure_result.top_cm, measure_result.upper_cm,
|
||||
measure_result.middle_cm, measure_result.lower_cm]
|
||||
court_name = ["顶庭", "上庭", "中庭", "下庭"]
|
||||
for i, name in enumerate(order):
|
||||
draw.text((left_margin, ys[i] - FONT_SIZE / 2), _LINE_NAMES[name],
|
||||
fill=LINE_COLOR, font=font)
|
||||
for i in range(4):
|
||||
y_mid = (ys[i] + ys[i + 1]) / 2 - FONT_SIZE / 2
|
||||
draw.text((left_margin + court_indent, y_mid),
|
||||
f"{court_name[i]} {court_cm[i]:.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"
|
||||
# --- 4. 七眼每段宽度:顶部 + 底部各标一次(只标数字 cm,相邻段上下错行) ---
|
||||
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)
|
||||
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)
|
||||
ty_top = y_top0 if i % 2 == 0 else y_top1
|
||||
ty_bot = y_bot0 if i % 2 == 0 else y_bot1
|
||||
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)
|
||||
|
||||
return canvas
|
||||
|
||||
|
||||
+4
-1
@@ -7,8 +7,11 @@ from conftest import fixture
|
||||
|
||||
import app as app_module
|
||||
|
||||
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
|
||||
app_module.ACCEPT_PASSWORDS = ["testpass"]
|
||||
|
||||
URL = "/api/v1/face/measure"
|
||||
H = {"X-Internal-Token": "testpass"} # 与 worker_config.json 一致
|
||||
H = {"X-Internal-Token": "testpass"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
Reference in New Issue
Block a user