Shoraky commited on
Commit
8b52dd7
·
verified ·
1 Parent(s): 1e1f04d

Support new analytics payload

Browse files
Files changed (1) hide show
  1. api.py +351 -105
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(preferred_keys or [])
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
- format_metric_series(metric_name(key), unit_for(key), [entry.get(key) for entry in entries])
863
- for key in ordered_metric_keys(metric_keys, preferred_keys)
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 = set(key for key in payload.keys() if key not in skip)
872
- observed_keys.update(key for key in (preferred_keys or []) if key not in skip)
 
 
873
  for key in ordered_metric_keys(observed_keys, preferred_keys):
874
  if key in skip:
875
  continue
876
- value = safe_float(payload.get(key))
877
  metrics.append({
878
  "name": metric_name(key),
879
- "value": round(value, 3) if value is not None else None,
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 = set(preferred_keys or [])
899
- observed_keys.update(analytics.keys())
 
 
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["analytics"]
1032
- sf = rep["start_frame"]
1033
- ef = rep["end_frame"]
 
 
 
1034
  fps = float(rep.get("fps", 30))
1035
- is_dribble = (an.get("action") == "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(rep["action"], {})
 
 
 
1085
 
1086
  if is_dribble:
1087
- dribble_frames = an.get("frames", [])
1088
- pre_metrics = build_series_from_entries(
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
- else:
1108
- pre_entries = an.get("pre_action", [])
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
- preferred_keys=action_layout.get("pre"),
 
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
- in_action_metrics.extend(
1123
- build_scalar_metrics(
1124
- an,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
  unit_for,
1126
- skip_keys={
1127
- "action",
1128
- "active_foot",
1129
- "touch_frame",
1130
- "pre_action",
1131
- "action_frame",
1132
- "post_action",
1133
- "frames",
1134
- "left_knee_angles",
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
- full_interval_metrics = build_top_level_interval_metrics(
1151
- an,
1152
- unit_for,
1153
- preferred_keys=FULL_INTERVAL_KEYS,
1154
- )
 
 
 
 
 
 
 
 
 
1155
 
1156
  formatted_actions.append({
1157
- "id": f"{rep['action'].lower()}-{uuid.uuid4().hex[:6]}",
1158
- "label": rep["action"],
 
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": tf - sf,
1168
- "inFrame": tf - sf,
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,