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

video2visData

Browse files
A12/service/__init__.py CHANGED
@@ -1,6 +1,4 @@
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"]
 
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"]
 
 
 
A12/service/contracts.py DELETED
@@ -1,134 +0,0 @@
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 DELETED
@@ -1,159 +0,0 @@
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 ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,28 +1,44 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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."
A12/tests/test_a12_service_contracts.py DELETED
@@ -1,29 +0,0 @@
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 DELETED
@@ -1,8 +0,0 @@
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 ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,6 +3,7 @@ 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
  import json
7
  import csv
8
  import os
@@ -416,43 +417,73 @@ with gr.Blocks(title="MoveNet Pose Estimation") as demo:
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
 
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
  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
pose_outputs/a12_animation_data_20260510_133855.json ADDED
The diff for this file is too large to render. See raw diff
 
pose_outputs/a12_keypoints_cut_20260510_133855.csv ADDED
The diff for this file is too large to render. See raw diff