feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)+接口1调试页分步可视化

- measure.py: MeasureResult 增 hairline_discarded 判定(顶庭<0.7cm),弃用时
  顶/上庭字段置null、face_total只算中下庭、hairline_source=discarded
- annotation.py: 弃用时保留头顶线、去掉发际线、只标中/下庭
- app.py: 接口1/6 弃用分支 + eye1/7竖向范围改用眉心 + 接口1调试页(measure-debug)
- 新增 static/test_interface1_debug.html 分步可视化页(9步原理)
This commit is contained in:
xsl
2026-07-27 23:07:54 +08:00
parent 5bcaeb2594
commit 48c9875381
4 changed files with 937 additions and 59 deletions
+459 -16
View File
@@ -14,7 +14,7 @@ from typing import Any, List, Optional
import cv2
import numpy as np
from PIL import Image
from PIL import Image, ImageDraw, ImageFont
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -401,21 +401,36 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response()
vd = result.vertical
if variant == "v6":
vd = result.vertical
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
if discarded:
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["upper_court_cm"] = None
data["four_courts"]["ratios"] = {
"upper_court": None,
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.middle_cm + result.lower_cm, 2)
data["landmarks"]["hairline"] = None
else:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
@@ -430,11 +445,14 @@ def _run_face_measure_data(image, variant="v1"):
data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
from face_analysis.annotation import _ear_edges_from_mask
top_y = (vd["brow_center"][1] if result.hairline_discarded
else vd["hair_top"][1])
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
result.vertical["hair_top"][1], result.vertical["chin_tip"][1],
top_y, vd["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
data["seven_eyes"]["eye1"] = (
None if (head_l is None) else round((lcx - head_l) / pc, 2))
@@ -446,6 +464,398 @@ def _run_face_measure_data(image, variant="v1"):
return data, result, hair_mask, ear_mask
# ---------------------------------------------------------------------------
# 接口1 调试:分步可视化(每一步的中间产物图)
# ---------------------------------------------------------------------------
# 调试接口 9 张分步图的 key(与前端 STEPS 一一对应)
_DEBUG_STEP_KEYS = [
"input", "landmarks", "pose", "segmentation",
"hairline", "vertical", "seven_eyes", "scale", "final",
]
def _overlay_mask(image_bgr, mask, color, alpha=0.45):
"""在 BGR 图上把 mask 区域以 color(BGR) 半透明叠加。mask 为 bool/uint8。"""
out = image_bgr.copy()
m = np.asarray(mask).astype(bool)
if m.shape[:2] != out.shape[:2]:
return out
overlay = out[m]
# alpha 混合
overlay = (overlay * (1 - alpha) + np.array(color, dtype=np.float32) * alpha)
out[m] = np.clip(overlay, 0, 255).astype(np.uint8)
return out
_DEBUG_FONT_PATH = os.path.join(
os.path.dirname(__file__), "face_analysis", "fonts", "NotoSansCJKsc-Regular.otf")
_debug_font_cache = {}
def _debug_font(size):
f = _debug_font_cache.get(size)
if f is None:
f = ImageFont.truetype(_DEBUG_FONT_PATH, size)
_debug_font_cache[size] = f
return f
def _draw_text_cv2(img, text, org, color=(255, 255, 255), scale=None, thickness=None,
bg=True, anchor="lt"):
"""在 BGR 图上绘制文字(支持中文,用 PIL + 思源黑体)。org=(x,y)。
cv2.putText 不支持中文(会显示成问号),故统一改用 PIL 渲染。color 为 BGR 三元组。
anchor: lt=左上角对齐 org / lb=左下角 / ct=水平垂直居中。bg=True 时画黑色背景框。
"""
h, w = img.shape[:2]
s = min(w, h)
scale = scale if scale else max(0.4, s * 0.0016)
thickness = thickness if thickness else max(1, round(s * 0.0022))
# PIL 字号与 cv2 scale 大致对应(cv2 scale≈字号/30
font_size = max(10, round(scale * 30))
font = _debug_font(font_size)
# BGR → RGB
rgb = (int(color[2]), int(color[1]), int(color[0]))
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_img)
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = org
if anchor == "lb":
text_y = y - th
elif anchor == "ct":
x = x - tw // 2
text_y = y - th // 2
else:
text_y = y
if bg:
pad = max(2, round(thickness * 1.2))
draw.rectangle(
[max(0, x - pad), max(0, text_y - pad),
min(w, x + tw + pad), min(h, text_y + th + pad)],
fill=(0, 0, 0))
# PIL text 的 y 是文字顶部基线,bbox 偏移需校正
draw.text((x, text_y - bbox[1]), text, fill=rgb, font=font)
img[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR)
return img
def _run_face_measure_data_debug(image):
"""接口1 调试:产出 9 步中间图 + 数值,逐步塞进返回 dict。
与 _run_face_measure_data 同链路,但每步把中间产物渲染成叠加图(JPG base64)
放进 data["steps"][key + "_base64"],关键数值放进 data["debug"]。
检测/姿态失败时,仍返回已完成的步骤图 + 对应错误码,供前端展示「卡在哪一步」。
返回 (data, error_code_or_None, error_msg_or_None)。
"""
h, w = image.shape[:2]
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face, _brow_center
from face_analysis.calibration import (
normalized_to_pixel, estimate_scale_factor,
_iris_diameter_px, _eye_width_px, _lm_list,
AVG_IRIS_DIAMETER_CM, AVG_EYE_WIDTH_CM,
)
from face_analysis.face_mesh_landmarks import (
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT, IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT,
PNP_INDICES,
)
from face_analysis.hair_segmenter import locate_hairline_by_segmentation
data = {"steps": {}, "debug": {"image_width": w, "image_height": h}}
steps = data["steps"]
dbg = data["debug"]
def put(key, bgr_img):
steps[key + "_base64"] = "data:image/jpeg;base64," + _jpg_b64(bgr_img)
# ① 输入原图
put("input", image)
# ② 人脸关键点检测
landmarks = detector.detect(image)
if landmarks is None:
dbg["num_landmarks"] = 0
return data, 1001, "无法识别人像"
lm = _lm_list(landmarks)
dbg["num_landmarks"] = len(lm)
vis_lm = image.copy()
# 先画全部 478 点(小白点)
s = min(w, h)
r_all = max(1, round(s * 0.0018))
for p in lm:
px = normalized_to_pixel(p, w, h)
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_all, (220, 220, 220), -1)
# 虹膜点 468~477(青色稍大)
r_iris = max(2, round(s * 0.0035))
for idx in [468, 469, 470, 471, 472, 473, 474, 475, 476, 477]:
if idx < len(lm):
px = normalized_to_pixel(lm[idx], w, h)
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_iris, (255, 200, 0), -1)
# 七眼 6 点 + 5 纵向点(红色 + 标号)
key_pts = {
"头顶(推算)": None, # 纵向点除眉心外由后续 measure 给出,这里只画能拿到的
"眉心": GLABELLA_9,
}
important = [
(GLABELLA_9, "眉间9"), (GLABELLA_151, "眉间151"), (NOSE_BOTTOM, "鼻翼下94"),
(CHIN_TIP, "下巴152"), (LEFT_EYE_OUTER, "左眼外33"), (LEFT_EYE_INNER, "左眼内133"),
(RIGHT_EYE_INNER, "右眼内362"), (RIGHT_EYE_OUTER, "右眼外263"),
(LEFT_CHEEK, "左脸234"), (RIGHT_CHEEK, "右脸454"),
]
r_imp = max(3, round(s * 0.005))
for idx, name in important:
px = normalized_to_pixel(lm[idx], w, h)
cv2.circle(vis_lm, (int(px[0]), int(px[1])), r_imp, (0, 0, 255), -1)
_draw_text_cv2(vis_lm, name, (int(px[0]) + r_imp + 2, int(px[1])),
color=(0, 255, 255), scale=max(0.35, s * 0.0013))
put("landmarks", vis_lm)
# ③ 头部姿态校验
head_pose = estimate_head_pose(lm, w, h) if hasattr(landmarks, "landmark") else estimate_head_pose(lm, w, h)
frontal = check_frontal_face(landmarks, w, h)
vis_pose = image.copy()
# 画 6 个 PnP 点(黄)
nose_tip_px = None
for idx in PNP_INDICES:
px = normalized_to_pixel(lm[idx], w, h)
cv2.circle(vis_pose, (int(px[0]), int(px[1])), max(3, round(s * 0.004)), (0, 255, 255), -1)
if idx == 1:
nose_tip_px = (int(px[0]), int(px[1]))
# 三轴箭头(鼻尖为原点)
if nose_tip_px is not None and head_pose is not None:
L = max(30, round(s * 0.08))
# yaw 绕 Y(竖轴) → 在屏幕上表现为左右;pitch 绕 X → 上下;roll 绕 Z → 面内旋转
yaw, pitch, roll = head_pose
import math
# 简化:用 roll 直接旋转 X/Y 轴示意,yaw 投影到横向、pitch 到纵向
cosr, sinr = math.cos(math.radians(roll)), math.sin(math.radians(roll))
# X 轴(红,向右)
cv2.arrowedLine(vis_pose, nose_tip_px,
(int(nose_tip_px[0] + L * cosr), int(nose_tip_px[1] + L * sinr)),
(0, 0, 255), max(2, round(s * 0.003)), tipLength=0.2)
# Y 轴(绿,向下)
cv2.arrowedLine(vis_pose, nose_tip_px,
(int(nose_tip_px[0] - L * sinr), int(nose_tip_px[1] + L * cosr)),
(0, 255, 0), max(2, round(s * 0.003)), tipLength=0.2)
# Z 轴(青,向内用圆圈示意)
cv2.circle(vis_pose, nose_tip_px, max(6, round(s * 0.012)), (255, 255, 0), max(1, round(s * 0.002)))
# 角度文字
txt = f"yaw={yaw:.1f} pitch={pitch:.1f} roll={roll:.1f}"
_draw_text_cv2(vis_pose, txt, (10, 10), color=(50, 255, 50),
scale=max(0.5, s * 0.0022))
_draw_text_cv2(vis_pose, f"frontal={'YES' if frontal else 'NO'}", (10, 40),
color=(50, 255, 50) if frontal else (50, 50, 255),
scale=max(0.5, s * 0.0022))
dbg["head_pose"] = {"yaw": round(yaw, 2), "pitch": round(pitch, 2),
"roll": round(roll, 2), "frontal": bool(frontal)}
put("pose", vis_pose)
if not frontal:
return data, 1003, "角度问题,请上传正面照"
# ④ 头发/耳朵分割
hair_mask = None
ear_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
pxs = [normalized_to_pixel(p, w, h) for p in lm]
face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs),
max(p[0] for p in pxs), max(p[1] for p in pxs))
hair_mask, ear_mask = get_segmenter().segment_hair_and_ears(image, face_box=face_box)
except Exception as seg_e: # noqa: BLE001
logger.warning("[debug] 头发/耳朵分割失败:%s", seg_e)
vis_seg = image.copy()
if hair_mask is not None:
vis_seg = _overlay_mask(vis_seg, hair_mask, (0, 200, 0), alpha=0.45)
dbg["hair_pixels"] = int(np.asarray(hair_mask).astype(bool).sum())
if ear_mask is not None:
vis_seg = _overlay_mask(vis_seg, ear_mask, (200, 80, 0), alpha=0.5)
dbg["ear_pixels"] = int(np.asarray(ear_mask).astype(bool).sum())
_draw_text_cv2(vis_seg, "绿=头发(hair=17) 蓝=耳朵(ear=7/8)", (10, 10),
color=(50, 255, 50), scale=max(0.45, s * 0.0018))
put("segmentation", vis_seg)
# 主测量(复用 measure_face,内部含 ⑤ 纵向决策 + 七眼 + 尺度)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
v = result.vertical
dbg["hairline_source"] = result.hairline_source
# ⑤ 纵向定位(发际线/头顶)—— 复刻方案 B 的中轴线扫描
vis_hl = image.copy()
if hair_mask is not None:
vis_hl = _overlay_mask(vis_hl, hair_mask, (0, 180, 0), alpha=0.3)
brow_x, brow_y = _brow_center(lm, w, h)
# 中轴线 ±3px 列带高亮(黄)
cx = int(round(brow_x))
cv2.line(vis_hl, (max(0, cx - 3), 0), (max(0, cx - 3), h), (0, 230, 255), 1)
cv2.line(vis_hl, (min(w - 1, cx + 3), 0), (min(w - 1, cx + 3), h), (0, 230, 255), 1)
# 画 hairline_y / hair_top_y 两条横线
hairline_y = int(v["hairline"][1])
hair_top_y = int(v["hair_top"][1])
cv2.line(vis_hl, (0, hair_top_y), (w, hair_top_y), (255, 255, 0), max(2, round(s * 0.0025)))
cv2.line(vis_hl, (0, hairline_y), (w, hairline_y), (0, 100, 255), max(2, round(s * 0.0025)))
_draw_text_cv2(vis_hl, f"hair_top_y={hair_top_y}", (hair_top_y if hair_top_y < h - 40 else h - 40, 0),
color=(255, 255, 0), scale=max(0.4, s * 0.0015))
# 文字标注位置:hairline_y 行右侧
_draw_text_cv2(vis_hl, f"hairline_y={hairline_y} (source={result.hairline_source})",
(hairline_y, w - int(s * 0.5)), color=(0, 200, 255),
scale=max(0.4, s * 0.0015))
# 发际线弃用提示:顶庭 < 0.7cm 视为贴近头顶、不可靠
if result.hairline_discarded:
gap_cm = result.top_cm
_draw_text_cv2(vis_hl,
f"⚠️ 发际线离头顶仅 {gap_cm:.2f}cm (<0.7cm),已弃用",
(10, 10), color=(40, 40, 255), scale=max(0.5, s * 0.0022))
put("hairline", vis_hl)
# ⑥ 四庭纵向点
vis_v = image.copy()
v_names = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
v_labels = ["头顶", "发际线", "眉心", "鼻翼下缘", "下巴尖"]
v_court_px = [v["top_court_px"], v["upper_court_px"], v["middle_court_px"], v["lower_court_px"]]
court_names = ["顶庭", "上庭", "中庭", "下庭"]
court_cm = [result.top_cm, result.upper_cm, result.middle_cm, result.lower_cm]
vx0 = min(int(v[n][0]) for n in v_names)
for i, name in enumerate(v_names):
x, y = int(v[name][0]), int(v[name][1])
cv2.circle(vis_v, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
# 画一条横线
cv2.line(vis_v, (vx0 - max(20, round(s * 0.04)), y),
(min(w - 1, vx0 + int(s * 0.02)), y), (0, 200, 255), 1)
_draw_text_cv2(vis_v, v_labels[i], (min(w - 60, x + 8), y),
color=(50, 255, 255), scale=max(0.4, s * 0.0015))
# 各庭段高(竖向虚线 + cm 文字)
for i in range(4):
y_a = int(v[v_names[i]][1])
y_b = int(v[v_names[i + 1]][1])
lx = max(10, vx0 - max(40, round(s * 0.08)))
cv2.line(vis_v, (lx, y_a), (lx, y_b), (0, 255, 100), max(2, round(s * 0.0025)))
cv2.circle(vis_v, (lx, y_a), 3, (0, 255, 100), -1)
cv2.circle(vis_v, (lx, y_b), 3, (0, 255, 100), -1)
_draw_text_cv2(vis_v, f"{court_names[i]} {court_cm[i]:.2f}cm",
(lx - int(s * 0.18), (y_a + y_b) // 2),
color=(100, 255, 100), scale=max(0.4, s * 0.0015))
dbg["vertical_points"] = {n: {"x": int(v[n][0]), "y": int(v[n][1])} for n in v_names}
put("vertical", vis_v)
# ⑦ 七眼横向点
vis_e = image.copy()
epts = result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner", "right_inner", "right_outer", "right_cheek"]
seven_labels = ["左脸颊", "左眼外", "左眼内", "右眼内", "右眼外", "右脸颊"]
ey0 = min(int(epts[k][1]) for k in seven_keys)
for i, k in enumerate(seven_keys):
x, y = int(epts[k][0]), int(epts[k][1])
cv2.circle(vis_e, (x, y), max(3, round(s * 0.004)), (0, 0, 255), -1)
cv2.line(vis_e, (x, max(0, ey0 - 20)), (x, min(h - 1, ey0 + 20)),
(0, 200, 255), 1)
_draw_text_cv2(vis_e, seven_labels[i], (x, ey0 - max(25, round(s * 0.04))),
color=(50, 255, 255), scale=max(0.4, s * 0.0015), anchor="ct")
# 头部最左/最右端线(耳朵外缘)
try:
from face_analysis.annotation import _ear_edges_from_mask
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask, v["hair_top"][1], v["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
if head_l is not None:
cv2.line(vis_e, (int(head_l), 0), (int(head_l), h), (255, 100, 255), max(1, round(s * 0.002)))
_draw_text_cv2(vis_e, "人头最左", (int(head_l), 10),
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
if head_r is not None:
cv2.line(vis_e, (int(head_r), 0), (int(head_r), h), (255, 100, 255), max(1, round(s * 0.002)))
_draw_text_cv2(vis_e, "人头最右", (int(head_r), 10),
color=(255, 150, 255), scale=max(0.35, s * 0.0013))
dbg["head_left_x"] = head_l
dbg["head_right_x"] = head_r
except Exception as e: # noqa: BLE001
logger.warning("[debug] 七眼端线绘制失败:%s", e)
dbg["seven_eye_points"] = {k: {"x": int(epts[k][0]), "y": int(epts[k][1])} for k in seven_keys}
put("seven_eyes", vis_e)
# ⑧ 尺度校准
vis_sc = image.copy()
px_per_cm = result.px_per_cm
iris_px = _iris_diameter_px(lm, w, h)
if iris_px is not None and iris_px > 0:
# 画左右虹膜直径线(青)
for (li, ri) in [(IRIS_LEFT_LEFT, IRIS_LEFT_RIGHT), (IRIS_RIGHT_LEFT, IRIS_RIGHT_RIGHT)]:
p1 = normalized_to_pixel(lm[li], w, h)
p2 = normalized_to_pixel(lm[ri], w, h)
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
(255, 200, 0), max(2, round(s * 0.004)))
cv2.circle(vis_sc, (int(p1[0]), int(p1[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
cv2.circle(vis_sc, (int(p2[0]), int(p2[1])), max(2, round(s * 0.003)), (255, 200, 0), -1)
method = "iris"
_draw_text_cv2(vis_sc, f"虹膜直径法: {iris_px:.1f}px / {AVG_IRIS_DIAMETER_CM}cm", (10, 10),
color=(255, 200, 0), scale=max(0.45, s * 0.0018))
else:
# 降级眼宽法(黄)
eye_px = _eye_width_px(lm, w, h)
method = "eye_width"
for (oi, ii) in [(LEFT_EYE_OUTER, LEFT_EYE_INNER), (RIGHT_EYE_INNER, RIGHT_EYE_OUTER)]:
p1 = normalized_to_pixel(lm[oi], w, h)
p2 = normalized_to_pixel(lm[ii], w, h)
cv2.line(vis_sc, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])),
(0, 255, 255), max(2, round(s * 0.004)))
_draw_text_cv2(vis_sc, f"眼宽法(降级): {eye_px:.1f}px / {AVG_EYE_WIDTH_CM}cm", (10, 10),
color=(0, 255, 255), scale=max(0.45, s * 0.0018))
_draw_text_cv2(vis_sc, f"px_per_cm = {px_per_cm:.3f}", (10, 40),
color=(50, 255, 50), scale=max(0.5, s * 0.002))
dbg["px_per_cm"] = round(px_per_cm, 4)
dbg["scale_method"] = method
put("scale", vis_sc)
# 把 to_response 的数值并入 data(前端指标速览复用)
data.update(result.to_response())
# 七眼段宽
try:
pc = result.px_per_cm
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
data.setdefault("seven_eyes", {})
for i in range(5):
a, b = inner_xs[i], inner_xs[i + 1]
data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
from face_analysis.annotation import _ear_edges_from_mask as _eef
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
# 弃用时用眉心做上界(与 _run_face_measure_data 一致)
top_y = v["brow_center"][1] if result.hairline_discarded else v["hair_top"][1]
head_l, head_r = _eef(ear_mask, hair_mask, top_y, v["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
data["seven_eyes"]["eye1"] = None if head_l is None else round((lcx - head_l) / pc, 2)
data["seven_eyes"]["eye7"] = None if head_r is None else round((head_r - rcx) / pc, 2)
except Exception as seg_e: # noqa: BLE001
logger.warning("[debug] 七眼段宽计算失败:%s", seg_e)
# ⑨ 最终标注图(原图 + 标注层叠加)
try:
from face_analysis.annotation import create_annotated_image
annotated = create_annotated_image(image, result, ear_mask=ear_mask, hair_mask=hair_mask)
anno_rgba = np.asarray(annotated)
# 叠加到原图
vis_final = image.copy()
alpha = anno_rgba[:, :, 3:4].astype(np.float32) / 255.0
vis_final = (vis_final.astype(np.float32) * (1 - alpha)
+ anno_rgba[:, :, :3].astype(np.float32) * alpha)
vis_final = np.clip(vis_final, 0, 255).astype(np.uint8)
put("final", vis_final)
except Exception as e: # noqa: BLE001
logger.warning("[debug] 标注图叠加失败:%s", e)
return data, None, None
async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。
@@ -581,6 +991,39 @@ async def face_measure(
return ok_data if ok_data is not None else err_data
# ---------------------------------------------------------------------------
# 接口1 调试:分步可视化(每一步中间产物图 + 原理)
# ---------------------------------------------------------------------------
@app.post("/api/v1/face/measure-debug", include_in_schema=False)
async def face_measure_debug(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
):
"""接口1 调试:返回算法每一步的中间产物图(data.steps.*_base64+ 数值(data.debug)。
与正式接口同链路,但额外产出 9 张分步叠加图(输入/关键点/姿态/分割/发际线/
四庭/七眼/尺度/最终标注),供调试页分步可视化。错误时仍返回已完成的步骤图。
"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
data, code, msg = _run_face_measure_data_debug(image)
if code is not None:
# 仍带分步图返回,前端可展示卡在哪一步
return {"code": code, "message": msg,
"request_id": "mock-request-id", "data": data}
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口1 调试处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 6:四庭七眼测量标注 v2(复刻接口1)
# ---------------------------------------------------------------------------