Merge remote issue 4 updates
Browse files- README.md +43 -20
- app.py +16 -139
- src/pozify/exercises/__init__.py +4 -0
- src/pozify/exercises/base.py +410 -0
- src/pozify/exercises/push_up/__init__.py +5 -0
- src/pozify/exercises/push_up/analyzer.py +117 -0
- src/pozify/exercises/push_up/issue_markers.py +50 -0
- src/pozify/{steps/rep_counters/push_up.py → exercises/push_up/strategy.py} +39 -4
- src/pozify/exercises/registry.py +31 -0
- src/pozify/exercises/shared/__init__.py +5 -0
- src/pozify/exercises/shared/analyzer.py +135 -0
- src/pozify/exercises/shared/issue_marker.py +192 -0
- src/pozify/{steps/rep_counters/base.py → exercises/shared/rep_counter.py} +5 -4
- src/pozify/exercises/shoulder_press/__init__.py +5 -0
- src/pozify/exercises/shoulder_press/analyzer.py +89 -0
- src/pozify/exercises/shoulder_press/issue_markers.py +44 -0
- src/pozify/{steps/rep_counters/shoulder_press.py → exercises/shoulder_press/strategy.py} +38 -4
- src/pozify/exercises/squat/__init__.py +5 -0
- src/pozify/exercises/squat/analyzer.py +126 -0
- src/pozify/exercises/squat/issue_markers.py +58 -0
- src/pozify/{steps/rep_counters/squat.py → exercises/squat/strategy.py} +37 -4
- src/pozify/exercises/unknown/__init__.py +4 -0
- src/pozify/exercises/unknown/analyzer.py +26 -0
- src/pozify/{steps/rep_counters/registry.py → exercises/unknown/strategy.py} +14 -20
- src/pozify/pipeline.py +19 -14
- src/pozify/steps/annotated_renderer.py +214 -3
- src/pozify/steps/coach_summary.py +8 -8
- src/pozify/steps/issue_marker.py +0 -53
- src/pozify/steps/rep_analysis.py +0 -58
- src/pozify/steps/rep_counter.py +0 -10
- src/pozify/steps/rep_counters/__init__.py +0 -5
- src/pozify/steps/variation_detector.py +0 -33
- tests/test_annotated_renderer.py +20 -0
- tests/test_issue_marker.py +197 -0
- tests/test_pipeline_contracts.py +7 -0
- tests/test_rep_analysis_variation.py +293 -0
- tests/test_rep_counter.py +60 -29
- web/app.js +51 -0
- web/styles.css +78 -1
README.md
CHANGED
|
@@ -21,7 +21,7 @@ tags:
|
|
| 21 |
|
| 22 |
Pozify is a Gradio + uv base project for a video-based workout form review app.
|
| 23 |
|
| 24 |
-
The current implementation contains the full application pipeline, step inputs/outputs, and JSON data contracts.
|
| 25 |
|
| 26 |
## Run The App
|
| 27 |
|
|
@@ -37,15 +37,15 @@ uv run gradio app.py
|
|
| 37 |
|
| 38 |
The app runs at `http://127.0.0.1:7860` by default.
|
| 39 |
|
| 40 |
-
The pipeline runs
|
| 41 |
-
`run_pipeline(..., mock=True)` or set:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
POZIFY_MOCK_MODE=1 uv run python app.py
|
| 45 |
```
|
| 46 |
|
| 47 |
-
To
|
| 48 |
-
annotated video rendering, set:
|
| 49 |
|
| 50 |
```bash
|
| 51 |
POZIFY_MOCK_MODE=0 uv run python app.py
|
|
@@ -92,6 +92,10 @@ user profile + input video
|
|
| 92 |
-> final report
|
| 93 |
```
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
## Project Structure
|
| 96 |
|
| 97 |
```text
|
|
@@ -100,15 +104,28 @@ src/pozify/
|
|
| 100 |
contracts.py
|
| 101 |
pipeline.py
|
| 102 |
artifacts.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
steps/
|
| 104 |
video_qc.py
|
| 105 |
pose_landmarker.py
|
| 106 |
pose_cleaning.py
|
| 107 |
exercise_classifier.py
|
| 108 |
-
rep_counter.py
|
| 109 |
-
rep_analysis.py
|
| 110 |
-
variation_detector.py
|
| 111 |
-
issue_marker.py
|
| 112 |
annotated_renderer.py
|
| 113 |
coach_summary.py
|
| 114 |
verifier.py
|
|
@@ -168,28 +185,34 @@ Each step lives in `src/pozify/steps/` and exposes a `run(...)` function.
|
|
| 168 |
|
| 169 |
## Adding Exercises
|
| 170 |
|
| 171 |
-
Supported exercise metadata is centralized in `src/pozify/exercise_catalog.py`. To add a new
|
| 172 |
exercise, add one `ExerciseSpec` with:
|
| 173 |
|
| 174 |
- `key` and `display_name`
|
| 175 |
- `default_variation` and confidence
|
| 176 |
-
- `metric_factory` for mock
|
| 177 |
- optional `variation_hints`, default `not_issues`, and `mock_issue`
|
| 178 |
|
| 179 |
The Gradio dropdown, profile validation, variation detector, rep analysis, and issue marker read from
|
| 180 |
this catalog, so new exercises do not require changing each step just to become selectable. Real
|
| 181 |
-
exercise-specific
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
Recommended replacement order:
|
| 185 |
|
| 186 |
1. `video_qc.py`: read real video metadata with OpenCV.
|
| 187 |
2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
|
| 188 |
3. `exercise_classifier.py`: load the exercise router model.
|
| 189 |
-
4. `
|
| 190 |
-
5. `
|
| 191 |
-
6. `
|
| 192 |
-
7. `
|
|
|
|
| 193 |
|
| 194 |
## Exercise Router Training
|
| 195 |
|
|
@@ -253,12 +276,12 @@ classes from the Riccio dataset, such as bicep curl, are mapped to `unknown`.
|
|
| 253 |
|
| 254 |
```bash
|
| 255 |
uv run python -m unittest discover -s tests
|
| 256 |
-
python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py src/pozify/
|
| 257 |
uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
|
| 258 |
```
|
| 259 |
|
| 260 |
-
The unit tests run the full mocked pipeline
|
| 261 |
-
|
| 262 |
|
| 263 |
## Git Hooks
|
| 264 |
|
|
|
|
| 21 |
|
| 22 |
Pozify is a Gradio + uv base project for a video-based workout form review app.
|
| 23 |
|
| 24 |
+
The current implementation contains the full application pipeline, step inputs/outputs, and JSON data contracts. Uploaded videos use real video QC, MediaPipe pose extraction, and real rep segmentation by default; no-video/demo runs still use mock pose data.
|
| 25 |
|
| 26 |
## Run The App
|
| 27 |
|
|
|
|
| 37 |
|
| 38 |
The app runs at `http://127.0.0.1:7860` by default.
|
| 39 |
|
| 40 |
+
The pipeline runs with real pose extraction by default when a video path is provided. No-video runs
|
| 41 |
+
default to mock mode. To force mock mode in scripts, call `run_pipeline(..., mock=True)` or set:
|
| 42 |
|
| 43 |
```bash
|
| 44 |
POZIFY_MOCK_MODE=1 uv run python app.py
|
| 45 |
```
|
| 46 |
|
| 47 |
+
To force the end-to-end app to use real video QC, MediaPipe pose extraction, real rep segmentation,
|
| 48 |
+
and annotated video rendering even when `POZIFY_MOCK_MODE` is set elsewhere, set:
|
| 49 |
|
| 50 |
```bash
|
| 51 |
POZIFY_MOCK_MODE=0 uv run python app.py
|
|
|
|
| 92 |
-> final report
|
| 93 |
```
|
| 94 |
|
| 95 |
+
After classification, the pipeline creates one object for the detected exercise class with the video
|
| 96 |
+
manifest, cleaned pose sequence, and user profile. Rep counting, rep analysis, variation detection,
|
| 97 |
+
and issue marking then run as methods on that exercise object.
|
| 98 |
+
|
| 99 |
## Project Structure
|
| 100 |
|
| 101 |
```text
|
|
|
|
| 104 |
contracts.py
|
| 105 |
pipeline.py
|
| 106 |
artifacts.py
|
| 107 |
+
exercises/
|
| 108 |
+
push_up/
|
| 109 |
+
strategy.py
|
| 110 |
+
analyzer.py
|
| 111 |
+
issue_markers.py
|
| 112 |
+
squat/
|
| 113 |
+
strategy.py
|
| 114 |
+
analyzer.py
|
| 115 |
+
issue_markers.py
|
| 116 |
+
shoulder_press/
|
| 117 |
+
strategy.py
|
| 118 |
+
analyzer.py
|
| 119 |
+
issue_markers.py
|
| 120 |
+
shared/
|
| 121 |
+
analyzer.py
|
| 122 |
+
issue_marker.py
|
| 123 |
+
rep_counter.py
|
| 124 |
steps/
|
| 125 |
video_qc.py
|
| 126 |
pose_landmarker.py
|
| 127 |
pose_cleaning.py
|
| 128 |
exercise_classifier.py
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
annotated_renderer.py
|
| 130 |
coach_summary.py
|
| 131 |
verifier.py
|
|
|
|
| 185 |
|
| 186 |
## Adding Exercises
|
| 187 |
|
| 188 |
+
Supported exercise metadata is centralized in `src/pozify/exercise_catalog.py`. To add a new
|
| 189 |
exercise, add one `ExerciseSpec` with:
|
| 190 |
|
| 191 |
- `key` and `display_name`
|
| 192 |
- `default_variation` and confidence
|
| 193 |
+
- `metric_factory` for legacy/mock fallback metadata
|
| 194 |
- optional `variation_hints`, default `not_issues`, and `mock_issue`
|
| 195 |
|
| 196 |
The Gradio dropdown, profile validation, variation detector, rep analysis, and issue marker read from
|
| 197 |
this catalog, so new exercises do not require changing each step just to become selectable. Real
|
| 198 |
+
exercise-specific code lives under `src/pozify/exercises/<exercise>/`:
|
| 199 |
+
|
| 200 |
+
- `strategy.py`: rep-counting signal, variation logic, and exercise strategy wiring
|
| 201 |
+
- `analyzer.py`: per-rep metrics
|
| 202 |
+
- `issue_markers.py`: frame-level issue rules
|
| 203 |
+
|
| 204 |
+
Shared analyzer and issue marker primitives live under `src/pozify/exercises/shared/`.
|
| 205 |
|
| 206 |
Recommended replacement order:
|
| 207 |
|
| 208 |
1. `video_qc.py`: read real video metadata with OpenCV.
|
| 209 |
2. `pose_backends/`: add or refine pose model adapters such as MediaPipe or MMPose.
|
| 210 |
3. `exercise_classifier.py`: load the exercise router model.
|
| 211 |
+
4. `exercises/<exercise>/strategy.py`: implement exercise-specific rep signals and variation logic.
|
| 212 |
+
5. `exercises/<exercise>/analyzer.py`: compute per-rep metrics.
|
| 213 |
+
6. `exercises/<exercise>/issue_markers.py`: compute real issue scores and intervals.
|
| 214 |
+
7. `annotated_renderer.py`: render skeleton overlays and issue highlights.
|
| 215 |
+
8. `coach_summary.py`: call the selected small language model with retrieved knowledge cards.
|
| 216 |
|
| 217 |
## Exercise Router Training
|
| 218 |
|
|
|
|
| 276 |
|
| 277 |
```bash
|
| 278 |
uv run python -m unittest discover -s tests
|
| 279 |
+
python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py src/pozify/exercises/*.py src/pozify/exercises/*/*.py
|
| 280 |
uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
|
| 281 |
```
|
| 282 |
|
| 283 |
+
The unit tests run the full mocked pipeline explicitly, then assert deterministic top-level keys for
|
| 284 |
+
each JSON artifact.
|
| 285 |
|
| 286 |
## Git Hooks
|
| 287 |
|
app.py
CHANGED
|
@@ -27,141 +27,6 @@ APP_DESCRIPTION = (
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
| 30 |
-
QUALITY_GUIDANCE = {
|
| 31 |
-
"too_short": "Record at least 10 seconds so the set contains enough movement context.",
|
| 32 |
-
"too_long": "Keep the clip under 60 seconds for the MVP analyzer.",
|
| 33 |
-
"too_dark": "Use brighter, even lighting and keep the body visible against the background.",
|
| 34 |
-
"too_blurry": "Stabilize the camera and avoid fast panning or heavy motion blur.",
|
| 35 |
-
"fps_too_low": "Use a camera mode with at least 15 FPS.",
|
| 36 |
-
"resolution_too_low": "Record at 480x360 or higher so joint positions are readable.",
|
| 37 |
-
"video_decode_failed": "Upload a playable video file; the current file could not be decoded.",
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def _pretty_json(value: dict[str, Any]) -> str:
|
| 42 |
-
return json.dumps(value, ensure_ascii=False, indent=2)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _quality_markdown(video_manifest: dict[str, Any]) -> str:
|
| 46 |
-
warnings = video_manifest["quality_warnings"]
|
| 47 |
-
if not warnings:
|
| 48 |
-
return "## Video Quality\n\nNo quality warnings detected."
|
| 49 |
-
|
| 50 |
-
warning_items = "\n".join(f"- `{warning}`: {QUALITY_GUIDANCE[warning]}" for warning in warnings)
|
| 51 |
-
status = (
|
| 52 |
-
"Analysis is blocked until the video can be decoded reliably."
|
| 53 |
-
if not video_manifest["analysis_allowed"]
|
| 54 |
-
else "Analysis completed, but capture quality may affect downstream feedback."
|
| 55 |
-
)
|
| 56 |
-
return f"""## Capture Quality
|
| 57 |
-
|
| 58 |
-
{status}
|
| 59 |
-
|
| 60 |
-
{warning_items}
|
| 61 |
-
"""
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _mock_status_markdown(report: dict[str, Any]) -> str:
|
| 65 |
-
mock_steps = report["artifacts"].get("mock_steps", [])
|
| 66 |
-
if not mock_steps:
|
| 67 |
-
return ""
|
| 68 |
-
steps = ", ".join(f"`{step}`" for step in mock_steps)
|
| 69 |
-
return (
|
| 70 |
-
"## Pipeline Status\n\n"
|
| 71 |
-
"The current run uses real video QC, pose extraction, rep segmentation, and annotated video rendering, "
|
| 72 |
-
f"but these steps still use placeholders: {steps}."
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def analyze_video(
|
| 77 |
-
video_path: str | None,
|
| 78 |
-
goal: str,
|
| 79 |
-
experience_level: str,
|
| 80 |
-
intended_exercise: str,
|
| 81 |
-
intended_variation: str,
|
| 82 |
-
limitations: list[str],
|
| 83 |
-
equipment: str,
|
| 84 |
-
) -> tuple[str | None, str, str, str]:
|
| 85 |
-
result = run_pipeline(
|
| 86 |
-
video_path=video_path,
|
| 87 |
-
profile_input={
|
| 88 |
-
"goal": goal,
|
| 89 |
-
"experience_level": experience_level,
|
| 90 |
-
"intended_exercise": intended_exercise,
|
| 91 |
-
"intended_variation": intended_variation or None,
|
| 92 |
-
"known_limitations": limitations,
|
| 93 |
-
"equipment": equipment,
|
| 94 |
-
},
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
report = result["final_report"]
|
| 98 |
-
video_quality = _quality_markdown(report["video_manifest"])
|
| 99 |
-
mock_status = _mock_status_markdown(report)
|
| 100 |
-
summary = report["coach_summary"]
|
| 101 |
-
if not report["video_manifest"]["analysis_allowed"]:
|
| 102 |
-
markdown = f"""{video_quality}
|
| 103 |
-
|
| 104 |
-
{mock_status}
|
| 105 |
-
|
| 106 |
-
## Run
|
| 107 |
-
|
| 108 |
-
- **Run ID:** `{report["run_id"]}`
|
| 109 |
-
- **Saved report:** `{Path(result["run_dir"]) / "final_report.json"}`
|
| 110 |
-
"""
|
| 111 |
-
artifact_path = Path(result["run_dir"]) / "final_report.json"
|
| 112 |
-
return (
|
| 113 |
-
result["annotated_video_path"],
|
| 114 |
-
markdown,
|
| 115 |
-
_pretty_json(report),
|
| 116 |
-
str(artifact_path),
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
fallback_note = (
|
| 120 |
-
"\nRouter confidence is low or window predictions disagree. Select the exercise manually and rerun.\n"
|
| 121 |
-
if report["exercise"]["fallback_required"]
|
| 122 |
-
else ""
|
| 123 |
-
)
|
| 124 |
-
markdown = f"""## Scan Summary
|
| 125 |
-
|
| 126 |
-
| Signal | Result |
|
| 127 |
-
| --- | --- |
|
| 128 |
-
| Exercise router | {report["exercise"]["exercise"]} ({report["exercise"]["confidence"]:.0%}) |
|
| 129 |
-
| Router fallback required | {report["exercise"]["fallback_required"]} |
|
| 130 |
-
| Variation | {report["variation"]["detected_variation"]} ({report["variation"]["variation_confidence"]:.0%}) |
|
| 131 |
-
| Reps | {len(report["reps"]["reps"])} |
|
| 132 |
-
| Analysis mode | {report["artifacts"].get("analysis_mode", "unknown")} |
|
| 133 |
-
| Pose source | {report["artifacts"].get("pose_source", "unknown")} |
|
| 134 |
-
| Primary finding | {summary["main_findings"][0] if summary["main_findings"] else "No finding emitted"} |
|
| 135 |
-
| Run ID | `{report["run_id"]}` |
|
| 136 |
-
{fallback_note}
|
| 137 |
-
|
| 138 |
-
{mock_status}
|
| 139 |
-
|
| 140 |
-
## Coach Notes
|
| 141 |
-
|
| 142 |
-
{summary["summary"]}
|
| 143 |
-
|
| 144 |
-
### What Went Well
|
| 145 |
-
{chr(10).join(f"- {item}" for item in summary["what_went_well"])}
|
| 146 |
-
|
| 147 |
-
### Top Fixes
|
| 148 |
-
{chr(10).join(f"- {item}" for item in summary["top_fixes"])}
|
| 149 |
-
|
| 150 |
-
### Next Session Plan
|
| 151 |
-
{chr(10).join(f"- {item}" for item in summary["next_session_plan"])}
|
| 152 |
-
|
| 153 |
-
{video_quality}
|
| 154 |
-
"""
|
| 155 |
-
|
| 156 |
-
artifact_path = Path(result["run_dir"]) / "final_report.json"
|
| 157 |
-
return (
|
| 158 |
-
result["annotated_video_path"],
|
| 159 |
-
markdown,
|
| 160 |
-
_pretty_json(report),
|
| 161 |
-
str(artifact_path),
|
| 162 |
-
)
|
| 163 |
-
|
| 164 |
-
|
| 165 |
server = gr.Server(
|
| 166 |
title="Pozify",
|
| 167 |
summary="Video-based workout form review API",
|
|
@@ -184,7 +49,13 @@ def index() -> FileResponse:
|
|
| 184 |
def config() -> dict[str, Any]:
|
| 185 |
return {
|
| 186 |
"description": APP_DESCRIPTION,
|
| 187 |
-
"goals": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
"experience_levels": ["beginner", "intermediate"],
|
| 189 |
"exercises": ["auto", *USER_SELECTABLE_EXERCISES],
|
| 190 |
"limitations": ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
|
|
@@ -225,12 +96,16 @@ async def analyze_api(
|
|
| 225 |
try:
|
| 226 |
parsed_limitations = json.loads(limitations)
|
| 227 |
except json.JSONDecodeError as exc:
|
| 228 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 229 |
|
| 230 |
if not isinstance(parsed_limitations, list) or not all(
|
| 231 |
isinstance(item, str) for item in parsed_limitations
|
| 232 |
):
|
| 233 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 234 |
|
| 235 |
video_path: str | None = None
|
| 236 |
if video is not None and video.filename:
|
|
@@ -258,7 +133,9 @@ async def analyze_api(
|
|
| 258 |
return {
|
| 259 |
"run_id": result["run_id"],
|
| 260 |
"run_dir": result["run_dir"],
|
| 261 |
-
"annotated_video_url": _artifact_url(
|
|
|
|
|
|
|
| 262 |
"final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
|
| 263 |
"report": result["final_report"],
|
| 264 |
}
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
server = gr.Server(
|
| 31 |
title="Pozify",
|
| 32 |
summary="Video-based workout form review API",
|
|
|
|
| 49 |
def config() -> dict[str, Any]:
|
| 50 |
return {
|
| 51 |
"description": APP_DESCRIPTION,
|
| 52 |
+
"goals": [
|
| 53 |
+
"strength",
|
| 54 |
+
"hypertrophy",
|
| 55 |
+
"endurance",
|
| 56 |
+
"mobility",
|
| 57 |
+
"beginner_practice",
|
| 58 |
+
],
|
| 59 |
"experience_levels": ["beginner", "intermediate"],
|
| 60 |
"exercises": ["auto", *USER_SELECTABLE_EXERCISES],
|
| 61 |
"limitations": ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
|
|
|
|
| 96 |
try:
|
| 97 |
parsed_limitations = json.loads(limitations)
|
| 98 |
except json.JSONDecodeError as exc:
|
| 99 |
+
raise HTTPException(
|
| 100 |
+
status_code=400, detail="Limitations must be valid JSON."
|
| 101 |
+
) from exc
|
| 102 |
|
| 103 |
if not isinstance(parsed_limitations, list) or not all(
|
| 104 |
isinstance(item, str) for item in parsed_limitations
|
| 105 |
):
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=400, detail="Limitations must be a JSON list of strings."
|
| 108 |
+
)
|
| 109 |
|
| 110 |
video_path: str | None = None
|
| 111 |
if video is not None and video.filename:
|
|
|
|
| 133 |
return {
|
| 134 |
"run_id": result["run_id"],
|
| 135 |
"run_dir": result["run_dir"],
|
| 136 |
+
"annotated_video_url": _artifact_url(
|
| 137 |
+
result["run_id"], result["annotated_video_path"]
|
| 138 |
+
),
|
| 139 |
"final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
|
| 140 |
"report": result["final_report"],
|
| 141 |
}
|
src/pozify/exercises/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 2 |
+
from pozify.exercises.registry import create_exercise_strategy
|
| 3 |
+
|
| 4 |
+
__all__ = ["ExerciseStrategy", "create_exercise_strategy"]
|
src/pozify/exercises/base.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from statistics import mean, pstdev
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from pozify.contracts import (
|
| 7 |
+
IssueMarker,
|
| 8 |
+
IssueMarkers,
|
| 9 |
+
PoseFrame,
|
| 10 |
+
PoseSequence,
|
| 11 |
+
Rep,
|
| 12 |
+
RepAnalysis,
|
| 13 |
+
RepAnalysisItem,
|
| 14 |
+
Reps,
|
| 15 |
+
UserProfile,
|
| 16 |
+
VideoManifest,
|
| 17 |
+
Variation,
|
| 18 |
+
)
|
| 19 |
+
from pozify.exercise_catalog import get_exercise_spec
|
| 20 |
+
from pozify.exercises.shared.analyzer import (
|
| 21 |
+
ExerciseMetricResult,
|
| 22 |
+
mean_optional,
|
| 23 |
+
round_optional,
|
| 24 |
+
safe_ratio,
|
| 25 |
+
score,
|
| 26 |
+
usable,
|
| 27 |
+
value_series,
|
| 28 |
+
)
|
| 29 |
+
from pozify.exercises.shared.issue_marker import (
|
| 30 |
+
IssueRule,
|
| 31 |
+
frame_scores_for_rule,
|
| 32 |
+
frames_for_rep,
|
| 33 |
+
group_violations,
|
| 34 |
+
marker_from_group,
|
| 35 |
+
minimum_run_length,
|
| 36 |
+
)
|
| 37 |
+
from pozify.exercises.shared.rep_counter import ExerciseRepCounter
|
| 38 |
+
from pozify.steps.rep_signals import average_axis
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ExerciseStrategy(ExerciseRepCounter):
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
*,
|
| 45 |
+
video_manifest: VideoManifest,
|
| 46 |
+
pose_sequence: PoseSequence,
|
| 47 |
+
profile: UserProfile,
|
| 48 |
+
) -> None:
|
| 49 |
+
self.video_manifest = video_manifest
|
| 50 |
+
self.pose_sequence = pose_sequence
|
| 51 |
+
self.profile = profile
|
| 52 |
+
|
| 53 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 54 |
+
raise NotImplementedError
|
| 55 |
+
|
| 56 |
+
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
|
| 57 |
+
raise NotImplementedError
|
| 58 |
+
|
| 59 |
+
def profile_not_issues(self, variation: str) -> list[str]:
|
| 60 |
+
return []
|
| 61 |
+
|
| 62 |
+
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 63 |
+
return ()
|
| 64 |
+
|
| 65 |
+
def analyze_reps(self, reps: Reps) -> RepAnalysis:
|
| 66 |
+
draft_items: list[tuple[Rep, dict[str, Any], float, float, float, list[str]]] = []
|
| 67 |
+
for rep in reps.reps:
|
| 68 |
+
rep_frames = self.frames_for_rep(rep)
|
| 69 |
+
primary_signal = self.primary_signal(rep_frames)
|
| 70 |
+
common_metrics = self.common_rep_metrics(rep, rep_frames, primary_signal)
|
| 71 |
+
exercise_metrics, rom_score, stability_score, symmetry_score, hints = self.metrics(rep_frames)
|
| 72 |
+
metrics = {**common_metrics, **exercise_metrics}
|
| 73 |
+
draft_items.append((rep, metrics, rom_score, stability_score, symmetry_score, hints))
|
| 74 |
+
|
| 75 |
+
average_duration = (
|
| 76 |
+
mean(item[1]["rep_duration_sec"] for item in draft_items)
|
| 77 |
+
if draft_items
|
| 78 |
+
else 0.0
|
| 79 |
+
)
|
| 80 |
+
items: list[RepAnalysisItem] = []
|
| 81 |
+
for rep, metrics, rom_score, stability_score, symmetry_score, hints in draft_items:
|
| 82 |
+
duration = metrics["rep_duration_sec"]
|
| 83 |
+
metrics["tempo_consistency_score"] = (
|
| 84 |
+
score(1.0 - abs(duration - average_duration) / max(average_duration, 0.1))
|
| 85 |
+
if average_duration
|
| 86 |
+
else 0.0
|
| 87 |
+
)
|
| 88 |
+
items.append(
|
| 89 |
+
RepAnalysisItem(
|
| 90 |
+
rep_id=rep.rep_id,
|
| 91 |
+
duration_sec=duration,
|
| 92 |
+
range_of_motion_score=rom_score,
|
| 93 |
+
stability_score=stability_score,
|
| 94 |
+
symmetry_score=symmetry_score,
|
| 95 |
+
metrics=metrics,
|
| 96 |
+
variation_hints=sorted(set(hints)),
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
aggregate_metrics = {
|
| 101 |
+
"avg_rom_score": (
|
| 102 |
+
round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0
|
| 103 |
+
),
|
| 104 |
+
"avg_stability_score": (
|
| 105 |
+
round(mean(item.stability_score for item in items), 2) if items else 0.0
|
| 106 |
+
),
|
| 107 |
+
"avg_symmetry_score": (
|
| 108 |
+
round(mean(item.symmetry_score for item in items), 2) if items else 0.0
|
| 109 |
+
),
|
| 110 |
+
"avg_rep_duration_sec": (
|
| 111 |
+
round(mean(item.duration_sec for item in items), 2) if items else 0.0
|
| 112 |
+
),
|
| 113 |
+
"avg_tempo_consistency_score": self.aggregate_numeric(items, "tempo_consistency_score")
|
| 114 |
+
or 0.0,
|
| 115 |
+
"avg_landmark_confidence": (
|
| 116 |
+
self.aggregate_numeric(items, "landmark_confidence") or self.pose_sequence.pose_valid_ratio
|
| 117 |
+
),
|
| 118 |
+
"fatigue_trend_rom_delta": self.fatigue_trend(items),
|
| 119 |
+
"pose_valid_ratio": self.pose_sequence.pose_valid_ratio,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
for metric_name in (
|
| 123 |
+
"hand_width_ratio",
|
| 124 |
+
"stance_width_ratio",
|
| 125 |
+
"bottom_pause_sec",
|
| 126 |
+
"lockout_quality",
|
| 127 |
+
"wrist_height_asymmetry",
|
| 128 |
+
"wrist_travel",
|
| 129 |
+
"knee_support_score",
|
| 130 |
+
):
|
| 131 |
+
aggregate_value = self.aggregate_numeric(items, metric_name)
|
| 132 |
+
if aggregate_value is not None:
|
| 133 |
+
aggregate_metrics[f"avg_{metric_name}"] = aggregate_value
|
| 134 |
+
|
| 135 |
+
return RepAnalysis(
|
| 136 |
+
exercise=self.exercise,
|
| 137 |
+
items=items,
|
| 138 |
+
aggregate_metrics=aggregate_metrics,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def resolve_variation(self, analysis: RepAnalysis) -> Variation:
|
| 142 |
+
if self.profile.intended_variation:
|
| 143 |
+
variation = self.profile.intended_variation
|
| 144 |
+
confidence = 0.95
|
| 145 |
+
not_issues = self.profile_not_issues(variation)
|
| 146 |
+
else:
|
| 147 |
+
variation, confidence, not_issues = self.detect_variation(analysis)
|
| 148 |
+
|
| 149 |
+
if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
|
| 150 |
+
not_issues.append("low_rom_requires_user_intent_check")
|
| 151 |
+
if analysis.aggregate_metrics.get("pose_valid_ratio", 1.0) < 0.8:
|
| 152 |
+
not_issues.append("low_pose_confidence_limits_variation_call")
|
| 153 |
+
|
| 154 |
+
return Variation(
|
| 155 |
+
exercise=self.exercise,
|
| 156 |
+
detected_variation=variation,
|
| 157 |
+
variation_confidence=confidence,
|
| 158 |
+
not_issues=sorted(set(not_issues)),
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def mark_issues(
|
| 162 |
+
self,
|
| 163 |
+
reps: Reps,
|
| 164 |
+
analysis: RepAnalysis,
|
| 165 |
+
variation: Variation,
|
| 166 |
+
) -> IssueMarkers:
|
| 167 |
+
rep_by_id = {rep.rep_id: rep for rep in reps.reps}
|
| 168 |
+
issues: list[IssueMarker] = []
|
| 169 |
+
|
| 170 |
+
for item in analysis.items:
|
| 171 |
+
rep = rep_by_id.get(item.rep_id)
|
| 172 |
+
if rep is None:
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
rep_frames = frames_for_rep(self.pose_sequence, rep)
|
| 176 |
+
if not rep_frames:
|
| 177 |
+
fallback = self.fallback_rep_marker(reps, item, variation)
|
| 178 |
+
if fallback is not None:
|
| 179 |
+
issues.append(fallback)
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
min_run_length = minimum_run_length(rep_frames)
|
| 183 |
+
for rule in self.issue_rules():
|
| 184 |
+
if set(rule.suppress_when_not_issue) & set(variation.not_issues):
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
scores = frame_scores_for_rule(rep_frames, self.exercise, rule)
|
| 188 |
+
for group in group_violations(scores, min_run_length):
|
| 189 |
+
issues.append(marker_from_group(rule, group, item, variation))
|
| 190 |
+
|
| 191 |
+
return IssueMarkers(
|
| 192 |
+
issues=sorted(
|
| 193 |
+
issues,
|
| 194 |
+
key=lambda issue: (issue.start_frame, issue.end_frame, issue.rep_id, issue.issue),
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def frames_for_rep(self, rep: Rep) -> list[PoseFrame]:
|
| 199 |
+
rep_frames = [
|
| 200 |
+
frame
|
| 201 |
+
for frame in self.pose_sequence.frames
|
| 202 |
+
if rep.start_frame <= frame.frame_index <= rep.end_frame
|
| 203 |
+
]
|
| 204 |
+
if rep_frames:
|
| 205 |
+
return rep_frames
|
| 206 |
+
|
| 207 |
+
if not self.pose_sequence.frames:
|
| 208 |
+
return []
|
| 209 |
+
closest = min(
|
| 210 |
+
self.pose_sequence.frames,
|
| 211 |
+
key=lambda frame: min(
|
| 212 |
+
abs(frame.frame_index - rep.start_frame),
|
| 213 |
+
abs(frame.frame_index - rep.mid_frame),
|
| 214 |
+
abs(frame.frame_index - rep.end_frame),
|
| 215 |
+
),
|
| 216 |
+
)
|
| 217 |
+
return [closest]
|
| 218 |
+
|
| 219 |
+
def primary_signal(self, frames: list[PoseFrame]) -> list[float | None]:
|
| 220 |
+
if self.exercise == "shoulder_press":
|
| 221 |
+
return value_series(
|
| 222 |
+
frames,
|
| 223 |
+
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
|
| 224 |
+
)
|
| 225 |
+
if self.exercise == "push_up":
|
| 226 |
+
return value_series(
|
| 227 |
+
frames,
|
| 228 |
+
lambda frame: mean_optional(
|
| 229 |
+
[
|
| 230 |
+
average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
|
| 231 |
+
average_axis(frame, ("left_hip", "right_hip"), "y"),
|
| 232 |
+
]
|
| 233 |
+
),
|
| 234 |
+
)
|
| 235 |
+
return value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 236 |
+
|
| 237 |
+
def common_rep_metrics(
|
| 238 |
+
self,
|
| 239 |
+
rep: Rep,
|
| 240 |
+
frames: list[PoseFrame],
|
| 241 |
+
primary_signal: list[float | None],
|
| 242 |
+
) -> dict[str, Any]:
|
| 243 |
+
eccentric_duration = round(max(0.0, rep.mid_sec - rep.start_sec), 2)
|
| 244 |
+
concentric_duration = round(max(0.0, rep.end_sec - rep.mid_sec), 2)
|
| 245 |
+
duration = round(max(0.0, rep.end_sec - rep.start_sec), 2)
|
| 246 |
+
smoothness_score, jerk_score = self.smoothness_score(primary_signal)
|
| 247 |
+
stability_axis = value_series(
|
| 248 |
+
frames,
|
| 249 |
+
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
|
| 250 |
+
)
|
| 251 |
+
stability_noise = self.std(stability_axis) or 0.0
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
"rep_duration_sec": duration,
|
| 255 |
+
"eccentric_duration_sec": eccentric_duration,
|
| 256 |
+
"concentric_duration_sec": concentric_duration,
|
| 257 |
+
"tempo_ratio": round_optional(safe_ratio(eccentric_duration, concentric_duration), 2),
|
| 258 |
+
"top_pause_sec": self.pause_duration(frames, primary_signal, target="top"),
|
| 259 |
+
"bottom_pause_sec": self.pause_duration(frames, primary_signal, target="bottom"),
|
| 260 |
+
"smoothness_score": smoothness_score,
|
| 261 |
+
"jerk_score": round_optional(jerk_score, 4),
|
| 262 |
+
"landmark_confidence": self.mean_visibility(frames),
|
| 263 |
+
"hip_lateral_drift": round_optional(stability_noise, 4),
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
def fallback_rep_marker(
|
| 267 |
+
self,
|
| 268 |
+
reps: Reps,
|
| 269 |
+
item: RepAnalysisItem,
|
| 270 |
+
variation: Variation,
|
| 271 |
+
) -> IssueMarker | None:
|
| 272 |
+
exercise_spec = get_exercise_spec(reps.exercise)
|
| 273 |
+
if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
|
| 274 |
+
return None
|
| 275 |
+
|
| 276 |
+
rep = next((rep for rep in reps.reps if rep.rep_id == item.rep_id), None)
|
| 277 |
+
if rep is None:
|
| 278 |
+
return None
|
| 279 |
+
|
| 280 |
+
issue_spec = exercise_spec.mock_issue
|
| 281 |
+
metric_value = (
|
| 282 |
+
item.range_of_motion_score
|
| 283 |
+
if issue_spec.evidence_metric == "range_of_motion_score"
|
| 284 |
+
else item.metrics.get(issue_spec.evidence_metric)
|
| 285 |
+
)
|
| 286 |
+
return IssueMarker(
|
| 287 |
+
rep_id=item.rep_id,
|
| 288 |
+
issue=issue_spec.issue,
|
| 289 |
+
severity=round(1.0 - item.stability_score, 2),
|
| 290 |
+
start_frame=rep.mid_frame,
|
| 291 |
+
end_frame=rep.end_frame,
|
| 292 |
+
start_sec=rep.mid_sec,
|
| 293 |
+
end_sec=rep.end_sec,
|
| 294 |
+
affected_joints=list(issue_spec.affected_joints),
|
| 295 |
+
evidence={
|
| 296 |
+
issue_spec.evidence_metric: metric_value,
|
| 297 |
+
"threshold": issue_spec.threshold,
|
| 298 |
+
"confidence": round(max(0.0, min(1.0, 1.0 - item.stability_score)), 2),
|
| 299 |
+
"variation_context": {
|
| 300 |
+
"detected_variation": variation.detected_variation,
|
| 301 |
+
"variation_confidence": variation.variation_confidence,
|
| 302 |
+
"not_issues": list(variation.not_issues),
|
| 303 |
+
},
|
| 304 |
+
"fallback": "rep_level_metrics",
|
| 305 |
+
},
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
def metric(self, analysis: RepAnalysis, name: str) -> float | None:
|
| 309 |
+
value = analysis.aggregate_metrics.get(name)
|
| 310 |
+
if isinstance(value, (int, float)):
|
| 311 |
+
return float(value)
|
| 312 |
+
return None
|
| 313 |
+
|
| 314 |
+
def confidence(
|
| 315 |
+
self,
|
| 316 |
+
base: float,
|
| 317 |
+
analysis: RepAnalysis,
|
| 318 |
+
supporting_metric: float | None,
|
| 319 |
+
) -> float:
|
| 320 |
+
rep_bonus = min(0.12, len(analysis.items) * 0.03)
|
| 321 |
+
metric_bonus = 0.0 if supporting_metric is None else min(0.1, abs(supporting_metric) * 0.03)
|
| 322 |
+
pose_bonus = min(0.08, float(analysis.aggregate_metrics.get("pose_valid_ratio", 0.0)) * 0.08)
|
| 323 |
+
return round(min(0.95, base + rep_bonus + metric_bonus + pose_bonus), 2)
|
| 324 |
+
|
| 325 |
+
def std(self, values: list[float | None]) -> float | None:
|
| 326 |
+
usable_values = usable(values)
|
| 327 |
+
if len(usable_values) < 2:
|
| 328 |
+
return 0.0 if usable_values else None
|
| 329 |
+
return pstdev(usable_values)
|
| 330 |
+
|
| 331 |
+
def mean_visibility(self, frames: list[PoseFrame]) -> float:
|
| 332 |
+
values: list[float | None] = []
|
| 333 |
+
for frame in frames:
|
| 334 |
+
if "mean_visibility" in frame.pose_quality:
|
| 335 |
+
values.append(float(frame.pose_quality["mean_visibility"]))
|
| 336 |
+
continue
|
| 337 |
+
landmark_values = [
|
| 338 |
+
landmark.get("visibility")
|
| 339 |
+
for landmark in frame.landmarks.values()
|
| 340 |
+
if landmark.get("visibility") is not None
|
| 341 |
+
]
|
| 342 |
+
values.extend(float(value) for value in landmark_values)
|
| 343 |
+
return score(mean_optional(values) if values else 0.0)
|
| 344 |
+
|
| 345 |
+
def smoothness_score(self, signal_values: list[float | None]) -> tuple[float, float | None]:
|
| 346 |
+
usable_values = usable(signal_values)
|
| 347 |
+
if len(usable_values) < 4:
|
| 348 |
+
return 0.5, None
|
| 349 |
+
|
| 350 |
+
deltas = [
|
| 351 |
+
usable_values[index] - usable_values[index - 1]
|
| 352 |
+
for index in range(1, len(usable_values))
|
| 353 |
+
]
|
| 354 |
+
jerks = [deltas[index] - deltas[index - 1] for index in range(1, len(deltas))]
|
| 355 |
+
if not jerks:
|
| 356 |
+
return 0.5, None
|
| 357 |
+
jerk = mean(abs(value) for value in jerks)
|
| 358 |
+
return score(1.0 - jerk * 8.0), jerk
|
| 359 |
+
|
| 360 |
+
def pause_duration(
|
| 361 |
+
self,
|
| 362 |
+
frames: list[PoseFrame],
|
| 363 |
+
signal_values: list[float | None],
|
| 364 |
+
*,
|
| 365 |
+
target: str,
|
| 366 |
+
) -> float:
|
| 367 |
+
usable_values = usable(signal_values)
|
| 368 |
+
if len(usable_values) < 3 or len(frames) < 3:
|
| 369 |
+
return 0.0
|
| 370 |
+
|
| 371 |
+
min_value = min(usable_values)
|
| 372 |
+
max_value = max(usable_values)
|
| 373 |
+
tolerance = max((max_value - min_value) * 0.08, 0.01)
|
| 374 |
+
if target == "bottom":
|
| 375 |
+
active = [value is not None and value >= max_value - tolerance for value in signal_values]
|
| 376 |
+
else:
|
| 377 |
+
active = [value is not None and value <= min_value + tolerance for value in signal_values]
|
| 378 |
+
|
| 379 |
+
longest = 0
|
| 380 |
+
current = 0
|
| 381 |
+
for item in active:
|
| 382 |
+
if item:
|
| 383 |
+
current += 1
|
| 384 |
+
longest = max(longest, current)
|
| 385 |
+
else:
|
| 386 |
+
current = 0
|
| 387 |
+
|
| 388 |
+
if longest <= 1:
|
| 389 |
+
return 0.0
|
| 390 |
+
frame_duration = (frames[-1].timestamp_sec - frames[0].timestamp_sec) / max(
|
| 391 |
+
1, len(frames) - 1
|
| 392 |
+
)
|
| 393 |
+
return round(longest * frame_duration, 2)
|
| 394 |
+
|
| 395 |
+
def aggregate_numeric(self, items: list[RepAnalysisItem], metric_name: str) -> float | None:
|
| 396 |
+
values = [
|
| 397 |
+
item.metrics.get(metric_name)
|
| 398 |
+
for item in items
|
| 399 |
+
if isinstance(item.metrics.get(metric_name), (int, float))
|
| 400 |
+
]
|
| 401 |
+
if not values:
|
| 402 |
+
return None
|
| 403 |
+
return round(sum(float(value) for value in values) / len(values), 4)
|
| 404 |
+
|
| 405 |
+
def fatigue_trend(self, items: list[RepAnalysisItem]) -> float:
|
| 406 |
+
if len(items) < 2:
|
| 407 |
+
return 0.0
|
| 408 |
+
first = items[0].range_of_motion_score
|
| 409 |
+
last = items[-1].range_of_motion_score
|
| 410 |
+
return round(last - first, 4)
|
src/pozify/exercises/push_up/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.push_up.analyzer import PushUpAnalyzer
|
| 2 |
+
from pozify.exercises.push_up.issue_markers import RULES as ISSUE_RULES
|
| 3 |
+
from pozify.exercises.push_up.strategy import PushUpExercise
|
| 4 |
+
|
| 5 |
+
__all__ = ["ISSUE_RULES", "PushUpAnalyzer", "PushUpExercise"]
|
src/pozify/exercises/push_up/analyzer.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import (
|
| 5 |
+
ExerciseMetricResult,
|
| 6 |
+
max_optional,
|
| 7 |
+
mean_optional,
|
| 8 |
+
mean_pair,
|
| 9 |
+
min_optional,
|
| 10 |
+
range_optional,
|
| 11 |
+
round_optional,
|
| 12 |
+
safe_ratio,
|
| 13 |
+
score,
|
| 14 |
+
side_delta,
|
| 15 |
+
std_optional,
|
| 16 |
+
value_series,
|
| 17 |
+
width,
|
| 18 |
+
)
|
| 19 |
+
from pozify.steps.rep_signals import average_axis, body_line_score
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PushUpAnalyzer:
|
| 23 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 24 |
+
elbow_angles = value_series(
|
| 25 |
+
frames,
|
| 26 |
+
lambda frame: mean_pair(
|
| 27 |
+
frame,
|
| 28 |
+
("left_shoulder", "left_elbow", "left_wrist"),
|
| 29 |
+
("right_shoulder", "right_elbow", "right_wrist"),
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
elbow_deltas = value_series(
|
| 33 |
+
frames,
|
| 34 |
+
lambda frame: side_delta(
|
| 35 |
+
frame,
|
| 36 |
+
("left_shoulder", "left_elbow", "left_wrist"),
|
| 37 |
+
("right_shoulder", "right_elbow", "right_wrist"),
|
| 38 |
+
),
|
| 39 |
+
)
|
| 40 |
+
body_line = value_series(frames, body_line_score)
|
| 41 |
+
shoulder_y = value_series(
|
| 42 |
+
frames,
|
| 43 |
+
lambda frame: average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
|
| 44 |
+
)
|
| 45 |
+
hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 46 |
+
ankle_y = value_series(
|
| 47 |
+
frames,
|
| 48 |
+
lambda frame: average_axis(frame, ("left_ankle", "right_ankle"), "y"),
|
| 49 |
+
)
|
| 50 |
+
hand_width = value_series(frames, lambda frame: width(frame, "left_wrist", "right_wrist"))
|
| 51 |
+
shoulder_width = value_series(
|
| 52 |
+
frames,
|
| 53 |
+
lambda frame: width(frame, "left_shoulder", "right_shoulder"),
|
| 54 |
+
)
|
| 55 |
+
elbow_width = value_series(frames, lambda frame: width(frame, "left_elbow", "right_elbow"))
|
| 56 |
+
knee_y = value_series(
|
| 57 |
+
frames,
|
| 58 |
+
lambda frame: average_axis(frame, ("left_knee", "right_knee"), "y"),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
min_elbow = min_optional(elbow_angles)
|
| 62 |
+
max_elbow = max_optional(elbow_angles)
|
| 63 |
+
elbow_rom = 0.0 if min_elbow is None or max_elbow is None else max_elbow - min_elbow
|
| 64 |
+
chest_depth = range_optional(shoulder_y) or 0.0
|
| 65 |
+
hand_width_ratio = safe_ratio(mean_optional(hand_width), mean_optional(shoulder_width))
|
| 66 |
+
elbow_flare = safe_ratio(mean_optional(elbow_width), mean_optional(shoulder_width))
|
| 67 |
+
body_line_mean = mean_optional(body_line)
|
| 68 |
+
hip_sag_score = None
|
| 69 |
+
if body_line_mean is not None:
|
| 70 |
+
hip_sag_score = max(0.0, 1.0 - body_line_mean)
|
| 71 |
+
knee_support_score = self._knee_support_score(hip_y, knee_y, ankle_y)
|
| 72 |
+
|
| 73 |
+
rom_score = score((elbow_rom / 80.0) * 0.65 + (chest_depth / 0.16) * 0.35)
|
| 74 |
+
hip_stability = 1.0 - min(1.0, (std_optional(hip_y) or 0.0) * 4.0)
|
| 75 |
+
stability_score = score(((body_line_mean or 0.5) * 0.75) + hip_stability * 0.25)
|
| 76 |
+
symmetry_score = score(1.0 - ((mean_optional(elbow_deltas) or 0.0) / 45.0))
|
| 77 |
+
|
| 78 |
+
metrics = {
|
| 79 |
+
"min_elbow_angle_deg": round_optional(min_elbow),
|
| 80 |
+
"max_elbow_angle_deg": round_optional(max_elbow),
|
| 81 |
+
"body_line_score": round_optional(body_line_mean),
|
| 82 |
+
"hip_sag_score": round_optional(hip_sag_score),
|
| 83 |
+
"hip_pike_score": round_optional(max(0.0, ((body_line_mean or 1.0) - 1.0) * -1.0), 4),
|
| 84 |
+
"chest_depth_proxy": round_optional(chest_depth, 4),
|
| 85 |
+
"hand_width_ratio": round_optional(hand_width_ratio, 3),
|
| 86 |
+
"elbow_flare_ratio": round_optional(elbow_flare, 3),
|
| 87 |
+
"lockout_quality": score(((max_elbow or 120.0) - 120.0) / 55.0),
|
| 88 |
+
"knee_support_score": knee_support_score,
|
| 89 |
+
}
|
| 90 |
+
hints = []
|
| 91 |
+
if hand_width_ratio is not None and hand_width_ratio > 1.45:
|
| 92 |
+
hints.append("wide_grip_push_up")
|
| 93 |
+
elif hand_width_ratio is not None and hand_width_ratio < 0.95:
|
| 94 |
+
hints.append("close_grip_push_up")
|
| 95 |
+
if knee_support_score >= 0.8:
|
| 96 |
+
hints.append("knee_push_up")
|
| 97 |
+
return metrics, rom_score, stability_score, symmetry_score, hints
|
| 98 |
+
|
| 99 |
+
def _knee_support_score(
|
| 100 |
+
self,
|
| 101 |
+
hip_y: list[float | None],
|
| 102 |
+
knee_y: list[float | None],
|
| 103 |
+
ankle_y: list[float | None],
|
| 104 |
+
) -> float:
|
| 105 |
+
scores: list[float] = []
|
| 106 |
+
for hip, knee, ankle in zip(hip_y, knee_y, ankle_y, strict=False):
|
| 107 |
+
if hip is None or knee is None or ankle is None:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
hip_to_ankle = abs(ankle - hip)
|
| 111 |
+
if hip_to_ankle <= 1e-6:
|
| 112 |
+
continue
|
| 113 |
+
knee_to_hip = abs(knee - hip)
|
| 114 |
+
bent_leg_score = score(1.0 - knee_to_hip / max(hip_to_ankle * 0.55, 0.01))
|
| 115 |
+
scores.append(bent_leg_score)
|
| 116 |
+
|
| 117 |
+
return score(mean_optional(scores) or 0.0)
|
src/pozify/exercises/push_up/issue_markers.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import mean_optional
|
| 5 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 6 |
+
from pozify.steps.rep_signals import angle_deg, body_line_score
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _elbow_angle(frame: PoseFrame) -> float | None:
|
| 10 |
+
return mean_optional(
|
| 11 |
+
[
|
| 12 |
+
angle_deg(frame, "left_shoulder", "left_elbow", "left_wrist"),
|
| 13 |
+
angle_deg(frame, "right_shoulder", "right_elbow", "right_wrist"),
|
| 14 |
+
]
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
RULES: tuple[IssueRule, ...] = (
|
| 19 |
+
IssueRule(
|
| 20 |
+
issue="hip_sag",
|
| 21 |
+
metric_name="body_line_score",
|
| 22 |
+
threshold=0.65,
|
| 23 |
+
comparison="lt",
|
| 24 |
+
affected_joints=(
|
| 25 |
+
"left_hip",
|
| 26 |
+
"right_hip",
|
| 27 |
+
"left_shoulder",
|
| 28 |
+
"right_shoulder",
|
| 29 |
+
"left_ankle",
|
| 30 |
+
"right_ankle",
|
| 31 |
+
),
|
| 32 |
+
getter=body_line_score,
|
| 33 |
+
),
|
| 34 |
+
IssueRule(
|
| 35 |
+
issue="incomplete_depth",
|
| 36 |
+
metric_name="elbow_angle_deg",
|
| 37 |
+
threshold=115.0,
|
| 38 |
+
comparison="gt",
|
| 39 |
+
affected_joints=(
|
| 40 |
+
"left_shoulder",
|
| 41 |
+
"right_shoulder",
|
| 42 |
+
"left_elbow",
|
| 43 |
+
"right_elbow",
|
| 44 |
+
"left_wrist",
|
| 45 |
+
"right_wrist",
|
| 46 |
+
),
|
| 47 |
+
getter=_elbow_angle,
|
| 48 |
+
phase="bottom",
|
| 49 |
+
),
|
| 50 |
+
)
|
src/pozify/{steps/rep_counters/push_up.py → exercises/push_up/strategy.py}
RENAMED
|
@@ -2,15 +2,23 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
-
from pozify.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 8 |
|
| 9 |
|
| 10 |
-
class
|
| 11 |
exercise = "push_up"
|
| 12 |
|
| 13 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 15 |
shoulder_y = [average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in sequence.frames]
|
| 16 |
elbow_bend = [
|
|
@@ -38,3 +46,30 @@ class PushUpRepCounter(ExerciseRepCounter):
|
|
| 38 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
+
from pozify.exercises.push_up.analyzer import PushUpAnalyzer
|
| 8 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
+
from pozify.exercises.push_up.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
| 14 |
+
class PushUpExercise(PushUpAnalyzer, ExerciseStrategy):
|
| 15 |
exercise = "push_up"
|
| 16 |
|
| 17 |
+
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
+
return ISSUE_RULES
|
| 19 |
+
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 23 |
shoulder_y = [average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in sequence.frames]
|
| 24 |
elbow_bend = [
|
|
|
|
| 46 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 47 |
}
|
| 48 |
|
| 49 |
+
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
|
| 50 |
+
hand_width = self.metric(analysis, "avg_hand_width_ratio")
|
| 51 |
+
knee_support = self.metric(analysis, "avg_knee_support_score")
|
| 52 |
+
not_issues: list[str] = []
|
| 53 |
+
|
| 54 |
+
if knee_support is not None and knee_support >= 0.8:
|
| 55 |
+
return "knee_push_up", self.confidence(0.74, analysis, knee_support), ["knee_contact"]
|
| 56 |
+
if hand_width is not None and hand_width >= 1.45:
|
| 57 |
+
return "wide_grip_push_up", self.confidence(0.72, analysis, hand_width), ["wide_hand_placement"]
|
| 58 |
+
if hand_width is not None and hand_width <= 0.95:
|
| 59 |
+
return (
|
| 60 |
+
"close_grip_push_up",
|
| 61 |
+
self.confidence(0.72, analysis, 1.0 - hand_width),
|
| 62 |
+
["close_hand_placement"],
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if hand_width is None:
|
| 66 |
+
not_issues.append("hand_width_unverified")
|
| 67 |
+
return "standard_push_up", self.confidence(0.62, analysis, hand_width), not_issues
|
| 68 |
+
|
| 69 |
+
def profile_not_issues(self, variation: str) -> list[str]:
|
| 70 |
+
mapping = {
|
| 71 |
+
"wide_grip_push_up": ["wide_hand_placement"],
|
| 72 |
+
"close_grip_push_up": ["close_hand_placement"],
|
| 73 |
+
"knee_push_up": ["knee_contact"],
|
| 74 |
+
}
|
| 75 |
+
return list(mapping.get(variation, []))
|
src/pozify/exercises/registry.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseSequence, UserProfile, VideoManifest
|
| 4 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 5 |
+
from pozify.exercises.push_up import PushUpExercise
|
| 6 |
+
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
| 7 |
+
from pozify.exercises.squat import SquatExercise
|
| 8 |
+
from pozify.exercises.unknown import UnknownExercise
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
EXERCISE_CLASSES: dict[str, type[ExerciseStrategy]] = {
|
| 12 |
+
"push_up": PushUpExercise,
|
| 13 |
+
"shoulder_press": ShoulderPressExercise,
|
| 14 |
+
"squat": SquatExercise,
|
| 15 |
+
"unknown": UnknownExercise,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def create_exercise_strategy(
|
| 20 |
+
exercise: str,
|
| 21 |
+
*,
|
| 22 |
+
video_manifest: VideoManifest,
|
| 23 |
+
pose_sequence: PoseSequence,
|
| 24 |
+
profile: UserProfile,
|
| 25 |
+
) -> ExerciseStrategy:
|
| 26 |
+
exercise_class = EXERCISE_CLASSES.get(exercise, UnknownExercise)
|
| 27 |
+
return exercise_class(
|
| 28 |
+
video_manifest=video_manifest,
|
| 29 |
+
pose_sequence=pose_sequence,
|
| 30 |
+
profile=profile,
|
| 31 |
+
)
|
src/pozify/exercises/shared/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.shared.analyzer import ExerciseAnalyzer, ExerciseMetricResult
|
| 2 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 3 |
+
from pozify.exercises.shared.rep_counter import ExerciseRepCounter
|
| 4 |
+
|
| 5 |
+
__all__ = ["ExerciseAnalyzer", "ExerciseMetricResult", "ExerciseRepCounter", "IssueRule"]
|
src/pozify/exercises/shared/analyzer.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from statistics import pstdev
|
| 4 |
+
from typing import Any, Callable, Protocol
|
| 5 |
+
|
| 6 |
+
from pozify.contracts import PoseFrame
|
| 7 |
+
from pozify.steps.rep_signals import angle_deg, average_axis, landmark_axis
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
NumberGetter = Callable[[PoseFrame], float | None]
|
| 11 |
+
ExerciseMetricResult = tuple[dict[str, Any], float, float, float, list[str]]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ExerciseAnalyzer(Protocol):
|
| 15 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 16 |
+
...
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def round_optional(value: float | None, digits: int = 2) -> float | None:
|
| 20 |
+
if value is None:
|
| 21 |
+
return None
|
| 22 |
+
return round(value, digits)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def score(value: float) -> float:
|
| 26 |
+
return round(min(1.0, max(0.0, value)), 2)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def usable(values: list[float | None]) -> list[float]:
|
| 30 |
+
return [value for value in values if value is not None]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def mean_optional(values: list[float | None]) -> float | None:
|
| 34 |
+
usable_values = usable(values)
|
| 35 |
+
if not usable_values:
|
| 36 |
+
return None
|
| 37 |
+
return sum(usable_values) / len(usable_values)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def min_optional(values: list[float | None]) -> float | None:
|
| 41 |
+
usable_values = usable(values)
|
| 42 |
+
return min(usable_values) if usable_values else None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def max_optional(values: list[float | None]) -> float | None:
|
| 46 |
+
usable_values = usable(values)
|
| 47 |
+
return max(usable_values) if usable_values else None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def range_optional(values: list[float | None]) -> float | None:
|
| 51 |
+
usable_values = usable(values)
|
| 52 |
+
if not usable_values:
|
| 53 |
+
return None
|
| 54 |
+
return max(usable_values) - min(usable_values)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def std_optional(values: list[float | None]) -> float | None:
|
| 58 |
+
usable_values = usable(values)
|
| 59 |
+
if len(usable_values) < 2:
|
| 60 |
+
return 0.0 if usable_values else None
|
| 61 |
+
return pstdev(usable_values)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def safe_ratio(numerator: float | None, denominator: float | None) -> float | None:
|
| 65 |
+
if numerator is None or denominator is None or abs(denominator) <= 1e-6:
|
| 66 |
+
return None
|
| 67 |
+
return numerator / denominator
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def width(frame: PoseFrame, left: str, right: str) -> float | None:
|
| 71 |
+
left_x = landmark_axis(frame, left, "x")
|
| 72 |
+
right_x = landmark_axis(frame, right, "x")
|
| 73 |
+
if left_x is None or right_x is None:
|
| 74 |
+
return None
|
| 75 |
+
return abs(right_x - left_x)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def mean_pair(
|
| 79 |
+
frame: PoseFrame,
|
| 80 |
+
first: tuple[str, str, str],
|
| 81 |
+
second: tuple[str, str, str],
|
| 82 |
+
) -> float | None:
|
| 83 |
+
values = [angle_deg(frame, *first), angle_deg(frame, *second)]
|
| 84 |
+
return mean_optional(values)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def side_delta(
|
| 88 |
+
frame: PoseFrame,
|
| 89 |
+
first: tuple[str, str, str],
|
| 90 |
+
second: tuple[str, str, str],
|
| 91 |
+
) -> float | None:
|
| 92 |
+
first_value = angle_deg(frame, *first)
|
| 93 |
+
second_value = angle_deg(frame, *second)
|
| 94 |
+
if first_value is None or second_value is None:
|
| 95 |
+
return None
|
| 96 |
+
return abs(first_value - second_value)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def torso_lean_deg(frame: PoseFrame, side: str) -> float | None:
|
| 100 |
+
shoulder_x = landmark_axis(frame, f"{side}_shoulder", "x")
|
| 101 |
+
shoulder_y = landmark_axis(frame, f"{side}_shoulder", "y")
|
| 102 |
+
hip_x = landmark_axis(frame, f"{side}_hip", "x")
|
| 103 |
+
hip_y = landmark_axis(frame, f"{side}_hip", "y")
|
| 104 |
+
if None in {shoulder_x, shoulder_y, hip_x, hip_y}:
|
| 105 |
+
return None
|
| 106 |
+
horizontal_offset = abs(float(shoulder_x) - float(hip_x))
|
| 107 |
+
vertical_offset = abs(float(shoulder_y) - float(hip_y))
|
| 108 |
+
if vertical_offset <= 1e-6:
|
| 109 |
+
return None
|
| 110 |
+
from math import atan2, degrees
|
| 111 |
+
|
| 112 |
+
return degrees(atan2(horizontal_offset, vertical_offset))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def value_series(frames: list[PoseFrame], getter: NumberGetter) -> list[float | None]:
|
| 116 |
+
return [getter(frame) for frame in frames]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def mean_visibility(frames: list[PoseFrame]) -> float:
|
| 120 |
+
values: list[float | None] = []
|
| 121 |
+
for frame in frames:
|
| 122 |
+
if "mean_visibility" in frame.pose_quality:
|
| 123 |
+
values.append(float(frame.pose_quality["mean_visibility"]))
|
| 124 |
+
continue
|
| 125 |
+
landmark_values = [
|
| 126 |
+
landmark.get("visibility")
|
| 127 |
+
for landmark in frame.landmarks.values()
|
| 128 |
+
if landmark.get("visibility") is not None
|
| 129 |
+
]
|
| 130 |
+
values.extend(float(value) for value in landmark_values)
|
| 131 |
+
return score(mean_optional(values) if values else 0.0)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def average_y(frame: PoseFrame, names: tuple[str, ...]) -> float | None:
|
| 135 |
+
return average_axis(frame, names, "y")
|
src/pozify/exercises/shared/issue_marker.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from statistics import mean
|
| 5 |
+
from typing import Callable, Literal
|
| 6 |
+
|
| 7 |
+
from pozify.contracts import IssueMarker, PoseFrame, PoseSequence, Rep, RepAnalysisItem, Variation
|
| 8 |
+
from pozify.steps.rep_signals import average_axis, smooth_signal
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
Comparison = Literal["lt", "gt"]
|
| 12 |
+
Phase = Literal["all", "bottom", "top"]
|
| 13 |
+
MetricGetter = Callable[[PoseFrame], float | None]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class IssueRule:
|
| 18 |
+
issue: str
|
| 19 |
+
metric_name: str
|
| 20 |
+
threshold: float
|
| 21 |
+
comparison: Comparison
|
| 22 |
+
affected_joints: tuple[str, ...]
|
| 23 |
+
getter: MetricGetter
|
| 24 |
+
phase: Phase = "all"
|
| 25 |
+
suppress_when_not_issue: tuple[str, ...] = ()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class FrameScore:
|
| 30 |
+
frame: PoseFrame
|
| 31 |
+
value: float
|
| 32 |
+
severity: float
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def frames_for_rep(sequence: PoseSequence | None, rep: Rep) -> list[PoseFrame]:
|
| 36 |
+
if sequence is None:
|
| 37 |
+
return []
|
| 38 |
+
return [
|
| 39 |
+
frame
|
| 40 |
+
for frame in sequence.frames
|
| 41 |
+
if rep.start_frame <= frame.frame_index <= rep.end_frame
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def phase_mask(frames: list[PoseFrame], exercise: str, phase: Phase) -> list[bool]:
|
| 46 |
+
if phase == "all":
|
| 47 |
+
return [True for _ in frames]
|
| 48 |
+
|
| 49 |
+
if exercise == "shoulder_press":
|
| 50 |
+
values = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in frames]
|
| 51 |
+
top_is_low = True
|
| 52 |
+
elif exercise == "push_up":
|
| 53 |
+
values = [
|
| 54 |
+
average_axis(frame, ("left_shoulder", "right_shoulder"), "y") for frame in frames
|
| 55 |
+
]
|
| 56 |
+
top_is_low = False
|
| 57 |
+
else:
|
| 58 |
+
values = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in frames]
|
| 59 |
+
top_is_low = False
|
| 60 |
+
|
| 61 |
+
usable_values = [value for value in values if value is not None]
|
| 62 |
+
if not usable_values:
|
| 63 |
+
return [False for _ in frames]
|
| 64 |
+
|
| 65 |
+
minimum = min(usable_values)
|
| 66 |
+
maximum = max(usable_values)
|
| 67 |
+
value_range = maximum - minimum
|
| 68 |
+
if value_range <= 1e-6:
|
| 69 |
+
return [True for _ in frames]
|
| 70 |
+
|
| 71 |
+
if phase == "top":
|
| 72 |
+
cutoff = minimum + value_range * 0.35 if top_is_low else maximum - value_range * 0.35
|
| 73 |
+
return [
|
| 74 |
+
value is not None and (value <= cutoff if top_is_low else value >= cutoff)
|
| 75 |
+
for value in values
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
cutoff = maximum - value_range * 0.35 if not top_is_low else minimum + value_range * 0.35
|
| 79 |
+
return [
|
| 80 |
+
value is not None and (value >= cutoff if not top_is_low else value <= cutoff)
|
| 81 |
+
for value in values
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def violates(value: float, rule: IssueRule) -> bool:
|
| 86 |
+
if rule.comparison == "lt":
|
| 87 |
+
return value < rule.threshold
|
| 88 |
+
return value > rule.threshold
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def severity(value: float, rule: IssueRule) -> float:
|
| 92 |
+
denominator = max(abs(rule.threshold), 1e-6)
|
| 93 |
+
if rule.comparison == "lt":
|
| 94 |
+
return min(1.0, max(0.0, (rule.threshold - value) / denominator))
|
| 95 |
+
return min(1.0, max(0.0, (value - rule.threshold) / denominator))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def minimum_run_length(frames: list[PoseFrame]) -> int:
|
| 99 |
+
return max(2, min(5, round(len(frames) * 0.08)))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def group_violations(
|
| 103 |
+
scores: list[FrameScore | None],
|
| 104 |
+
minimum_length: int,
|
| 105 |
+
) -> list[list[FrameScore]]:
|
| 106 |
+
groups: list[list[FrameScore]] = []
|
| 107 |
+
current: list[FrameScore] = []
|
| 108 |
+
for score_item in scores:
|
| 109 |
+
if score_item is None:
|
| 110 |
+
if len(current) >= minimum_length:
|
| 111 |
+
groups.append(current)
|
| 112 |
+
current = []
|
| 113 |
+
continue
|
| 114 |
+
current.append(score_item)
|
| 115 |
+
|
| 116 |
+
if len(current) >= minimum_length:
|
| 117 |
+
groups.append(current)
|
| 118 |
+
return groups
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def confidence(group: list[FrameScore], rep_item: RepAnalysisItem, variation: Variation) -> float:
|
| 122 |
+
visibility_values = [
|
| 123 |
+
frame_score.frame.pose_quality.get("mean_visibility")
|
| 124 |
+
for frame_score in group
|
| 125 |
+
if frame_score.frame.pose_quality.get("mean_visibility") is not None
|
| 126 |
+
]
|
| 127 |
+
visibility = mean(float(value) for value in visibility_values) if visibility_values else 0.6
|
| 128 |
+
support = mean(frame_score.severity for frame_score in group)
|
| 129 |
+
value = (visibility * 0.5) + (support * 0.3) + (variation.variation_confidence * 0.2)
|
| 130 |
+
value *= max(0.5, min(1.0, rep_item.stability_score))
|
| 131 |
+
return round(min(1.0, max(0.0, value)), 2)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def marker_from_group(
|
| 135 |
+
rule: IssueRule,
|
| 136 |
+
group: list[FrameScore],
|
| 137 |
+
rep_item: RepAnalysisItem,
|
| 138 |
+
variation: Variation,
|
| 139 |
+
) -> IssueMarker:
|
| 140 |
+
metric_values = [frame_score.value for frame_score in group]
|
| 141 |
+
peak_score = max(group, key=lambda frame_score: frame_score.severity)
|
| 142 |
+
marker_severity = round(
|
| 143 |
+
min(1.0, max(0.0, mean(frame_score.severity for frame_score in group))),
|
| 144 |
+
2,
|
| 145 |
+
)
|
| 146 |
+
start = group[0].frame
|
| 147 |
+
end = group[-1].frame
|
| 148 |
+
evidence = {
|
| 149 |
+
rule.metric_name: round(peak_score.value, 4),
|
| 150 |
+
"threshold": rule.threshold,
|
| 151 |
+
"confidence": confidence(group, rep_item, variation),
|
| 152 |
+
"variation_context": {
|
| 153 |
+
"detected_variation": variation.detected_variation,
|
| 154 |
+
"variation_confidence": variation.variation_confidence,
|
| 155 |
+
"not_issues": list(variation.not_issues),
|
| 156 |
+
},
|
| 157 |
+
"mean_metric_value": round(mean(metric_values), 4),
|
| 158 |
+
"supporting_frames": len(group),
|
| 159 |
+
}
|
| 160 |
+
if rep_item.variation_hints:
|
| 161 |
+
evidence["rep_variation_hints"] = list(rep_item.variation_hints)
|
| 162 |
+
|
| 163 |
+
return IssueMarker(
|
| 164 |
+
rep_id=rep_item.rep_id,
|
| 165 |
+
issue=rule.issue,
|
| 166 |
+
severity=marker_severity,
|
| 167 |
+
start_frame=start.frame_index,
|
| 168 |
+
end_frame=end.frame_index,
|
| 169 |
+
start_sec=start.timestamp_sec,
|
| 170 |
+
end_sec=end.timestamp_sec,
|
| 171 |
+
affected_joints=list(rule.affected_joints),
|
| 172 |
+
evidence=evidence,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def frame_scores_for_rule(
|
| 177 |
+
frames: list[PoseFrame],
|
| 178 |
+
exercise: str,
|
| 179 |
+
rule: IssueRule,
|
| 180 |
+
) -> list[FrameScore | None]:
|
| 181 |
+
raw_values = [rule.getter(frame) for frame in frames]
|
| 182 |
+
values = smooth_signal(raw_values, window_radius=2)
|
| 183 |
+
active_phase = phase_mask(frames, exercise, rule.phase)
|
| 184 |
+
scores: list[FrameScore | None] = []
|
| 185 |
+
|
| 186 |
+
for frame, value, in_phase in zip(frames, values, active_phase, strict=False):
|
| 187 |
+
if value is None or not in_phase or not violates(value, rule):
|
| 188 |
+
scores.append(None)
|
| 189 |
+
continue
|
| 190 |
+
scores.append(FrameScore(frame=frame, value=value, severity=severity(value, rule)))
|
| 191 |
+
|
| 192 |
+
return scores
|
src/pozify/{steps/rep_counters/base.py → exercises/shared/rep_counter.py}
RENAMED
|
@@ -117,17 +117,19 @@ def partial_reps(
|
|
| 117 |
|
| 118 |
class ExerciseRepCounter(ABC):
|
| 119 |
exercise: str
|
|
|
|
| 120 |
min_cycle_frames = MIN_CYCLE_FRAMES
|
| 121 |
min_phase_frames = MIN_PHASE_FRAMES
|
| 122 |
min_signal_range = MIN_SIGNAL_RANGE
|
| 123 |
min_usable_signal_samples = MIN_USABLE_SIGNAL_SAMPLES
|
| 124 |
|
| 125 |
@abstractmethod
|
| 126 |
-
def build_signal(self
|
| 127 |
"""Build the exercise-specific normalized motion signal."""
|
| 128 |
|
| 129 |
-
def count(self
|
| 130 |
-
|
|
|
|
| 131 |
signal_range = debug["raw_signal_range"]
|
| 132 |
extrema = find_local_extrema(samples)
|
| 133 |
min_amplitude = max(self.min_signal_range, signal_range * 0.35)
|
|
@@ -177,4 +179,3 @@ class ExerciseRepCounter(ABC):
|
|
| 177 |
],
|
| 178 |
}
|
| 179 |
return reps, debug_payload
|
| 180 |
-
|
|
|
|
| 117 |
|
| 118 |
class ExerciseRepCounter(ABC):
|
| 119 |
exercise: str
|
| 120 |
+
pose_sequence: PoseSequence
|
| 121 |
min_cycle_frames = MIN_CYCLE_FRAMES
|
| 122 |
min_phase_frames = MIN_PHASE_FRAMES
|
| 123 |
min_signal_range = MIN_SIGNAL_RANGE
|
| 124 |
min_usable_signal_samples = MIN_USABLE_SIGNAL_SAMPLES
|
| 125 |
|
| 126 |
@abstractmethod
|
| 127 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 128 |
"""Build the exercise-specific normalized motion signal."""
|
| 129 |
|
| 130 |
+
def count(self) -> tuple[Reps, dict[str, Any]]:
|
| 131 |
+
sequence = self.pose_sequence
|
| 132 |
+
samples, debug = self.build_signal()
|
| 133 |
signal_range = debug["raw_signal_range"]
|
| 134 |
extrema = find_local_extrema(samples)
|
| 135 |
min_amplitude = max(self.min_signal_range, signal_range * 0.35)
|
|
|
|
| 179 |
],
|
| 180 |
}
|
| 181 |
return reps, debug_payload
|
|
|
src/pozify/exercises/shoulder_press/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.shoulder_press.analyzer import ShoulderPressAnalyzer
|
| 2 |
+
from pozify.exercises.shoulder_press.issue_markers import RULES as ISSUE_RULES
|
| 3 |
+
from pozify.exercises.shoulder_press.strategy import ShoulderPressExercise
|
| 4 |
+
|
| 5 |
+
__all__ = ["ISSUE_RULES", "ShoulderPressAnalyzer", "ShoulderPressExercise"]
|
src/pozify/exercises/shoulder_press/analyzer.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from statistics import mean
|
| 4 |
+
|
| 5 |
+
from pozify.contracts import PoseFrame
|
| 6 |
+
from pozify.exercises.shared.analyzer import (
|
| 7 |
+
ExerciseMetricResult,
|
| 8 |
+
max_optional,
|
| 9 |
+
mean_optional,
|
| 10 |
+
mean_pair,
|
| 11 |
+
min_optional,
|
| 12 |
+
range_optional,
|
| 13 |
+
round_optional,
|
| 14 |
+
score,
|
| 15 |
+
side_delta,
|
| 16 |
+
value_series,
|
| 17 |
+
)
|
| 18 |
+
from pozify.steps.rep_signals import average_axis, landmark_axis
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ShoulderPressAnalyzer:
|
| 22 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 23 |
+
elbow_angles = value_series(
|
| 24 |
+
frames,
|
| 25 |
+
lambda frame: mean_pair(
|
| 26 |
+
frame,
|
| 27 |
+
("left_shoulder", "left_elbow", "left_wrist"),
|
| 28 |
+
("right_shoulder", "right_elbow", "right_wrist"),
|
| 29 |
+
),
|
| 30 |
+
)
|
| 31 |
+
elbow_deltas = value_series(
|
| 32 |
+
frames,
|
| 33 |
+
lambda frame: side_delta(
|
| 34 |
+
frame,
|
| 35 |
+
("left_shoulder", "left_elbow", "left_wrist"),
|
| 36 |
+
("right_shoulder", "right_elbow", "right_wrist"),
|
| 37 |
+
),
|
| 38 |
+
)
|
| 39 |
+
wrist_y = value_series(
|
| 40 |
+
frames,
|
| 41 |
+
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "y"),
|
| 42 |
+
)
|
| 43 |
+
wrist_x = value_series(
|
| 44 |
+
frames,
|
| 45 |
+
lambda frame: average_axis(frame, ("left_wrist", "right_wrist"), "x"),
|
| 46 |
+
)
|
| 47 |
+
left_wrist_y = value_series(frames, lambda frame: landmark_axis(frame, "left_wrist", "y"))
|
| 48 |
+
right_wrist_y = value_series(frames, lambda frame: landmark_axis(frame, "right_wrist", "y"))
|
| 49 |
+
wrist_asymmetry = [
|
| 50 |
+
abs(left - right)
|
| 51 |
+
for left, right in zip(left_wrist_y, right_wrist_y, strict=False)
|
| 52 |
+
if left is not None and right is not None
|
| 53 |
+
]
|
| 54 |
+
shoulder_y = value_series(
|
| 55 |
+
frames,
|
| 56 |
+
lambda frame: average_axis(frame, ("left_shoulder", "right_shoulder"), "y"),
|
| 57 |
+
)
|
| 58 |
+
hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 59 |
+
|
| 60 |
+
min_elbow = min_optional(elbow_angles)
|
| 61 |
+
max_elbow = max_optional(elbow_angles)
|
| 62 |
+
wrist_travel = range_optional(wrist_y) or 0.0
|
| 63 |
+
wrist_lateral_drift = range_optional(wrist_x) or 0.0
|
| 64 |
+
lockout_quality = score(((max_elbow or 120.0) - 120.0) / 55.0)
|
| 65 |
+
verticality = score(1.0 - wrist_lateral_drift / max(0.01, wrist_travel))
|
| 66 |
+
asymmetry = mean(wrist_asymmetry) if wrist_asymmetry else 0.0
|
| 67 |
+
back_arch_proxy = abs((range_optional(hip_y) or 0.0) - (range_optional(shoulder_y) or 0.0))
|
| 68 |
+
|
| 69 |
+
rom_score = score((wrist_travel / 0.28) * 0.55 + lockout_quality * 0.45)
|
| 70 |
+
stability_score = score(verticality * 0.65 + (1.0 - min(1.0, back_arch_proxy * 4.0)) * 0.35)
|
| 71 |
+
symmetry_score = score(1.0 - max(asymmetry * 5.0, (mean_optional(elbow_deltas) or 0.0) / 45.0))
|
| 72 |
+
|
| 73 |
+
metrics = {
|
| 74 |
+
"min_elbow_angle_deg": round_optional(min_elbow),
|
| 75 |
+
"max_elbow_angle_deg": round_optional(max_elbow),
|
| 76 |
+
"wrist_path_verticality": verticality,
|
| 77 |
+
"lockout_quality": lockout_quality,
|
| 78 |
+
"wrist_height_asymmetry": round_optional(asymmetry, 4),
|
| 79 |
+
"left_right_wrist_delta": round_optional(asymmetry, 4),
|
| 80 |
+
"back_arch_proxy": round_optional(back_arch_proxy, 4),
|
| 81 |
+
"overhead_stability_score": stability_score,
|
| 82 |
+
"wrist_travel": round_optional(wrist_travel, 4),
|
| 83 |
+
}
|
| 84 |
+
hints = []
|
| 85 |
+
if lockout_quality < 0.65:
|
| 86 |
+
hints.append("partial_press")
|
| 87 |
+
if asymmetry > 0.12:
|
| 88 |
+
hints.append("asymmetric_press")
|
| 89 |
+
return metrics, rom_score, stability_score, symmetry_score, hints
|
src/pozify/exercises/shoulder_press/issue_markers.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import mean_optional
|
| 5 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 6 |
+
from pozify.steps.rep_signals import angle_deg, landmark_axis
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _elbow_angle(frame: PoseFrame) -> float | None:
|
| 10 |
+
return mean_optional(
|
| 11 |
+
[
|
| 12 |
+
angle_deg(frame, "left_shoulder", "left_elbow", "left_wrist"),
|
| 13 |
+
angle_deg(frame, "right_shoulder", "right_elbow", "right_wrist"),
|
| 14 |
+
]
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _wrist_height_asymmetry(frame: PoseFrame) -> float | None:
|
| 19 |
+
left = landmark_axis(frame, "left_wrist", "y")
|
| 20 |
+
right = landmark_axis(frame, "right_wrist", "y")
|
| 21 |
+
if left is None or right is None:
|
| 22 |
+
return None
|
| 23 |
+
return abs(left - right)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
RULES: tuple[IssueRule, ...] = (
|
| 27 |
+
IssueRule(
|
| 28 |
+
issue="incomplete_lockout",
|
| 29 |
+
metric_name="elbow_angle_deg",
|
| 30 |
+
threshold=155.0,
|
| 31 |
+
comparison="lt",
|
| 32 |
+
affected_joints=("left_elbow", "right_elbow", "left_wrist", "right_wrist"),
|
| 33 |
+
getter=_elbow_angle,
|
| 34 |
+
phase="top",
|
| 35 |
+
),
|
| 36 |
+
IssueRule(
|
| 37 |
+
issue="asymmetry",
|
| 38 |
+
metric_name="wrist_height_asymmetry",
|
| 39 |
+
threshold=0.1,
|
| 40 |
+
comparison="gt",
|
| 41 |
+
affected_joints=("left_wrist", "right_wrist", "left_elbow", "right_elbow"),
|
| 42 |
+
getter=_wrist_height_asymmetry,
|
| 43 |
+
),
|
| 44 |
+
)
|
src/pozify/{steps/rep_counters/shoulder_press.py → exercises/shoulder_press/strategy.py}
RENAMED
|
@@ -2,15 +2,23 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
-
from pozify.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 8 |
|
| 9 |
|
| 10 |
-
class
|
| 11 |
exercise = "shoulder_press"
|
| 12 |
|
| 13 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
wrist_y = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in sequence.frames]
|
| 15 |
elbow_bend = [
|
| 16 |
mean_optional(
|
|
@@ -36,3 +44,29 @@ class ShoulderPressRepCounter(ExerciseRepCounter):
|
|
| 36 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
+
from pozify.exercises.shoulder_press.analyzer import ShoulderPressAnalyzer
|
| 8 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
+
from pozify.exercises.shoulder_press.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
| 14 |
+
class ShoulderPressExercise(ShoulderPressAnalyzer, ExerciseStrategy):
|
| 15 |
exercise = "shoulder_press"
|
| 16 |
|
| 17 |
+
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
+
return ISSUE_RULES
|
| 19 |
+
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
wrist_y = [average_axis(frame, ("left_wrist", "right_wrist"), "y") for frame in sequence.frames]
|
| 23 |
elbow_bend = [
|
| 24 |
mean_optional(
|
|
|
|
| 44 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 45 |
}
|
| 46 |
|
| 47 |
+
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
|
| 48 |
+
lockout_quality = self.metric(analysis, "avg_lockout_quality")
|
| 49 |
+
wrist_travel = self.metric(analysis, "avg_wrist_travel")
|
| 50 |
+
wrist_asymmetry = self.metric(analysis, "avg_wrist_height_asymmetry")
|
| 51 |
+
|
| 52 |
+
if wrist_asymmetry is not None and wrist_asymmetry >= 0.12:
|
| 53 |
+
return (
|
| 54 |
+
"asymmetric_press",
|
| 55 |
+
self.confidence(0.76, analysis, wrist_asymmetry),
|
| 56 |
+
["intentional_asymmetry_check"],
|
| 57 |
+
)
|
| 58 |
+
if (lockout_quality is not None and lockout_quality <= 0.65) or (
|
| 59 |
+
wrist_travel is not None and wrist_travel < 0.24
|
| 60 |
+
):
|
| 61 |
+
support = 1.0 - lockout_quality if lockout_quality is not None else wrist_travel
|
| 62 |
+
return "partial_press", self.confidence(0.72, analysis, support), ["partial_range_of_motion"]
|
| 63 |
+
|
| 64 |
+
not_issues = ["lockout_unverified"] if lockout_quality is None else []
|
| 65 |
+
return "standing_shoulder_press", self.confidence(0.62, analysis, lockout_quality), not_issues
|
| 66 |
+
|
| 67 |
+
def profile_not_issues(self, variation: str) -> list[str]:
|
| 68 |
+
mapping = {
|
| 69 |
+
"partial_press": ["partial_range_of_motion"],
|
| 70 |
+
"asymmetric_press": ["intentional_asymmetry_check"],
|
| 71 |
+
}
|
| 72 |
+
return list(mapping.get(variation, []))
|
src/pozify/exercises/squat/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.squat.analyzer import SquatAnalyzer
|
| 2 |
+
from pozify.exercises.squat.issue_markers import RULES as ISSUE_RULES
|
| 3 |
+
from pozify.exercises.squat.strategy import SquatExercise
|
| 4 |
+
|
| 5 |
+
__all__ = ["ISSUE_RULES", "SquatAnalyzer", "SquatExercise"]
|
src/pozify/exercises/squat/analyzer.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import (
|
| 5 |
+
ExerciseMetricResult,
|
| 6 |
+
max_optional,
|
| 7 |
+
mean_optional,
|
| 8 |
+
mean_pair,
|
| 9 |
+
min_optional,
|
| 10 |
+
round_optional,
|
| 11 |
+
safe_ratio,
|
| 12 |
+
score,
|
| 13 |
+
side_delta,
|
| 14 |
+
std_optional,
|
| 15 |
+
torso_lean_deg,
|
| 16 |
+
value_series,
|
| 17 |
+
width,
|
| 18 |
+
)
|
| 19 |
+
from pozify.steps.rep_signals import average_axis
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SquatAnalyzer:
|
| 23 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 24 |
+
knee_angles = value_series(
|
| 25 |
+
frames,
|
| 26 |
+
lambda frame: mean_pair(
|
| 27 |
+
frame,
|
| 28 |
+
("left_hip", "left_knee", "left_ankle"),
|
| 29 |
+
("right_hip", "right_knee", "right_ankle"),
|
| 30 |
+
),
|
| 31 |
+
)
|
| 32 |
+
hip_angles = value_series(
|
| 33 |
+
frames,
|
| 34 |
+
lambda frame: mean_pair(
|
| 35 |
+
frame,
|
| 36 |
+
("left_shoulder", "left_hip", "left_knee"),
|
| 37 |
+
("right_shoulder", "right_hip", "right_knee"),
|
| 38 |
+
),
|
| 39 |
+
)
|
| 40 |
+
knee_deltas = value_series(
|
| 41 |
+
frames,
|
| 42 |
+
lambda frame: side_delta(
|
| 43 |
+
frame,
|
| 44 |
+
("left_hip", "left_knee", "left_ankle"),
|
| 45 |
+
("right_hip", "right_knee", "right_ankle"),
|
| 46 |
+
),
|
| 47 |
+
)
|
| 48 |
+
hip_deltas = value_series(
|
| 49 |
+
frames,
|
| 50 |
+
lambda frame: side_delta(
|
| 51 |
+
frame,
|
| 52 |
+
("left_shoulder", "left_hip", "left_knee"),
|
| 53 |
+
("right_shoulder", "right_hip", "right_knee"),
|
| 54 |
+
),
|
| 55 |
+
)
|
| 56 |
+
hip_y = value_series(frames, lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"))
|
| 57 |
+
knee_y = value_series(
|
| 58 |
+
frames,
|
| 59 |
+
lambda frame: average_axis(frame, ("left_knee", "right_knee"), "y"),
|
| 60 |
+
)
|
| 61 |
+
ankle_width = value_series(frames, lambda frame: width(frame, "left_ankle", "right_ankle"))
|
| 62 |
+
shoulder_width = value_series(
|
| 63 |
+
frames,
|
| 64 |
+
lambda frame: width(frame, "left_shoulder", "right_shoulder"),
|
| 65 |
+
)
|
| 66 |
+
knee_width = value_series(frames, lambda frame: width(frame, "left_knee", "right_knee"))
|
| 67 |
+
torso_lean = value_series(
|
| 68 |
+
frames,
|
| 69 |
+
lambda frame: mean_optional(
|
| 70 |
+
[torso_lean_deg(frame, "left"), torso_lean_deg(frame, "right")]
|
| 71 |
+
),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
min_knee = min_optional(knee_angles)
|
| 75 |
+
max_knee = max_optional(knee_angles)
|
| 76 |
+
min_hip = min_optional(hip_angles)
|
| 77 |
+
max_hip = max_optional(hip_angles)
|
| 78 |
+
hip_depth_delta = None
|
| 79 |
+
max_hip_y = max_optional(hip_y)
|
| 80 |
+
mean_knee_y = mean_optional(knee_y)
|
| 81 |
+
if max_hip_y is not None and mean_knee_y is not None:
|
| 82 |
+
hip_depth_delta = max_hip_y - mean_knee_y
|
| 83 |
+
|
| 84 |
+
stance_ratio = safe_ratio(mean_optional(ankle_width), mean_optional(shoulder_width))
|
| 85 |
+
knee_tracking_ratio = safe_ratio(mean_optional(knee_width), mean_optional(ankle_width))
|
| 86 |
+
valgus_proxy = None if knee_tracking_ratio is None else max(0.0, 1.0 - knee_tracking_ratio)
|
| 87 |
+
symmetry_delta = mean_optional(knee_deltas + hip_deltas) or 0.0
|
| 88 |
+
stability_noise = (std_optional(hip_y) or 0.0) + (std_optional(knee_width) or 0.0)
|
| 89 |
+
|
| 90 |
+
knee_rom = 0.0 if min_knee is None or max_knee is None else max_knee - min_knee
|
| 91 |
+
depth_score = score((hip_depth_delta + 0.08) / 0.18) if hip_depth_delta is not None else 0.5
|
| 92 |
+
angle_score = score(knee_rom / 65.0)
|
| 93 |
+
rom_score = score(angle_score * 0.55 + depth_score * 0.45)
|
| 94 |
+
stability_score = score(1.0 - stability_noise * 5.0)
|
| 95 |
+
symmetry_score = score(1.0 - symmetry_delta / 45.0)
|
| 96 |
+
|
| 97 |
+
hip_x = value_series(
|
| 98 |
+
frames,
|
| 99 |
+
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "x"),
|
| 100 |
+
)
|
| 101 |
+
metrics = {
|
| 102 |
+
"min_knee_angle_deg": round_optional(min_knee),
|
| 103 |
+
"max_knee_angle_deg": round_optional(max_knee),
|
| 104 |
+
"min_hip_angle_deg": round_optional(min_hip),
|
| 105 |
+
"max_hip_angle_deg": round_optional(max_hip),
|
| 106 |
+
"hip_depth_delta": round_optional(hip_depth_delta, 4),
|
| 107 |
+
"hip_depth_relative_to_knee": (
|
| 108 |
+
"below_parallel"
|
| 109 |
+
if hip_depth_delta is not None and hip_depth_delta >= 0.03
|
| 110 |
+
else "parallel"
|
| 111 |
+
if hip_depth_delta is not None and hip_depth_delta >= -0.03
|
| 112 |
+
else "above_parallel"
|
| 113 |
+
),
|
| 114 |
+
"max_torso_lean_deg": round_optional(max_optional(torso_lean)),
|
| 115 |
+
"knee_valgus_proxy": round_optional(valgus_proxy, 4),
|
| 116 |
+
"knee_tracking_score": score(1.0 - (valgus_proxy or 0.0)),
|
| 117 |
+
"stance_width_ratio": round_optional(stance_ratio, 3),
|
| 118 |
+
"hip_shift": round_optional(std_optional(hip_x), 4),
|
| 119 |
+
"bottom_stability_score": stability_score,
|
| 120 |
+
}
|
| 121 |
+
hints = []
|
| 122 |
+
if stance_ratio is not None and stance_ratio > 1.35:
|
| 123 |
+
hints.append("wide_squat_stance")
|
| 124 |
+
elif stance_ratio is not None and stance_ratio < 0.85:
|
| 125 |
+
hints.append("narrow_squat_stance")
|
| 126 |
+
return metrics, rom_score, stability_score, symmetry_score, hints
|
src/pozify/exercises/squat/issue_markers.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import mean_optional, safe_ratio, torso_lean_deg, width
|
| 5 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 6 |
+
from pozify.steps.rep_signals import average_axis
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _depth_delta(frame: PoseFrame) -> float | None:
|
| 10 |
+
hip_y = average_axis(frame, ("left_hip", "right_hip"), "y")
|
| 11 |
+
knee_y = average_axis(frame, ("left_knee", "right_knee"), "y")
|
| 12 |
+
if hip_y is None or knee_y is None:
|
| 13 |
+
return None
|
| 14 |
+
return hip_y - knee_y
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _knee_valgus_proxy(frame: PoseFrame) -> float | None:
|
| 18 |
+
knee_width = width(frame, "left_knee", "right_knee")
|
| 19 |
+
ankle_width = width(frame, "left_ankle", "right_ankle")
|
| 20 |
+
tracking_ratio = safe_ratio(knee_width, ankle_width)
|
| 21 |
+
if tracking_ratio is None:
|
| 22 |
+
return None
|
| 23 |
+
return max(0.0, 1.0 - tracking_ratio)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _torso_lean(frame: PoseFrame) -> float | None:
|
| 27 |
+
return mean_optional([torso_lean_deg(frame, "left"), torso_lean_deg(frame, "right")])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
RULES: tuple[IssueRule, ...] = (
|
| 31 |
+
IssueRule(
|
| 32 |
+
issue="shallow_depth",
|
| 33 |
+
metric_name="hip_depth_delta",
|
| 34 |
+
threshold=-0.03,
|
| 35 |
+
comparison="lt",
|
| 36 |
+
affected_joints=("left_hip", "right_hip", "left_knee", "right_knee"),
|
| 37 |
+
getter=_depth_delta,
|
| 38 |
+
phase="bottom",
|
| 39 |
+
),
|
| 40 |
+
IssueRule(
|
| 41 |
+
issue="knee_valgus",
|
| 42 |
+
metric_name="knee_valgus_proxy",
|
| 43 |
+
threshold=0.25,
|
| 44 |
+
comparison="gt",
|
| 45 |
+
affected_joints=("left_knee", "right_knee", "left_ankle", "right_ankle"),
|
| 46 |
+
getter=_knee_valgus_proxy,
|
| 47 |
+
phase="bottom",
|
| 48 |
+
),
|
| 49 |
+
IssueRule(
|
| 50 |
+
issue="excessive_torso_lean",
|
| 51 |
+
metric_name="torso_lean_deg",
|
| 52 |
+
threshold=35.0,
|
| 53 |
+
comparison="gt",
|
| 54 |
+
affected_joints=("left_shoulder", "right_shoulder", "left_hip", "right_hip"),
|
| 55 |
+
getter=_torso_lean,
|
| 56 |
+
phase="bottom",
|
| 57 |
+
),
|
| 58 |
+
)
|
src/pozify/{steps/rep_counters/squat.py → exercises/squat/strategy.py}
RENAMED
|
@@ -2,15 +2,23 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
-
from pozify.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 8 |
|
| 9 |
|
| 10 |
-
class
|
| 11 |
exercise = "squat"
|
| 12 |
|
| 13 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 15 |
knee_bend = [
|
| 16 |
mean_optional(
|
|
@@ -33,3 +41,28 @@ class SquatRepCounter(ExerciseRepCounter):
|
|
| 33 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis
|
| 6 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 7 |
+
from pozify.exercises.squat.analyzer import SquatAnalyzer
|
| 8 |
+
from pozify.exercises.shared.issue_marker import IssueRule
|
| 9 |
+
from pozify.exercises.squat.issue_markers import RULES as ISSUE_RULES
|
| 10 |
+
from pozify.exercises.shared.rep_counter import combine, mean_optional, normalized_samples
|
| 11 |
from pozify.steps.rep_signals import SignalSample, angle_deg, average_axis, body_line_score
|
| 12 |
|
| 13 |
|
| 14 |
+
class SquatExercise(SquatAnalyzer, ExerciseStrategy):
|
| 15 |
exercise = "squat"
|
| 16 |
|
| 17 |
+
def issue_rules(self) -> tuple[IssueRule, ...]:
|
| 18 |
+
return ISSUE_RULES
|
| 19 |
+
|
| 20 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 21 |
+
sequence = self.pose_sequence
|
| 22 |
hip_y = [average_axis(frame, ("left_hip", "right_hip"), "y") for frame in sequence.frames]
|
| 23 |
knee_bend = [
|
| 24 |
mean_optional(
|
|
|
|
| 41 |
"body_line_mean": round(mean_optional(body_line) or 0.0, 4),
|
| 42 |
}
|
| 43 |
|
| 44 |
+
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
|
| 45 |
+
stance_width = self.metric(analysis, "avg_stance_width_ratio")
|
| 46 |
+
bottom_pause = self.metric(analysis, "avg_bottom_pause_sec")
|
| 47 |
+
|
| 48 |
+
if bottom_pause is not None and bottom_pause >= 0.4:
|
| 49 |
+
return "pause_squat", self.confidence(0.76, analysis, bottom_pause), ["bottom_pause"]
|
| 50 |
+
if stance_width is not None and stance_width >= 1.35:
|
| 51 |
+
return "wide_squat_stance", self.confidence(0.72, analysis, stance_width), ["wide_stance"]
|
| 52 |
+
if stance_width is not None and stance_width <= 0.85:
|
| 53 |
+
return (
|
| 54 |
+
"narrow_squat_stance",
|
| 55 |
+
self.confidence(0.72, analysis, 1.0 - stance_width),
|
| 56 |
+
["narrow_stance"],
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
not_issues = ["stance_width_unverified"] if stance_width is None else []
|
| 60 |
+
return "normal_squat_stance", self.confidence(0.62, analysis, stance_width), not_issues
|
| 61 |
+
|
| 62 |
+
def profile_not_issues(self, variation: str) -> list[str]:
|
| 63 |
+
mapping = {
|
| 64 |
+
"wide_squat_stance": ["wide_stance"],
|
| 65 |
+
"narrow_squat_stance": ["narrow_stance"],
|
| 66 |
+
"pause_squat": ["bottom_pause"],
|
| 67 |
+
}
|
| 68 |
+
return list(mapping.get(variation, []))
|
src/pozify/exercises/unknown/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pozify.exercises.unknown.analyzer import UnknownAnalyzer
|
| 2 |
+
from pozify.exercises.unknown.strategy import UnknownExercise
|
| 3 |
+
|
| 4 |
+
__all__ = ["UnknownAnalyzer", "UnknownExercise"]
|
src/pozify/exercises/unknown/analyzer.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pozify.contracts import PoseFrame
|
| 4 |
+
from pozify.exercises.shared.analyzer import (
|
| 5 |
+
ExerciseMetricResult,
|
| 6 |
+
mean_visibility,
|
| 7 |
+
range_optional,
|
| 8 |
+
score,
|
| 9 |
+
value_series,
|
| 10 |
+
)
|
| 11 |
+
from pozify.steps.rep_signals import average_axis
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class UnknownAnalyzer:
|
| 15 |
+
def metrics(self, frames: list[PoseFrame]) -> ExerciseMetricResult:
|
| 16 |
+
movement_signal = value_series(
|
| 17 |
+
frames,
|
| 18 |
+
lambda frame: average_axis(frame, ("left_hip", "right_hip"), "y"),
|
| 19 |
+
)
|
| 20 |
+
movement = range_optional(movement_signal) or 0.0
|
| 21 |
+
confidence = mean_visibility(frames)
|
| 22 |
+
metrics = {
|
| 23 |
+
"movement_consistency_score": score(1.0 - movement * 4.0),
|
| 24 |
+
"landmark_confidence": confidence,
|
| 25 |
+
}
|
| 26 |
+
return metrics, 0.0, metrics["movement_consistency_score"], confidence, []
|
src/pozify/{steps/rep_counters/registry.py → exercises/unknown/strategy.py}
RENAMED
|
@@ -2,33 +2,27 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from pozify.contracts import
|
| 6 |
-
from pozify.
|
| 7 |
-
from pozify.
|
| 8 |
-
from pozify.
|
| 9 |
-
from pozify.steps.rep_counters.squat import SquatRepCounter
|
| 10 |
from pozify.steps.rep_signals import SignalSample
|
| 11 |
|
| 12 |
|
| 13 |
-
class
|
| 14 |
exercise = "unknown"
|
| 15 |
|
| 16 |
-
def build_signal(self
|
| 17 |
return [], {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 18 |
|
| 19 |
-
def count(self
|
| 20 |
reps = Reps(exercise=self.exercise, reps=[], partial_reps=[{"reason": "unknown_exercise"}])
|
| 21 |
return reps, {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def get_rep_counter(exercise: str) -> ExerciseRepCounter:
|
| 33 |
-
return REP_COUNTERS.get(exercise, REP_COUNTERS["unknown"])
|
| 34 |
-
|
|
|
|
| 2 |
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from pozify.contracts import RepAnalysis, Reps
|
| 6 |
+
from pozify.exercise_catalog import get_exercise_spec
|
| 7 |
+
from pozify.exercises.base import ExerciseStrategy
|
| 8 |
+
from pozify.exercises.unknown.analyzer import UnknownAnalyzer
|
|
|
|
| 9 |
from pozify.steps.rep_signals import SignalSample
|
| 10 |
|
| 11 |
|
| 12 |
+
class UnknownExercise(UnknownAnalyzer, ExerciseStrategy):
|
| 13 |
exercise = "unknown"
|
| 14 |
|
| 15 |
+
def build_signal(self) -> tuple[list[SignalSample], dict[str, Any]]:
|
| 16 |
return [], {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 17 |
|
| 18 |
+
def count(self) -> tuple[Reps, dict[str, Any]]:
|
| 19 |
reps = Reps(exercise=self.exercise, reps=[], partial_reps=[{"reason": "unknown_exercise"}])
|
| 20 |
return reps, {"selected_signal": "none", "thresholds": {}, "extrema": [], "accepted_reps": []}
|
| 21 |
|
| 22 |
+
def detect_variation(self, analysis: RepAnalysis) -> tuple[str, float, list[str]]:
|
| 23 |
+
exercise_spec = get_exercise_spec(analysis.exercise)
|
| 24 |
+
return (
|
| 25 |
+
exercise_spec.default_variation,
|
| 26 |
+
exercise_spec.default_variation_confidence,
|
| 27 |
+
list(exercise_spec.default_variation_not_issues),
|
| 28 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/pipeline.py
CHANGED
|
@@ -8,16 +8,13 @@ from uuid import uuid4
|
|
| 8 |
|
| 9 |
from pozify.artifacts import write_json
|
| 10 |
from pozify.contracts import UserProfile, to_dict
|
|
|
|
| 11 |
from pozify.steps import (
|
| 12 |
annotated_renderer,
|
| 13 |
coach_summary,
|
| 14 |
exercise_classifier,
|
| 15 |
-
issue_marker,
|
| 16 |
pose_cleaning,
|
| 17 |
pose_landmarker,
|
| 18 |
-
rep_analysis,
|
| 19 |
-
rep_counter,
|
| 20 |
-
variation_detector,
|
| 21 |
verifier,
|
| 22 |
video_qc,
|
| 23 |
)
|
|
@@ -26,8 +23,12 @@ from pozify.steps import (
|
|
| 26 |
RUNS_DIR = Path("runs")
|
| 27 |
|
| 28 |
|
| 29 |
-
def _env_mock_mode() -> bool:
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return value not in {"0", "false", "no", "off"}
|
| 32 |
|
| 33 |
|
|
@@ -37,7 +38,7 @@ def run_pipeline(
|
|
| 37 |
*,
|
| 38 |
mock: bool | None = None,
|
| 39 |
) -> dict[str, Any]:
|
| 40 |
-
mock_mode = _env_mock_mode() if mock is None else mock
|
| 41 |
|
| 42 |
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
| 43 |
run_dir = RUNS_DIR / run_id
|
|
@@ -73,17 +74,24 @@ def run_pipeline(
|
|
| 73 |
classification = exercise_classifier.run(cleaned_pose_sequence, profile, mock=mock_mode)
|
| 74 |
write_artifact("exercise_classification.json", classification)
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
write_artifact("reps.json", reps)
|
| 78 |
write_artifact("rep_debug.json", rep_debug)
|
| 79 |
|
| 80 |
-
analysis =
|
| 81 |
write_artifact("rep_analysis.json", analysis)
|
| 82 |
|
| 83 |
-
variation =
|
| 84 |
write_artifact("variation.json", variation)
|
| 85 |
|
| 86 |
-
issues =
|
| 87 |
write_artifact("issue_markers.json", issues)
|
| 88 |
|
| 89 |
annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
|
|
@@ -95,9 +103,6 @@ def run_pipeline(
|
|
| 95 |
)
|
| 96 |
analysis_mode = "mock" if mock_mode else "real"
|
| 97 |
mock_steps = [
|
| 98 |
-
"rep_analysis",
|
| 99 |
-
"variation_detector",
|
| 100 |
-
"issue_marker",
|
| 101 |
"coach_summary",
|
| 102 |
"verifier",
|
| 103 |
]
|
|
|
|
| 8 |
|
| 9 |
from pozify.artifacts import write_json
|
| 10 |
from pozify.contracts import UserProfile, to_dict
|
| 11 |
+
from pozify.exercises import create_exercise_strategy
|
| 12 |
from pozify.steps import (
|
| 13 |
annotated_renderer,
|
| 14 |
coach_summary,
|
| 15 |
exercise_classifier,
|
|
|
|
| 16 |
pose_cleaning,
|
| 17 |
pose_landmarker,
|
|
|
|
|
|
|
|
|
|
| 18 |
verifier,
|
| 19 |
video_qc,
|
| 20 |
)
|
|
|
|
| 23 |
RUNS_DIR = Path("runs")
|
| 24 |
|
| 25 |
|
| 26 |
+
def _env_mock_mode(video_path: str | None) -> bool:
|
| 27 |
+
configured = os.getenv("POZIFY_MOCK_MODE")
|
| 28 |
+
if configured is None:
|
| 29 |
+
return video_path is None
|
| 30 |
+
|
| 31 |
+
value = configured.strip().lower()
|
| 32 |
return value not in {"0", "false", "no", "off"}
|
| 33 |
|
| 34 |
|
|
|
|
| 38 |
*,
|
| 39 |
mock: bool | None = None,
|
| 40 |
) -> dict[str, Any]:
|
| 41 |
+
mock_mode = _env_mock_mode(video_path) if mock is None else mock
|
| 42 |
|
| 43 |
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
| 44 |
run_dir = RUNS_DIR / run_id
|
|
|
|
| 74 |
classification = exercise_classifier.run(cleaned_pose_sequence, profile, mock=mock_mode)
|
| 75 |
write_artifact("exercise_classification.json", classification)
|
| 76 |
|
| 77 |
+
exercise = create_exercise_strategy(
|
| 78 |
+
classification.exercise,
|
| 79 |
+
video_manifest=manifest,
|
| 80 |
+
pose_sequence=cleaned_pose_sequence,
|
| 81 |
+
profile=profile,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
reps, rep_debug = exercise.count()
|
| 85 |
write_artifact("reps.json", reps)
|
| 86 |
write_artifact("rep_debug.json", rep_debug)
|
| 87 |
|
| 88 |
+
analysis = exercise.analyze_reps(reps)
|
| 89 |
write_artifact("rep_analysis.json", analysis)
|
| 90 |
|
| 91 |
+
variation = exercise.resolve_variation(analysis)
|
| 92 |
write_artifact("variation.json", variation)
|
| 93 |
|
| 94 |
+
issues = exercise.mark_issues(reps, analysis, variation)
|
| 95 |
write_artifact("issue_markers.json", issues)
|
| 96 |
|
| 97 |
annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
|
|
|
|
| 103 |
)
|
| 104 |
analysis_mode = "mock" if mock_mode else "real"
|
| 105 |
mock_steps = [
|
|
|
|
|
|
|
|
|
|
| 106 |
"coach_summary",
|
| 107 |
"verifier",
|
| 108 |
]
|
src/pozify/steps/annotated_renderer.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from pathlib import Path
|
|
|
|
|
|
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
import cv2
|
|
@@ -24,6 +27,186 @@ SKELETON_EDGES = [
|
|
| 24 |
]
|
| 25 |
|
| 26 |
PREFERRED_VIDEO_CODECS = ("avc1", "H264", "mp4v")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def _frame_landmark_points(
|
|
@@ -103,7 +286,12 @@ def _draw_overlays(
|
|
| 103 |
)
|
| 104 |
|
| 105 |
|
| 106 |
-
def _open_video_writer(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
for codec in PREFERRED_VIDEO_CODECS:
|
| 108 |
writer = cv2.VideoWriter(
|
| 109 |
str(output_path),
|
|
@@ -127,9 +315,21 @@ def run(
|
|
| 127 |
if not manifest.analysis_allowed or not manifest.video_path:
|
| 128 |
return manifest.video_path
|
| 129 |
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
if not capture.isOpened():
|
| 132 |
capture.release()
|
|
|
|
|
|
|
| 133 |
return manifest.video_path
|
| 134 |
|
| 135 |
fps = manifest.fps if manifest.fps > 0 else 30.0
|
|
@@ -140,9 +340,12 @@ def run(
|
|
| 140 |
return manifest.video_path
|
| 141 |
|
| 142 |
output_path = run_dir / "annotated_video.mp4"
|
| 143 |
-
|
|
|
|
| 144 |
if writer is None:
|
| 145 |
capture.release()
|
|
|
|
|
|
|
| 146 |
return manifest.video_path
|
| 147 |
|
| 148 |
pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
|
|
@@ -180,5 +383,13 @@ def run(
|
|
| 180 |
finally:
|
| 181 |
writer.release()
|
| 182 |
capture.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
return str(output_path)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
from pathlib import Path
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import cv2
|
|
|
|
| 27 |
]
|
| 28 |
|
| 29 |
PREFERRED_VIDEO_CODECS = ("avc1", "H264", "mp4v")
|
| 30 |
+
HDR_TRANSFERS = {"arib-std-b67", "smpte2084"}
|
| 31 |
+
HDR_PRIMARIES = {"bt2020"}
|
| 32 |
+
BT709_COLOR_ARGS = (
|
| 33 |
+
"-color_primaries",
|
| 34 |
+
"bt709",
|
| 35 |
+
"-color_trc",
|
| 36 |
+
"bt709",
|
| 37 |
+
"-colorspace",
|
| 38 |
+
"bt709",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _tool_path(name: str) -> str | None:
|
| 43 |
+
return shutil.which(name)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _video_color_metadata(video_path: str) -> dict[str, str]:
|
| 47 |
+
ffprobe = _tool_path("ffprobe")
|
| 48 |
+
if ffprobe is None:
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
command = [
|
| 52 |
+
ffprobe,
|
| 53 |
+
"-v",
|
| 54 |
+
"error",
|
| 55 |
+
"-select_streams",
|
| 56 |
+
"v:0",
|
| 57 |
+
"-show_entries",
|
| 58 |
+
"stream=color_space,color_transfer,color_primaries,color_range",
|
| 59 |
+
"-of",
|
| 60 |
+
"json",
|
| 61 |
+
video_path,
|
| 62 |
+
]
|
| 63 |
+
try:
|
| 64 |
+
result = subprocess.run(
|
| 65 |
+
command,
|
| 66 |
+
check=True,
|
| 67 |
+
capture_output=True,
|
| 68 |
+
text=True,
|
| 69 |
+
timeout=10,
|
| 70 |
+
)
|
| 71 |
+
payload = json.loads(result.stdout)
|
| 72 |
+
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
|
| 73 |
+
return {}
|
| 74 |
+
|
| 75 |
+
streams = payload.get("streams")
|
| 76 |
+
if not isinstance(streams, list) or not streams:
|
| 77 |
+
return {}
|
| 78 |
+
stream = streams[0]
|
| 79 |
+
if not isinstance(stream, dict):
|
| 80 |
+
return {}
|
| 81 |
+
metadata: dict[str, str] = {}
|
| 82 |
+
for key, value in stream.items():
|
| 83 |
+
if value is None or isinstance(value, (dict, list)):
|
| 84 |
+
continue
|
| 85 |
+
normalized_value = str(value).lower()
|
| 86 |
+
if normalized_value == "unknown":
|
| 87 |
+
continue
|
| 88 |
+
metadata[key] = normalized_value
|
| 89 |
+
return metadata
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _needs_sdr_conversion(color_metadata: dict[str, str]) -> bool:
|
| 93 |
+
transfer = color_metadata.get("color_transfer", "")
|
| 94 |
+
primaries = color_metadata.get("color_primaries", "")
|
| 95 |
+
return transfer in HDR_TRANSFERS or primaries in HDR_PRIMARIES
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _sdr_filter(color_metadata: dict[str, str]) -> str:
|
| 99 |
+
transfer = color_metadata.get("color_transfer", "arib-std-b67")
|
| 100 |
+
primaries = color_metadata.get("color_primaries", "bt2020")
|
| 101 |
+
matrix = color_metadata.get("color_space", "bt2020nc")
|
| 102 |
+
if transfer not in HDR_TRANSFERS:
|
| 103 |
+
transfer = "arib-std-b67"
|
| 104 |
+
if primaries not in HDR_PRIMARIES:
|
| 105 |
+
primaries = "bt2020"
|
| 106 |
+
if matrix not in {"bt2020nc", "bt2020c"}:
|
| 107 |
+
matrix = "bt2020nc"
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
f"zscale=transfer=linear:transferin={transfer}:"
|
| 111 |
+
f"primariesin={primaries}:matrixin={matrix}:npl=100,"
|
| 112 |
+
"tonemap=tonemap=hable:desat=0,"
|
| 113 |
+
"zscale=transfer=bt709:primaries=bt709:matrix=bt709:range=tv,"
|
| 114 |
+
"format=yuv420p"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _transcode_hdr_to_sdr(
|
| 119 |
+
input_path: Path,
|
| 120 |
+
output_path: Path,
|
| 121 |
+
color_metadata: dict[str, str],
|
| 122 |
+
) -> bool:
|
| 123 |
+
ffmpeg = _tool_path("ffmpeg")
|
| 124 |
+
if ffmpeg is None:
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
command = [
|
| 128 |
+
ffmpeg,
|
| 129 |
+
"-y",
|
| 130 |
+
"-v",
|
| 131 |
+
"error",
|
| 132 |
+
"-i",
|
| 133 |
+
str(input_path),
|
| 134 |
+
"-vf",
|
| 135 |
+
_sdr_filter(color_metadata),
|
| 136 |
+
"-an",
|
| 137 |
+
"-c:v",
|
| 138 |
+
"libx264",
|
| 139 |
+
"-preset",
|
| 140 |
+
"veryfast",
|
| 141 |
+
"-crf",
|
| 142 |
+
"18",
|
| 143 |
+
"-pix_fmt",
|
| 144 |
+
"yuv420p",
|
| 145 |
+
*BT709_COLOR_ARGS,
|
| 146 |
+
str(output_path),
|
| 147 |
+
]
|
| 148 |
+
try:
|
| 149 |
+
subprocess.run(command, check=True, capture_output=True, timeout=120)
|
| 150 |
+
except (subprocess.SubprocessError, OSError):
|
| 151 |
+
return False
|
| 152 |
+
return output_path.exists() and output_path.stat().st_size > 0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _encode_bt709_output(
|
| 156 |
+
raw_video_path: Path,
|
| 157 |
+
output_path: Path,
|
| 158 |
+
audio_source_path: Path | None,
|
| 159 |
+
) -> bool:
|
| 160 |
+
ffmpeg = _tool_path("ffmpeg")
|
| 161 |
+
if ffmpeg is None:
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
command = [
|
| 165 |
+
ffmpeg,
|
| 166 |
+
"-y",
|
| 167 |
+
"-v",
|
| 168 |
+
"error",
|
| 169 |
+
"-i",
|
| 170 |
+
str(raw_video_path),
|
| 171 |
+
]
|
| 172 |
+
if audio_source_path is not None:
|
| 173 |
+
command.extend(["-i", str(audio_source_path)])
|
| 174 |
+
|
| 175 |
+
command.extend(
|
| 176 |
+
[
|
| 177 |
+
"-map",
|
| 178 |
+
"0:v:0",
|
| 179 |
+
]
|
| 180 |
+
)
|
| 181 |
+
if audio_source_path is not None:
|
| 182 |
+
command.extend(["-map", "1:a?"])
|
| 183 |
+
|
| 184 |
+
command.extend(
|
| 185 |
+
[
|
| 186 |
+
"-c:v",
|
| 187 |
+
"libx264",
|
| 188 |
+
"-preset",
|
| 189 |
+
"veryfast",
|
| 190 |
+
"-crf",
|
| 191 |
+
"18",
|
| 192 |
+
"-vf",
|
| 193 |
+
"setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,format=yuv420p",
|
| 194 |
+
"-pix_fmt",
|
| 195 |
+
"yuv420p",
|
| 196 |
+
*BT709_COLOR_ARGS,
|
| 197 |
+
]
|
| 198 |
+
)
|
| 199 |
+
if audio_source_path is not None:
|
| 200 |
+
command.extend(["-c:a", "aac", "-b:a", "128k", "-shortest"])
|
| 201 |
+
else:
|
| 202 |
+
command.append("-an")
|
| 203 |
+
command.append(str(output_path))
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
subprocess.run(command, check=True, capture_output=True, timeout=120)
|
| 207 |
+
except (subprocess.SubprocessError, OSError):
|
| 208 |
+
return False
|
| 209 |
+
return output_path.exists() and output_path.stat().st_size > 0
|
| 210 |
|
| 211 |
|
| 212 |
def _frame_landmark_points(
|
|
|
|
| 286 |
)
|
| 287 |
|
| 288 |
|
| 289 |
+
def _open_video_writer(
|
| 290 |
+
output_path: Path,
|
| 291 |
+
fps: float,
|
| 292 |
+
width: int,
|
| 293 |
+
height: int,
|
| 294 |
+
) -> tuple[cv2.VideoWriter | None, str | None]:
|
| 295 |
for codec in PREFERRED_VIDEO_CODECS:
|
| 296 |
writer = cv2.VideoWriter(
|
| 297 |
str(output_path),
|
|
|
|
| 315 |
if not manifest.analysis_allowed or not manifest.video_path:
|
| 316 |
return manifest.video_path
|
| 317 |
|
| 318 |
+
source_path = Path(manifest.video_path)
|
| 319 |
+
color_metadata = _video_color_metadata(manifest.video_path)
|
| 320 |
+
render_input_path = source_path
|
| 321 |
+
temporary_paths: list[Path] = []
|
| 322 |
+
if _needs_sdr_conversion(color_metadata):
|
| 323 |
+
sdr_input_path = run_dir / "renderer_sdr_input.mp4"
|
| 324 |
+
if _transcode_hdr_to_sdr(source_path, sdr_input_path, color_metadata):
|
| 325 |
+
render_input_path = sdr_input_path
|
| 326 |
+
temporary_paths.append(sdr_input_path)
|
| 327 |
+
|
| 328 |
+
capture = cv2.VideoCapture(str(render_input_path))
|
| 329 |
if not capture.isOpened():
|
| 330 |
capture.release()
|
| 331 |
+
for temporary_path in temporary_paths:
|
| 332 |
+
temporary_path.unlink(missing_ok=True)
|
| 333 |
return manifest.video_path
|
| 334 |
|
| 335 |
fps = manifest.fps if manifest.fps > 0 else 30.0
|
|
|
|
| 340 |
return manifest.video_path
|
| 341 |
|
| 342 |
output_path = run_dir / "annotated_video.mp4"
|
| 343 |
+
raw_output_path = run_dir / "annotated_video_raw.mp4" if _tool_path("ffmpeg") else output_path
|
| 344 |
+
writer, _codec = _open_video_writer(raw_output_path, fps, width, height)
|
| 345 |
if writer is None:
|
| 346 |
capture.release()
|
| 347 |
+
for temporary_path in temporary_paths:
|
| 348 |
+
temporary_path.unlink(missing_ok=True)
|
| 349 |
return manifest.video_path
|
| 350 |
|
| 351 |
pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
|
|
|
|
| 383 |
finally:
|
| 384 |
writer.release()
|
| 385 |
capture.release()
|
| 386 |
+
for temporary_path in temporary_paths:
|
| 387 |
+
temporary_path.unlink(missing_ok=True)
|
| 388 |
+
|
| 389 |
+
if raw_output_path != output_path:
|
| 390 |
+
encoded = _encode_bt709_output(raw_output_path, output_path, source_path)
|
| 391 |
+
if not encoded:
|
| 392 |
+
raw_output_path.replace(output_path)
|
| 393 |
+
raw_output_path.unlink(missing_ok=True)
|
| 394 |
|
| 395 |
return str(output_path)
|
src/pozify/steps/coach_summary.py
CHANGED
|
@@ -22,29 +22,29 @@ def run(
|
|
| 22 |
issue_labels = [issue.issue for issue in issues.issues]
|
| 23 |
main_issue = issue_labels[0] if issue_labels else "no_major_issue"
|
| 24 |
issue_text = (
|
| 25 |
-
f"The
|
| 26 |
if issues.issues
|
| 27 |
-
else "The
|
| 28 |
)
|
| 29 |
|
| 30 |
return CoachSummary(
|
| 31 |
summary=(
|
| 32 |
f"The pipeline segmented {len(reps.reps)} {classification.exercise} reps from the current pose stream. "
|
| 33 |
-
f"Exercise routing, variation labeling,
|
| 34 |
-
f"The
|
| 35 |
),
|
| 36 |
what_went_well=[
|
| 37 |
f"Current placeholder ROM score is {analysis.aggregate_metrics['avg_rom_score']}.",
|
| 38 |
f"Current placeholder symmetry score is {analysis.aggregate_metrics['avg_symmetry_score']}.",
|
| 39 |
],
|
| 40 |
main_findings=[
|
| 41 |
-
f"
|
| 42 |
if issues.issues
|
| 43 |
-
else "No
|
| 44 |
],
|
| 45 |
variation_explanation=(
|
| 46 |
-
f"`{variation.detected_variation}`
|
| 47 |
-
f"It should
|
| 48 |
),
|
| 49 |
top_fixes=[
|
| 50 |
"Keep the same setup and record from a stable camera angle.",
|
|
|
|
| 22 |
issue_labels = [issue.issue for issue in issues.issues]
|
| 23 |
main_issue = issue_labels[0] if issue_labels else "no_major_issue"
|
| 24 |
issue_text = (
|
| 25 |
+
f"The strongest frame-level marker is `{main_issue}`, with timestamps and metric evidence in the issue timeline."
|
| 26 |
if issues.issues
|
| 27 |
+
else "The frame-level issue rules did not find a sustained threshold violation."
|
| 28 |
)
|
| 29 |
|
| 30 |
return CoachSummary(
|
| 31 |
summary=(
|
| 32 |
f"The pipeline segmented {len(reps.reps)} {classification.exercise} reps from the current pose stream. "
|
| 33 |
+
f"Exercise routing, variation labeling, and coaching language may still use lightweight rules or placeholders. "
|
| 34 |
+
f"The detected variation is `{variation.detected_variation}`. {issue_text}"
|
| 35 |
),
|
| 36 |
what_went_well=[
|
| 37 |
f"Current placeholder ROM score is {analysis.aggregate_metrics['avg_rom_score']}.",
|
| 38 |
f"Current placeholder symmetry score is {analysis.aggregate_metrics['avg_symmetry_score']}.",
|
| 39 |
],
|
| 40 |
main_findings=[
|
| 41 |
+
f"Frame-level marker `{main_issue}` appears in {len(issues.issues)} interval(s)."
|
| 42 |
if issues.issues
|
| 43 |
+
else "No sustained issue interval detected."
|
| 44 |
],
|
| 45 |
variation_explanation=(
|
| 46 |
+
f"`{variation.detected_variation}` was included as context for interpreting issue markers. "
|
| 47 |
+
f"It should be reviewed alongside camera angle and pose confidence."
|
| 48 |
),
|
| 49 |
top_fixes=[
|
| 50 |
"Keep the same setup and record from a stable camera angle.",
|
src/pozify/steps/issue_marker.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pozify.contracts import (
|
| 4 |
-
ExerciseClassification,
|
| 5 |
-
IssueMarker,
|
| 6 |
-
IssueMarkers,
|
| 7 |
-
RepAnalysis,
|
| 8 |
-
Reps,
|
| 9 |
-
Variation,
|
| 10 |
-
)
|
| 11 |
-
from pozify.exercise_catalog import get_exercise_spec
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def run(
|
| 15 |
-
classification: ExerciseClassification,
|
| 16 |
-
reps: Reps,
|
| 17 |
-
analysis: RepAnalysis,
|
| 18 |
-
variation: Variation,
|
| 19 |
-
) -> IssueMarkers:
|
| 20 |
-
issues: list[IssueMarker] = []
|
| 21 |
-
exercise_spec = get_exercise_spec(classification.exercise)
|
| 22 |
-
for item in analysis.items:
|
| 23 |
-
if item.stability_score >= 0.78 or exercise_spec.mock_issue is None:
|
| 24 |
-
continue
|
| 25 |
-
|
| 26 |
-
rep = next(rep for rep in reps.reps if rep.rep_id == item.rep_id)
|
| 27 |
-
issue_spec = exercise_spec.mock_issue
|
| 28 |
-
metric_value = (
|
| 29 |
-
item.range_of_motion_score
|
| 30 |
-
if issue_spec.evidence_metric == "range_of_motion_score"
|
| 31 |
-
else item.metrics.get(issue_spec.evidence_metric)
|
| 32 |
-
)
|
| 33 |
-
evidence = {
|
| 34 |
-
issue_spec.evidence_metric: metric_value,
|
| 35 |
-
"threshold": issue_spec.threshold,
|
| 36 |
-
"variation": variation.detected_variation,
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
issues.append(
|
| 40 |
-
IssueMarker(
|
| 41 |
-
rep_id=item.rep_id,
|
| 42 |
-
issue=issue_spec.issue,
|
| 43 |
-
severity=round(1.0 - item.stability_score, 2),
|
| 44 |
-
start_frame=rep.mid_frame,
|
| 45 |
-
end_frame=rep.end_frame,
|
| 46 |
-
start_sec=rep.mid_sec,
|
| 47 |
-
end_sec=rep.end_sec,
|
| 48 |
-
affected_joints=list(issue_spec.affected_joints),
|
| 49 |
-
evidence=evidence,
|
| 50 |
-
)
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
return IssueMarkers(issues=issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_analysis.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from statistics import mean
|
| 4 |
-
from typing import Any
|
| 5 |
-
|
| 6 |
-
from pozify.contracts import (
|
| 7 |
-
ExerciseClassification,
|
| 8 |
-
PoseSequence,
|
| 9 |
-
RepAnalysis,
|
| 10 |
-
RepAnalysisItem,
|
| 11 |
-
Reps,
|
| 12 |
-
)
|
| 13 |
-
from pozify.exercise_catalog import get_exercise_spec
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def _clamp_score(value: float) -> float:
|
| 17 |
-
return round(min(1.0, max(0.0, value)), 2)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _metrics_for_exercise(exercise: str, rep_id: int) -> dict[str, Any]:
|
| 21 |
-
fatigue_penalty = max(0, rep_id - 3) * 0.08
|
| 22 |
-
return get_exercise_spec(exercise).metric_factory(rep_id, fatigue_penalty)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def run(
|
| 26 |
-
classification: ExerciseClassification,
|
| 27 |
-
reps: Reps,
|
| 28 |
-
sequence: PoseSequence,
|
| 29 |
-
) -> RepAnalysis:
|
| 30 |
-
items: list[RepAnalysisItem] = []
|
| 31 |
-
exercise_spec = get_exercise_spec(classification.exercise)
|
| 32 |
-
for rep in reps.reps:
|
| 33 |
-
fatigue_penalty = max(0, rep.rep_id - 3) * 0.08
|
| 34 |
-
items.append(
|
| 35 |
-
RepAnalysisItem(
|
| 36 |
-
rep_id=rep.rep_id,
|
| 37 |
-
duration_sec=round(rep.end_sec - rep.start_sec, 2),
|
| 38 |
-
range_of_motion_score=_clamp_score(0.88 - fatigue_penalty),
|
| 39 |
-
stability_score=_clamp_score(0.86 - fatigue_penalty),
|
| 40 |
-
symmetry_score=_clamp_score(0.9 - fatigue_penalty / 2),
|
| 41 |
-
metrics=_metrics_for_exercise(classification.exercise, rep.rep_id),
|
| 42 |
-
variation_hints=list(exercise_spec.variation_hints),
|
| 43 |
-
)
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
aggregate_metrics = {
|
| 47 |
-
"avg_rom_score": round(mean(item.range_of_motion_score for item in items), 2) if items else 0.0,
|
| 48 |
-
"avg_stability_score": round(mean(item.stability_score for item in items), 2) if items else 0.0,
|
| 49 |
-
"avg_symmetry_score": round(mean(item.symmetry_score for item in items), 2) if items else 0.0,
|
| 50 |
-
"pose_valid_ratio": sequence.pose_valid_ratio,
|
| 51 |
-
"mock": True,
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
return RepAnalysis(
|
| 55 |
-
exercise=classification.exercise,
|
| 56 |
-
items=items,
|
| 57 |
-
aggregate_metrics=aggregate_metrics,
|
| 58 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_counter.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from typing import Any
|
| 4 |
-
|
| 5 |
-
from pozify.contracts import ExerciseClassification, PoseSequence, Reps
|
| 6 |
-
from pozify.steps.rep_counters import get_rep_counter
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def run(classification: ExerciseClassification, sequence: PoseSequence) -> tuple[Reps, dict[str, Any]]:
|
| 10 |
-
return get_rep_counter(classification.exercise).count(sequence)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/rep_counters/__init__.py
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
from pozify.steps.rep_counters.base import ExerciseRepCounter
|
| 2 |
-
from pozify.steps.rep_counters.registry import get_rep_counter
|
| 3 |
-
|
| 4 |
-
__all__ = ["ExerciseRepCounter", "get_rep_counter"]
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/pozify/steps/variation_detector.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pozify.contracts import ExerciseClassification, RepAnalysis, UserProfile, Variation
|
| 4 |
-
from pozify.exercise_catalog import get_exercise_spec
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def run(
|
| 8 |
-
classification: ExerciseClassification,
|
| 9 |
-
analysis: RepAnalysis,
|
| 10 |
-
profile: UserProfile,
|
| 11 |
-
) -> Variation:
|
| 12 |
-
exercise_spec = get_exercise_spec(classification.exercise)
|
| 13 |
-
if profile.intended_variation:
|
| 14 |
-
variation = profile.intended_variation
|
| 15 |
-
confidence = 0.95
|
| 16 |
-
else:
|
| 17 |
-
variation = exercise_spec.default_variation
|
| 18 |
-
confidence = exercise_spec.default_variation_confidence
|
| 19 |
-
|
| 20 |
-
not_issues = (
|
| 21 |
-
list(exercise_spec.default_variation_not_issues)
|
| 22 |
-
if variation == exercise_spec.default_variation
|
| 23 |
-
else []
|
| 24 |
-
)
|
| 25 |
-
if analysis.aggregate_metrics.get("avg_rom_score", 1.0) < 0.7:
|
| 26 |
-
not_issues.append("mock_low_rom_requires_user_intent_check")
|
| 27 |
-
|
| 28 |
-
return Variation(
|
| 29 |
-
exercise=classification.exercise,
|
| 30 |
-
detected_variation=variation,
|
| 31 |
-
variation_confidence=confidence,
|
| 32 |
-
not_issues=not_issues,
|
| 33 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_annotated_renderer.py
CHANGED
|
@@ -95,6 +95,26 @@ class AnnotatedRendererTests(unittest.TestCase):
|
|
| 95 |
self.assertIsNotNone(output_path)
|
| 96 |
self.assertTrue(Path(str(output_path)).exists())
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
if __name__ == "__main__":
|
| 100 |
unittest.main()
|
|
|
|
| 95 |
self.assertIsNotNone(output_path)
|
| 96 |
self.assertTrue(Path(str(output_path)).exists())
|
| 97 |
|
| 98 |
+
def test_hdr_metadata_requires_sdr_conversion(self) -> None:
|
| 99 |
+
self.assertTrue(
|
| 100 |
+
annotated_renderer._needs_sdr_conversion(
|
| 101 |
+
{
|
| 102 |
+
"color_space": "bt2020nc",
|
| 103 |
+
"color_transfer": "arib-std-b67",
|
| 104 |
+
"color_primaries": "bt2020",
|
| 105 |
+
}
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
self.assertFalse(
|
| 109 |
+
annotated_renderer._needs_sdr_conversion(
|
| 110 |
+
{
|
| 111 |
+
"color_space": "bt709",
|
| 112 |
+
"color_transfer": "bt709",
|
| 113 |
+
"color_primaries": "bt709",
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
|
| 119 |
if __name__ == "__main__":
|
| 120 |
unittest.main()
|
tests/test_issue_marker.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
from typing import Callable
|
| 7 |
+
import unittest
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 10 |
+
|
| 11 |
+
from pozify.contracts import (
|
| 12 |
+
IssueMarkers,
|
| 13 |
+
PoseFrame,
|
| 14 |
+
PoseSequence,
|
| 15 |
+
Rep,
|
| 16 |
+
Reps,
|
| 17 |
+
UserProfile,
|
| 18 |
+
VideoManifest,
|
| 19 |
+
)
|
| 20 |
+
from pozify.exercises import create_exercise_strategy
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
| 24 |
+
return PoseFrame(
|
| 25 |
+
frame_index=frame_index,
|
| 26 |
+
timestamp_sec=round(frame_index / 30.0, 3),
|
| 27 |
+
landmarks=landmarks,
|
| 28 |
+
world_landmarks={},
|
| 29 |
+
pose_quality={"mean_visibility": 0.92, "normalized": True},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _wave(frame_index: int, cycle_frames: int = 30) -> float:
|
| 34 |
+
return (1.0 - math.cos(2.0 * math.pi * (frame_index / cycle_frames))) / 2.0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _reps(exercise: str, end_frame: int = 29) -> Reps:
|
| 38 |
+
return Reps(
|
| 39 |
+
exercise=exercise,
|
| 40 |
+
reps=[Rep(1, 0, end_frame // 2, end_frame, 0.0, round((end_frame // 2) / 30.0, 3), round(end_frame / 30.0, 3))],
|
| 41 |
+
partial_reps=[],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _profile(exercise: str = "auto") -> UserProfile:
|
| 46 |
+
return UserProfile(
|
| 47 |
+
goal="beginner_practice",
|
| 48 |
+
experience_level="beginner",
|
| 49 |
+
intended_exercise=exercise,
|
| 50 |
+
intended_variation=None,
|
| 51 |
+
known_limitations=[],
|
| 52 |
+
equipment="bodyweight",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 57 |
+
return VideoManifest(
|
| 58 |
+
video_path=None,
|
| 59 |
+
fps=30.0,
|
| 60 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 61 |
+
total_frames=len(sequence.frames),
|
| 62 |
+
sampled_frames=len(sequence.frames),
|
| 63 |
+
width=720,
|
| 64 |
+
height=1280,
|
| 65 |
+
codec=None,
|
| 66 |
+
container=None,
|
| 67 |
+
brightness_mean=None,
|
| 68 |
+
blur_laplacian_var=None,
|
| 69 |
+
quality_warnings=[],
|
| 70 |
+
analysis_allowed=True,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _sequence(factory: Callable[[int, float], dict[str, dict[str, float]]], count: int = 30) -> PoseSequence:
|
| 75 |
+
return PoseSequence(
|
| 76 |
+
frames=[_frame(frame_index, factory(frame_index, _wave(frame_index, count))) for frame_index in range(count)],
|
| 77 |
+
normalized=True,
|
| 78 |
+
smoothing_method="none",
|
| 79 |
+
pose_valid_ratio=1.0,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _push_up_landmarks(frame_index: int, depth: float, *, noise_only: bool = False) -> dict[str, dict[str, float]]:
|
| 84 |
+
shoulder_y = 0.38 + depth * 0.16
|
| 85 |
+
hip_y = 0.46 + depth * 0.16
|
| 86 |
+
ankle_y = 0.54 + depth * 0.16
|
| 87 |
+
if 10 <= frame_index <= 18 and not noise_only:
|
| 88 |
+
hip_y += 0.45
|
| 89 |
+
if frame_index == 15 and noise_only:
|
| 90 |
+
hip_y += 0.45
|
| 91 |
+
|
| 92 |
+
shoulder_width = 0.4
|
| 93 |
+
hand_width = shoulder_width * 1.7
|
| 94 |
+
return {
|
| 95 |
+
"left_shoulder": {"x": 0.3, "y": shoulder_y},
|
| 96 |
+
"right_shoulder": {"x": 0.7, "y": shoulder_y},
|
| 97 |
+
"left_elbow": {"x": 0.36 + depth * 0.02, "y": 0.46 + depth * 0.1},
|
| 98 |
+
"right_elbow": {"x": 0.64 - depth * 0.02, "y": 0.46 + depth * 0.1},
|
| 99 |
+
"left_wrist": {"x": 0.5 - hand_width / 2, "y": 0.5 + depth * 0.04},
|
| 100 |
+
"right_wrist": {"x": 0.5 + hand_width / 2, "y": 0.5 + depth * 0.04},
|
| 101 |
+
"left_hip": {"x": 0.42, "y": hip_y},
|
| 102 |
+
"right_hip": {"x": 0.58, "y": hip_y},
|
| 103 |
+
"left_knee": {"x": 0.44, "y": hip_y + 0.2},
|
| 104 |
+
"right_knee": {"x": 0.56, "y": hip_y + 0.2},
|
| 105 |
+
"left_ankle": {"x": 0.44, "y": ankle_y},
|
| 106 |
+
"right_ankle": {"x": 0.56, "y": ankle_y},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _squat_landmarks(frame_index: int, depth: float) -> dict[str, dict[str, float]]:
|
| 111 |
+
del frame_index
|
| 112 |
+
hip_y = 0.54 + depth * 0.08
|
| 113 |
+
knee_y = 0.74
|
| 114 |
+
knee_half_width = 0.04 + (1.0 - depth) * 0.08
|
| 115 |
+
return {
|
| 116 |
+
"left_shoulder": {"x": 0.74, "y": 0.26 + depth * 0.02},
|
| 117 |
+
"right_shoulder": {"x": 0.86, "y": 0.26 + depth * 0.02},
|
| 118 |
+
"left_hip": {"x": 0.43, "y": hip_y},
|
| 119 |
+
"right_hip": {"x": 0.57, "y": hip_y},
|
| 120 |
+
"left_knee": {"x": 0.5 - knee_half_width, "y": knee_y},
|
| 121 |
+
"right_knee": {"x": 0.5 + knee_half_width, "y": knee_y},
|
| 122 |
+
"left_ankle": {"x": 0.3, "y": 0.94},
|
| 123 |
+
"right_ankle": {"x": 0.7, "y": 0.94},
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _shoulder_press_landmarks(frame_index: int, lift: float) -> dict[str, dict[str, float]]:
|
| 128 |
+
del frame_index
|
| 129 |
+
wrist_y = 0.74 - lift * 0.12
|
| 130 |
+
return {
|
| 131 |
+
"left_shoulder": {"x": 0.42, "y": 0.42},
|
| 132 |
+
"right_shoulder": {"x": 0.58, "y": 0.42},
|
| 133 |
+
"left_elbow": {"x": 0.35, "y": 0.56 - lift * 0.04},
|
| 134 |
+
"right_elbow": {"x": 0.65, "y": 0.56 - lift * 0.04},
|
| 135 |
+
"left_wrist": {"x": 0.18, "y": wrist_y + 0.08},
|
| 136 |
+
"right_wrist": {"x": 0.82, "y": wrist_y - 0.08},
|
| 137 |
+
"left_hip": {"x": 0.44, "y": 0.74},
|
| 138 |
+
"right_hip": {"x": 0.56, "y": 0.74},
|
| 139 |
+
"left_ankle": {"x": 0.45, "y": 0.96},
|
| 140 |
+
"right_ankle": {"x": 0.55, "y": 0.96},
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _run_markers(exercise: str, sequence: PoseSequence) -> IssueMarkers:
|
| 145 |
+
exercise_strategy = create_exercise_strategy(
|
| 146 |
+
exercise,
|
| 147 |
+
video_manifest=_video_manifest(sequence),
|
| 148 |
+
pose_sequence=sequence,
|
| 149 |
+
profile=_profile(exercise),
|
| 150 |
+
)
|
| 151 |
+
reps = _reps(exercise, len(sequence.frames) - 1)
|
| 152 |
+
analysis = exercise_strategy.analyze_reps(reps)
|
| 153 |
+
variation = exercise_strategy.resolve_variation(analysis)
|
| 154 |
+
return exercise_strategy.mark_issues(reps, analysis, variation)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class IssueMarkerTests(unittest.TestCase):
|
| 158 |
+
def test_push_up_hip_sag_interval_includes_evidence_and_variation_context(self) -> None:
|
| 159 |
+
markers = _run_markers("push_up", _sequence(_push_up_landmarks))
|
| 160 |
+
hip_sag = next(issue for issue in markers.issues if issue.issue == "hip_sag")
|
| 161 |
+
|
| 162 |
+
self.assertGreaterEqual(hip_sag.end_frame - hip_sag.start_frame, 2)
|
| 163 |
+
self.assertLess(hip_sag.evidence["body_line_score"], hip_sag.evidence["threshold"])
|
| 164 |
+
self.assertIn("confidence", hip_sag.evidence)
|
| 165 |
+
self.assertEqual(
|
| 166 |
+
hip_sag.evidence["variation_context"]["detected_variation"],
|
| 167 |
+
"wide_grip_push_up",
|
| 168 |
+
)
|
| 169 |
+
self.assertIn("wide_hand_placement", hip_sag.evidence["variation_context"]["not_issues"])
|
| 170 |
+
self.assertIn("left_hip", hip_sag.affected_joints)
|
| 171 |
+
|
| 172 |
+
def test_single_frame_push_up_sag_noise_is_filtered_out(self) -> None:
|
| 173 |
+
markers = _run_markers(
|
| 174 |
+
"push_up",
|
| 175 |
+
_sequence(lambda frame_index, depth: _push_up_landmarks(frame_index, depth, noise_only=True)),
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
self.assertNotIn("hip_sag", {issue.issue for issue in markers.issues})
|
| 179 |
+
|
| 180 |
+
def test_squat_rules_emit_depth_valgus_and_torso_lean_intervals(self) -> None:
|
| 181 |
+
markers = _run_markers("squat", _sequence(_squat_landmarks))
|
| 182 |
+
labels = {issue.issue for issue in markers.issues}
|
| 183 |
+
|
| 184 |
+
self.assertIn("shallow_depth", labels)
|
| 185 |
+
self.assertIn("knee_valgus", labels)
|
| 186 |
+
self.assertIn("excessive_torso_lean", labels)
|
| 187 |
+
|
| 188 |
+
def test_shoulder_press_rules_emit_lockout_and_asymmetry_intervals(self) -> None:
|
| 189 |
+
markers = _run_markers("shoulder_press", _sequence(_shoulder_press_landmarks))
|
| 190 |
+
labels = {issue.issue for issue in markers.issues}
|
| 191 |
+
|
| 192 |
+
self.assertIn("incomplete_lockout", labels)
|
| 193 |
+
self.assertIn("asymmetry", labels)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
unittest.main()
|
tests/test_pipeline_contracts.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
import sys
|
| 5 |
import tempfile
|
| 6 |
import unittest
|
| 7 |
from pathlib import Path
|
|
|
|
| 8 |
|
| 9 |
import cv2
|
| 10 |
import numpy as np
|
|
@@ -227,6 +229,11 @@ class PipelineContractTests(unittest.TestCase):
|
|
| 227 |
report = result["final_report"]
|
| 228 |
self.assertEqual(report["exercise"]["exercise"], exercise)
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
if __name__ == "__main__":
|
| 232 |
unittest.main()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import os
|
| 5 |
import sys
|
| 6 |
import tempfile
|
| 7 |
import unittest
|
| 8 |
from pathlib import Path
|
| 9 |
+
from unittest.mock import patch
|
| 10 |
|
| 11 |
import cv2
|
| 12 |
import numpy as np
|
|
|
|
| 229 |
report = result["final_report"]
|
| 230 |
self.assertEqual(report["exercise"]["exercise"], exercise)
|
| 231 |
|
| 232 |
+
def test_mock_mode_defaults_to_real_when_video_path_is_present(self) -> None:
|
| 233 |
+
with patch.dict(os.environ, {}, clear=True):
|
| 234 |
+
self.assertFalse(pipeline._env_mock_mode("sample.mp4"))
|
| 235 |
+
self.assertTrue(pipeline._env_mock_mode(None))
|
| 236 |
+
|
| 237 |
|
| 238 |
if __name__ == "__main__":
|
| 239 |
unittest.main()
|
tests/test_rep_analysis_variation.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import math
|
| 5 |
+
import sys
|
| 6 |
+
from typing import Callable
|
| 7 |
+
import unittest
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 10 |
+
|
| 11 |
+
from pozify.contracts import (
|
| 12 |
+
PoseFrame,
|
| 13 |
+
PoseSequence,
|
| 14 |
+
Rep,
|
| 15 |
+
Reps,
|
| 16 |
+
UserProfile,
|
| 17 |
+
VideoManifest,
|
| 18 |
+
)
|
| 19 |
+
from pozify.exercises import create_exercise_strategy
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
| 23 |
+
return PoseFrame(
|
| 24 |
+
frame_index=frame_index,
|
| 25 |
+
timestamp_sec=round(frame_index / 30.0, 3),
|
| 26 |
+
landmarks=landmarks,
|
| 27 |
+
world_landmarks={},
|
| 28 |
+
pose_quality={"mean_visibility": 0.95, "normalized": True},
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _wave(frame_index: int, cycle_frames: int) -> float:
|
| 33 |
+
return (1.0 - math.cos(2.0 * math.pi * (frame_index / cycle_frames))) / 2.0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _push_up_landmarks(depth: float, *, hand_ratio: float = 1.7) -> dict[str, dict[str, float]]:
|
| 37 |
+
shoulder_y = 0.38 + depth * 0.16
|
| 38 |
+
hip_y = 0.46 + depth * 0.16
|
| 39 |
+
ankle_y = 0.54 + depth * 0.16
|
| 40 |
+
elbow_y = 0.46 + depth * 0.1
|
| 41 |
+
wrist_y = 0.5 + depth * 0.04
|
| 42 |
+
shoulder_width = 0.4
|
| 43 |
+
hand_width = shoulder_width * hand_ratio
|
| 44 |
+
return {
|
| 45 |
+
"left_shoulder": {"x": 0.3, "y": shoulder_y},
|
| 46 |
+
"right_shoulder": {"x": 0.7, "y": shoulder_y},
|
| 47 |
+
"left_elbow": {"x": 0.36 + depth * 0.02, "y": elbow_y},
|
| 48 |
+
"right_elbow": {"x": 0.64 - depth * 0.02, "y": elbow_y},
|
| 49 |
+
"left_wrist": {"x": 0.5 - hand_width / 2, "y": wrist_y},
|
| 50 |
+
"right_wrist": {"x": 0.5 + hand_width / 2, "y": wrist_y},
|
| 51 |
+
"left_hip": {"x": 0.42, "y": hip_y},
|
| 52 |
+
"right_hip": {"x": 0.58, "y": hip_y},
|
| 53 |
+
"left_ankle": {"x": 0.44, "y": ankle_y},
|
| 54 |
+
"right_ankle": {"x": 0.56, "y": ankle_y},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _knee_push_up_landmarks(depth: float, *, hand_ratio: float = 1.1) -> dict[str, dict[str, float]]:
|
| 59 |
+
shoulder_y = 0.38 + depth * 0.16
|
| 60 |
+
hip_y = 0.46 + depth * 0.16
|
| 61 |
+
knee_y = hip_y + 0.03
|
| 62 |
+
ankle_y = hip_y + 0.28
|
| 63 |
+
elbow_y = 0.46 + depth * 0.1
|
| 64 |
+
wrist_y = 0.5 + depth * 0.04
|
| 65 |
+
shoulder_width = 0.4
|
| 66 |
+
hand_width = shoulder_width * hand_ratio
|
| 67 |
+
return {
|
| 68 |
+
"left_shoulder": {"x": 0.3, "y": shoulder_y},
|
| 69 |
+
"right_shoulder": {"x": 0.7, "y": shoulder_y},
|
| 70 |
+
"left_elbow": {"x": 0.36 + depth * 0.02, "y": elbow_y},
|
| 71 |
+
"right_elbow": {"x": 0.64 - depth * 0.02, "y": elbow_y},
|
| 72 |
+
"left_wrist": {"x": 0.5 - hand_width / 2, "y": wrist_y},
|
| 73 |
+
"right_wrist": {"x": 0.5 + hand_width / 2, "y": wrist_y},
|
| 74 |
+
"left_hip": {"x": 0.42, "y": hip_y},
|
| 75 |
+
"right_hip": {"x": 0.58, "y": hip_y},
|
| 76 |
+
"left_knee": {"x": 0.44, "y": knee_y},
|
| 77 |
+
"right_knee": {"x": 0.56, "y": knee_y},
|
| 78 |
+
"left_ankle": {"x": 0.44, "y": ankle_y},
|
| 79 |
+
"right_ankle": {"x": 0.56, "y": ankle_y},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _straight_leg_push_up_landmarks(depth: float) -> dict[str, dict[str, float]]:
|
| 84 |
+
shoulder_y = 0.38 + depth * 0.1
|
| 85 |
+
hip_y = 0.46 + depth * 0.1
|
| 86 |
+
knee_y = 0.5 + depth * 0.1
|
| 87 |
+
ankle_y = 0.52 + depth * 0.1
|
| 88 |
+
elbow_y = 0.46 + depth * 0.06
|
| 89 |
+
wrist_y = 0.52 + depth * 0.02
|
| 90 |
+
return {
|
| 91 |
+
"left_shoulder": {"x": 0.3, "y": shoulder_y},
|
| 92 |
+
"right_shoulder": {"x": 0.32, "y": shoulder_y},
|
| 93 |
+
"left_elbow": {"x": 0.35, "y": elbow_y},
|
| 94 |
+
"right_elbow": {"x": 0.37, "y": elbow_y},
|
| 95 |
+
"left_wrist": {"x": 0.42, "y": wrist_y},
|
| 96 |
+
"right_wrist": {"x": 0.44, "y": wrist_y},
|
| 97 |
+
"left_hip": {"x": 0.58, "y": hip_y},
|
| 98 |
+
"right_hip": {"x": 0.6, "y": hip_y},
|
| 99 |
+
"left_knee": {"x": 0.72, "y": knee_y},
|
| 100 |
+
"right_knee": {"x": 0.74, "y": knee_y},
|
| 101 |
+
"left_ankle": {"x": 0.88, "y": ankle_y},
|
| 102 |
+
"right_ankle": {"x": 0.9, "y": ankle_y},
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _squat_landmarks(depth: float, *, stance_ratio: float = 1.5) -> dict[str, dict[str, float]]:
|
| 107 |
+
shoulder_width = 0.2
|
| 108 |
+
stance_width = shoulder_width * stance_ratio
|
| 109 |
+
hip_y = 0.52 + depth * 0.22
|
| 110 |
+
shoulder_y = 0.28 + depth * 0.06
|
| 111 |
+
knee_y = 0.7
|
| 112 |
+
return {
|
| 113 |
+
"left_shoulder": {"x": 0.4, "y": shoulder_y},
|
| 114 |
+
"right_shoulder": {"x": 0.6, "y": shoulder_y},
|
| 115 |
+
"left_hip": {"x": 0.43, "y": hip_y},
|
| 116 |
+
"right_hip": {"x": 0.57, "y": hip_y},
|
| 117 |
+
"left_knee": {"x": 0.43 + depth * 0.05, "y": knee_y},
|
| 118 |
+
"right_knee": {"x": 0.57 - depth * 0.05, "y": knee_y},
|
| 119 |
+
"left_ankle": {"x": 0.5 - stance_width / 2, "y": 0.92},
|
| 120 |
+
"right_ankle": {"x": 0.5 + stance_width / 2, "y": 0.92},
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _shoulder_press_landmarks(lift: float, *, partial: bool = True) -> dict[str, dict[str, float]]:
|
| 125 |
+
wrist_top = 0.56 if partial else 0.42
|
| 126 |
+
wrist_y = 0.76 - lift * (0.76 - wrist_top)
|
| 127 |
+
elbow_y = 0.6 - lift * 0.06
|
| 128 |
+
return {
|
| 129 |
+
"left_shoulder": {"x": 0.42, "y": 0.42},
|
| 130 |
+
"right_shoulder": {"x": 0.58, "y": 0.42},
|
| 131 |
+
"left_elbow": {"x": 0.4 - lift * 0.02, "y": elbow_y},
|
| 132 |
+
"right_elbow": {"x": 0.6 + lift * 0.02, "y": elbow_y},
|
| 133 |
+
"left_wrist": {"x": 0.4, "y": wrist_y},
|
| 134 |
+
"right_wrist": {"x": 0.6, "y": wrist_y},
|
| 135 |
+
"left_hip": {"x": 0.44, "y": 0.74},
|
| 136 |
+
"right_hip": {"x": 0.56, "y": 0.74},
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _sequence(exercise: str) -> PoseSequence:
|
| 141 |
+
frames = []
|
| 142 |
+
for frame_index in range(25):
|
| 143 |
+
wave = _wave(frame_index, 24)
|
| 144 |
+
if exercise == "push_up":
|
| 145 |
+
landmarks = _push_up_landmarks(wave)
|
| 146 |
+
elif exercise == "shoulder_press":
|
| 147 |
+
landmarks = _shoulder_press_landmarks(wave)
|
| 148 |
+
else:
|
| 149 |
+
landmarks = _squat_landmarks(wave)
|
| 150 |
+
frames.append(_frame(frame_index, landmarks))
|
| 151 |
+
return PoseSequence(
|
| 152 |
+
frames=frames,
|
| 153 |
+
normalized=True,
|
| 154 |
+
smoothing_method="none",
|
| 155 |
+
pose_valid_ratio=1.0,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _custom_sequence(
|
| 160 |
+
landmark_factory: Callable[[float], dict[str, dict[str, float]]],
|
| 161 |
+
) -> PoseSequence:
|
| 162 |
+
frames = []
|
| 163 |
+
for frame_index in range(25):
|
| 164 |
+
frames.append(_frame(frame_index, landmark_factory(_wave(frame_index, 24))))
|
| 165 |
+
return PoseSequence(
|
| 166 |
+
frames=frames,
|
| 167 |
+
normalized=True,
|
| 168 |
+
smoothing_method="none",
|
| 169 |
+
pose_valid_ratio=1.0,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _reps(exercise: str) -> Reps:
|
| 174 |
+
return Reps(
|
| 175 |
+
exercise=exercise,
|
| 176 |
+
reps=[Rep(1, 0, 12, 24, 0.0, 0.4, 0.8)],
|
| 177 |
+
partial_reps=[],
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _profile() -> UserProfile:
|
| 182 |
+
return UserProfile(
|
| 183 |
+
goal="beginner_practice",
|
| 184 |
+
experience_level="beginner",
|
| 185 |
+
intended_exercise="auto",
|
| 186 |
+
intended_variation=None,
|
| 187 |
+
known_limitations=[],
|
| 188 |
+
equipment="bodyweight",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 193 |
+
return VideoManifest(
|
| 194 |
+
video_path=None,
|
| 195 |
+
fps=30.0,
|
| 196 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 197 |
+
total_frames=len(sequence.frames),
|
| 198 |
+
sampled_frames=len(sequence.frames),
|
| 199 |
+
width=720,
|
| 200 |
+
height=1280,
|
| 201 |
+
codec=None,
|
| 202 |
+
container=None,
|
| 203 |
+
brightness_mean=None,
|
| 204 |
+
blur_laplacian_var=None,
|
| 205 |
+
quality_warnings=[],
|
| 206 |
+
analysis_allowed=True,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _exercise_strategy(exercise: str, sequence: PoseSequence, profile: UserProfile | None = None):
|
| 211 |
+
return create_exercise_strategy(
|
| 212 |
+
exercise,
|
| 213 |
+
video_manifest=_video_manifest(sequence),
|
| 214 |
+
pose_sequence=sequence,
|
| 215 |
+
profile=profile or _profile(),
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class RepAnalysisVariationTests(unittest.TestCase):
|
| 220 |
+
def test_push_up_metrics_detect_wide_grip_variation(self) -> None:
|
| 221 |
+
sequence = _sequence("push_up")
|
| 222 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 223 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 224 |
+
variation = exercise.resolve_variation(analysis)
|
| 225 |
+
|
| 226 |
+
self.assertEqual(variation.detected_variation, "wide_grip_push_up")
|
| 227 |
+
self.assertIn("wide_hand_placement", variation.not_issues)
|
| 228 |
+
self.assertGreater(analysis.items[0].metrics["hand_width_ratio"], 1.45)
|
| 229 |
+
self.assertIn("body_line_score", analysis.items[0].metrics)
|
| 230 |
+
|
| 231 |
+
def test_push_up_metrics_detect_knee_push_up_variation(self) -> None:
|
| 232 |
+
sequence = _custom_sequence(_knee_push_up_landmarks)
|
| 233 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 234 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 235 |
+
variation = exercise.resolve_variation(analysis)
|
| 236 |
+
|
| 237 |
+
self.assertEqual(variation.detected_variation, "knee_push_up")
|
| 238 |
+
self.assertIn("knee_contact", variation.not_issues)
|
| 239 |
+
self.assertGreaterEqual(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 240 |
+
|
| 241 |
+
def test_straight_leg_push_up_does_not_false_positive_as_knee_push_up(self) -> None:
|
| 242 |
+
sequence = _custom_sequence(_straight_leg_push_up_landmarks)
|
| 243 |
+
exercise = _exercise_strategy("push_up", sequence)
|
| 244 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 245 |
+
variation = exercise.resolve_variation(analysis)
|
| 246 |
+
|
| 247 |
+
self.assertNotEqual(variation.detected_variation, "knee_push_up")
|
| 248 |
+
self.assertNotIn("knee_contact", variation.not_issues)
|
| 249 |
+
self.assertLess(analysis.aggregate_metrics["avg_knee_support_score"], 0.8)
|
| 250 |
+
|
| 251 |
+
def test_squat_metrics_detect_wide_stance_variation(self) -> None:
|
| 252 |
+
sequence = _sequence("squat")
|
| 253 |
+
exercise = _exercise_strategy("squat", sequence)
|
| 254 |
+
analysis = exercise.analyze_reps(_reps("squat"))
|
| 255 |
+
variation = exercise.resolve_variation(analysis)
|
| 256 |
+
|
| 257 |
+
self.assertEqual(variation.detected_variation, "wide_squat_stance")
|
| 258 |
+
self.assertIn("wide_stance", variation.not_issues)
|
| 259 |
+
self.assertGreater(analysis.items[0].metrics["stance_width_ratio"], 1.35)
|
| 260 |
+
self.assertIn("min_knee_angle_deg", analysis.items[0].metrics)
|
| 261 |
+
|
| 262 |
+
def test_shoulder_press_metrics_detect_partial_press_variation(self) -> None:
|
| 263 |
+
sequence = _sequence("shoulder_press")
|
| 264 |
+
exercise = _exercise_strategy("shoulder_press", sequence)
|
| 265 |
+
analysis = exercise.analyze_reps(_reps("shoulder_press"))
|
| 266 |
+
variation = exercise.resolve_variation(analysis)
|
| 267 |
+
|
| 268 |
+
self.assertEqual(variation.detected_variation, "partial_press")
|
| 269 |
+
self.assertIn("partial_range_of_motion", variation.not_issues)
|
| 270 |
+
self.assertIn("lockout_quality", analysis.items[0].metrics)
|
| 271 |
+
self.assertLess(analysis.aggregate_metrics["avg_wrist_travel"], 0.24)
|
| 272 |
+
|
| 273 |
+
def test_profile_intended_variation_overrides_metric_rule(self) -> None:
|
| 274 |
+
profile = UserProfile(
|
| 275 |
+
goal="beginner_practice",
|
| 276 |
+
experience_level="beginner",
|
| 277 |
+
intended_exercise="push_up",
|
| 278 |
+
intended_variation="close_grip_push_up",
|
| 279 |
+
known_limitations=[],
|
| 280 |
+
equipment="bodyweight",
|
| 281 |
+
)
|
| 282 |
+
sequence = _sequence("push_up")
|
| 283 |
+
exercise = _exercise_strategy("push_up", sequence, profile)
|
| 284 |
+
analysis = exercise.analyze_reps(_reps("push_up"))
|
| 285 |
+
variation = exercise.resolve_variation(analysis)
|
| 286 |
+
|
| 287 |
+
self.assertEqual(variation.detected_variation, "close_grip_push_up")
|
| 288 |
+
self.assertEqual(variation.variation_confidence, 0.95)
|
| 289 |
+
self.assertIn("close_hand_placement", variation.not_issues)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
if __name__ == "__main__":
|
| 293 |
+
unittest.main()
|
tests/test_rep_counter.py
CHANGED
|
@@ -7,12 +7,11 @@ import unittest
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 9 |
|
| 10 |
-
from pozify.contracts import
|
| 11 |
-
from pozify.
|
| 12 |
-
from pozify.
|
| 13 |
-
from pozify.
|
| 14 |
-
from pozify.
|
| 15 |
-
from pozify.steps.rep_counters.squat import SquatRepCounter
|
| 16 |
|
| 17 |
|
| 18 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
@@ -107,52 +106,84 @@ def _sequence_for_exercise(exercise: str, cycles: int, *, partial_tail: bool = F
|
|
| 107 |
)
|
| 108 |
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
def test_segments_squat_reps(self) -> None:
|
| 120 |
-
|
|
|
|
| 121 |
self.assertEqual(len(reps.reps), 3)
|
| 122 |
self.assertEqual(reps.reps[0].start_frame, 0)
|
| 123 |
self.assertTrue(debug["accepted_reps"])
|
| 124 |
|
| 125 |
def test_segments_push_up_reps(self) -> None:
|
| 126 |
-
|
|
|
|
| 127 |
self.assertEqual(len(reps.reps), 3)
|
| 128 |
self.assertEqual(reps.partial_reps, [])
|
| 129 |
|
| 130 |
def test_segments_shoulder_press_reps(self) -> None:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
_sequence_for_exercise("shoulder_press", 3),
|
| 134 |
-
)
|
| 135 |
self.assertEqual(len(reps.reps), 3)
|
| 136 |
self.assertEqual(reps.reps[0].mid_frame, 12)
|
| 137 |
|
| 138 |
def test_reports_partial_last_rep(self) -> None:
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
_sequence_for_exercise("push_up", 2, partial_tail=True),
|
| 142 |
-
)
|
| 143 |
self.assertEqual(len(reps.reps), 2)
|
| 144 |
self.assertIn("ends_mid_rep", {item["reason"] for item in reps.partial_reps})
|
| 145 |
|
| 146 |
def test_unknown_exercise_is_not_segmented(self) -> None:
|
| 147 |
-
|
|
|
|
| 148 |
self.assertEqual(reps.reps, [])
|
| 149 |
self.assertEqual(reps.partial_reps, [{"reason": "unknown_exercise"}])
|
| 150 |
self.assertEqual(debug["selected_signal"], "none")
|
| 151 |
|
| 152 |
-
def
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
self.assertIsInstance(
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
if __name__ == "__main__":
|
|
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 9 |
|
| 10 |
+
from pozify.contracts import PoseFrame, PoseSequence, UserProfile, VideoManifest
|
| 11 |
+
from pozify.exercises import create_exercise_strategy
|
| 12 |
+
from pozify.exercises.push_up import PushUpExercise
|
| 13 |
+
from pozify.exercises.shoulder_press import ShoulderPressExercise
|
| 14 |
+
from pozify.exercises.squat import SquatExercise
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def _frame(frame_index: int, landmarks: dict[str, dict[str, float]]) -> PoseFrame:
|
|
|
|
| 106 |
)
|
| 107 |
|
| 108 |
|
| 109 |
+
def _video_manifest(sequence: PoseSequence) -> VideoManifest:
|
| 110 |
+
return VideoManifest(
|
| 111 |
+
video_path=None,
|
| 112 |
+
fps=30.0,
|
| 113 |
+
duration_sec=round(len(sequence.frames) / 30.0, 3),
|
| 114 |
+
total_frames=len(sequence.frames),
|
| 115 |
+
sampled_frames=len(sequence.frames),
|
| 116 |
+
width=720,
|
| 117 |
+
height=1280,
|
| 118 |
+
codec=None,
|
| 119 |
+
container=None,
|
| 120 |
+
brightness_mean=None,
|
| 121 |
+
blur_laplacian_var=None,
|
| 122 |
+
quality_warnings=[],
|
| 123 |
+
analysis_allowed=True,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _profile(exercise: str = "auto") -> UserProfile:
|
| 128 |
+
return UserProfile(
|
| 129 |
+
goal="beginner_practice",
|
| 130 |
+
experience_level="beginner",
|
| 131 |
+
intended_exercise=exercise,
|
| 132 |
+
intended_variation=None,
|
| 133 |
+
known_limitations=[],
|
| 134 |
+
equipment="bodyweight",
|
| 135 |
+
)
|
| 136 |
|
| 137 |
+
|
| 138 |
+
def _exercise_strategy(exercise: str, sequence: PoseSequence):
|
| 139 |
+
return create_exercise_strategy(
|
| 140 |
+
exercise,
|
| 141 |
+
video_manifest=_video_manifest(sequence),
|
| 142 |
+
pose_sequence=sequence,
|
| 143 |
+
profile=_profile(exercise),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class RepCounterTests(unittest.TestCase):
|
| 148 |
def test_segments_squat_reps(self) -> None:
|
| 149 |
+
sequence = _sequence_for_exercise("squat", 3)
|
| 150 |
+
reps, debug = _exercise_strategy("squat", sequence).count()
|
| 151 |
self.assertEqual(len(reps.reps), 3)
|
| 152 |
self.assertEqual(reps.reps[0].start_frame, 0)
|
| 153 |
self.assertTrue(debug["accepted_reps"])
|
| 154 |
|
| 155 |
def test_segments_push_up_reps(self) -> None:
|
| 156 |
+
sequence = _sequence_for_exercise("push_up", 3)
|
| 157 |
+
reps, _debug = _exercise_strategy("push_up", sequence).count()
|
| 158 |
self.assertEqual(len(reps.reps), 3)
|
| 159 |
self.assertEqual(reps.partial_reps, [])
|
| 160 |
|
| 161 |
def test_segments_shoulder_press_reps(self) -> None:
|
| 162 |
+
sequence = _sequence_for_exercise("shoulder_press", 3)
|
| 163 |
+
reps, _debug = _exercise_strategy("shoulder_press", sequence).count()
|
|
|
|
|
|
|
| 164 |
self.assertEqual(len(reps.reps), 3)
|
| 165 |
self.assertEqual(reps.reps[0].mid_frame, 12)
|
| 166 |
|
| 167 |
def test_reports_partial_last_rep(self) -> None:
|
| 168 |
+
sequence = _sequence_for_exercise("push_up", 2, partial_tail=True)
|
| 169 |
+
reps, _debug = _exercise_strategy("push_up", sequence).count()
|
|
|
|
|
|
|
| 170 |
self.assertEqual(len(reps.reps), 2)
|
| 171 |
self.assertIn("ends_mid_rep", {item["reason"] for item in reps.partial_reps})
|
| 172 |
|
| 173 |
def test_unknown_exercise_is_not_segmented(self) -> None:
|
| 174 |
+
sequence = _sequence_for_exercise("push_up", 1)
|
| 175 |
+
reps, debug = _exercise_strategy("unknown", sequence).count()
|
| 176 |
self.assertEqual(reps.reps, [])
|
| 177 |
self.assertEqual(reps.partial_reps, [{"reason": "unknown_exercise"}])
|
| 178 |
self.assertEqual(debug["selected_signal"], "none")
|
| 179 |
|
| 180 |
+
def test_exercises_resolve_to_specific_strategies(self) -> None:
|
| 181 |
+
sequence = _sequence_for_exercise("push_up", 1)
|
| 182 |
+
push_up = _exercise_strategy("push_up", sequence)
|
| 183 |
+
self.assertIsInstance(push_up, PushUpExercise)
|
| 184 |
+
self.assertIsInstance(_exercise_strategy("shoulder_press", sequence), ShoulderPressExercise)
|
| 185 |
+
self.assertIsInstance(_exercise_strategy("squat", sequence), SquatExercise)
|
| 186 |
+
self.assertIsNot(push_up, _exercise_strategy("push_up", sequence))
|
| 187 |
|
| 188 |
|
| 189 |
if __name__ == "__main__":
|
web/app.js
CHANGED
|
@@ -51,10 +51,12 @@ function Summary({ result }) {
|
|
| 51 |
const report = result.report;
|
| 52 |
const summary = report.coach_summary;
|
| 53 |
const warnings = report.video_manifest.quality_warnings || [];
|
|
|
|
| 54 |
const stats = [
|
| 55 |
["Exercise", report.exercise.exercise],
|
| 56 |
["Variation", report.variation.detected_variation],
|
| 57 |
["Reps", String(report.reps.reps.length)],
|
|
|
|
| 58 |
["Mode", report.artifacts.analysis_mode],
|
| 59 |
];
|
| 60 |
|
|
@@ -77,6 +79,7 @@ function Summary({ result }) {
|
|
| 77 |
h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
|
| 78 |
h(NoteList, { title: "Next session", items: summary.next_session_plan }),
|
| 79 |
),
|
|
|
|
| 80 |
warnings.length
|
| 81 |
? h(
|
| 82 |
"div",
|
|
@@ -87,6 +90,54 @@ function Summary({ result }) {
|
|
| 87 |
);
|
| 88 |
}
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
function NoteList({ title, items }) {
|
| 91 |
return h(
|
| 92 |
"article",
|
|
|
|
| 51 |
const report = result.report;
|
| 52 |
const summary = report.coach_summary;
|
| 53 |
const warnings = report.video_manifest.quality_warnings || [];
|
| 54 |
+
const issues = report.issue_markers?.issues || [];
|
| 55 |
const stats = [
|
| 56 |
["Exercise", report.exercise.exercise],
|
| 57 |
["Variation", report.variation.detected_variation],
|
| 58 |
["Reps", String(report.reps.reps.length)],
|
| 59 |
+
["Issues", String(issues.length)],
|
| 60 |
["Mode", report.artifacts.analysis_mode],
|
| 61 |
];
|
| 62 |
|
|
|
|
| 79 |
h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
|
| 80 |
h(NoteList, { title: "Next session", items: summary.next_session_plan }),
|
| 81 |
),
|
| 82 |
+
h(IssueTimeline, { issues }),
|
| 83 |
warnings.length
|
| 84 |
? h(
|
| 85 |
"div",
|
|
|
|
| 90 |
);
|
| 91 |
}
|
| 92 |
|
| 93 |
+
function issueEvidence(issue) {
|
| 94 |
+
const entries = Object.entries(issue.evidence || {}).filter(
|
| 95 |
+
([key, value]) =>
|
| 96 |
+
!["threshold", "confidence", "variation_context", "supporting_frames", "fallback"].includes(key) &&
|
| 97 |
+
typeof value !== "object",
|
| 98 |
+
);
|
| 99 |
+
const [metric, value] = entries[0] || ["metric", "n/a"];
|
| 100 |
+
return `${label(metric)} ${value} vs ${issue.evidence?.threshold ?? "n/a"}`;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function IssueTimeline({ issues }) {
|
| 104 |
+
return h(
|
| 105 |
+
"section",
|
| 106 |
+
{ className: "issue-timeline", "aria-label": "Issue timeline" },
|
| 107 |
+
h(
|
| 108 |
+
"div",
|
| 109 |
+
{ className: "timeline-head" },
|
| 110 |
+
h("h3", null, "Issue timeline"),
|
| 111 |
+
h("span", null, issues.length ? `${issues.length} interval${issues.length === 1 ? "" : "s"}` : "clear"),
|
| 112 |
+
),
|
| 113 |
+
issues.length
|
| 114 |
+
? h(
|
| 115 |
+
"div",
|
| 116 |
+
{ className: "timeline-list" },
|
| 117 |
+
issues.map((issue, index) =>
|
| 118 |
+
h(
|
| 119 |
+
"article",
|
| 120 |
+
{ className: "timeline-item", key: `${issue.rep_id}-${issue.issue}-${index}` },
|
| 121 |
+
h(
|
| 122 |
+
"div",
|
| 123 |
+
{ className: "timeline-main" },
|
| 124 |
+
h("strong", null, label(issue.issue)),
|
| 125 |
+
h("span", null, `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`),
|
| 126 |
+
),
|
| 127 |
+
h(
|
| 128 |
+
"div",
|
| 129 |
+
{ className: "timeline-meta" },
|
| 130 |
+
h("span", null, `severity ${Math.round(issue.severity * 100)}%`),
|
| 131 |
+
h("span", null, issueEvidence(issue)),
|
| 132 |
+
h("span", null, issue.affected_joints.map(label).join(", ")),
|
| 133 |
+
),
|
| 134 |
+
),
|
| 135 |
+
),
|
| 136 |
+
)
|
| 137 |
+
: h("p", null, "No sustained threshold violations were found."),
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
function NoteList({ title, items }) {
|
| 142 |
return h(
|
| 143 |
"article",
|
web/styles.css
CHANGED
|
@@ -373,7 +373,7 @@ input[type="text"]:focus,
|
|
| 373 |
|
| 374 |
.stat-grid {
|
| 375 |
display: grid;
|
| 376 |
-
grid-template-columns: repeat(
|
| 377 |
gap: 10px;
|
| 378 |
margin: 16px 0;
|
| 379 |
}
|
|
@@ -432,6 +432,78 @@ input[type="text"]:focus,
|
|
| 432 |
margin-top: 14px;
|
| 433 |
}
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
.quality-list span {
|
| 436 |
border-radius: 999px;
|
| 437 |
padding: 7px 10px;
|
|
@@ -532,4 +604,9 @@ input[type="text"]:focus,
|
|
| 532 |
.panel-head {
|
| 533 |
display: grid;
|
| 534 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
}
|
|
|
|
| 373 |
|
| 374 |
.stat-grid {
|
| 375 |
display: grid;
|
| 376 |
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
| 377 |
gap: 10px;
|
| 378 |
margin: 16px 0;
|
| 379 |
}
|
|
|
|
| 432 |
margin-top: 14px;
|
| 433 |
}
|
| 434 |
|
| 435 |
+
.issue-timeline {
|
| 436 |
+
margin-top: 14px;
|
| 437 |
+
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 438 |
+
border-radius: 8px;
|
| 439 |
+
padding: 14px;
|
| 440 |
+
background: rgba(255, 253, 247, 0.62);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.timeline-head {
|
| 444 |
+
display: flex;
|
| 445 |
+
justify-content: space-between;
|
| 446 |
+
gap: 12px;
|
| 447 |
+
align-items: center;
|
| 448 |
+
margin-bottom: 10px;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.timeline-head h3 {
|
| 452 |
+
margin: 0;
|
| 453 |
+
color: var(--forest);
|
| 454 |
+
font-size: 0.98rem;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.timeline-head span,
|
| 458 |
+
.timeline-meta span {
|
| 459 |
+
color: var(--muted);
|
| 460 |
+
font-size: 0.82rem;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.timeline-list {
|
| 464 |
+
display: grid;
|
| 465 |
+
gap: 8px;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.timeline-item {
|
| 469 |
+
display: grid;
|
| 470 |
+
grid-template-columns: minmax(150px, 0.36fr) minmax(0, 0.64fr);
|
| 471 |
+
gap: 12px;
|
| 472 |
+
border-left: 3px solid var(--clay);
|
| 473 |
+
border-radius: 8px;
|
| 474 |
+
padding: 10px 12px;
|
| 475 |
+
background: rgba(255, 249, 236, 0.72);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.timeline-main strong,
|
| 479 |
+
.timeline-main span {
|
| 480 |
+
display: block;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.timeline-main strong {
|
| 484 |
+
color: var(--forest-deep);
|
| 485 |
+
font-size: 0.95rem;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.timeline-main span {
|
| 489 |
+
margin-top: 3px;
|
| 490 |
+
color: var(--muted);
|
| 491 |
+
font-size: 0.84rem;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.timeline-meta {
|
| 495 |
+
display: flex;
|
| 496 |
+
flex-wrap: wrap;
|
| 497 |
+
gap: 6px;
|
| 498 |
+
align-items: center;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.timeline-meta span {
|
| 502 |
+
border-radius: 999px;
|
| 503 |
+
padding: 6px 8px;
|
| 504 |
+
background: rgba(33, 79, 58, 0.08);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
.quality-list span {
|
| 508 |
border-radius: 999px;
|
| 509 |
padding: 7px 10px;
|
|
|
|
| 604 |
.panel-head {
|
| 605 |
display: grid;
|
| 606 |
}
|
| 607 |
+
|
| 608 |
+
.timeline-item,
|
| 609 |
+
.timeline-head {
|
| 610 |
+
display: grid;
|
| 611 |
+
}
|
| 612 |
}
|