Amol Kaushik commited on
Commit
cb9dde6
Β·
1 Parent(s): f21bc2f

a15 report

Browse files
Files changed (2) hide show
  1. A15/A15_Report.ipynb +3 -0
  2. app.py +176 -0
A15/A15_Report.ipynb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ab46afad9a686a7fbb3410f25cf8a944946fc4e968831ff28820f1b092d1678e
3
+ size 264201
app.py CHANGED
@@ -16,6 +16,142 @@ import cv2
16
  import tempfile
17
  import time
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  # Initialize MoveNet pose estimator
20
  pose_estimator = MoveNetPoseEstimator(model_name='lightning')
21
 
@@ -575,6 +711,46 @@ with gr.Blocks(title="MoveNet Pose Estimation") as demo:
575
  ]
576
  )
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
  # Example section
580
  with gr.Accordion("ℹ️ Information", open=False):
 
16
  import tempfile
17
  import time
18
 
19
+ # --- A15 scoring model (lazy-loaded) -------------------------------------
20
+ A15_JOINTS = [
21
+ 'head', 'left_shoulder', 'left_elbow', 'right_shoulder', 'right_elbow',
22
+ 'left_hand', 'right_hand', 'left_hip', 'right_hip',
23
+ 'left_knee', 'right_knee', 'left_foot', 'right_foot',
24
+ ]
25
+ A15_C = 10 # frames per clip the scorer was trained on
26
+ _A15_MODEL = None
27
+ _A15_SCALER = None
28
+
29
+
30
+ def _load_a15_scorer():
31
+ """Lazy-load the deployed regression scorer (issue #20 wiring)."""
32
+ global _A15_MODEL, _A15_SCALER
33
+ if _A15_MODEL is not None and _A15_SCALER is not None:
34
+ return _A15_MODEL, _A15_SCALER
35
+ import joblib
36
+ from tensorflow import keras
37
+ from tensorflow.keras import layers
38
+ repo_root = Path(__file__).parent
39
+ model_path = repo_root / 'models' / 'scoring_model.keras'
40
+ scaler_path = repo_root / 'models' / 'scoring_scaler.pkl'
41
+ try:
42
+ _A15_MODEL = keras.models.load_model(str(model_path))
43
+ except (TypeError, ValueError):
44
+ # Saved with a newer Keras (e.g. extra `quantization_config` kwarg);
45
+ # rebuild Dense_medium and load weights only. Architecture matches
46
+ # training_summary.json's deployed champion.
47
+ inp = keras.Input(shape=(390,))
48
+ x = layers.Dense(64, activation='relu')(inp)
49
+ x = layers.Dropout(0.2)(x)
50
+ out = layers.Dense(1, activation='linear')(x)
51
+ _A15_MODEL = keras.Model(inp, out, name='Dense')
52
+ _A15_MODEL.load_weights(str(model_path))
53
+ _A15_SCALER = joblib.load(str(scaler_path))
54
+ return _A15_MODEL, _A15_SCALER
55
+
56
+
57
+ def _a15_sample_frames(df) -> np.ndarray:
58
+ df.columns = df.columns.str.strip()
59
+ idx = np.linspace(0, len(df) - 1, A15_C).astype(int)
60
+ sub = df.iloc[idx]
61
+ frames = []
62
+ for _, row in sub.iterrows():
63
+ frames.append([[row[f'{j}_x'], row[f'{j}_y'], row[f'{j}_z']]
64
+ for j in A15_JOINTS])
65
+ return np.array(frames, dtype=np.float32)
66
+
67
+
68
+ def _a15_score_band(score: float) -> str:
69
+ if score < 1.0:
70
+ return "GREEN β€” acceptable form (0-1)"
71
+ if score < 2.0:
72
+ return "AMBER β€” borderline (1-2)"
73
+ return "RED β€” poor form (2-4)"
74
+
75
+
76
+ def run_a15_scoring(video_path, quality_threshold):
77
+ """End-to-end A15 scoring: video β†’ cut 3D CSV β†’ 0-4 score with timing."""
78
+ if video_path is None:
79
+ return "No video uploaded", "N/A", "N/A", {}
80
+
81
+ import pandas as pd
82
+
83
+ # 1) Upstream: pose extraction + 3D lift + A12 cut via ExercisePipeline.
84
+ t_up_start = time.perf_counter()
85
+ pipeline = ExercisePipeline(quality_threshold=quality_threshold)
86
+ try:
87
+ results = pipeline.process_video(video_path)
88
+ finally:
89
+ pipeline.close()
90
+ t_upstream = (time.perf_counter() - t_up_start) * 1000.0
91
+
92
+ if results is None or results.get("pipeline_stopped"):
93
+ return (
94
+ f"REJECTED β€” poor recording quality "
95
+ f"(conf {results.get('recording_confidence', 0):.2f})"
96
+ if results else "REJECTED β€” could not open video",
97
+ "N/A",
98
+ "N/A",
99
+ results or {},
100
+ )
101
+
102
+ # 2) Load the cut 3D CSV produced by the pipeline.
103
+ stem = Path(video_path).stem
104
+ cut_csv = Path(__file__).parent / "outputs" / f"{stem}_cut_3d_points.csv"
105
+ if not cut_csv.exists():
106
+ return ("ERROR β€” cut 3D CSV not produced by pipeline", "N/A", "N/A", results)
107
+
108
+ df = pd.read_csv(cut_csv)
109
+ if len(df) < A15_C:
110
+ return (
111
+ f"REJECTED β€” too few frames after cut ({len(df)} < {A15_C})",
112
+ "N/A", "N/A", results,
113
+ )
114
+
115
+ # 3) Adapter: sample, scale, predict (timed separately).
116
+ model, scaler = _load_a15_scorer()
117
+ t_sample_s = time.perf_counter()
118
+ frames = _a15_sample_frames(df)
119
+ flat = frames.reshape(1, -1)
120
+ scaled = scaler.transform(flat).astype(np.float32)
121
+ if len(model.input_shape) == 3:
122
+ scaled = scaled.reshape(1, A15_C, len(A15_JOINTS) * 3)
123
+ t_adapter = (time.perf_counter() - t_sample_s) * 1000.0
124
+
125
+ t_nn_s = time.perf_counter()
126
+ raw = float(model.predict(scaled, verbose=0).flatten()[0])
127
+ t_nn = (time.perf_counter() - t_nn_s) * 1000.0
128
+
129
+ score = float(np.clip(raw, 0.0, 4.0))
130
+ band = _a15_score_band(score)
131
+
132
+ t_total = t_upstream + t_adapter + t_nn
133
+ timing_md = (
134
+ f"**Score:** `{score:.2f} / 4` \n"
135
+ f"**Band:** {band} \n"
136
+ f"**Decision time (NN only):** {t_nn:.1f} ms \n"
137
+ f"**Adapter (sample + scale):** {t_adapter:.1f} ms \n"
138
+ f"**Upstream (pose + 3D lift + cut):** {t_upstream:.1f} ms \n"
139
+ f"**End-to-end total:** {t_total/1000:.2f} s \n"
140
+ f"**NN as % of total:** {(t_nn/t_total)*100:.2f} %"
141
+ )
142
+
143
+ results_with_score = dict(results)
144
+ results_with_score["a15_score"] = round(score, 4)
145
+ results_with_score["a15_band"] = band
146
+ results_with_score["a15_timing_ms"] = {
147
+ "nn_predict": round(t_nn, 2),
148
+ "adapter": round(t_adapter, 2),
149
+ "upstream": round(t_upstream, 2),
150
+ "total": round(t_total, 2),
151
+ }
152
+ return (band, f"{score:.2f} / 4", timing_md, results_with_score)
153
+ # --- end A15 ------------------------------------------------------------
154
+
155
  # Initialize MoveNet pose estimator
156
  pose_estimator = MoveNetPoseEstimator(model_name='lightning')
157
 
 
711
  ]
712
  )
713
 
714
+ # A15 Exercise Scoring tab β€” 0-4 regression score
715
+ with gr.TabItem("Exercise Scoring (A15)"):
716
+ gr.Markdown(
717
+ """
718
+ ## A15: Exercise Scoring (0–4 regression)
719
+
720
+ **Score scale:** `0` = perfect form, `4` = worst kept clip.
721
+
722
+ Bands:
723
+ - **GREEN** `< 1` β€” acceptable form
724
+ - **AMBER** `1–2` β€” borderline, consider another take
725
+ - **RED** `β‰₯ 2` β€” poor form
726
+
727
+ The same upstream pipeline as A14 is reused (pose extraction +
728
+ 3D lift + A12 start/stop cut). Decision-time of the NN and the
729
+ overall response-time breakdown are reported alongside the score.
730
+ """
731
+ )
732
+
733
+ with gr.Row():
734
+ with gr.Column():
735
+ a15_input_video = gr.Video(label="Upload Exercise Video")
736
+ a15_threshold = gr.Slider(
737
+ minimum=0.1, maximum=0.9, value=0.6, step=0.05,
738
+ label="Recording Quality Threshold"
739
+ )
740
+ a15_run_btn = gr.Button("Run A15 scoring", variant="primary")
741
+
742
+ with gr.Column():
743
+ a15_band = gr.Textbox(label="Band", interactive=False)
744
+ a15_score = gr.Textbox(label="Score (0–4)", interactive=False)
745
+ a15_timing = gr.Markdown(label="Timing breakdown")
746
+ a15_json = gr.JSON(label="Full results")
747
+
748
+ a15_run_btn.click(
749
+ fn=run_a15_scoring,
750
+ inputs=[a15_input_video, a15_threshold],
751
+ outputs=[a15_band, a15_score, a15_timing, a15_json],
752
+ )
753
+
754
 
755
  # Example section
756
  with gr.Accordion("ℹ️ Information", open=False):