worker 侧从 Mock 替换为真实算法: - face_analysis 包:detector(MediaPipe 478点) / pose(solvePnP 姿态) / calibration(虹膜直径法) / hair_segmenter+bisenet_model(方案B 头发分割) / measure(方案A兜底+B/A决策+七眼+换算) / annotation(numpy渐变线+中文标注) - app.py:/api/v1/face/measure 接真实实现,返回 annotated_image_base64 (不落盘不拼URL,落盘由网关做);加 X-Internal-Token 鉴权、/health 就绪态、 可配置分辨率门槛、异常兜底 - 部署:start.sh/run_worker.sh/hair-worker.service 监听 8187;worker_config 示例 - 测试 tests/:Tier1合成真值<1e-6 + Tier2缩放不变 + Tier3叠加 + 错误码集成 + 数值回归,pytest 24 项全绿 - 文档补实测基线表 + RTX5090/torch 说明 注:worker 为 RTX 5090(sm_120),pinned torch 2.2.2(cu121) 只到 sm_90, BiSeNet 已自动回退 CPU(方案B 正常);要用 GPU 需换 torch cu128(≥2.7)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
79 lines
3.1 KiB
Python
79 lines
3.1 KiB
Python
"""Tier 2 缩放不变性 + Tier 3 落点可视化 + 数值回归(均走方案 A,torch 无关、确定性)。"""
|
|
import os
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from conftest import fixture, OUTPUT
|
|
|
|
from face_analysis.detector import detector
|
|
from face_analysis.measure import measure_face
|
|
|
|
|
|
def _run(img):
|
|
h, w = img.shape[:2]
|
|
lms = detector.detect(img)
|
|
assert lms is not None
|
|
# 固定走方案 A(mask=None),保证确定性与 torch 无关
|
|
return measure_face(lms, None, w, h)
|
|
|
|
|
|
def test_scale_invariance():
|
|
"""等比放大 2×:占比几乎不变(±0.5%),cm 近似不变(±2%)。"""
|
|
img = cv2.imread(fixture("frontal.jpg"))
|
|
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
|
|
r1, r2 = _run(img), _run(big)
|
|
cm1 = {"top": r1.top_cm, "upper": r1.upper_cm, "middle": r1.middle_cm, "lower": r1.lower_cm}
|
|
cm2 = {"top": r2.top_cm, "upper": r2.upper_cm, "middle": r2.middle_cm, "lower": r2.lower_cm}
|
|
d1, d2 = r1.to_response(), r2.to_response()
|
|
for k in ["top_court", "upper_court", "middle_court", "lower_court"]:
|
|
a = d1["four_courts"]["ratios"][k]
|
|
b = d2["four_courts"]["ratios"][k]
|
|
assert abs(a - b) < 0.005, f"ratio {k} 漂移过大: {a} vs {b}"
|
|
for k in ["top", "upper", "middle", "lower"]:
|
|
assert abs(cm1[k] - cm2[k]) / cm1[k] < 0.02, f"cm {k} 漂移过大: {cm1[k]} vs {cm2[k]}"
|
|
|
|
|
|
def test_landmark_overlay():
|
|
"""生成 5 纵向点叠加图供人工核验,并断言坐标在图内且自上而下有序。"""
|
|
img = cv2.imread(fixture("frontal.jpg"))
|
|
h, w = img.shape[:2]
|
|
r = _run(img)
|
|
pts = r.to_response()["landmarks"]
|
|
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
|
ys = [pts[n]["y"] for n in order]
|
|
# 坐标在图像范围内
|
|
for n in order:
|
|
assert 0 <= pts[n]["x"] <= w
|
|
assert 0 <= pts[n]["y"] <= h
|
|
# 自上而下严格递增
|
|
assert ys == sorted(ys), f"纵向点未自上而下有序: {ys}"
|
|
# dump 叠加图
|
|
canvas = img.copy()
|
|
for n in order:
|
|
cv2.circle(canvas, (pts[n]["x"], pts[n]["y"]), 4, (0, 0, 255), -1)
|
|
cv2.line(canvas, (0, pts[n]["y"]), (w, pts[n]["y"]), (0, 255, 0), 1)
|
|
cv2.imwrite(os.path.join(OUTPUT, "frontal_landmarks.png"), canvas)
|
|
|
|
|
|
# 数值回归基线:frontal.jpg 方案A 首次实测值,防重构回归(容差 1%)。
|
|
_BASELINE = {
|
|
"face_total_height_cm": 24.01,
|
|
"top_court_cm": 5.06, "upper_court_cm": 6.07,
|
|
"middle_court_cm": 7.23, "lower_court_cm": 5.64,
|
|
"eye_width_cm": 2.52, "face_width_cm": 12.49, "inter_eye_distance_cm": 3.24,
|
|
}
|
|
|
|
|
|
def test_numeric_regression():
|
|
img = cv2.imread(fixture("frontal.jpg"))
|
|
d = _run(img).to_response()
|
|
got = {
|
|
"face_total_height_cm": d["face_total_height_cm"],
|
|
**{k: d["four_courts"][k] for k in
|
|
["top_court_cm", "upper_court_cm", "middle_court_cm", "lower_court_cm"]},
|
|
**{k: d["seven_eyes"][k] for k in
|
|
["eye_width_cm", "face_width_cm", "inter_eye_distance_cm"]},
|
|
}
|
|
for k, base in _BASELINE.items():
|
|
assert abs(got[k] - base) / base < 0.01, f"{k} 回归: 基线 {base}, 实测 {got[k]}"
|