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 cv2
import numpy as np import numpy as np
from PIL import Image from PIL import Image, ImageDraw, ImageFont
from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -401,21 +401,36 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e) logger.warning("头发/耳朵分割失败,回退方案A%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose) result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response() data = result.to_response()
vd = result.vertical
if variant == "v6": if variant == "v6":
vd = result.vertical if discarded:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"] # 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["ratios"] = { data["four_courts"]["upper_court_cm"] = None
"upper_court": round(vd["upper_court_px"] / base_px, 3), data["four_courts"]["ratios"] = {
"middle_court": round(vd["middle_court_px"] / base_px, 3), "upper_court": None,
"lower_court": round(vd["lower_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( data["four_courts"].pop("top_court_cm", None)
result.upper_cm + result.middle_cm + result.lower_cm, 2) data["face_total_height_cm"] = round(
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、 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=右耳外段。 # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 # 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}"] = ( data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2)) None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6": if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线 # 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
from face_analysis.annotation import _ear_edges_from_mask 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( head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_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) lcx, rcx, (lcx + rcx) / 2)
data["seven_eyes"]["eye1"] = ( data["seven_eyes"]["eye1"] = (
None if (head_l is None) else round((lcx - head_l) / pc, 2)) 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 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"): async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"""接口1/6 共用实现:四庭七眼测量 + 标注图生成。返回 (ok_dict, err_dict)。 """接口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 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) # 接口 6:四庭七眼测量标注 v2(复刻接口1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+17 -4
View File
@@ -212,7 +212,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
buf = np.zeros((h, w, 4), dtype=np.uint8) buf = np.zeros((h, w, 4), dtype=np.uint8)
if variant == "v6": # 发际线弃用(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"] order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else: else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"] order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
@@ -239,7 +243,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
face_cx = (fx0 + fx1) / 2 face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格) over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点 face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# v6 竖线纵向范围 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点 # 竖线纵向范围v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over v_bot = fy1 if variant == "v6" else fy1 + over
@@ -265,19 +269,28 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm") draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 --- # --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
if variant == "v6": # 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_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"] court_name = ["上庭", "中庭", "下庭"]
n_court = 3 n_court = 3
court_start = 0
else: else:
court_cm = [measure_result.top_cm, measure_result.upper_cm, court_cm = [measure_result.top_cm, measure_result.upper_cm,
measure_result.middle_cm, measure_result.lower_cm] measure_result.middle_cm, measure_result.lower_cm]
court_name = ["顶庭", "上庭", "中庭", "下庭"] court_name = ["顶庭", "上庭", "中庭", "下庭"]
n_court = 4 n_court = 4
court_start = 0
arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线) arrow_x = max(arrow_size + 1, fx0 - pad) # 竖箭头所在 x(脸左侧,贴近最左竖线)
court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和 court_total = sum(court_cm) or 1.0 # 各庭占比分母 = 四庭(v6 三庭)之和
for i in range(n_court): for i in range(n_court):
y_a, y_b = ys[i], ys[i + 1] y_a, y_b = ys[court_start + i], ys[court_start + i + 1]
# 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线) # 竖向虚线双箭头,覆盖该庭高度(略收一点避免压到横线)
inset = min(arrow_size, (y_b - y_a) * 0.12) inset = min(arrow_size, (y_b - y_a) * 0.12)
draw_dashed_line_with_arrows( draw_dashed_line_with_arrows(
+95 -39
View File
@@ -144,9 +144,22 @@ def measure_seven_eyes(landmarks, image_width, image_height):
} }
def pt_or_none(vertical, name):
"""vertical dict 的点 → {"x","y"},值为 None 时返回 None。"""
v = vertical.get(name)
if v is None:
return None
return {"x": int(round(v[0])), "y": int(round(v[1]))}
class MeasureResult: class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。""" """测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose, def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None): landmarks=None, image_width=None, image_height=None):
self.vertical = vertical self.vertical = vertical
@@ -164,7 +177,19 @@ class MeasureResult:
self.upper_cm = vertical["upper_court_px"] / px_per_cm self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm self.lower_cm = vertical["lower_court_px"] / px_per_cm
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm # 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded"face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
self.face_width_cm = eyes["face_width_px"] / px_per_cm
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
# 七眼厘米 # 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -172,46 +197,77 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self): def to_response(self):
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"] # 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"]) # landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
fw_px = self.eyes["face_width_px"] if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
def pt(name): data = {
x, y = self.vertical[name] "face_total_height_cm": round(self.face_total_cm, 2),
return {"x": int(round(x)), "y": int(round(y))} "four_courts": {
"top_court_cm": None,
data = { "upper_court_cm": None,
"face_total_height_cm": round(self.face_total_cm, 2), "middle_court_cm": round(self.middle_cm, 2),
"four_courts": { "lower_court_cm": round(self.lower_cm, 2),
"top_court_cm": round(self.top_cm, 2), "ratios": {
"upper_court_cm": round(self.upper_cm, 2), "top_court": None,
"middle_court_cm": round(self.middle_cm, 2), "upper_court": None,
"lower_court_cm": round(self.lower_cm, 2), "middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"ratios": { "lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
"top_court": round(self.vertical["top_court_px"] / total_px, 3), },
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
}, },
}, "seven_eyes": {
"seven_eyes": { "eye_width_cm": round(self.eye_width_cm, 2),
"eye_width_cm": round(self.eye_width_cm, 2), "face_width_cm": round(self.face_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2), "inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2), "ratios": {
"ratios": { "eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"eye_width": round(self.eyes["eye_width_px"] / fw_px, 3), "inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / fw_px, 3), },
}, },
}, "landmarks": {
"landmarks": { "hair_top": None,
"hair_top": pt("hair_top"), "hairline": None,
"hairline": pt("hairline"), "brow_center": pt_or_none(self.vertical, "brow_center"),
"brow_center": pt("brow_center"), "nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"nose_bottom": pt("nose_bottom"), "chin_tip": pt_or_none(self.vertical, "chin_tip"),
"chin_tip": pt("chin_tip"), },
}, "hairline_source": self.hairline_source,
"hairline_source": self.hairline_source, }
} else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
# left/right_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。 # left/right_positionmediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。 # landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h: if self.landmarks is not None and self.w and self.h:
+366
View File
@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口1 调试页(分步可视化)— 四庭七眼测量</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
h2 { font-size: 17px; margin: 24px 0 12px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.7; }
.subtitle code { background:#f0f0f0; padding:1px 6px; border-radius:4px; font-size:12px; }
/* 上传区 */
.upload-card { background: #fff; border-radius: 12px; padding: 20px 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.file-input input[type=file].dragover { border-color: #2563eb; background: #eff6ff; }
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.upload-hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
/* 状态 */
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-top: 12px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
/* 指标卡片 */
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.metric { background: #f9fafb; border-radius: 8px; padding: 12px 14px; }
.metric .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; }
.metric .value { font-size: 18px; font-weight: 700; color: #111827; margin-top: 2px; }
.metric .unit { font-size: 12px; color: #6b7280; font-weight: 400; }
/* debug 数值面板 */
.debug-info { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-top: 16px; }
.debug-info .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 24px; font-size: 13px; }
.debug-info .grid div { color: #4b5563; }
.debug-info .grid b { color: #111827; }
/* 分步卡片 */
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.step .cap { font-size: 13px; font-weight: 600; padding: 10px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display:flex; align-items:center; gap:8px; }
.step .cap .badge { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:26px; padding:0 6px; border-radius:6px; background:#2563eb; color:#fff; font-size:14px; font-weight:700; }
.step .cap .ttext { flex:1; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; font-size:11px; }
.step .desc { font-size: 12.5px; line-height: 1.7; color: #4b5563; padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #f0f0f0; }
.step .desc b { color:#1f2937; }
.step img { width: 100%; display: block; background: #222; cursor: zoom-in; }
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
/* JSON */
.panel { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; margin-top:16px; }
.panel-header { font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.json-panel { max-height: 500px; overflow: auto; }
.json-content { padding: 14px 18px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
/* lightbox */
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); display: none; align-items: center; justify-content: center; z-index: 999; cursor: zoom-out; padding: 30px; }
#lightbox img { max-width: 95%; max-height: 95%; }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>📐 接口1 调试页 <span style="font-size:13px;color:#888">(分步可视化 + 原理)</span></h1>
<div class="subtitle">
独立调试页,把「四庭七眼测量」算法的每一步中间产物都可视化出来,并标注原理。<br>
调试接口:<code>POST /api/v1/face/measure-debug</code> &nbsp;|&nbsp; 与正式接口 <code>/api/v1/face/measure</code> 同算法链路,额外返回 9 张分步图。<br>
算法链路:取图 → 关键点检测 → 姿态校验 → 头发/耳朵分割 → 发际线定位 → 四庭纵向 → 七眼横向 → 尺度校准 → 最终标注。
</div>
<!-- 上传区 -->
<div class="upload-card">
<div class="upload-row">
<div class="file-input">
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除结果</button>
</div>
<div class="upload-hint">支持 JPG / PNG 正面照 &nbsp;|&nbsp; 也可拖拽图片到文件选择框</div>
<div id="statusBar" class="status hidden"></div>
</div>
<!-- 指标速览 -->
<h2 class="hidden" id="metricsTitle">📊 指标速览</h2>
<div id="metricsBar" class="metrics hidden"></div>
<!-- debug 数值 -->
<div id="debugPanel" class="debug-info hidden">
<div style="font-weight:700;font-size:13px;margin-bottom:8px">🔍 中间数值(data.debug</div>
<div class="grid" id="debugGrid"></div>
</div>
<!-- 分步可视化 -->
<h2 class="hidden" id="stepsTitle">🪜 分步可视化(每一步中间产物 + 原理)</h2>
<div class="steps" id="stepsGrid"></div>
<!-- JSON -->
<div class="panel hidden" id="jsonPanel">
<div class="panel-header">
<span>📋 JSON 响应</span>
<button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button>
</div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
<!-- lightbox -->
<div id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/face/measure-debug';
// 9 个步骤的配置:no=序号, key=后端 steps 字段名, title=标题, sub=子标题, desc=原理说明
const STEPS = [
{ no: '①', key: 'input', title: '输入原图', sub: '用户上传的正面照',
desc: '算法入口:接收 JPG/PNG 图片,解码为 OpenCV BGR 数组。后续所有关键点、分割、测量都基于这张图,坐标原点在图片左上角,x 向右、y 向下。'
},
{ no: '②', key: 'landmarks', title: '人脸关键点检测', sub: '白=全部478点,青=虹膜点,红+标号=关键测量点',
desc: '用 <b>MediaPipe Face Mesh</b>468 点拓扑,开启 refine_landmarks 再加 10 个虹膜点,共 478 点)定位人脸。'
+ '中/下庭(眉心→鼻翼下缘→下巴尖)能直接从关键点读出;上/顶庭因发际线被头发遮挡、关键点不稳定,需后续分割补齐。'
+ '虹膜点(468~477)是尺度校准的天然标尺。图中红点为七眼 6 点 + 鼻翼/下巴/眉间等纵向基准点。'
},
{ no: '③', key: 'pose', title: '头部姿态校验', sub: '黄=PnP 6点,红/绿/青=三轴,文字=yaw/pitch/roll',
desc: '用 6 个关键点(鼻尖/下巴/双眼外角/双嘴角)配通用 3D 头模,<b>cv2.solvePnP</b> 解出欧拉角。'
+ 'yaw=左右扭头、pitch=上下点头、roll=面内倾斜。三轴绝对值均 ≤ 30° 才算正面照,否则报「角度问题」(1003)。'
+ 'ITERATIVE 解偶尔收敛到相机后方(翻转解),检测到 tz&lt;0 时用 SQPNP 重解正深度解。'
},
{ no: '④', key: 'segmentation', title: '头发/耳朵分割', sub: '绿=头发(hair=17),蓝=耳朵(ear=7/8)',
desc: '<b>BiSeNet</b>CelebAMask-HQ 19 类语义分割,hair=17/l_ear=7/r_ear=8)单次推理同时出头发和耳朵 mask。'
+ '整张大场景图(脸只占一小块)会欠分割丢耳朵,故先按人脸框放大裁剪再分割,让脸接近训练分布。'
+ '头发 mask 用于下一步定位发际线;耳朵 mask 用于七眼取「人头最左/最右」端线。'
},
{ no: '⑤', key: 'hairline', title: '发际线/头顶定位', sub: '黄列带=中轴线扫描,红横线=hairline,黄横线=hair_top',
desc: '<b>方案 B(分割,优先)</b>:在面部中轴线 ±3px 列带上扫头发 mask,最靠下的头发行 = 发际线 hairline_y'
+ '全图头发 mask 最高点 = 头顶 hair_top_y;合理性校验(头顶&lt;发际线&lt;眉心、各庭为正)不过则回退。'
+ '<b>方案 A(兜底)</b>:实测中/下庭,按比例常量(顶:上:中:下=0.22:0.25:0.28:0.25)推算上/顶庭。'
+ 'source=segmentation 表示用了真实分割,=estimated 表示回退估算。'
},
{ no: '⑥', key: 'vertical', title: '四庭纵向点', sub: '红点=5个纵向基准,绿竖线=各庭段高(cm)',
desc: '把 5 个纵向点(头顶/发际线/眉心/鼻翼下缘/下巴尖)连成 4 段:'
+ '<b>顶庭</b>(头顶→发际线)、<b>上庭</b>(发际线→眉心)、<b>中庭</b>(眉心→鼻翼下缘)、<b>下庭</b>(鼻翼下缘→下巴尖)。'
+ '各段像素高 ÷ px_per_cm = 厘米值。这是「四庭」比例分析的物理依据。'
},
{ no: '⑦', key: 'seven_eyes', title: '七眼横向点', sub: '红点=6个横向基准,紫线=人头最左/最右端线',
desc: '取左右脸颊/眼外角/眼内角 6 个关键点的横坐标,把头宽切成 5 段(左脸颊/左眼/两眼间距/右眼/右脸颊)。'
+ '再用耳朵分割外缘补「人头最左/最右」两条端线(紫色),共 7 段 = <b>七眼</b>。'
+ '耳朵被头发遮挡时该侧端线省略(看不到耳朵就不画),对应 eye1/eye7 返回 null。'
},
{ no: '⑧', key: 'scale', title: '尺度校准', sub: '青=虹膜直径线,黄=眼宽线(降级),文字=px_per_cm',
desc: '把像素换算成厘米。<b>虹膜直径法(优先)</b>:成人虹膜直径高度稳定(平均 11.7mm=1.17cm),'
+ '左右虹膜直径像素均值 ÷ 1.17 = px_per_cm。虹膜点缺失时<b>降级用眼宽法</b>(外→内眼角均值 2.85cm)。'
+ '这一步决定了所有 cm 数值的准确度——标尺错了,后面全错。'
},
{ no: '⑨', key: 'final', title: '最终标注图', sub: '原图 + 标注层叠加 = 接口1 正式输出',
desc: '把前面算出的四庭七眼数据,用 <b>create_annotated_image()</b> 渲染成透明底 RGBA 标注层'
+ '(白线/白字、渐变消失横竖线、虚线双箭头、各段 cm+百分比),再叠加回原图。'
+ '这就是正式接口 <code>/api/v1/face/measure</code> 返回的 annotated_image_base64。'
},
];
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
function pick(obj, name) {
if (!obj) return null;
return obj[name + '_url'] || obj[name + '_base64'] || null;
}
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function stepCard(st, src) {
const div = document.createElement('div');
div.className = 'step';
const hasImg = src && src.length > 50;
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
: '<div class="noimg">无图(后端未返回此步)</div>';
const badge = '<span class="badge">' + st.no + '</span>';
const lenTxt = hasImg ? ' (' + src.length + '字符)' : '';
const ttext = '<span class="ttext">' + st.title + '<small>' + st.sub + lenTxt + '</small></span>';
const desc = '<div class="desc">' + st.desc + '</div>';
div.innerHTML = '<div class="cap">' + badge + ttext + '</div>' + desc + img;
return div;
}
function renderResult(d) {
const s = d.steps || {};
const grid = $('stepsGrid');
grid.innerHTML = '';
STEPS.forEach(st => {
grid.appendChild(stepCard(st, pick(s, st.key)));
});
}
function renderMetrics(data) {
const fc = data.four_courts || {};
const se = data.seven_eyes || {};
const items = [
{ label: '全脸总高', value: data.face_total_height_cm, unit: 'cm' },
{ label: '顶庭', value: fc.top_court_cm, unit: 'cm' },
{ label: '上庭', value: fc.upper_court_cm, unit: 'cm' },
{ label: '中庭', value: fc.middle_court_cm, unit: 'cm' },
{ label: '下庭', value: fc.lower_court_cm, unit: 'cm' },
{ label: '单眼宽度', value: se.eye_width_cm, unit: 'cm' },
{ label: '脸宽', value: se.face_width_cm, unit: 'cm' },
{ label: '两眼间距', value: se.inter_eye_distance_cm, unit: 'cm' },
];
let html = '';
items.forEach(m => {
// null 值(发际线弃用时顶/上庭)显示「已弃用」,灰色弱化
if (m.value === null || m.value === undefined) {
html += '<div class="metric" style="opacity:.5"><div class="label">' + m.label + '</div>'
+ '<div class="value" style="font-size:13px;color:#9ca3af">已弃用</div></div>';
} else {
html += '<div class="metric"><div class="label">' + m.label + '</div><div class="value">'
+ m.value + ' <span class="unit">' + m.unit + '</span></div></div>';
}
});
$('metricsBar').innerHTML = html;
}
function renderDebug(dbg) {
if (!dbg) { $('debugPanel').classList.add('hidden'); return; }
const rows = [];
const push = (k, v) => rows.push('<div>' + k + ' <b>' + v + '</b></div>');
push('图片尺寸', (dbg.image_width||'?') + ' × ' + (dbg.image_height||'?') + ' px');
push('关键点数', dbg.num_landmarks || 0);
if (dbg.head_pose) {
push('姿态 yaw/pitch/roll',
dbg.head_pose.yaw + '° / ' + dbg.head_pose.pitch + '° / ' + dbg.head_pose.roll + '°');
push('是否正面', dbg.head_pose.frontal ? 'YES' : 'NO');
}
push('头发像素', dbg.hair_pixels ?? '—');
push('耳朵像素', dbg.ear_pixels ?? '—');
const srcMap = { segmentation: '分割(方案B)', estimated: '估算(方案A兜底)',
discarded: '⚠️ 已弃用(离头顶<0.7cm)' };
push('发际线来源', srcMap[dbg.hairline_source] || dbg.hairline_source || '—');
push('尺度方法', dbg.scale_method === 'iris' ? '虹膜直径法' :
(dbg.scale_method === 'eye_width' ? '眼宽法(降级)' : dbg.scale_method || '—'));
push('px_per_cm', dbg.px_per_cm ?? '—');
if (dbg.head_left_x != null || dbg.head_right_x != null) {
push('人头最左/最右 x', (dbg.head_left_x ?? 'null') + ' / ' + (dbg.head_right_x ?? 'null'));
}
$('debugGrid').innerHTML = rows.join('');
$('debugPanel').classList.remove('hidden');
}
async function submitTest() {
const fileInput = $('imageFile');
let file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const _reqStart = performance.now();
const btn = $('submitBtn');
btn.disabled = true;
btn.textContent = '⏳ 请求中...';
setStatus('正在请求 ' + ENDPOINT + ' ...', 'info');
// 隐藏旧结果
['metricsTitle','metricsBar','stepsTitle','jsonPanel'].forEach(id => $(id).classList.add('hidden'));
$('debugPanel').classList.add('hidden');
$('stepsGrid').innerHTML = '';
const form = new FormData();
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': 'dev-shared-secret-2026' },
body: form
});
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('jsonPanel').classList.remove('hidden');
$('stepsTitle').classList.remove('hidden');
const d = json.data || {};
if (json.code === 0) {
setStatus('✅ 请求成功 (' + _elapsed + 's) — 完整 9 步可视化', 'success');
renderResult(d);
renderMetrics(d);
renderDebug(d.debug);
$('metricsTitle').classList.remove('hidden');
$('metricsBar').classList.remove('hidden');
} else {
// 业务错误(如 1001 未检出人脸 / 1003 非正面):仍展示已完成的步骤图
setStatus('⚠️ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' ' + json.message
+ '(下方展示算法走到哪一步)', 'error');
if (d.steps) renderResult(d);
if (d.debug) renderDebug(d.debug);
}
} catch (err) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '🚀 提交测试';
}
}
function clearResults() {
$('metricsTitle').classList.add('hidden');
$('metricsBar').classList.add('hidden');
$('debugPanel').classList.add('hidden');
$('stepsTitle').classList.add('hidden');
$('stepsGrid').innerHTML = '';
$('jsonPanel').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
}
function copyJson() {
const text = $('jsonContent').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = '✅ 已复制';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// 拖拽上传
document.addEventListener('DOMContentLoaded', () => {
const dropZone = $('imageFile');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
dropZone.files = e.dataTransfer.files;
setStatus('已选择: ' + e.dataTransfer.files[0].name, 'info');
}
});
dropZone.addEventListener('change', () => {
if (dropZone.files.length) setStatus('已选择: ' + dropZone.files[0].name, 'info');
});
});
</script>
</body>
</html>