Files
john a400130c67
CI / api-test (push) Has been cancelled
CI / web-build (push) Has been cancelled
Sprint 6: report review workflow, CI/K8s, and client tabs.
Add coach review APIs, pose calibration thresholds, Gitea CI, Kubernetes skeleton, H5 practice page, and mini program tab bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:44:11 +08:00

196 lines
6.8 KiB
Python

"""Pure posture metric helpers — testable without MediaPipe/OpenCV."""
from __future__ import annotations
import math
from statistics import mean, pstdev
# MediaPipe Pose landmark indices
NOSE = 0
LEFT_SHOULDER = 11
RIGHT_SHOULDER = 12
LEFT_HIP = 23
RIGHT_HIP = 24
Landmark = dict[str, float]
FrameLandmarks = dict[int, Landmark]
from app.ai.calibration import CALIBRATION, METRIC_NAMES
def _point(frame: FrameLandmarks, idx: int) -> Landmark | None:
point = frame.get(idx)
if not point or point.get("visibility", 0) < 0.5:
return None
return point
def _shoulder_width(frame: FrameLandmarks) -> float | None:
left = _point(frame, LEFT_SHOULDER)
right = _point(frame, RIGHT_SHOULDER)
if not left or not right:
return None
width = abs(right["x"] - left["x"])
return width if width > 1e-4 else None
def score_head_forward(frame: FrameLandmarks) -> float | None:
nose = _point(frame, NOSE)
left = _point(frame, LEFT_SHOULDER)
right = _point(frame, RIGHT_SHOULDER)
width = _shoulder_width(frame)
if not nose or not left or not right or not width:
return None
mid_x = (left["x"] + right["x"]) / 2
offset = abs(nose["x"] - mid_x) / width
return min(100.0, max(0.0, offset * CALIBRATION["head_forward_scale"]))
def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
left = _point(frame, LEFT_SHOULDER)
right = _point(frame, RIGHT_SHOULDER)
width = _shoulder_width(frame)
if not left or not right or not width:
return None
diff = abs(left["y"] - right["y"]) / width
return min(100.0, max(0.0, diff * CALIBRATION["shoulder_asymmetry_scale"]))
def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
left = _point(frame, LEFT_HIP)
right = _point(frame, RIGHT_HIP)
if not left or not right:
return None
angle = abs(math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"])))
tilt = min(angle, 180 - angle)
return min(100.0, max(0.0, tilt * CALIBRATION["pelvic_tilt_scale"]))
def value_to_level(value: float) -> str:
if value < CALIBRATION["normal_max"]:
return "normal"
if value < CALIBRATION["low_max"]:
return "low"
if value < CALIBRATION["medium_max"]:
return "medium"
return "high"
def aggregate_metric(values: list[float]) -> tuple[float, str, float]:
avg = mean(values)
level = value_to_level(avg)
confidence = min(0.98, 0.72 + min(len(values), 24) * 0.01)
return round(avg, 1), level, round(confidence, 2)
def build_screening_report(frames: list[FrameLandmarks]) -> dict:
head_vals: list[float] = []
shoulder_vals: list[float] = []
pelvic_vals: list[float] = []
for frame in frames:
head = score_head_forward(frame)
shoulder = score_shoulder_asymmetry(frame)
pelvic = score_pelvic_tilt(frame)
if head is not None:
head_vals.append(head)
if shoulder is not None:
shoulder_vals.append(shoulder)
if pelvic is not None:
pelvic_vals.append(pelvic)
if not head_vals and not shoulder_vals and not pelvic_vals:
raise ValueError("insufficient_pose_frames")
metrics = []
for name, values in zip(METRIC_NAMES, (head_vals, shoulder_vals, pelvic_vals), strict=True):
if not values:
continue
value, level, confidence = aggregate_metric(values)
metrics.append({"name": name, "value": value, "level": level, "confidence": confidence})
worst = max((m["value"] for m in metrics), default=0)
risk_level = value_to_level(worst)
if worst >= CALIBRATION["review_required_min"]:
risk_level = "review_required"
if risk_level == "normal":
summary = "体态指标整体正常,建议保持日常活动与姿势习惯"
elif risk_level == "low":
summary = "存在轻度体态偏差,建议开始基础纠正训练"
elif risk_level == "medium":
summary = "建议关注头前伸与高低肩,开始针对性训练"
elif risk_level == "review_required":
summary = "指标偏高,已提交机构教练复核"
else:
summary = "多项指标偏高,建议尽快安排专业评估与干预"
recommendations = []
head_metric = next((m for m in metrics if m["name"] == "头前伸"), None)
shoulder_metric = next((m for m in metrics if m["name"] == "高低肩"), None)
if head_metric and head_metric["level"] in ("medium", "high"):
recommendations.append("每日肩胛稳定训练 5 分钟")
recommendations.append("颈后肌群拉伸 3 组")
if shoulder_metric and shoulder_metric["level"] in ("medium", "high"):
recommendations.append("对称性肩带激活训练 2 组")
if not recommendations:
recommendations.append("保持每日 20 分钟户外活动")
recommendations.append("28 天后建议复测对比")
return {
"riskLevel": risk_level,
"summary": summary,
"metrics": metrics,
"recommendations": recommendations,
"disclaimer": "本报告用于健康管理建议,不构成医疗诊断。",
}
def build_movement_report(frames: list[FrameLandmarks]) -> dict:
if len(frames) < 3:
raise ValueError("insufficient_pose_frames")
nose_y = []
shoulder_angles = []
for frame in frames:
nose = _point(frame, NOSE)
left = _point(frame, LEFT_SHOULDER)
right = _point(frame, RIGHT_SHOULDER)
if nose:
nose_y.append(nose["y"])
if left and right:
shoulder_angles.append(
math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"]))
)
stability = 90.0
if len(nose_y) >= 3:
stability = max(55.0, 100.0 - pstdev(nose_y) * 900)
angle_score = 85.0
if len(shoulder_angles) >= 3:
angle_score = max(50.0, 100.0 - pstdev(shoulder_angles) * 2.5)
rhythm = min(100.0, 70 + len(frames) * 1.2)
trajectory = min(100.0, stability + 5)
completion = min(100.0, 60 + len(frames) * 2)
score = round((trajectory + angle_score + rhythm + stability + completion) / 5)
return {
"taskType": "movement_scoring",
"score": score,
"repsCompleted": max(1, len(frames) // 4),
"durationSeconds": len(frames) * 8,
"dimensions": {
"trajectory": round(trajectory),
"angle": round(angle_score),
"rhythm": round(rhythm),
"stability": round(stability),
"completion": round(completion),
},
"baselineCompare": {
"headNeckAngle": {"screening": 21, "current": max(8, 21 - score // 10), "delta": -(score // 10)},
"shoulderDiffMm": {"screening": 12, "current": max(3, 12 - score // 12), "delta": -(score // 12)},
},
"disclaimer": "本报告用于运动训练反馈,不构成医疗诊断。",
}