Compare commits
2
Commits
5bcaeb2594
...
616fc26143
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616fc26143 | ||
|
|
48c9875381 |
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+90
-39
@@ -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,14 @@ 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.eye_width_cm = eyes["eye_width_px"] / px_per_cm
|
||||||
@@ -172,46 +192,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_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
|
# left/right_position:mediapipe 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:
|
||||||
|
|||||||
@@ -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> | 与正式接口 <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 正面照 | 也可拖拽图片到文件选择框</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<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;合理性校验(头顶<发际线<眉心、各庭为正)不过则回退。'
|
||||||
|
+ '<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>
|
||||||
Reference in New Issue
Block a user