Reem commited on
Commit
92e8ff4
·
1 Parent(s): 532acb8

Revert repo to stable version c1edb3 (Issue rollback)

Browse files
A12/service/__init__.py CHANGED
@@ -1,4 +1,6 @@
1
- from .pipeline import run_video_pipeline, validate_video
2
- from .ui import run_a12_video_tab
3
 
4
- __all__ = ["run_video_pipeline", "validate_video", "run_a12_video_tab"]
 
 
 
 
1
+ """A12 service endpoint package."""
 
2
 
3
+ from A12.service.model_service import predict_pose_csv, safe_predict_pose_csv
4
+ from A12.service.ui import run_a12_tab
5
+
6
+ __all__ = ["predict_pose_csv", "safe_predict_pose_csv", "run_a12_tab"]
A12/service/contracts.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Input/output contracts for the A12 Gradio service tab.
2
+
3
+ The selected endpoint flavour is a Gradio tab inside app.py. The endpoint
4
+ accepts a pose-feature CSV, validates that it has the feature columns used by
5
+ Rasa's A12 classifiers, and returns a structured prediction dictionary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Dict, Iterable, List, Tuple
12
+
13
+ import pandas as pd
14
+
15
+ JOINTS: List[str] = [
16
+ "head",
17
+ "left_shoulder",
18
+ "left_elbow",
19
+ "right_shoulder",
20
+ "right_elbow",
21
+ "left_hand",
22
+ "right_hand",
23
+ "left_hip",
24
+ "right_hip",
25
+ "left_knee",
26
+ "right_knee",
27
+ "left_foot",
28
+ "right_foot",
29
+ ]
30
+
31
+ KINECT_COLS: List[str] = [axis for joint in JOINTS for axis in (f"{joint}_x", f"{joint}_y", f"{joint}_z")]
32
+ POSENET_COLS: List[str] = [axis for joint in JOINTS for axis in (f"{joint}_x", f"{joint}_y")]
33
+
34
+ EXTRA_COLS: List[str] = [
35
+ "left_hand_to_left_shoulder",
36
+ "right_hand_to_right_shoulder",
37
+ "left_hand_to_left_hip",
38
+ "right_hand_to_right_hip",
39
+ "left_elbow_to_left_shoulder",
40
+ "right_elbow_to_right_shoulder",
41
+ "head_to_hip",
42
+ "head_vx",
43
+ "head_vy",
44
+ "head_vz",
45
+ "head_speed",
46
+ "left_hand_vx",
47
+ "left_hand_vy",
48
+ "left_hand_vz",
49
+ "left_hand_speed",
50
+ "right_hand_vx",
51
+ "right_hand_vy",
52
+ "right_hand_vz",
53
+ "right_hand_speed",
54
+ "head_ax",
55
+ "head_ay",
56
+ "head_az",
57
+ "head_accel",
58
+ "left_hand_ax",
59
+ "left_hand_ay",
60
+ "left_hand_az",
61
+ "left_hand_accel",
62
+ "right_hand_ax",
63
+ "right_hand_ay",
64
+ "right_hand_az",
65
+ "right_hand_accel",
66
+ ]
67
+
68
+ FEATURES_BY_PROBLEM: Dict[str, List[str]] = {
69
+ "A": KINECT_COLS + EXTRA_COLS,
70
+ "B": POSENET_COLS + EXTRA_COLS,
71
+ }
72
+
73
+ LABEL_NAMES = ["non-exercise", "exercise"]
74
+
75
+
76
+ def normalize_problem(problem: str) -> str:
77
+ """Return canonical problem name A/B or raise ValueError."""
78
+ value = str(problem).strip().upper()
79
+ if value.startswith("A"):
80
+ return "A"
81
+ if value.startswith("B"):
82
+ return "B"
83
+ raise ValueError("Problem must be 'A' or 'B'.")
84
+
85
+
86
+ def read_pose_csv(csv_path: str | Path) -> pd.DataFrame:
87
+ """Read a pose feature CSV and strip whitespace from column names."""
88
+ if not csv_path:
89
+ raise ValueError("Please upload a pose CSV file.")
90
+
91
+ path = Path(csv_path)
92
+ if path.suffix.lower() != ".csv":
93
+ raise ValueError("The uploaded file must be a .csv pose-feature file.")
94
+ if not path.exists():
95
+ raise ValueError(f"CSV file not found: {path}")
96
+
97
+ df = pd.read_csv(path)
98
+ df.columns = df.columns.str.strip()
99
+ if df.empty:
100
+ raise ValueError("The pose CSV is empty.")
101
+ return df
102
+
103
+
104
+ def missing_columns(df: pd.DataFrame, expected: Iterable[str]) -> List[str]:
105
+ """Return expected columns that are not present in *df*."""
106
+ return [column for column in expected if column not in df.columns]
107
+
108
+
109
+ def validate_pose_dataframe(df: pd.DataFrame, problem: str) -> Tuple[pd.DataFrame, List[str]]:
110
+ """Validate and return numeric feature matrix as a DataFrame.
111
+
112
+ The current A12 saved scalers expect all base pose columns plus engineered
113
+ columns from A12_classifier.py. This function fails fast with a useful
114
+ message instead of silently filling missing model features.
115
+ """
116
+ problem_key = normalize_problem(problem)
117
+ expected = FEATURES_BY_PROBLEM[problem_key]
118
+ missing = missing_columns(df, expected)
119
+ if missing:
120
+ preview = ", ".join(missing[:8])
121
+ suffix = "..." if len(missing) > 8 else ""
122
+ raise ValueError(
123
+ f"CSV is missing {len(missing)} required columns for Problem {problem_key}: "
124
+ f"{preview}{suffix}"
125
+ )
126
+
127
+ features = df[expected].apply(pd.to_numeric, errors="coerce")
128
+ if features.isna().any().any():
129
+ bad = features.columns[features.isna().any()].tolist()[:8]
130
+ raise ValueError(
131
+ "Pose CSV contains non-numeric or missing values in required columns: "
132
+ + ", ".join(bad)
133
+ )
134
+ return features, expected
A12/service/model_service.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prediction service for A12 start/stop exercise classifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any, Dict
9
+
10
+ import joblib
11
+ import numpy as np
12
+
13
+ from A12.service.contracts import LABEL_NAMES, normalize_problem, read_pose_csv, validate_pose_dataframe
14
+
15
+ MODEL_VERSION = "A12 Dense classifiers from A12_results"
16
+
17
+ MODEL_FILES = {
18
+ "A": {
19
+ "name": "A_Kinect_Dense_relu_adam_bs64",
20
+ "weights": "A_Kinect_Dense_relu_adam_bs64.weights.h5",
21
+ "scaler": "A_Kinect_Dense_relu_adam_bs64_scaler.pkl",
22
+ },
23
+ "B": {
24
+ "name": "B_PoseNet_Dense_relu_adam_bs64",
25
+ "weights": "B_PoseNet_Dense_relu_adam_bs64.weights.h5",
26
+ "scaler": "B_PoseNet_Dense_relu_adam_bs64_scaler.pkl",
27
+ },
28
+ }
29
+
30
+
31
+ def _project_root() -> Path:
32
+ return Path(__file__).resolve().parents[2]
33
+
34
+
35
+ def _model_dir() -> Path:
36
+ """Prefer A12/A12_results, but also support A12_results at repo root."""
37
+ root = _project_root()
38
+ candidates = [root / "A12" / "A12_results", root / "A12_results"]
39
+ for candidate in candidates:
40
+ if candidate.exists():
41
+ return candidate
42
+ return candidates[0]
43
+
44
+
45
+ def _build_dense_model(input_dim: int):
46
+ """Build the same dense architecture used by A12_classifier.py."""
47
+ import tensorflow as tf
48
+ from tensorflow import keras
49
+ from tensorflow.keras import layers, regularizers
50
+
51
+ # Keeping seed stable avoids warnings about unseeded initialisers before
52
+ # loading trained weights.
53
+ tf.random.set_seed(42)
54
+ inputs = keras.Input(shape=(input_dim,), name="input")
55
+ x = layers.Dense(
56
+ 128,
57
+ activation="relu",
58
+ kernel_regularizer=regularizers.l2(1e-4),
59
+ name="dense_1",
60
+ )(inputs)
61
+ x = layers.Dropout(0.2, name="drop_1")(x)
62
+ x = layers.Dense(
63
+ 64,
64
+ activation="relu",
65
+ kernel_regularizer=regularizers.l2(1e-4),
66
+ name="dense_2",
67
+ )(x)
68
+ x = layers.Dropout(0.2, name="drop_2")(x)
69
+ outputs = layers.Dense(2, activation="softmax", name="output")(x)
70
+ return keras.Model(inputs, outputs, name="Dense")
71
+
72
+
73
+ @lru_cache(maxsize=2)
74
+ def load_classifier(problem: str) -> Dict[str, Any]:
75
+ """Load model weights and scaler for Problem A or B."""
76
+ problem_key = normalize_problem(problem)
77
+ info = MODEL_FILES[problem_key]
78
+ directory = _model_dir()
79
+ scaler_path = directory / info["scaler"]
80
+ weights_path = directory / info["weights"]
81
+
82
+ if not scaler_path.exists() or not weights_path.exists():
83
+ raise FileNotFoundError(
84
+ "Missing A12 model files. Expected files in A12/A12_results/: "
85
+ f"{info['scaler']} and {info['weights']}"
86
+ )
87
+
88
+ scaler = joblib.load(scaler_path)
89
+ input_dim = int(getattr(scaler, "n_features_in_"))
90
+ model = _build_dense_model(input_dim)
91
+ model.load_weights(weights_path)
92
+ return {"model": model, "scaler": scaler, "model_name": info["name"]}
93
+
94
+
95
+ def predict_pose_csv(csv_path: str, problem: str = "B") -> Dict[str, Any]:
96
+ """Predict exercise/non-exercise frames from an uploaded pose CSV."""
97
+ started = time.perf_counter()
98
+ problem_key = normalize_problem(problem)
99
+ df = read_pose_csv(csv_path)
100
+ features, feature_names = validate_pose_dataframe(df, problem_key)
101
+ bundle = load_classifier(problem_key)
102
+
103
+ scaler = bundle["scaler"]
104
+ model = bundle["model"]
105
+ x_scaled = scaler.transform(features.values.astype(np.float32))
106
+ probabilities = model.predict(x_scaled, verbose=0)
107
+ frame_predictions = np.argmax(probabilities, axis=1)
108
+ exercise_confidence = probabilities[:, 1]
109
+
110
+ exercise_ratio = float(np.mean(frame_predictions == 1))
111
+ overall_label_index = int(exercise_ratio >= 0.5)
112
+ overall_confidence = float(np.mean(exercise_confidence))
113
+
114
+ frame_preview = []
115
+ for idx in range(min(10, len(frame_predictions))):
116
+ frame_preview.append(
117
+ {
118
+ "frame_index": idx,
119
+ "label": LABEL_NAMES[int(frame_predictions[idx])],
120
+ "confidence": float(np.max(probabilities[idx])),
121
+ "probabilities": {
122
+ LABEL_NAMES[0]: float(probabilities[idx, 0]),
123
+ LABEL_NAMES[1]: float(probabilities[idx, 1]),
124
+ },
125
+ }
126
+ )
127
+
128
+ return {
129
+ "status": "ok",
130
+ "endpoint": "Gradio tab inside app.py",
131
+ "problem": problem_key,
132
+ "model_name": bundle["model_name"],
133
+ "model_version": MODEL_VERSION,
134
+ "input_contract": "Pose feature CSV with the same feature columns used by A12_classifier.py.",
135
+ "metadata": {
136
+ "rows": int(len(df)),
137
+ "features": int(len(feature_names)),
138
+ "inference_time_ms": round((time.perf_counter() - started) * 1000, 2),
139
+ },
140
+ "prediction": {
141
+ "label": LABEL_NAMES[overall_label_index],
142
+ "confidence": overall_confidence,
143
+ "exercise_frame_ratio": exercise_ratio,
144
+ },
145
+ "frame_preview": frame_preview,
146
+ }
147
+
148
+
149
+ def safe_predict_pose_csv(csv_path: str | None, problem: str = "B") -> Dict[str, Any]:
150
+ """UI-safe wrapper that returns structured errors instead of crashing Gradio."""
151
+ try:
152
+ return predict_pose_csv(str(csv_path), problem)
153
+ except Exception as exc: # Gradio should show JSON instead of runtime crash.
154
+ return {
155
+ "status": "error",
156
+ "endpoint": "Gradio tab inside app.py",
157
+ "problem": str(problem),
158
+ "message": str(exc),
159
+ }
A12/service/pipeline.py DELETED
@@ -1,285 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import csv
4
- import json
5
- import math
6
- import os
7
- import time
8
- from datetime import datetime
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Tuple
11
-
12
- import cv2
13
- import numpy as np
14
-
15
- from .schemas import ClassificationResult, PipelineMetadata, PipelineOutput
16
-
17
- try:
18
- from A8.pose_estimator import MoveNetPoseEstimator
19
- except Exception: # pragma: no cover - used only if A8 is unavailable during isolated tests
20
- MoveNetPoseEstimator = None
21
-
22
- try:
23
- from A12.pose_interpolator import smooth_pose_sequence
24
- except Exception: # pragma: no cover
25
- smooth_pose_sequence = None
26
-
27
- KEYPOINT_NAMES = [
28
- "nose", "left_eye", "right_eye", "left_ear", "right_ear",
29
- "left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
30
- "left_wrist", "right_wrist", "left_hip", "right_hip",
31
- "left_knee", "right_knee", "left_ankle", "right_ankle",
32
- ]
33
-
34
- # 13-joint subset used by the classification data in A13.
35
- CLASSIFIER_JOINTS = [
36
- "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist",
37
- "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle", "nose",
38
- ]
39
-
40
- SKELETON_EDGES = [
41
- ("left_shoulder", "right_shoulder"), ("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist"),
42
- ("right_shoulder", "right_elbow"), ("right_elbow", "right_wrist"), ("left_shoulder", "left_hip"),
43
- ("right_shoulder", "right_hip"), ("left_hip", "right_hip"), ("left_hip", "left_knee"),
44
- ("left_knee", "left_ankle"), ("right_hip", "right_knee"), ("right_knee", "right_ankle"),
45
- ]
46
-
47
-
48
- def validate_video(video_path: str | None) -> Path:
49
- if not video_path:
50
- raise ValueError("A video file is required.")
51
- path = Path(video_path)
52
- if not path.exists():
53
- raise ValueError(f"Video file does not exist: {video_path}")
54
- if path.suffix.lower() not in {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}:
55
- raise ValueError("Unsupported video type. Use mp4, mov, avi, mkv, webm, or m4v.")
56
- cap = cv2.VideoCapture(str(path))
57
- ok = cap.isOpened()
58
- cap.release()
59
- if not ok:
60
- raise ValueError("OpenCV could not open this video. Try re-encoding it as mp4.")
61
- return path
62
-
63
-
64
- def _pose_result_to_frame_dict(pose_result: Dict[str, Any], frame_id: int, timestamp_s: float) -> Dict[str, Any]:
65
- keypoints = pose_result.get("keypoints", {}) or {}
66
- out = []
67
- for name in KEYPOINT_NAMES:
68
- kp = keypoints.get(name, {}) or {}
69
- out.append({
70
- "name": name,
71
- "x": kp.get("x"),
72
- "y": kp.get("y"),
73
- "z": 0.0,
74
- "score": kp.get("confidence", kp.get("score", 0.0)),
75
- })
76
- return {
77
- "frame_id": frame_id,
78
- "timestamp": timestamp_s,
79
- "poses": [{"pose_id": 0, "keypoints": out}],
80
- "inference_time_ms": pose_result.get("inference_time_ms", 0.0),
81
- }
82
-
83
-
84
- def _get_pose_estimator():
85
- if MoveNetPoseEstimator is None:
86
- raise RuntimeError("A8.pose_estimator.MoveNetPoseEstimator could not be imported.")
87
- return MoveNetPoseEstimator(model_name="lightning")
88
-
89
-
90
- def _detect_motion_window(frames: List[Dict[str, Any]], min_window: int = 10) -> Tuple[int, int]:
91
- """Cut leading/trailing frames by motion energy of 13 classifier joints."""
92
- if len(frames) <= min_window:
93
- return 0, max(0, len(frames) - 1)
94
-
95
- coords = []
96
- for f in frames:
97
- kp_by_name = {kp["name"]: kp for kp in f["poses"][0]["keypoints"]}
98
- row = []
99
- for name in CLASSIFIER_JOINTS:
100
- kp = kp_by_name.get(name, {})
101
- x = kp.get("x")
102
- y = kp.get("y")
103
- row.append([np.nan if x is None else float(x), np.nan if y is None else float(y)])
104
- coords.append(row)
105
- arr = np.asarray(coords, dtype="float32")
106
- arr = np.nan_to_num(arr, nan=np.nanmean(arr) if not np.isnan(arr).all() else 0.0)
107
- velocity = np.linalg.norm(np.diff(arr, axis=0), axis=-1).mean(axis=1)
108
- if len(velocity) == 0 or float(np.max(velocity)) == 0.0:
109
- return 0, len(frames) - 1
110
-
111
- threshold = max(float(np.percentile(velocity, 35)), float(np.max(velocity)) * 0.08)
112
- active = np.where(velocity >= threshold)[0]
113
- if active.size == 0:
114
- return 0, len(frames) - 1
115
-
116
- start = max(0, int(active[0]) - 2)
117
- end = min(len(frames) - 1, int(active[-1]) + 3)
118
- if end - start + 1 < min_window:
119
- mid = (start + end) // 2
120
- start = max(0, mid - min_window // 2)
121
- end = min(len(frames) - 1, start + min_window - 1)
122
- return start, end
123
-
124
-
125
- def _frames_to_classifier_sequence(frames: List[Dict[str, Any]], sequence_len: int = 10) -> np.ndarray:
126
- """Return B-problem PoseNet-like data: shape (10, 13, 2)."""
127
- if not frames:
128
- raise ValueError("No pose frames were produced from the video.")
129
- indices = np.linspace(0, len(frames) - 1, sequence_len, dtype=int)
130
- sequence = np.zeros((sequence_len, len(CLASSIFIER_JOINTS), 2), dtype="float32")
131
- for out_i, frame_i in enumerate(indices):
132
- kp_by_name = {kp["name"]: kp for kp in frames[frame_i]["poses"][0]["keypoints"]}
133
- for joint_i, name in enumerate(CLASSIFIER_JOINTS):
134
- kp = kp_by_name.get(name, {})
135
- sequence[out_i, joint_i, 0] = 0.0 if kp.get("x") is None else float(kp.get("x"))
136
- sequence[out_i, joint_i, 1] = 0.0 if kp.get("y") is None else float(kp.get("y"))
137
- # normalize per clip to make dimensions roughly model-friendly
138
- max_abs = float(np.max(np.abs(sequence)))
139
- if max_abs > 1.5:
140
- sequence[:, :, 0] /= max_abs
141
- sequence[:, :, 1] /= max_abs
142
- return sequence
143
-
144
-
145
- def _classify(sequence: np.ndarray) -> ClassificationResult:
146
- """Use a real persisted model if present; otherwise deterministic dummy classifier.
147
-
148
- Replace this function with the Issue #10 champion wrapper when it is merged.
149
- Expected future wrapper: A13.models.champion.predict_proba(sequence[None, ...]).
150
- """
151
- try:
152
- from A13.models.champion import predict_good_probability # type: ignore
153
- good_prob = float(predict_good_probability(sequence))
154
- mode = "champion_model"
155
- except Exception:
156
- # Deterministic fallback so app integration can be finished before #10/#11 is merged.
157
- # Higher motion smoothness is treated as slightly better. This is not a scientific classifier.
158
- velocity = np.linalg.norm(np.diff(sequence, axis=0), axis=-1).mean()
159
- good_prob = float(np.clip(0.55 + (0.08 - velocity) * 0.8, 0.05, 0.95))
160
- mode = "deterministic_dummy_until_issue_10_model_is_available"
161
- label = "good" if good_prob >= 0.5 else "bad"
162
- return ClassificationResult(
163
- label=label,
164
- is_good=label == "good",
165
- confidence=max(good_prob, 1.0 - good_prob),
166
- probabilities={"bad": 1.0 - good_prob, "good": good_prob, "mode": mode},
167
- )
168
-
169
-
170
- def _write_keypoints_csv(frames: List[Dict[str, Any]], path: Path) -> None:
171
- with path.open("w", newline="") as f:
172
- writer = csv.writer(f)
173
- writer.writerow(["frame_id", "timestamp", "joint", "x", "y", "z", "confidence"])
174
- for frame in frames:
175
- for kp in frame["poses"][0]["keypoints"]:
176
- writer.writerow([
177
- frame["frame_id"], frame["timestamp"], kp["name"], kp.get("x"), kp.get("y"), kp.get("z", 0.0), kp.get("score", 0.0)
178
- ])
179
-
180
-
181
- def _write_animation_json(frames: List[Dict[str, Any]], path: Path) -> None:
182
- skeleton_frames = []
183
- for frame in frames:
184
- joints = {kp["name"]: {"x": kp.get("x"), "y": kp.get("y"), "z": kp.get("z", 0.0), "confidence": kp.get("score", 0.0)}
185
- for kp in frame["poses"][0]["keypoints"]}
186
- skeleton_frames.append({"frame_id": frame["frame_id"], "timestamp": frame["timestamp"], "joints": joints})
187
- path.write_text(json.dumps({"joint_names": KEYPOINT_NAMES, "edges": SKELETON_EDGES, "frames": skeleton_frames}, indent=2))
188
-
189
-
190
- def run_video_pipeline(
191
- video_path: str | None,
192
- confidence_threshold: float = 0.3,
193
- smoothing_strategy: str = "exponential",
194
- smoothing_method: str = "zscore",
195
- output_dir: str | os.PathLike[str] = "pose_outputs",
196
- ) -> PipelineOutput:
197
- start_time = time.perf_counter()
198
- warnings: List[str] = []
199
- video = validate_video(video_path)
200
- out_dir = Path(output_dir)
201
- out_dir.mkdir(parents=True, exist_ok=True)
202
-
203
- cap = cv2.VideoCapture(str(video))
204
- fps = float(cap.get(cv2.CAP_PROP_FPS) or 30.0)
205
- if fps <= 0 or fps > 240:
206
- warnings.append(f"Invalid FPS reported by OpenCV ({fps}); using 30 FPS.")
207
- fps = 30.0
208
- width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
209
- height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
210
-
211
- pose_estimator = _get_pose_estimator()
212
- original_frames: List[np.ndarray] = []
213
- pose_frames: List[Dict[str, Any]] = []
214
- frame_id = 0
215
- while True:
216
- ok, frame = cap.read()
217
- if not ok:
218
- break
219
- original_frames.append(frame)
220
- pose_result = pose_estimator.detect_pose(frame)
221
- pose_frames.append(_pose_result_to_frame_dict(pose_result, frame_id, frame_id / fps))
222
- frame_id += 1
223
- cap.release()
224
-
225
- if not pose_frames:
226
- raise ValueError("The video contained no readable frames.")
227
-
228
- if smooth_pose_sequence is not None:
229
- try:
230
- pose_frames = smooth_pose_sequence(
231
- pose_frames,
232
- strategy=smoothing_strategy,
233
- outlier_method=smoothing_method,
234
- outlier_threshold=3.0,
235
- window_size=7,
236
- min_confidence=0.2,
237
- )
238
- except Exception as exc:
239
- warnings.append(f"Smoothing failed; using unsmoothed poses. Error: {exc}")
240
- else:
241
- warnings.append("A12.pose_interpolator could not be imported; using unsmoothed poses.")
242
-
243
- cut_start, cut_end = _detect_motion_window(pose_frames)
244
- cut_pose_frames = pose_frames[cut_start:cut_end + 1]
245
- cut_video_frames = original_frames[cut_start:cut_end + 1]
246
-
247
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
248
- annotated_video = out_dir / f"a12_annotated_cut_{timestamp}.mp4"
249
- keypoints_csv = out_dir / f"a12_keypoints_cut_{timestamp}.csv"
250
- animation_json = out_dir / f"a12_animation_data_{timestamp}.json"
251
-
252
- writer = cv2.VideoWriter(str(annotated_video), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
253
- for frame, pose_frame in zip(cut_video_frames, cut_pose_frames):
254
- # Redraw via estimator on frame. If a future A11 pipeline returns annotated frames, replace this block.
255
- pose_result = pose_estimator.detect_pose(frame)
256
- annotated = pose_estimator.draw_keypoints(frame, pose_result, confidence_threshold=confidence_threshold)
257
- writer.write(annotated)
258
- writer.release()
259
-
260
- _write_keypoints_csv(cut_pose_frames, keypoints_csv)
261
- _write_animation_json(cut_pose_frames, animation_json)
262
-
263
- sequence = _frames_to_classifier_sequence(cut_pose_frames)
264
- classification = _classify(sequence)
265
- classifier_mode = str(classification.probabilities.pop("mode", "unknown"))
266
-
267
- elapsed_ms = (time.perf_counter() - start_time) * 1000.0
268
- return PipelineOutput(
269
- annotated_video_path=str(annotated_video),
270
- animation_data_path=str(animation_json),
271
- keypoints_csv_path=str(keypoints_csv),
272
- classification=classification,
273
- metadata=PipelineMetadata(
274
- model_version="A12-pipeline-integration-v1",
275
- inference_time_ms=elapsed_ms,
276
- frame_count_original=len(pose_frames),
277
- frame_count_cut=len(cut_pose_frames),
278
- fps=fps,
279
- cut_start_frame=cut_start,
280
- cut_end_frame=cut_end,
281
- smoothing_strategy=f"{smoothing_strategy}/{smoothing_method}",
282
- classifier_mode=classifier_mode,
283
- ),
284
- warnings=warnings,
285
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
A12/service/schemas.py DELETED
@@ -1,45 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass, asdict
4
- from typing import Any, Dict, List, Optional
5
-
6
-
7
- @dataclass
8
- class PipelineMetadata:
9
- model_version: str
10
- inference_time_ms: float
11
- frame_count_original: int
12
- frame_count_cut: int
13
- fps: float
14
- cut_start_frame: int
15
- cut_end_frame: int
16
- smoothing_strategy: str
17
- classifier_mode: str
18
-
19
-
20
- @dataclass
21
- class ClassificationResult:
22
- label: str
23
- is_good: bool
24
- confidence: float
25
- probabilities: Dict[str, float]
26
-
27
-
28
- @dataclass
29
- class PipelineOutput:
30
- annotated_video_path: str
31
- animation_data_path: str
32
- keypoints_csv_path: str
33
- classification: ClassificationResult
34
- metadata: PipelineMetadata
35
- warnings: List[str]
36
-
37
- def to_json(self) -> Dict[str, Any]:
38
- return {
39
- "annotated_video_path": self.annotated_video_path,
40
- "animation_data_path": self.animation_data_path,
41
- "keypoints_csv_path": self.keypoints_csv_path,
42
- "classification": asdict(self.classification),
43
- "metadata": asdict(self.metadata),
44
- "warnings": self.warnings,
45
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
A12/service/ui.py CHANGED
@@ -1,44 +1,28 @@
 
 
1
  from __future__ import annotations
2
 
3
- import json
4
- from typing import Any, Dict, Tuple
5
 
6
- from .pipeline import run_video_pipeline, validate_video
7
 
8
 
 
 
 
 
 
9
 
10
- def run_a12_video_tab(video_path: str, confidence_threshold: float, smoothing_strategy: str, smoothing_method: str):
11
- """Gradio callback: video -> annotated video, animation json, keypoints csv, JSON, Markdown."""
12
- try:
13
- result = run_video_pipeline(
14
- video_path=video_path,
15
- confidence_threshold=confidence_threshold,
16
- smoothing_strategy=smoothing_strategy,
17
- smoothing_method=smoothing_method,
18
- )
19
- payload = result.to_json()
20
- summary = f"""
21
- ### A12 pipeline completed
22
 
23
- - **Classification:** `{payload['classification']['label']}`
24
- - **Confidence:** `{payload['classification']['confidence']:.3f}`
25
- - **Frames:** `{payload['metadata']['frame_count_cut']}` cut from `{payload['metadata']['frame_count_original']}` original frames
26
- - **Cut window:** frames `{payload['metadata']['cut_start_frame']}` to `{payload['metadata']['cut_end_frame']}`
27
- - **Classifier mode:** `{payload['metadata']['classifier_mode']}`
 
 
28
  """
29
- if payload["warnings"]:
30
- summary += "\n### Warnings\n" + "\n".join(f"- {w}" for w in payload["warnings"])
31
- return result.annotated_video_path, result.animation_data_path, result.keypoints_csv_path, payload, summary
32
- except Exception as exc:
33
- return None, None, None, {"error": str(exc)}, f"### Error\n{exc}"
34
-
35
-
36
- # Backward-compatible function name used by the uploaded app.py A12 CSV prototype.
37
- def run_a12_tab(csv_path: str, problem: str = "B"):
38
- if not csv_path:
39
- return {"error": "Upload a CSV file first."}, "### Error\nUpload a CSV file first."
40
- return {
41
- "message": "This CSV endpoint was superseded by the Issue #12 video pipeline tab.",
42
- "csv_path": csv_path,
43
- "problem": problem,
44
- }, "### CSV received\nThe Issue #12 implementation uses the video pipeline endpoint."
 
1
+ """Small UI helpers for the A12 Gradio tab."""
2
+
3
  from __future__ import annotations
4
 
5
+ from typing import Any, Dict
 
6
 
7
+ from A12.service.model_service import safe_predict_pose_csv
8
 
9
 
10
+ def run_a12_tab(csv_file: str | None, problem: str) -> tuple[Dict[str, Any], str]:
11
+ """Run prediction and return JSON plus a concise Markdown summary."""
12
+ result = safe_predict_pose_csv(csv_file, problem)
13
+ if result.get("status") != "ok":
14
+ return result, f"### Prediction failed\n\n{result.get('message', 'Unknown error')}"
15
 
16
+ prediction = result["prediction"]
17
+ metadata = result["metadata"]
18
+ summary = f"""### A12 prediction
 
 
 
 
 
 
 
 
 
19
 
20
+ - **Problem:** {result['problem']}
21
+ - **Model:** {result['model_name']}
22
+ - **Overall label:** {prediction['label']}
23
+ - **Confidence:** {prediction['confidence']:.3f}
24
+ - **Exercise frame ratio:** {prediction['exercise_frame_ratio']:.3f}
25
+ - **Rows processed:** {metadata['rows']}
26
+ - **Inference time:** {metadata['inference_time_ms']} ms
27
  """
28
+ return result, summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
A12/tests/test_a12_service_contracts.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import pytest
3
+
4
+ from A12.service.contracts import FEATURES_BY_PROBLEM, normalize_problem, validate_pose_dataframe
5
+
6
+
7
+ def test_normalize_problem_accepts_a_and_b():
8
+ assert normalize_problem("A") == "A"
9
+ assert normalize_problem("b") == "B"
10
+ assert normalize_problem("B - PoseNet") == "B"
11
+
12
+
13
+ def test_normalize_problem_rejects_unknown_value():
14
+ with pytest.raises(ValueError):
15
+ normalize_problem("C")
16
+
17
+
18
+ def test_validate_pose_dataframe_reports_missing_columns():
19
+ df = pd.DataFrame({"head_x": [1.0]})
20
+ with pytest.raises(ValueError, match="missing"):
21
+ validate_pose_dataframe(df, "B")
22
+
23
+
24
+ def test_validate_pose_dataframe_accepts_problem_b_feature_schema():
25
+ df = pd.DataFrame({col: [0.5] for col in FEATURES_BY_PROBLEM["B"]})
26
+ features, names = validate_pose_dataframe(df, "B")
27
+ assert list(features.columns) == FEATURES_BY_PROBLEM["B"]
28
+ assert names == FEATURES_BY_PROBLEM["B"]
29
+ assert features.shape == (1, len(FEATURES_BY_PROBLEM["B"]))
A12/tests/test_a12_service_ui.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from A12.service.ui import run_a12_tab
2
+
3
+
4
+ def test_run_a12_tab_returns_structured_error_for_missing_file():
5
+ result, summary = run_a12_tab(None, "B")
6
+ assert result["status"] == "error"
7
+ assert "message" in result
8
+ assert "Prediction failed" in summary
A12/tests/test_service_schema.py DELETED
@@ -1,30 +0,0 @@
1
- from A12.service.schemas import ClassificationResult, PipelineMetadata, PipelineOutput
2
-
3
-
4
- def test_pipeline_output_schema():
5
- payload = PipelineOutput(
6
- annotated_video_path="out.mp4",
7
- animation_data_path="anim.json",
8
- keypoints_csv_path="points.csv",
9
- classification=ClassificationResult(
10
- label="good",
11
- is_good=True,
12
- confidence=0.8,
13
- probabilities={"good": 0.8, "bad": 0.2},
14
- ),
15
- metadata=PipelineMetadata(
16
- model_version="test",
17
- inference_time_ms=1.0,
18
- frame_count_original=20,
19
- frame_count_cut=10,
20
- fps=30.0,
21
- cut_start_frame=2,
22
- cut_end_frame=11,
23
- smoothing_strategy="exponential/zscore",
24
- classifier_mode="dummy",
25
- ),
26
- warnings=[],
27
- ).to_json()
28
- assert payload["classification"]["label"] in {"good", "bad"}
29
- assert set(payload["classification"]["probabilities"]) == {"good", "bad"}
30
- assert payload["metadata"]["frame_count_cut"] == 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
A12/tests/test_service_validation.py DELETED
@@ -1,15 +0,0 @@
1
- import pytest
2
-
3
- from A12.service.pipeline import validate_video
4
-
5
-
6
- def test_validate_video_requires_input():
7
- with pytest.raises(ValueError, match="required"):
8
- validate_video(None)
9
-
10
-
11
- def test_validate_video_rejects_unknown_extension(tmp_path):
12
- path = tmp_path / "not_a_video.txt"
13
- path.write_text("hello")
14
- with pytest.raises(ValueError, match="Unsupported"):
15
- validate_video(str(path))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -3,7 +3,6 @@ import gradio as gr
3
  from A8.pose_estimator import MoveNetPoseEstimator
4
  from A12.pose_interpolator import smooth_pose_sequence
5
  from A12.service.ui import run_a12_tab
6
- from A12.service.ui import run_a12_video_tab
7
  import json
8
  import csv
9
  import os
@@ -417,73 +416,43 @@ with gr.Blocks(title="MoveNet Pose Estimation") as demo:
417
  outputs=[video_output, video_result]
418
  )
419
 
420
- # A12 Video Pipeline Tab
421
- with gr.TabItem("🧪 A12 Video Pipeline"):
 
 
422
  gr.Markdown(
423
  """
424
- ### Issue #12: App development and pipeline integration
425
 
426
- Endpoint alternative chosen: **Gradio tab inside the existing app.py**.
427
-
428
- **Input:** one video file.
429
- **Output:** annotated cut 2D video, 3D-animation data JSON, keypoints CSV,
430
- and good/bad classification JSON.
431
  """
432
  )
433
-
434
  with gr.Row():
435
  with gr.Column():
436
- a12_video_input = gr.Video(label="Input exercise video")
437
- a12_confidence = gr.Slider(
438
- minimum=0.0,
439
- maximum=1.0,
440
- value=0.3,
441
- step=0.05,
442
- label="Confidence threshold"
443
- )
444
- a12_smoothing_strategy = gr.Dropdown(
445
- choices=[
446
- "exponential",
447
- "moving_average",
448
- "gaussian",
449
- "median",
450
- "savitzky_golay",
451
- "kalman",
452
- "spline",
453
- "hybrid"
454
- ],
455
- value="exponential",
456
- label="Smoothing strategy",
457
  )
458
- a12_smoothing_method = gr.Dropdown(
459
- choices=["zscore", "velocity", "none"],
460
- value="zscore",
461
- label="Outlier detection method",
 
462
  )
463
- a12_run_btn = gr.Button("Run A12 pipeline", variant="primary")
464
 
465
  with gr.Column():
466
- a12_video_output = gr.Video(label="Annotated cut 2D video")
467
- a12_animation_file = gr.File(label="3D animation data JSON")
468
- a12_keypoints_file = gr.File(label="Cut keypoints CSV")
469
- a12_json_output = gr.JSON(label="Structured output")
470
- a12_summary = gr.Markdown()
471
-
472
- a12_run_btn.click(
473
- fn=run_a12_video_tab,
474
- inputs=[
475
- a12_video_input,
476
- a12_confidence,
477
- a12_smoothing_strategy,
478
- a12_smoothing_method
479
- ],
480
- outputs=[
481
- a12_video_output,
482
- a12_animation_file,
483
- a12_keypoints_file,
484
- a12_json_output,
485
- a12_summary
486
- ],
487
  )
488
 
489
  # Example section
 
3
  from A8.pose_estimator import MoveNetPoseEstimator
4
  from A12.pose_interpolator import smooth_pose_sequence
5
  from A12.service.ui import run_a12_tab
 
6
  import json
7
  import csv
8
  import os
 
416
  outputs=[video_output, video_result]
417
  )
418
 
419
+
420
+
421
+ # A12 Classifier Tab
422
+ with gr.TabItem("🧪 A12 Classifier"):
423
  gr.Markdown(
424
  """
425
+ ### A12 Service Endpoint: Pose CSV classifier
426
 
427
+ Endpoint alternative chosen: **Gradio tab inside the existing app**.
428
+ This keeps the HuggingFace Space architecture simple and avoids a
429
+ separate REST/FastAPI service. Upload a pose-feature CSV exported
430
+ with the same feature schema used by the A12 classifier.
 
431
  """
432
  )
 
433
  with gr.Row():
434
  with gr.Column():
435
+ a12_csv_input = gr.File(
436
+ label="Pose feature CSV",
437
+ file_types=[".csv"],
438
+ type="filepath",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  )
440
+ a12_problem_input = gr.Radio(
441
+ choices=["A", "B"],
442
+ value="B",
443
+ label="Classifier problem",
444
+ info="A = Kinect 3D features, B = PoseNet 2D features",
445
  )
446
+ a12_predict_btn = gr.Button("Run A12 classifier", variant="primary")
447
 
448
  with gr.Column():
449
+ a12_summary_output = gr.Markdown(label="Summary")
450
+ a12_json_output = gr.JSON(label="Structured JSON output")
451
+
452
+ a12_predict_btn.click(
453
+ fn=run_a12_tab,
454
+ inputs=[a12_csv_input, a12_problem_input],
455
+ outputs=[a12_json_output, a12_summary_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  )
457
 
458
  # Example section
pose_outputs/a12_animation_data_20260510_133855.json DELETED
The diff for this file is too large to render. See raw diff
 
pose_outputs/a12_keypoints_cut_20260510_133855.csv DELETED
The diff for this file is too large to render. See raw diff