Spaces:
Running
Running
Support new analytics payload
Browse files
api.py
CHANGED
|
@@ -750,11 +750,139 @@ def safe_float(value):
|
|
| 750 |
return None
|
| 751 |
|
| 752 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
def metric_name(key: str) -> str:
|
| 754 |
return key.replace("_", " ").title()
|
| 755 |
|
| 756 |
|
| 757 |
FULL_INTERVAL_KEYS = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
"left_knee_angles",
|
| 759 |
"right_knee_angles",
|
| 760 |
"torso_pitch_angles",
|
|
@@ -767,6 +895,13 @@ ACTION_METRIC_LAYOUTS = {
|
|
| 767 |
"Pass": {
|
| 768 |
"pre": ["body_to_ball_angle"],
|
| 769 |
"in": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
"body_to_ball_angle",
|
| 771 |
"l_r_foot_distance",
|
| 772 |
"trunc_pitch_angle",
|
|
@@ -786,8 +921,16 @@ ACTION_METRIC_LAYOUTS = {
|
|
| 786 |
"top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
|
| 787 |
},
|
| 788 |
"Shot": {
|
| 789 |
-
"pre": ["body_to_ball_angle"],
|
| 790 |
"in": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
"body_to_ball_angle",
|
| 792 |
"l_r_foot_distance",
|
| 793 |
"trunc_pitch_angle",
|
|
@@ -805,13 +948,20 @@ ACTION_METRIC_LAYOUTS = {
|
|
| 805 |
"r_elbow_shoulder_hip_angle",
|
| 806 |
"active_ankle_angle",
|
| 807 |
],
|
| 808 |
-
"post": ["head_angle", "body_to_ball_angle"],
|
| 809 |
"top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
|
| 810 |
},
|
| 811 |
"Receive": {
|
| 812 |
"pre": ["body_orientation_vs_ball", "head_angle"],
|
| 813 |
"in": [
|
|
|
|
|
|
|
| 814 |
"head_angle",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
"l_knee_angle",
|
| 816 |
"r_knee_angle",
|
| 817 |
"trunc_pitch_angle",
|
|
@@ -829,15 +979,22 @@ ACTION_METRIC_LAYOUTS = {
|
|
| 829 |
},
|
| 830 |
"Dribble": {
|
| 831 |
"frames": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
"ball_feet_distance",
|
| 833 |
"trunk_pitch",
|
| 834 |
"trunk_roll",
|
| 835 |
-
"head_angle",
|
| 836 |
"ball_possession_score",
|
| 837 |
],
|
| 838 |
"top_level_scalars": [],
|
| 839 |
},
|
| 840 |
}
|
|
|
|
| 841 |
|
| 842 |
|
| 843 |
def ordered_metric_keys(observed_keys, preferred_keys=None):
|
|
@@ -846,38 +1003,98 @@ def ordered_metric_keys(observed_keys, preferred_keys=None):
|
|
| 846 |
return preferred + extras
|
| 847 |
|
| 848 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
def build_series_from_entries(entries, unit_for, skip_keys=None, preferred_keys=None):
|
| 850 |
skip = {"frame"}
|
| 851 |
if skip_keys:
|
| 852 |
skip.update(skip_keys)
|
| 853 |
|
| 854 |
-
metric_keys = set(
|
| 855 |
for entry in entries:
|
| 856 |
metric_keys.update(
|
| 857 |
key for key in entry.keys()
|
| 858 |
-
if key not in skip
|
| 859 |
)
|
| 860 |
|
| 861 |
-
series = [
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
return series
|
| 866 |
|
| 867 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
def build_scalar_metrics(payload, unit_for, skip_keys=None, preferred_keys=None):
|
|
|
|
|
|
|
| 869 |
skip = set(skip_keys or [])
|
|
|
|
| 870 |
metrics = []
|
| 871 |
-
observed_keys =
|
| 872 |
-
|
|
|
|
|
|
|
| 873 |
for key in ordered_metric_keys(observed_keys, preferred_keys):
|
| 874 |
if key in skip:
|
| 875 |
continue
|
| 876 |
-
value =
|
| 877 |
metrics.append({
|
| 878 |
"name": metric_name(key),
|
| 879 |
-
"value":
|
| 880 |
-
"unit": unit_for(key),
|
| 881 |
})
|
| 882 |
return metrics
|
| 883 |
|
|
@@ -891,12 +1108,20 @@ def build_top_level_interval_metrics(analytics, unit_for, skip_keys=None, prefer
|
|
| 891 |
"action_frame",
|
| 892 |
"post_action",
|
| 893 |
"frames",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
}
|
| 895 |
if skip_keys:
|
| 896 |
skip.update(skip_keys)
|
| 897 |
|
| 898 |
-
observed_keys =
|
| 899 |
-
|
|
|
|
|
|
|
| 900 |
|
| 901 |
series = []
|
| 902 |
for key in ordered_metric_keys(observed_keys, preferred_keys):
|
|
@@ -1028,39 +1253,21 @@ async def analyze_endpoint(
|
|
| 1028 |
})
|
| 1029 |
continue
|
| 1030 |
|
| 1031 |
-
an = rep
|
| 1032 |
-
|
| 1033 |
-
|
|
|
|
|
|
|
|
|
|
| 1034 |
fps = float(rep.get("fps", 30))
|
| 1035 |
-
is_dribble =
|
| 1036 |
-
|
| 1037 |
-
if is_dribble:
|
| 1038 |
-
tf = (sf + ef) // 2
|
| 1039 |
-
else:
|
| 1040 |
-
tf = an.get("touch_frame", (sf + ef) // 2)
|
| 1041 |
|
| 1042 |
# --- Skeleton: support full COCO-25/WB joint range (0–32) ---
|
| 1043 |
-
skeleton_frames =
|
| 1044 |
-
raw_ball_history = rep.get("ball_history", {})
|
| 1045 |
-
for f_idx in range(sf, ef + 1):
|
| 1046 |
-
raw_skel = rep["skel_history"].get(f_idx, {})
|
| 1047 |
-
raw_ball = raw_ball_history.get(f_idx)
|
| 1048 |
-
# Find the max joint index present so we don't truncate
|
| 1049 |
-
max_joint = max(raw_skel.keys()) if raw_skel else 32
|
| 1050 |
-
n_joints = max(33, max_joint + 1)
|
| 1051 |
-
joints = []
|
| 1052 |
-
for j in range(n_joints):
|
| 1053 |
-
pt = raw_skel.get(j)
|
| 1054 |
-
if pt is not None:
|
| 1055 |
-
joints.append([float(pt[0]), float(pt[1]), float(pt[2])])
|
| 1056 |
-
else:
|
| 1057 |
-
joints.append([0.0, 0.0, 0.0])
|
| 1058 |
-
frame_payload = {"frame": f_idx - sf, "joints": joints}
|
| 1059 |
-
if raw_ball is not None:
|
| 1060 |
-
frame_payload["ball"] = [float(raw_ball[0]), float(raw_ball[1]), float(raw_ball[2])]
|
| 1061 |
-
skeleton_frames.append(frame_payload)
|
| 1062 |
|
| 1063 |
# --- Unit dictionary for known metric names ---
|
|
|
|
| 1064 |
UNITS = {
|
| 1065 |
"head_angle": "°", "l_knee_angle": "°", "r_knee_angle": "°",
|
| 1066 |
"trunc_pitch_angle": "°", "trunc_roll_angle": "°",
|
|
@@ -1077,85 +1284,122 @@ async def analyze_endpoint(
|
|
| 1077 |
"active_foot_height_pct": "%", "ball_height_pct_body": "%",
|
| 1078 |
"ball_possession_score": "%", "ball_feet_distance": "cm",
|
| 1079 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1080 |
|
| 1081 |
def unit_for(key):
|
| 1082 |
return UNITS.get(key, "")
|
| 1083 |
|
| 1084 |
-
action_layout = ACTION_METRIC_LAYOUTS.get(
|
|
|
|
|
|
|
|
|
|
| 1085 |
|
| 1086 |
if is_dribble:
|
| 1087 |
-
|
| 1088 |
-
pre_metrics =
|
| 1089 |
-
dribble_frames,
|
| 1090 |
-
unit_for,
|
| 1091 |
-
preferred_keys=action_layout.get("frames"),
|
| 1092 |
-
)
|
| 1093 |
in_action_metrics = []
|
| 1094 |
-
frame_metric_keys = ordered_metric_keys(
|
| 1095 |
-
{k for frame in dribble_frames for k in frame.keys() if k != "frame"},
|
| 1096 |
-
action_layout.get("frames"),
|
| 1097 |
-
)
|
| 1098 |
-
for key in frame_metric_keys:
|
| 1099 |
-
numeric_values = [safe_float(frame.get(key)) for frame in dribble_frames]
|
| 1100 |
-
numeric_values = [value for value in numeric_values if value is not None]
|
| 1101 |
-
in_action_metrics.append({
|
| 1102 |
-
"name": f"Avg {metric_name(key)}",
|
| 1103 |
-
"value": round(float(np.mean(numeric_values)), 3) if numeric_values else None,
|
| 1104 |
-
"unit": unit_for(key),
|
| 1105 |
-
})
|
| 1106 |
post_metrics = []
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
post_entries = an.get("post_action", [])
|
| 1110 |
-
action_frame_data = an.get("action_frame", {})
|
| 1111 |
-
pre_metrics = build_series_from_entries(
|
| 1112 |
-
pre_entries,
|
| 1113 |
unit_for,
|
| 1114 |
-
|
|
|
|
| 1115 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
in_action_metrics = build_scalar_metrics(
|
| 1117 |
action_frame_data,
|
| 1118 |
unit_for,
|
| 1119 |
skip_keys={"active_foot"},
|
| 1120 |
preferred_keys=action_layout.get("in"),
|
| 1121 |
)
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
unit_for,
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
"right_knee_angles",
|
| 1136 |
-
"torso_pitch_angles",
|
| 1137 |
-
"head_angles",
|
| 1138 |
-
"mid_foot_ball_distances",
|
| 1139 |
-
"left_right_foot_distances",
|
| 1140 |
-
},
|
| 1141 |
-
preferred_keys=action_layout.get("top_level_scalars"),
|
| 1142 |
)
|
| 1143 |
-
)
|
| 1144 |
-
post_metrics = build_series_from_entries(
|
| 1145 |
-
post_entries,
|
| 1146 |
-
unit_for,
|
| 1147 |
-
preferred_keys=action_layout.get("post"),
|
| 1148 |
-
)
|
| 1149 |
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1155 |
|
| 1156 |
formatted_actions.append({
|
| 1157 |
-
"id": f"{
|
| 1158 |
-
"label":
|
|
|
|
| 1159 |
"start": raw.get("start", "00:00:00:00"),
|
| 1160 |
"end": raw.get("end", "00:00:00:00"),
|
| 1161 |
"fps": fps,
|
|
@@ -1164,14 +1408,16 @@ async def analyze_endpoint(
|
|
| 1164 |
"startSeconds": max(0.0, sf / max(1.0, fps)),
|
| 1165 |
"endSeconds": max(0.0, (ef + 1) / max(1.0, fps)),
|
| 1166 |
"totalFrames": ef - sf + 1,
|
| 1167 |
-
"preFrames":
|
| 1168 |
-
"inFrame":
|
| 1169 |
-
"postFrames": ef - tf,
|
| 1170 |
"cameraClips": camera_videos,
|
| 1171 |
"sourceCameraClips": source_camera_videos,
|
| 1172 |
"preMetrics": pre_metrics,
|
|
|
|
| 1173 |
"inActionMetrics": in_action_metrics,
|
| 1174 |
"postMetrics": post_metrics,
|
|
|
|
| 1175 |
"fullIntervalMetrics": full_interval_metrics,
|
| 1176 |
"skeleton": skeleton_frames,
|
| 1177 |
"rawAnalytics": an,
|
|
|
|
| 750 |
return None
|
| 751 |
|
| 752 |
|
| 753 |
+
def frame_number(value):
|
| 754 |
+
try:
|
| 755 |
+
if value is None:
|
| 756 |
+
return None
|
| 757 |
+
return int(float(value))
|
| 758 |
+
except Exception:
|
| 759 |
+
return None
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def relative_frame(value, start_frame=0, fallback=0):
|
| 763 |
+
frame = frame_number(value)
|
| 764 |
+
if frame is None:
|
| 765 |
+
return fallback
|
| 766 |
+
start = frame_number(start_frame) or 0
|
| 767 |
+
return frame - start if frame >= start else frame
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def safe_metric_value(value):
|
| 771 |
+
if value is None:
|
| 772 |
+
return None
|
| 773 |
+
number = safe_float(value)
|
| 774 |
+
if number is not None:
|
| 775 |
+
return round(number, 3)
|
| 776 |
+
if isinstance(value, (str, bool)):
|
| 777 |
+
return value
|
| 778 |
+
if isinstance(value, (dict, list, tuple)):
|
| 779 |
+
return None
|
| 780 |
+
return str(value)
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def point_to_float_list(point):
|
| 784 |
+
if point is None:
|
| 785 |
+
return None
|
| 786 |
+
if hasattr(point, "tolist"):
|
| 787 |
+
point = point.tolist()
|
| 788 |
+
try:
|
| 789 |
+
values = list(point)
|
| 790 |
+
except Exception:
|
| 791 |
+
return None
|
| 792 |
+
if len(values) < 3:
|
| 793 |
+
return None
|
| 794 |
+
xyz = []
|
| 795 |
+
for component in values[:3]:
|
| 796 |
+
number = safe_float(component)
|
| 797 |
+
if number is None:
|
| 798 |
+
return None
|
| 799 |
+
xyz.append(float(number))
|
| 800 |
+
return xyz
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
def map_value(mapping, key):
|
| 804 |
+
if not isinstance(mapping, dict):
|
| 805 |
+
return None
|
| 806 |
+
if key in mapping:
|
| 807 |
+
return mapping[key]
|
| 808 |
+
str_key = str(key)
|
| 809 |
+
if str_key in mapping:
|
| 810 |
+
return mapping[str_key]
|
| 811 |
+
target = frame_number(key)
|
| 812 |
+
for current_key, value in mapping.items():
|
| 813 |
+
if frame_number(current_key) == target:
|
| 814 |
+
return value
|
| 815 |
+
return None
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
def skeleton_joint(raw_skel, joint_index):
|
| 819 |
+
if raw_skel is None:
|
| 820 |
+
return None
|
| 821 |
+
if hasattr(raw_skel, "tolist") and not isinstance(raw_skel, dict):
|
| 822 |
+
raw_skel = raw_skel.tolist()
|
| 823 |
+
if isinstance(raw_skel, dict):
|
| 824 |
+
if joint_index in raw_skel:
|
| 825 |
+
return raw_skel[joint_index]
|
| 826 |
+
return raw_skel.get(str(joint_index))
|
| 827 |
+
if isinstance(raw_skel, (list, tuple)) and joint_index < len(raw_skel):
|
| 828 |
+
return raw_skel[joint_index]
|
| 829 |
+
return None
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
def max_joint_index(raw_skel):
|
| 833 |
+
if raw_skel is None:
|
| 834 |
+
return 32
|
| 835 |
+
if hasattr(raw_skel, "tolist") and not isinstance(raw_skel, dict):
|
| 836 |
+
raw_skel = raw_skel.tolist()
|
| 837 |
+
if isinstance(raw_skel, dict):
|
| 838 |
+
keys = [frame_number(key) for key in raw_skel.keys()]
|
| 839 |
+
keys = [key for key in keys if key is not None]
|
| 840 |
+
return max(keys, default=32)
|
| 841 |
+
if isinstance(raw_skel, (list, tuple)):
|
| 842 |
+
return max(32, len(raw_skel) - 1)
|
| 843 |
+
return 32
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def build_skeleton_frames(rep, analytics, start_frame, end_frame):
|
| 847 |
+
tracking_data = analytics.get("tracking_data", {}) if isinstance(analytics, dict) else {}
|
| 848 |
+
player_skeletons = tracking_data.get("player_skeletons") if isinstance(tracking_data, dict) else None
|
| 849 |
+
ball_positions = tracking_data.get("ball_positions") if isinstance(tracking_data, dict) else None
|
| 850 |
+
|
| 851 |
+
if not isinstance(player_skeletons, dict):
|
| 852 |
+
player_skeletons = rep.get("skel_history", {})
|
| 853 |
+
if not isinstance(ball_positions, dict):
|
| 854 |
+
ball_positions = rep.get("ball_history", {})
|
| 855 |
+
|
| 856 |
+
skeleton_frames = []
|
| 857 |
+
for f_idx in range(start_frame, end_frame + 1):
|
| 858 |
+
raw_skel = map_value(player_skeletons, f_idx)
|
| 859 |
+
raw_ball = map_value(ball_positions, f_idx)
|
| 860 |
+
n_joints = max(33, max_joint_index(raw_skel) + 1)
|
| 861 |
+
joints = []
|
| 862 |
+
for joint_index in range(n_joints):
|
| 863 |
+
point = point_to_float_list(skeleton_joint(raw_skel, joint_index))
|
| 864 |
+
joints.append(point if point is not None else [0.0, 0.0, 0.0])
|
| 865 |
+
frame_payload = {"frame": f_idx - start_frame, "joints": joints}
|
| 866 |
+
ball_point = point_to_float_list(raw_ball)
|
| 867 |
+
if ball_point is not None:
|
| 868 |
+
frame_payload["ball"] = ball_point
|
| 869 |
+
skeleton_frames.append(frame_payload)
|
| 870 |
+
return skeleton_frames
|
| 871 |
+
|
| 872 |
+
|
| 873 |
def metric_name(key: str) -> str:
|
| 874 |
return key.replace("_", " ").title()
|
| 875 |
|
| 876 |
|
| 877 |
FULL_INTERVAL_KEYS = [
|
| 878 |
+
"head_angle",
|
| 879 |
+
"left_knee_angle",
|
| 880 |
+
"right_knee_angle",
|
| 881 |
+
"trunk_pitch_angle",
|
| 882 |
+
"active_foot_ball_distance",
|
| 883 |
+
"stationary_foot_ball_distance_pctw_shoulder",
|
| 884 |
+
"foot_anteroposterior_offset",
|
| 885 |
+
"foot_inclination_angle",
|
| 886 |
"left_knee_angles",
|
| 887 |
"right_knee_angles",
|
| 888 |
"torso_pitch_angles",
|
|
|
|
| 895 |
"Pass": {
|
| 896 |
"pre": ["body_to_ball_angle"],
|
| 897 |
"in": [
|
| 898 |
+
"foot_region",
|
| 899 |
+
"ball_contact_zone",
|
| 900 |
+
"head_angle",
|
| 901 |
+
"left_knee_angle",
|
| 902 |
+
"right_knee_angle",
|
| 903 |
+
"trunk_pitch_angle",
|
| 904 |
+
"stationary_foot_ball_distance_pctw_shoulder",
|
| 905 |
"body_to_ball_angle",
|
| 906 |
"l_r_foot_distance",
|
| 907 |
"trunc_pitch_angle",
|
|
|
|
| 921 |
"top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
|
| 922 |
},
|
| 923 |
"Shot": {
|
| 924 |
+
"pre": ["max_backward_swing_distance", "max_backward_swing_angle", "body_to_ball_angle"],
|
| 925 |
"in": [
|
| 926 |
+
"foot_region",
|
| 927 |
+
"ball_contact_zone",
|
| 928 |
+
"head_angle",
|
| 929 |
+
"left_knee_angle",
|
| 930 |
+
"right_knee_angle",
|
| 931 |
+
"trunk_pitch_angle",
|
| 932 |
+
"stationary_foot_ball_distance_pctw_shoulder",
|
| 933 |
+
"foot_inclination_angle",
|
| 934 |
"body_to_ball_angle",
|
| 935 |
"l_r_foot_distance",
|
| 936 |
"trunc_pitch_angle",
|
|
|
|
| 948 |
"r_elbow_shoulder_hip_angle",
|
| 949 |
"active_ankle_angle",
|
| 950 |
],
|
| 951 |
+
"post": ["max_forward_swing_distance", "max_forward_swing_angle", "head_angle", "body_to_ball_angle"],
|
| 952 |
"top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
|
| 953 |
},
|
| 954 |
"Receive": {
|
| 955 |
"pre": ["body_orientation_vs_ball", "head_angle"],
|
| 956 |
"in": [
|
| 957 |
+
"foot_region",
|
| 958 |
+
"ball_contact_zone",
|
| 959 |
"head_angle",
|
| 960 |
+
"left_knee_angle",
|
| 961 |
+
"right_knee_angle",
|
| 962 |
+
"trunk_pitch_angle",
|
| 963 |
+
"stationary_foot_ball_distance_pctw_shoulder",
|
| 964 |
+
"foot_anteroposterior_offset",
|
| 965 |
"l_knee_angle",
|
| 966 |
"r_knee_angle",
|
| 967 |
"trunc_pitch_angle",
|
|
|
|
| 979 |
},
|
| 980 |
"Dribble": {
|
| 981 |
"frames": [
|
| 982 |
+
"head_angle",
|
| 983 |
+
"left_knee_angle",
|
| 984 |
+
"right_knee_angle",
|
| 985 |
+
"trunk_pitch_angle",
|
| 986 |
+
"left_heel_height",
|
| 987 |
+
"right_heel_height",
|
| 988 |
+
"active_foot_ball_distance",
|
| 989 |
"ball_feet_distance",
|
| 990 |
"trunk_pitch",
|
| 991 |
"trunk_roll",
|
|
|
|
| 992 |
"ball_possession_score",
|
| 993 |
],
|
| 994 |
"top_level_scalars": [],
|
| 995 |
},
|
| 996 |
}
|
| 997 |
+
ACTION_METRIC_LAYOUTS["Shoot"] = ACTION_METRIC_LAYOUTS["Shot"]
|
| 998 |
|
| 999 |
|
| 1000 |
def ordered_metric_keys(observed_keys, preferred_keys=None):
|
|
|
|
| 1003 |
return preferred + extras
|
| 1004 |
|
| 1005 |
|
| 1006 |
+
def is_frame_metric_payload(payload):
|
| 1007 |
+
if isinstance(payload, list):
|
| 1008 |
+
return True
|
| 1009 |
+
if not isinstance(payload, dict) or not payload:
|
| 1010 |
+
return False
|
| 1011 |
+
keys_are_frames = all(frame_number(key) is not None for key in payload.keys())
|
| 1012 |
+
has_metric_dict = any(isinstance(value, dict) for value in payload.values())
|
| 1013 |
+
return keys_are_frames and has_metric_dict
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
def entries_from_frame_payload(payload, start_frame=0):
|
| 1017 |
+
entries = []
|
| 1018 |
+
if isinstance(payload, list):
|
| 1019 |
+
for index, item in enumerate(payload):
|
| 1020 |
+
if not isinstance(item, dict):
|
| 1021 |
+
continue
|
| 1022 |
+
entry = dict(item)
|
| 1023 |
+
entry["frame"] = relative_frame(entry.get("frame", index), start_frame, index)
|
| 1024 |
+
entries.append(entry)
|
| 1025 |
+
return entries
|
| 1026 |
+
|
| 1027 |
+
if not isinstance(payload, dict):
|
| 1028 |
+
return entries
|
| 1029 |
+
|
| 1030 |
+
def sort_key(item):
|
| 1031 |
+
frame = frame_number(item[0])
|
| 1032 |
+
return (frame is None, frame if frame is not None else 0)
|
| 1033 |
+
|
| 1034 |
+
for index, (frame_key, frame_payload) in enumerate(sorted(payload.items(), key=sort_key)):
|
| 1035 |
+
if not isinstance(frame_payload, dict):
|
| 1036 |
+
continue
|
| 1037 |
+
entry = dict(frame_payload)
|
| 1038 |
+
entry["frame"] = relative_frame(frame_key, start_frame, index)
|
| 1039 |
+
entries.append(entry)
|
| 1040 |
+
return entries
|
| 1041 |
+
|
| 1042 |
+
|
| 1043 |
def build_series_from_entries(entries, unit_for, skip_keys=None, preferred_keys=None):
|
| 1044 |
skip = {"frame"}
|
| 1045 |
if skip_keys:
|
| 1046 |
skip.update(skip_keys)
|
| 1047 |
|
| 1048 |
+
metric_keys = set()
|
| 1049 |
for entry in entries:
|
| 1050 |
metric_keys.update(
|
| 1051 |
key for key in entry.keys()
|
| 1052 |
+
if key not in skip and safe_float(entry.get(key)) is not None
|
| 1053 |
)
|
| 1054 |
|
| 1055 |
+
series = []
|
| 1056 |
+
for key in ordered_metric_keys(metric_keys, preferred_keys):
|
| 1057 |
+
series.append({
|
| 1058 |
+
"name": metric_name(key),
|
| 1059 |
+
"unit": unit_for(key),
|
| 1060 |
+
"values": [
|
| 1061 |
+
{
|
| 1062 |
+
"frame": relative_frame(entry.get("frame"), 0, index),
|
| 1063 |
+
"value": safe_float(entry.get(key)),
|
| 1064 |
+
}
|
| 1065 |
+
for index, entry in enumerate(entries)
|
| 1066 |
+
],
|
| 1067 |
+
})
|
| 1068 |
return series
|
| 1069 |
|
| 1070 |
|
| 1071 |
+
def build_series_from_frame_payload(payload, unit_for, start_frame=0, skip_keys=None, preferred_keys=None):
|
| 1072 |
+
return build_series_from_entries(
|
| 1073 |
+
entries_from_frame_payload(payload, start_frame),
|
| 1074 |
+
unit_for,
|
| 1075 |
+
skip_keys=skip_keys,
|
| 1076 |
+
preferred_keys=preferred_keys,
|
| 1077 |
+
)
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
def build_scalar_metrics(payload, unit_for, skip_keys=None, preferred_keys=None):
|
| 1081 |
+
if not isinstance(payload, dict):
|
| 1082 |
+
return []
|
| 1083 |
skip = set(skip_keys or [])
|
| 1084 |
+
skip.add("frame")
|
| 1085 |
metrics = []
|
| 1086 |
+
observed_keys = {
|
| 1087 |
+
key for key, value in payload.items()
|
| 1088 |
+
if key not in skip and not isinstance(value, (dict, list, tuple))
|
| 1089 |
+
}
|
| 1090 |
for key in ordered_metric_keys(observed_keys, preferred_keys):
|
| 1091 |
if key in skip:
|
| 1092 |
continue
|
| 1093 |
+
value = safe_metric_value(payload.get(key))
|
| 1094 |
metrics.append({
|
| 1095 |
"name": metric_name(key),
|
| 1096 |
+
"value": value,
|
| 1097 |
+
"unit": unit_for(key) if isinstance(value, (int, float)) else "",
|
| 1098 |
})
|
| 1099 |
return metrics
|
| 1100 |
|
|
|
|
| 1108 |
"action_frame",
|
| 1109 |
"post_action",
|
| 1110 |
"frames",
|
| 1111 |
+
"in_action_data",
|
| 1112 |
+
"pre_action_data",
|
| 1113 |
+
"post_action_data",
|
| 1114 |
+
"full_interval_data",
|
| 1115 |
+
"per_frame",
|
| 1116 |
+
"tracking_data",
|
| 1117 |
}
|
| 1118 |
if skip_keys:
|
| 1119 |
skip.update(skip_keys)
|
| 1120 |
|
| 1121 |
+
observed_keys = {
|
| 1122 |
+
key for key, value in analytics.items()
|
| 1123 |
+
if key not in skip and isinstance(value, list)
|
| 1124 |
+
}
|
| 1125 |
|
| 1126 |
series = []
|
| 1127 |
for key in ordered_metric_keys(observed_keys, preferred_keys):
|
|
|
|
| 1253 |
})
|
| 1254 |
continue
|
| 1255 |
|
| 1256 |
+
an = rep.get("analytics", {})
|
| 1257 |
+
if not isinstance(an, dict):
|
| 1258 |
+
an = {}
|
| 1259 |
+
action_name = rep.get("action", raw.get("label", an.get("action", "Unknown")))
|
| 1260 |
+
sf = int(rep.get("start_frame", 0))
|
| 1261 |
+
ef = int(rep.get("end_frame", sf))
|
| 1262 |
fps = float(rep.get("fps", 30))
|
| 1263 |
+
is_dribble = action_name.lower() == "dribble" or "per_frame" in an
|
| 1264 |
+
tf = relative_frame(an.get("touch_frame"), sf, (ef - sf) // 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1265 |
|
| 1266 |
# --- Skeleton: support full COCO-25/WB joint range (0–32) ---
|
| 1267 |
+
skeleton_frames = build_skeleton_frames(rep, an, sf, ef)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1268 |
|
| 1269 |
# --- Unit dictionary for known metric names ---
|
| 1270 |
+
DEG = "\u00b0"
|
| 1271 |
UNITS = {
|
| 1272 |
"head_angle": "°", "l_knee_angle": "°", "r_knee_angle": "°",
|
| 1273 |
"trunc_pitch_angle": "°", "trunc_roll_angle": "°",
|
|
|
|
| 1284 |
"active_foot_height_pct": "%", "ball_height_pct_body": "%",
|
| 1285 |
"ball_possession_score": "%", "ball_feet_distance": "cm",
|
| 1286 |
}
|
| 1287 |
+
UNITS.update({
|
| 1288 |
+
"left_knee_angle": DEG,
|
| 1289 |
+
"right_knee_angle": DEG,
|
| 1290 |
+
"trunk_pitch_angle": DEG,
|
| 1291 |
+
"max_backward_swing_angle": DEG,
|
| 1292 |
+
"max_forward_swing_angle": DEG,
|
| 1293 |
+
"foot_inclination_angle": DEG,
|
| 1294 |
+
"active_foot_ball_distance": "cm",
|
| 1295 |
+
"stationary_foot_ball_distance_pctw_shoulder": "%",
|
| 1296 |
+
"left_heel_height": "cm",
|
| 1297 |
+
"right_heel_height": "cm",
|
| 1298 |
+
"foot_anteroposterior_offset": "cm",
|
| 1299 |
+
"max_backward_swing_distance": "cm",
|
| 1300 |
+
"max_forward_swing_distance": "cm",
|
| 1301 |
+
})
|
| 1302 |
|
| 1303 |
def unit_for(key):
|
| 1304 |
return UNITS.get(key, "")
|
| 1305 |
|
| 1306 |
+
action_layout = ACTION_METRIC_LAYOUTS.get(action_name, {})
|
| 1307 |
+
active_foot = an.get("active_foot")
|
| 1308 |
+
pre_action_metrics = []
|
| 1309 |
+
post_action_metrics = []
|
| 1310 |
|
| 1311 |
if is_dribble:
|
| 1312 |
+
dribble_payload = an.get("per_frame", an.get("frames", []))
|
| 1313 |
+
pre_metrics = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1314 |
in_action_metrics = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1315 |
post_metrics = []
|
| 1316 |
+
full_interval_metrics = build_series_from_frame_payload(
|
| 1317 |
+
dribble_payload,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
unit_for,
|
| 1319 |
+
start_frame=sf,
|
| 1320 |
+
preferred_keys=action_layout.get("frames"),
|
| 1321 |
)
|
| 1322 |
+
else:
|
| 1323 |
+
pre_payload = an.get("pre_action_data", an.get("pre_action", []))
|
| 1324 |
+
post_payload = an.get("post_action_data", an.get("post_action", []))
|
| 1325 |
+
action_frame_data = an.get("in_action_data", an.get("action_frame", {}))
|
| 1326 |
+
if is_frame_metric_payload(pre_payload):
|
| 1327 |
+
pre_metrics = build_series_from_frame_payload(
|
| 1328 |
+
pre_payload,
|
| 1329 |
+
unit_for,
|
| 1330 |
+
start_frame=sf,
|
| 1331 |
+
preferred_keys=action_layout.get("pre"),
|
| 1332 |
+
)
|
| 1333 |
+
else:
|
| 1334 |
+
pre_metrics = []
|
| 1335 |
+
pre_action_metrics = build_scalar_metrics(
|
| 1336 |
+
pre_payload,
|
| 1337 |
+
unit_for,
|
| 1338 |
+
preferred_keys=action_layout.get("pre"),
|
| 1339 |
+
)
|
| 1340 |
in_action_metrics = build_scalar_metrics(
|
| 1341 |
action_frame_data,
|
| 1342 |
unit_for,
|
| 1343 |
skip_keys={"active_foot"},
|
| 1344 |
preferred_keys=action_layout.get("in"),
|
| 1345 |
)
|
| 1346 |
+
if "in_action_data" not in an:
|
| 1347 |
+
in_action_metrics.extend(
|
| 1348 |
+
build_scalar_metrics(
|
| 1349 |
+
an,
|
| 1350 |
+
unit_for,
|
| 1351 |
+
skip_keys={
|
| 1352 |
+
"action",
|
| 1353 |
+
"active_foot",
|
| 1354 |
+
"touch_frame",
|
| 1355 |
+
"pre_action",
|
| 1356 |
+
"action_frame",
|
| 1357 |
+
"post_action",
|
| 1358 |
+
"frames",
|
| 1359 |
+
"left_knee_angles",
|
| 1360 |
+
"right_knee_angles",
|
| 1361 |
+
"torso_pitch_angles",
|
| 1362 |
+
"head_angles",
|
| 1363 |
+
"mid_foot_ball_distances",
|
| 1364 |
+
"left_right_foot_distances",
|
| 1365 |
+
},
|
| 1366 |
+
preferred_keys=action_layout.get("top_level_scalars"),
|
| 1367 |
+
)
|
| 1368 |
+
)
|
| 1369 |
+
if is_frame_metric_payload(post_payload):
|
| 1370 |
+
post_metrics = build_series_from_frame_payload(
|
| 1371 |
+
post_payload,
|
| 1372 |
unit_for,
|
| 1373 |
+
start_frame=sf,
|
| 1374 |
+
preferred_keys=action_layout.get("post"),
|
| 1375 |
+
)
|
| 1376 |
+
else:
|
| 1377 |
+
post_metrics = []
|
| 1378 |
+
post_action_metrics = build_scalar_metrics(
|
| 1379 |
+
post_payload,
|
| 1380 |
+
unit_for,
|
| 1381 |
+
preferred_keys=action_layout.get("post"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1382 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1383 |
|
| 1384 |
+
full_payload = an.get("full_interval_data")
|
| 1385 |
+
if full_payload is not None:
|
| 1386 |
+
full_interval_metrics = build_series_from_frame_payload(
|
| 1387 |
+
full_payload,
|
| 1388 |
+
unit_for,
|
| 1389 |
+
start_frame=sf,
|
| 1390 |
+
preferred_keys=FULL_INTERVAL_KEYS,
|
| 1391 |
+
)
|
| 1392 |
+
else:
|
| 1393 |
+
full_interval_metrics = build_top_level_interval_metrics(
|
| 1394 |
+
an,
|
| 1395 |
+
unit_for,
|
| 1396 |
+
preferred_keys=FULL_INTERVAL_KEYS,
|
| 1397 |
+
)
|
| 1398 |
|
| 1399 |
formatted_actions.append({
|
| 1400 |
+
"id": f"{action_name.lower()}-{uuid.uuid4().hex[:6]}",
|
| 1401 |
+
"label": action_name,
|
| 1402 |
+
"activeFoot": active_foot,
|
| 1403 |
"start": raw.get("start", "00:00:00:00"),
|
| 1404 |
"end": raw.get("end", "00:00:00:00"),
|
| 1405 |
"fps": fps,
|
|
|
|
| 1408 |
"startSeconds": max(0.0, sf / max(1.0, fps)),
|
| 1409 |
"endSeconds": max(0.0, (ef + 1) / max(1.0, fps)),
|
| 1410 |
"totalFrames": ef - sf + 1,
|
| 1411 |
+
"preFrames": max(0, tf),
|
| 1412 |
+
"inFrame": max(0, tf),
|
| 1413 |
+
"postFrames": max(0, (ef - sf) - tf),
|
| 1414 |
"cameraClips": camera_videos,
|
| 1415 |
"sourceCameraClips": source_camera_videos,
|
| 1416 |
"preMetrics": pre_metrics,
|
| 1417 |
+
"preActionMetrics": pre_action_metrics,
|
| 1418 |
"inActionMetrics": in_action_metrics,
|
| 1419 |
"postMetrics": post_metrics,
|
| 1420 |
+
"postActionMetrics": post_action_metrics,
|
| 1421 |
"fullIntervalMetrics": full_interval_metrics,
|
| 1422 |
"skeleton": skeleton_frames,
|
| 1423 |
"rawAnalytics": an,
|