Spaces:
Running
Running
Reem commited on
Commit ·
92e8ff4
1
Parent(s): 532acb8
Revert repo to stable version c1edb3 (Issue rollback)
Browse files- A12/service/__init__.py +5 -3
- A12/service/contracts.py +134 -0
- A12/service/model_service.py +159 -0
- A12/service/pipeline.py +0 -285
- A12/service/schemas.py +0 -45
- A12/service/ui.py +20 -36
- A12/tests/test_a12_service_contracts.py +29 -0
- A12/tests/test_a12_service_ui.py +8 -0
- A12/tests/test_service_schema.py +0 -30
- A12/tests/test_service_validation.py +0 -15
- app.py +26 -57
- pose_outputs/a12_animation_data_20260510_133855.json +0 -0
- pose_outputs/a12_keypoints_cut_20260510_133855.csv +0 -0
A12/service/__init__.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
|
| 2 |
-
from .ui import run_a12_video_tab
|
| 3 |
|
| 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"]
|
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
|
| 4 |
-
from typing import Any, Dict, Tuple
|
| 5 |
|
| 6 |
-
from .
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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 |
-
- **
|
| 24 |
-
- **
|
| 25 |
-
- **
|
| 26 |
-
- **
|
| 27 |
-
- **
|
|
|
|
|
|
|
| 28 |
"""
|
| 29 |
-
|
| 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 |
-
|
| 421 |
-
|
|
|
|
|
|
|
| 422 |
gr.Markdown(
|
| 423 |
"""
|
| 424 |
-
###
|
| 425 |
|
| 426 |
-
Endpoint alternative chosen: **Gradio tab inside the existing app
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
and good/bad classification JSON.
|
| 431 |
"""
|
| 432 |
)
|
| 433 |
-
|
| 434 |
with gr.Row():
|
| 435 |
with gr.Column():
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 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 |
-
|
| 459 |
-
choices=["
|
| 460 |
-
value="
|
| 461 |
-
label="
|
|
|
|
| 462 |
)
|
| 463 |
-
|
| 464 |
|
| 465 |
with gr.Column():
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 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
|
|
|